diff --git a/handlers/admin_view.go b/handlers/admin_view.go new file mode 100644 index 0000000..2b798bf --- /dev/null +++ b/handlers/admin_view.go @@ -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) + } +} diff --git a/handlers/auth.go b/handlers/auth.go index 09dc889..05188a4 100644 --- a/handlers/auth.go +++ b/handlers/auth.go @@ -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") diff --git a/handlers/comment.go b/handlers/comment.go index 468ac5f..5b6981d 100644 --- a/handlers/comment.go +++ b/handlers/comment.go @@ -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 diff --git a/handlers/post.go b/handlers/post.go index a77a2c3..5d4589d 100644 --- a/handlers/post.go +++ b/handlers/post.go @@ -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 diff --git a/handlers/view.go b/handlers/view.go index 745b6fe..30733bb 100644 --- a/handlers/view.go +++ b/handlers/view.go @@ -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) } diff --git a/middleware/auth.go b/middleware/auth.go index 7a92bf4..e69a3b8 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -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) { diff --git a/routers/router.go b/routers/router.go index b79ccf6..8f6806f 100644 --- a/routers/router.go +++ b/routers/router.go @@ -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) diff --git a/static/admin/app.js b/static/admin/app.js index 52a6e92..4ef2db8 100644 --- a/static/admin/app.js +++ b/static/admin/app.js @@ -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 => `
渲染中...
'; 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') }, diff --git a/static/admin/index.html b/static/admin/index.html index b547487..cb25e13 100644 --- a/static/admin/index.html +++ b/static/admin/index.html @@ -1,11 +1,11 @@ - +{{define "admin_index"}}后台管理
基础设置
支持 jpg/png/gif/webp 格式,单个文件最大 5MB