后台独立登录页面,并加入权限控制。

This commit is contained in:
2026-08-11 18:04:02 +08:00
parent 808f2e7523
commit a1d57d6b44
12 changed files with 638 additions and 178 deletions
+105
View File
@@ -0,0 +1,105 @@
package handlers
import (
"html/template"
"log/slog"
"net/http"
"sync"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
// adminTemplatePath 后台单页模板(含 {{define}} 分块,按权限渲染)
const adminTemplatePath = "./static/admin/index.html"
// AdminPageData 后台页面模板数据
type AdminPageData struct {
Menus []AdminMenuGroup // 当前角色可见菜单(登录接口同源)
PageSet map[string]bool // 菜单中的页面标识集合,模板据此渲染对应页面框架
DisplayName string // 侧边栏显示的用户名
}
var (
adminTmplOnce sync.Once
adminTmpl *template.Template
adminTmplErr error
)
// getAdminTemplate 懒加载后台模板(仅解析一次)
func getAdminTemplate() (*template.Template, error) {
adminTmplOnce.Do(func() {
adminTmpl, adminTmplErr = template.ParseFiles(adminTemplatePath)
if adminTmplErr != nil {
slog.Error("解析后台模板失败", "path", adminTemplatePath, "error", adminTmplErr)
}
})
return adminTmpl, adminTmplErr
}
// menuPageSet 从菜单提取页面标识集合
func menuPageSet(menus []AdminMenuGroup) map[string]bool {
set := make(map[string]bool)
for _, g := range menus {
for _, item := range g.Items {
set[item.Key] = true
}
}
return set
}
// AdminLoginView 独立登录页
func AdminLoginView(c *gin.Context) {
c.File("./static/admin/login.html")
}
// AdminView 后台管理页面:校验登录态后按角色用模板渲染菜单与页面框架
func AdminView(c *gin.Context) {
tokenString, err := c.Cookie("token")
if err != nil || tokenString == "" {
c.Redirect(http.StatusFound, "/admin/login")
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
c.Redirect(http.StatusFound, "/admin/login")
return
}
// 校验用户当前状态(禁用账号不允许进入后台)
var user models.User
if err := database.DB.First(&user, claims.UserID).Error; err != nil || user.Status == 0 || !user.IsActive {
c.Redirect(http.StatusFound, "/admin/login")
return
}
tmpl, tmplErr := getAdminTemplate()
if tmplErr != nil {
c.String(http.StatusInternalServerError, "后台模板加载失败")
return
}
// 菜单与登录接口保持同一来源,页面框架只渲染菜单内的页面
menus := GetMenusByRole(user.Role)
displayName := user.Nickname
if displayName == "" {
displayName = user.Username
}
data := AdminPageData{
Menus: menus,
PageSet: menuPageSet(menus),
DisplayName: displayName,
}
c.Header("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(c.Writer, "admin_index", data); err != nil {
slog.Error("渲染后台页面失败", "error", err)
}
}
+61
View File
@@ -18,6 +18,50 @@ func JWTSecret() []byte {
return jwtSecret
}
// AdminMenuItem 后台菜单项
type AdminMenuItem struct {
Key string `json:"key"` // 页面标识,与页面 div 的 data-page 对应
Name string `json:"name"` // 菜单显示名称
}
// AdminMenuGroup 后台菜单分组(Group 为空表示顶级菜单)
type AdminMenuGroup struct {
Group string `json:"group"`
Items []AdminMenuItem `json:"items"`
}
// GetMenusByRole 根据角色返回后台菜单
func GetMenusByRole(role string) []AdminMenuGroup {
if role == "admin" {
return []AdminMenuGroup{
{Group: "", Items: []AdminMenuItem{
{Key: "dashboard", Name: "仪表盘"},
}},
{Group: "内容管理", Items: []AdminMenuItem{
{Key: "posts", Name: "文章管理"},
{Key: "categories", Name: "分类管理"},
{Key: "tags", Name: "标签管理"},
{Key: "pages", Name: "页面管理"},
{Key: "comments", Name: "评论管理"},
}},
{Group: "系统管理", Items: []AdminMenuItem{
{Key: "users", Name: "用户管理"},
{Key: "files", Name: "文件管理"},
{Key: "themes", Name: "主题管理"},
{Key: "settings", Name: "基础设置"},
}},
}
}
// 普通用户:仪表盘 + 文章管理 + 评论管理
return []AdminMenuGroup{
{Group: "", Items: []AdminMenuItem{
{Key: "dashboard", Name: "仪表盘"},
{Key: "posts", Name: "文章管理"},
{Key: "comments", Name: "评论管理"},
}},
}
}
// 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
@@ -88,6 +132,9 @@ func Login(c *gin.Context) {
return
}
// 同时写入 Cookie,供服务端渲染后台页面时识别登录态
c.SetCookie("token", tokenString, 7*24*3600, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{
"token": tokenString,
"user": gin.H{
@@ -100,9 +147,23 @@ func Login(c *gin.Context) {
"last_login_at": user.LastLoginAt,
"login_count": user.LoginCount,
},
// 菜单由登录接口根据角色返回
"menus": GetMenusByRole(user.Role),
})
}
// 退出登录(清除服务端 Cookie)
func Logout(c *gin.Context) {
c.SetCookie("token", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "已退出登录"})
}
// WebLogout 前台页面退出登录(清除 Cookie 后回首页)
func WebLogout(c *gin.Context) {
c.SetCookie("token", "", -1, "/", "", false, true)
c.Redirect(http.StatusFound, "/")
}
// 获取当前用户信息
func GetCurrentUser(c *gin.Context) {
userID, _ := c.Get("userID")
+38
View File
@@ -30,6 +30,14 @@ func GetComments(c *gin.Context) {
return db.Select("id", "title", "slug")
})
// 非管理员只能查看自己文章上的评论
if !isAdmin(c) {
if userID, exists := c.Get("userID"); exists {
db = db.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
}
if postID != "" {
db = db.Where("post_id = ?", postID)
}
@@ -95,6 +103,21 @@ func CreateComment(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"data": comment})
}
// canManageComment 检查当前用户是否有权管理该评论(管理员始终可以,普通用户只能管理自己文章上的评论)
func canManageComment(c *gin.Context, comment *models.Comment) bool {
if isAdmin(c) {
return true
}
if userID, exists := c.Get("userID"); exists {
var post models.Post
if err := database.DB.Select("author_id").First(&post, comment.PostID).Error; err != nil {
return false
}
return post.AuthorID == userID.(uint)
}
return false
}
// 审核评论
func ApproveComment(c *gin.Context) {
id := c.Param("id")
@@ -105,6 +128,11 @@ func ApproveComment(c *gin.Context) {
return
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return
}
comment.Status = "approved"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "审核失败"})
@@ -124,6 +152,11 @@ func MarkSpamComment(c *gin.Context) {
return
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return
}
comment.Status = "spam"
if err := database.DB.Save(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
@@ -143,6 +176,11 @@ func DeleteComment(c *gin.Context) {
return
}
if !canManageComment(c, &comment) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权管理该评论"})
return
}
if err := database.DB.Delete(&comment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除评论失败"})
return
+39 -13
View File
@@ -82,22 +82,23 @@ func GetPosts(c *gin.Context) {
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author")
// 前端只显示已发布的文章
if !isAdmin(c) {
// 管理员可查看全部;登录用户可看到自己的全部文章(含草稿);未登录只能看已发布
if isAdmin(c) {
if query.Status != "" {
db = db.Where("status = ?", query.Status)
}
} else if userId, exists := c.Get("userID"); exists {
// 登录用户:只看自己的,不限状态(后台管理需要看到草稿)
db = db.Where("author_id = ?", userId)
} else {
// 未登录:只看已发布
db = db.Where("status = ?", "published")
} else if query.Status != "" {
db = db.Where("status = ?", query.Status)
}
if query.CategoryID > 0 {
db = db.Where("category_id = ?", query.CategoryID)
}
userId, exists := c.Get("userID")
if exists {
db = db.Where("author_id = ?", userId)
}
if query.TagID > 0 {
db = db.Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Where("post_tags.tag_id = ?", query.TagID)
@@ -141,9 +142,13 @@ func GetPost(c *gin.Context) {
query = query.Where("slug = ?", id)
}
// 非管理员只能查看已发布文章
// 非管理员只能查看已发布文章,但可以查看自己的草稿(编辑用)
if !isAdmin(c) {
query = query.Where("status = ?", "published")
if userId, exists := c.Get("userID"); exists {
query = query.Where("status = ? OR author_id = ?", "published", userId)
} else {
query = query.Where("status = ?", "published")
}
}
if err := query.First(&post).Error; err != nil {
@@ -176,7 +181,7 @@ func CreatePost(c *gin.Context) {
AuthorID: userID.(uint),
CategoryID: req.CategoryID,
Status: req.Status,
IsTop: req.IsTop,
IsTop: req.IsTop && isAdmin(c), // 非管理员不允许置顶
}
if req.Status == "published" {
@@ -216,6 +221,15 @@ func UpdatePost(c *gin.Context) {
return
}
// 非管理员只能编辑自己的文章
if !isAdmin(c) {
userID, _ := c.Get("userID")
if post.AuthorID != userID.(uint) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权编辑他人的文章"})
return
}
}
var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -251,7 +265,10 @@ func UpdatePost(c *gin.Context) {
now := time.Now()
updates["published_at"] = &now
}
updates["is_top"] = req.IsTop
// 非管理员不允许置顶
if isAdmin(c) {
updates["is_top"] = req.IsTop
}
// 处理标签
if len(req.Tags) > 0 {
@@ -288,6 +305,15 @@ func DeletePost(c *gin.Context) {
return
}
// 非管理员只能删除自己的文章
if !isAdmin(c) {
userID, _ := c.Get("userID")
if post.AuthorID != userID.(uint) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权删除他人的文章"})
return
}
}
if err := database.DB.Delete(&post).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除文章失败"})
return
+23
View File
@@ -25,6 +25,16 @@ func loadSidebarData() (categories []models.Category, tags []models.Tag, postCou
return
}
// loginInfo 从上下文提取登录态信息(供侧边栏系统菜单区分已登录/未登录)
func loginInfo(c *gin.Context) (isLoggedIn bool, loginName string) {
if _, ok := c.Get("userID"); !ok {
return false, ""
}
name, _ := c.Get("username")
loginName, _ = name.(string)
return true, loginName
}
// 首页
type IndexData struct {
Title string
@@ -42,6 +52,9 @@ type IndexData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
func IndexView(c *gin.Context) {
@@ -129,6 +142,7 @@ func IndexView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "index", data)
}
@@ -156,6 +170,9 @@ type PostDetailData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
func PostView(c *gin.Context) {
@@ -280,6 +297,7 @@ func PostView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
// 只有当查询成功时才赋值
if prevErr == nil {
@@ -307,6 +325,9 @@ type PageDetailData struct {
PostCount int64
CategoryCount int64
TagCount int64
// 登录态(侧边栏系统菜单用)
IsLoggedIn bool
LoginName string
}
func PageView(c *gin.Context) {
@@ -359,6 +380,7 @@ func PageView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "page", data)
}
@@ -464,6 +486,7 @@ func SearchView(c *gin.Context) {
CategoryCount: categoryCount,
TagCount: tagCount,
}
data.IsLoggedIn, data.LoginName = loginInfo(c)
c.HTML(http.StatusOK, "index", data)
}
+13 -9
View File
@@ -60,22 +60,26 @@ func AdminRequired() gin.HandlerFunc {
}
}
// 可选认证中间件(用于某些既支持游客又支持登录用户的接口)
// OptionalAuth 可选认证中间件(用于某些既支持游客又支持登录用户的接口/页面)
// 优先读 Authorization 头,其次回退到 Cookie(服务端渲染的前台页面依赖 Cookie)
func OptionalAuth() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString := ""
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
if authHeader != "" {
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) == 2 && parts[0] == "Bearer" {
tokenString = parts[1]
}
}
if tokenString == "" {
tokenString, _ = c.Cookie("token")
}
if tokenString == "" {
c.Next()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Next()
return
}
tokenString := parts[1]
claims := &handlers.Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
+30 -15
View File
@@ -17,16 +17,19 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) {
// 后台管理(放在前面避免冲突)
r.Static("/admin/static", "./static/admin")
r.GET("/admin", func(c *gin.Context) {
c.File("./static/admin/index.html")
})
// 独立登录页
r.GET("/admin/login", handlers.AdminLoginView)
// 后台首页:服务端按登录角色用模板渲染菜单与页面框架
r.GET("/admin", handlers.AdminView)
// 前端页面路由
r.GET("/", handlers.IndexView)
r.GET("/post/:slug", handlers.PostView)
r.GET("/page/:slug", handlers.PageView)
r.GET("/search", handlers.SearchView)
// 前端页面路由(可选认证:侧边栏根据登录态显示系统菜单)
r.GET("/", middleware.OptionalAuth(), handlers.IndexView)
r.GET("/post/:slug", middleware.OptionalAuth(), handlers.PostView)
r.GET("/page/:slug", middleware.OptionalAuth(), handlers.PageView)
r.GET("/search", middleware.OptionalAuth(), handlers.SearchView)
r.POST("/comment", handlers.SubmitComment)
// 前台退出登录:清除 Cookie 后回首页
r.GET("/logout", handlers.WebLogout)
// API 路由组
api := r.Group("/api")
@@ -47,6 +50,7 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) {
// 用户认证
api.POST("/auth/login", handlers.Login)
api.POST("/auth/logout", handlers.Logout)
// 需要登录的 API
auth := api.Group("/")
@@ -55,6 +59,23 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) {
auth.GET("/auth/me", handlers.GetCurrentUser)
auth.PUT("/auth/me", handlers.UpdateUser)
auth.PUT("/auth/password", handlers.ChangePassword)
// 文章管理(所有登录用户可新建文章,修改/删除自己的文章)
auth.POST("/posts", handlers.CreatePost)
auth.PUT("/posts/:id", handlers.UpdatePost)
auth.DELETE("/posts/:id", handlers.DeletePost)
// Markdown 预览(文章编辑器用)
auth.POST("/markdown/preview", handlers.PreviewMarkdown)
// 文件上传(文章编辑器封面图等)
auth.POST("/upload", handlers.UploadImage)
// 评论管理(登录用户管理自己文章的评论)
auth.GET("/my/comments", handlers.GetComments)
auth.PUT("/my/comments/:id/approve", handlers.ApproveComment)
auth.PUT("/my/comments/:id/spam", handlers.MarkSpamComment)
auth.DELETE("/my/comments/:id", handlers.DeleteComment)
}
// 主题管理(可选认证)
@@ -70,13 +91,7 @@ func SetupRoutes(r *gin.Engine, cfg interface{}) {
admin := api.Group("/admin")
admin.Use(middleware.JWTAuth(), middleware.AdminRequired())
{
// 文章管理
admin.POST("/posts", handlers.CreatePost)
admin.PUT("/posts/:id", handlers.UpdatePost)
admin.DELETE("/posts/:id", handlers.DeletePost)
// Markdown 预览(文章编辑器用)
admin.POST("/markdown/preview", handlers.PreviewMarkdown)
// Markdown 预览(文章编辑器用)已移至登录组
// 分类管理
admin.POST("/categories", handlers.CreateCategory)
+127 -93
View File
@@ -4,25 +4,34 @@ const API_BASE = '/api';
// 当前登录用户
let currentUser = null;
// 页面初始化
// 跳转到独立登录页
function goLogin() {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('menus');
currentUser = null;
location.replace('/admin/login');
}
// 页面初始化(登录已移至独立页面 /admin/login,菜单与页面框架由服务端模板按权限渲染)
document.addEventListener('DOMContentLoaded', () => {
const token = localStorage.getItem('token');
if (token) {
currentUser = JSON.parse(localStorage.getItem('user') || '{}');
document.getElementById('username').textContent = currentUser.nickname || currentUser.username;
showSidebar();
showPage('dashboard');
} else {
// 未登录状态:隐藏所有页面,只显示登录页
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const loginPage = document.getElementById('login-page');
if (loginPage) {
loginPage.classList.remove('hidden');
}
hideSidebar();
if (!token) {
goLogin();
return;
}
currentUser = JSON.parse(localStorage.getItem('user') || '{}');
const usernameEl = document.getElementById('username');
if (usernameEl) {
usernameEl.textContent = currentUser.nickname || currentUser.username;
}
// 绑定菜单点击事件
// 向服务端校验登录态与角色(不信任本地缓存,防止残留会话导致权限误判)
refreshCurrentUser();
showPage('dashboard');
// 绑定菜单点击事件(菜单由服务端模板渲染,只包含有权限的页面)
document.querySelectorAll('.menu-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
@@ -43,20 +52,17 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
// 登录表单
document.getElementById('login-form').addEventListener('submit', handleLogin);
// 文章表单
document.getElementById('post-form').addEventListener('submit', handlePostSubmit);
// 模态框表单
document.getElementById('modal-form').addEventListener('submit', handleModalSubmit);
// 退出登录
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', logout);
}
// 用户表单
document.getElementById('user-form').addEventListener('submit', handleUserSubmit);
// 基础设置表单
document.getElementById('settings-form').addEventListener('submit', handleSettingsSubmit);
// 各表单绑定(页面未渲染时对应元素不存在,跳过绑定)
bindForm('post-form', handlePostSubmit);
bindForm('modal-form', handleModalSubmit);
bindForm('user-form', handleUserSubmit);
bindForm('settings-form', handleSettingsSubmit);
// 封面图上传
const coverFileInput = document.getElementById('post-cover-file');
@@ -71,33 +77,50 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// 显示登录页
function showLogin() {
hideSidebar();
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const loginPage = document.getElementById('login-page');
if (loginPage) {
loginPage.classList.remove('hidden');
// 安全绑定表单提交(元素不存在时忽略)
function bindForm(formId, handler) {
const form = document.getElementById(formId);
if (form) {
form.addEventListener('submit', handler);
}
}
// 显示侧边栏
function showSidebar() {
document.querySelector('.sidebar').style.display = 'flex';
// 向服务端获取权威用户信息,校验 token 与角色
async function refreshCurrentUser() {
try {
const res = await fetch(`${API_BASE}/auth/me`, { headers: getHeaders() });
if (!res.ok) {
goLogin();
return;
}
const data = await res.json();
const fresh = data.data || {};
// 角色发生变化时重新加载页面,让服务端按新角色重新渲染菜单
if (currentUser && fresh.role && currentUser.role !== fresh.role) {
localStorage.setItem('user', JSON.stringify(fresh));
location.reload();
return;
}
currentUser = Object.assign({}, currentUser, fresh);
localStorage.setItem('user', JSON.stringify(currentUser));
const usernameEl = document.getElementById('username');
if (usernameEl) {
usernameEl.textContent = currentUser.nickname || currentUser.username;
}
} catch (err) {
console.error('校验登录态失败:', err);
}
}
// 隐藏侧边栏
function hideSidebar() {
document.querySelector('.sidebar').style.display = 'none';
}
// 切换页面
// 切换页面(页面框架由服务端模板渲染,未渲染的页面不存在也不加载数据)
function showPage(pageName) {
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const targetPage = document.getElementById(pageName + '-page');
if (targetPage) {
targetPage.classList.remove('hidden');
if (!targetPage) {
alert('无权访问该页面');
return;
}
targetPage.classList.remove('hidden');
// 加载对应数据
switch(pageName) {
@@ -137,41 +160,14 @@ function showPage(pageName) {
}
}
// 登录
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
// 退出登录:通知服务端清除 Cookie 后跳转登录页
async function logout() {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
currentUser = data.user;
document.getElementById('username').textContent = data.user.nickname || data.user.username;
showSidebar();
showPage('dashboard');
} else {
alert(data.error || '登录失败');
}
await fetch(`${API_BASE}/auth/logout`, { method: 'POST', headers: getHeaders() });
} catch (err) {
alert('网络错误');
// 忽略网络异常,本地登出继续执行
}
}
// 退出登录
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
currentUser = null;
showLogin();
goLogin();
}
// 获取请求头
@@ -189,7 +185,7 @@ async function loadDashboard() {
fetch(`${API_BASE}/posts?page_size=1`).then(r => r.json()),
fetch(`${API_BASE}/categories`).then(r => r.json()),
fetch(`${API_BASE}/tags`).then(r => r.json()),
fetch(`${API_BASE}/comments?status=pending`).then(r => r.json())
fetch(`${commentsAPIBase()}/comments?status=pending`).then(r => r.json())
]);
document.getElementById('stat-posts').textContent = posts.total || 0;
@@ -201,16 +197,26 @@ async function loadDashboard() {
}
}
// 加载文章列表
async function loadPosts() {
// 文章列表分页状态(每页 15 条)
const POSTS_PAGE_SIZE = 15;
let postsCurrentPage = 1;
// 加载文章列表(分页)
async function loadPosts(page = postsCurrentPage) {
try {
const res = await fetch(`${API_BASE}/posts?page_size=100`, { headers: getHeaders() });
const res = await fetch(`${API_BASE}/posts?page=${page}&page_size=${POSTS_PAGE_SIZE}`, { headers: getHeaders() });
const data = await res.json();
const list = data.data || [];
const total = data.total || 0;
const totalPages = Math.max(1, Math.ceil(total / POSTS_PAGE_SIZE));
postsCurrentPage = Math.min(Math.max(1, data.page || page), totalPages);
const tbody = document.getElementById('posts-list');
tbody.innerHTML = data.data?.map(post => `
tbody.innerHTML = list.map(post => `
<tr>
<td>${post.title}</td>
<td>${post.author?.nickname || post.author?.username || '-'}</td>
<td>${post.category?.name || '-'}</td>
<td><span class="status-badge status-${post.status}">${getStatusText(post.status)}</span></td>
<td>${post.views}</td>
@@ -221,12 +227,29 @@ async function loadPosts() {
<button class="btn-danger" onclick="deletePost(${post.id})">删除</button>
</td>
</tr>
`).join('') || '<tr><td colspan="6" style="text-align:center">暂无文章</td></tr>';
`).join('') || '<tr><td colspan="7" style="text-align:center">暂无文章</td></tr>';
renderPostsPagination(total, totalPages);
} catch (err) {
console.error('加载文章失败:', err);
}
}
// 渲染文章列表分页条
function renderPostsPagination(total, totalPages) {
const box = document.getElementById('posts-pagination');
if (!box) return;
const buttons = [];
buttons.push(`<button ${postsCurrentPage <= 1 ? 'disabled' : ''} onclick="loadPosts(${postsCurrentPage - 1})">上一页</button>`);
for (let i = 1; i <= totalPages; i++) {
buttons.push(`<button class="${i === postsCurrentPage ? 'active' : ''}" onclick="loadPosts(${i})">${i}</button>`);
}
buttons.push(`<button ${postsCurrentPage >= totalPages ? 'disabled' : ''} onclick="loadPosts(${postsCurrentPage + 1})">下一页</button>`);
box.innerHTML = buttons.join('') + `<span class="pagination-info">共 ${total} 条,第 ${postsCurrentPage}/${totalPages} 页</span>`;
}
// 显示文章表单
async function showPostForm(isEdit = false) {
document.getElementById('post-form-title').textContent = isEdit ? '编辑文章' : '新建文章';
@@ -235,6 +258,12 @@ async function showPostForm(isEdit = false) {
setCover('');
mdShowEdit();
// 非管理员隐藏置顶选项
const istopGroup = document.getElementById('post-istop')?.closest('.form-group');
if (istopGroup) {
istopGroup.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
}
// 加载分类选项
const res = await fetch(`${API_BASE}/categories`);
const data = await res.json();
@@ -296,7 +325,7 @@ async function handlePostSubmit(e) {
};
try {
const url = id ? `${API_BASE}/admin/posts/${id}` : `${API_BASE}/admin/posts`;
const url = id ? `${API_BASE}/posts/${id}` : `${API_BASE}/posts`;
const method = id ? 'PUT' : 'POST';
const res = await fetch(url, {
@@ -376,7 +405,7 @@ async function mdShowPreview() {
preview.classList.remove('hidden');
preview.innerHTML = '<p class="md-preview-tip">渲染中...</p>';
try {
const res = await fetch(`${API_BASE}/admin/markdown/preview`, {
const res = await fetch(`${API_BASE}/markdown/preview`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ content: ta.value })
@@ -394,7 +423,7 @@ async function changePostStatus(id, status) {
if (!confirm(`确定要${actionText}这篇文章吗?`)) return;
try {
const res = await fetch(`${API_BASE}/admin/posts/${id}`, {
const res = await fetch(`${API_BASE}/posts/${id}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ status })
@@ -416,7 +445,7 @@ async function deletePost(id) {
if (!confirm('确定要删除这篇文章吗?')) return;
try {
const res = await fetch(`${API_BASE}/admin/posts/${id}`, {
const res = await fetch(`${API_BASE}/posts/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -501,10 +530,15 @@ async function loadPages() {
}
}
// 评论管理 API 基础路径(管理员走 admin 组,普通用户走 my 组)
function commentsAPIBase() {
return (currentUser && currentUser.role === 'admin') ? `${API_BASE}/admin` : `${API_BASE}/my`;
}
// 加载评论(postId 为空时加载全部)
async function loadComments(postId = '') {
try {
const url = postId ? `${API_BASE}/admin/comments?post_id=${postId}` : `${API_BASE}/admin/comments`;
const url = postId ? `${commentsAPIBase()}/comments?post_id=${postId}` : `${commentsAPIBase()}/comments`;
const res = await fetch(url, { headers: getHeaders() });
const data = await res.json();
@@ -708,7 +742,7 @@ async function deletePage(id) {
// 通过评论
async function approveComment(id) {
await fetch(`${API_BASE}/admin/comments/${id}/approve`, {
await fetch(`${commentsAPIBase()}/comments/${id}/approve`, {
method: 'PUT',
headers: getHeaders()
});
@@ -718,7 +752,7 @@ async function approveComment(id) {
// 删除评论
async function deleteComment(id) {
if (!confirm('确定要删除这条评论吗?')) return;
await fetch(`${API_BASE}/admin/comments/${id}`, {
await fetch(`${commentsAPIBase()}/comments/${id}`, {
method: 'DELETE',
headers: getHeaders()
});
@@ -1421,7 +1455,7 @@ async function uploadImageFile(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE}/admin/upload`, {
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
// 注意:上传 FormData 时不能手动设置 Content-Type,浏览器会自动处理 boundary
headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') },
+65 -47
View File
@@ -1,11 +1,11 @@
<!DOCTYPE html>
{{define "admin_index"}}<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理 - GoBlog</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/admin/style.css?v=2">
<link rel="stylesheet" href="/static/admin/style.css?v=3">
</head>
<body>
<div id="app">
@@ -15,56 +15,49 @@
<p>后台管理</p>
</div>
<nav class="menu">
<a href="#dashboard" class="menu-item active" data-page="dashboard">仪表盘</a>
{{range .Menus}}{{if eq .Group ""}}{{range $i, $item := .Items}}<a href="#{{$item.Key}}" class="menu-item{{if eq $item.Key "dashboard"}} active{{end}}" data-page="{{$item.Key}}">{{$item.Name}}</a>
{{end}}{{else}}
<div class="menu-group open">
<button class="menu-group-header" type="button">内容管理</button>
<button class="menu-group-header" type="button">{{.Group}}</button>
<div class="menu-sub">
<a href="#posts" class="menu-item" data-page="posts">文章管理</a>
<a href="#categories" class="menu-item" data-page="categories">分类管理</a>
<a href="#tags" class="menu-item" data-page="tags">标签管理</a>
<a href="#pages" class="menu-item" data-page="pages">页面管理</a>
<a href="#comments" class="menu-item" data-page="comments">评论管理</a>
</div>
</div>
<div class="menu-group open">
<button class="menu-group-header" type="button">系统管理</button>
<div class="menu-sub">
<a href="#users" class="menu-item" data-page="users">用户管理</a>
<a href="#files" class="menu-item" data-page="files">文件管理</a>
<a href="#themes" class="menu-item" data-page="themes">主题管理</a>
<a href="#settings" class="menu-item" data-page="settings">基础设置</a>
{{range .Items}}<a href="#{{.Key}}" class="menu-item" data-page="{{.Key}}">{{.Name}}</a>
{{end}}
</div>
</div>
{{end}}{{end}}
</nav>
<div class="user-info">
<span id="username">Admin</span>
<button onclick="logout()">退出</button>
<span id="username">{{.DisplayName}}</span>
<button id="logout-btn">退出</button>
</div>
</aside>
<main class="main-content">
<!-- 登录页面 -->
<div id="login-page" class="page">
<div class="login-box">
<h2>管理员登录</h2>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" required>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" required>
</div>
<button type="submit">登录</button>
</form>
</div>
</div>
{{if index .PageSet "dashboard"}}{{template "admin_page_dashboard" .}}{{end}}
{{if index .PageSet "posts"}}{{template "admin_page_posts" .}}{{template "admin_page_post_form" .}}{{end}}
{{if index .PageSet "categories"}}{{template "admin_page_categories" .}}{{end}}
{{if index .PageSet "tags"}}{{template "admin_page_tags" .}}{{end}}
{{if index .PageSet "pages"}}{{template "admin_page_pages" .}}{{end}}
{{if index .PageSet "comments"}}{{template "admin_page_comments" .}}{{end}}
{{if index .PageSet "users"}}{{template "admin_page_users" .}}{{end}}
{{if index .PageSet "settings"}}{{template "admin_page_settings" .}}{{end}}
{{if index .PageSet "files"}}{{template "admin_page_files" .}}{{end}}
{{if index .PageSet "themes"}}{{template "admin_page_themes" .}}{{template "admin_page_theme_editor" .}}{{end}}
</main>
</div>
{{if or (index .PageSet "categories") (index .PageSet "tags") (index .PageSet "pages")}}{{template "admin_modal_common" .}}{{end}}
{{if index .PageSet "users"}}{{template "admin_modal_user" .}}{{end}}
{{if index .PageSet "themes"}}{{template "admin_modal_theme" .}}{{end}}
<script src="/static/admin/app.js?v=19"></script>
</body>
</html>
{{end}}
{{define "admin_page_dashboard"}}
<!-- 仪表盘 -->
<div id="dashboard-page" class="page hidden">
<div id="dashboard-page" class="page">
<h1>仪表盘</h1>
<div class="stats-grid">
<div class="stat-card">
@@ -85,7 +78,9 @@
</div>
</div>
</div>
{{end}}
{{define "admin_page_posts"}}
<!-- 文章管理 -->
<div id="posts-page" class="page hidden">
<div class="page-header">
@@ -96,6 +91,7 @@
<thead>
<tr>
<th>标题</th>
<th>作者</th>
<th>分类</th>
<th>状态</th>
<th>浏览量</th>
@@ -105,8 +101,11 @@
</thead>
<tbody id="posts-list"></tbody>
</table>
<div id="posts-pagination" class="pagination"></div>
</div>
{{end}}
{{define "admin_page_post_form"}}
<!-- 文章表单 -->
<div id="post-form-page" class="page hidden">
<div class="page-header">
@@ -191,7 +190,9 @@
<button type="submit" class="btn-primary" onclick="mdShowEdit()">保存</button>
</form>
</div>
{{end}}
{{define "admin_page_categories"}}
<!-- 分类管理 -->
<div id="categories-page" class="page hidden">
<div class="page-header">
@@ -210,7 +211,9 @@
<tbody id="categories-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_tags"}}
<!-- 标签管理 -->
<div id="tags-page" class="page hidden">
<div class="page-header">
@@ -229,7 +232,9 @@
<tbody id="tags-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_pages"}}
<!-- 页面管理 -->
<div id="pages-page" class="page hidden">
<div class="page-header">
@@ -249,7 +254,9 @@
<tbody id="pages-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_comments"}}
<!-- 评论管理 -->
<div id="comments-page" class="page hidden">
<div class="page-header">
@@ -272,7 +279,9 @@
<tbody id="comments-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_users"}}
<!-- 用户管理 -->
<div id="users-page" class="page hidden">
<div class="page-header">
@@ -294,9 +303,11 @@
<tbody id="users-list"></tbody>
</table>
</div>
{{end}}
{{define "admin_page_settings"}}
<!-- 基础设置 -->
<div id="settings-page" class="page hidden">
<div id="settings-page" class="page hidden">
<div class="page-header">
<h1>基础设置</h1>
</div>
@@ -322,7 +333,9 @@
<button type="submit" class="btn-primary">保存设置</button>
</form>
</div>
{{end}}
{{define "admin_page_files"}}
<!-- 文件管理 -->
<div id="files-page" class="page hidden">
<div class="page-header">
@@ -335,7 +348,9 @@
<p class="upload-hint">支持 jpg/png/gif/webp 格式,单个文件最大 5MB</p>
<div class="file-grid" id="file-grid"></div>
</div>
{{end}}
{{define "admin_page_themes"}}
<!-- 主题管理 -->
<div id="themes-page" class="page hidden">
<div class="page-header">
@@ -356,7 +371,9 @@
<!-- 主题列表将动态加载 -->
</div>
</div>
{{end}}
{{define "admin_page_theme_editor"}}
<!-- 主题编辑器 -->
<div id="theme-editor-page" class="page hidden">
<div class="page-header">
@@ -379,9 +396,9 @@
</div>
</div>
</div>
</main>
</div>
{{end}}
{{define "admin_modal_common"}}
<!-- 分类/标签/页面 表单弹窗 -->
<div id="modal" class="modal hidden">
<div class="modal-content">
@@ -407,7 +424,9 @@
</form>
</div>
</div>
{{end}}
{{define "admin_modal_user"}}
<!-- 用户表单弹窗 -->
<div id="user-modal" class="modal hidden">
<div class="modal-content">
@@ -453,7 +472,9 @@
</form>
</div>
</div>
{{end}}
{{define "admin_modal_theme"}}
<!-- 创建主题弹窗 -->
<div id="theme-modal" class="modal hidden">
<div class="modal-content modal-large">
@@ -477,7 +498,4 @@
</form>
</div>
</div>
<script src="/static/admin/app.js?v=16"></script>
</body>
</html>
{{end}}
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - GoBlog 后台管理</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/admin/style.css?v=3">
</head>
<body>
<div id="login-page">
<div class="login-box">
<h2>管理员登录</h2>
<form id="login-form">
<div class="form-group">
<label>用户名</label>
<input type="text" id="login-username" required autocomplete="username">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" id="login-password" required autocomplete="current-password">
</div>
<div id="login-error" class="login-error hidden"></div>
<button type="submit" id="login-submit">登录</button>
</form>
</div>
</div>
<script>
// 已登录则直接进入后台
if (localStorage.getItem('token')) {
location.replace('/admin');
}
function showError(msg) {
const el = document.getElementById('login-error');
el.textContent = msg;
el.classList.remove('hidden');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('login-submit');
btn.disabled = true;
btn.textContent = '登录中...';
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (res.ok) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
// 菜单由登录接口返回,缓存供前端参考(页面菜单以服务端模板渲染为准)
localStorage.setItem('menus', JSON.stringify(data.menus || []));
location.href = '/admin';
} else {
showError(data.error || '登录失败');
btn.disabled = false;
btn.textContent = '登录';
}
} catch (err) {
showError('网络错误,请稍后重试');
btn.disabled = false;
btn.textContent = '登录';
}
});
</script>
</body>
</html>
+54
View File
@@ -313,6 +313,60 @@ body {
font-size: 16px;
}
.login-box button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
color: #e74c3c;
background: #fdecea;
border-radius: 4px;
padding: 8px 12px;
margin-bottom: 16px;
font-size: 14px;
}
/* 列表分页条 */
.pagination {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 16px;
}
.pagination button {
padding: 6px 12px;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
color: #333;
}
.pagination button:hover:not(:disabled):not(.active) {
border-color: #3498db;
color: #3498db;
}
.pagination button.active {
background: #3498db;
border-color: #3498db;
color: #fff;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination .pagination-info {
margin-left: auto;
color: #888;
font-size: 13px;
}
/* 弹窗 */
.modal {
position: fixed;
+7 -1
View File
@@ -61,16 +61,22 @@
</div>
{{end}}
<!-- 系统菜单 -->
<!-- 系统菜单(根据登录态区分:已登录显示进入后台/退出登录,未登录显示用户登录) -->
<div class="sidebar-widget">
<h3 class="widget-title">⚙️ 系统</h3>
<ul class="system-menu">
{{if .IsLoggedIn}}
<li class="system-menu-item">
<a href="/admin" class="system-menu-link">🔐 进入后台</a>
</li>
<li class="system-menu-item">
<a href="/logout" class="system-menu-link">🚪 退出登录{{if .LoginName}}({{.LoginName}}){{end}}</a>
</li>
{{else}}
<li class="system-menu-item">
<a href="/admin/login" class="system-menu-link">👤 用户登录</a>
</li>
{{end}}
</ul>
</div>
</aside>