handlers区分backend和frontend,需要确认。

This commit is contained in:
2026-08-12 17:57:31 +08:00
parent 60869ec214
commit 42c6f2450d
20 changed files with 242 additions and 137 deletions
+8
View File
@@ -117,3 +117,11 @@ func watchConfig() {
func GetGlobalConfig() *Config {
return globalConfig
}
// getConfig 获取全局配置(启动时已初始化,直接返回)
func GetConfig() *Config {
if cfg := GetGlobalConfig(); cfg != nil {
return cfg
}
return Load()
}
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"encoding/json"
@@ -21,7 +21,7 @@ type AdminPageData struct {
Menus []AdminMenuGroup // 当前角色可见菜单(登录接口同源)
PageSet map[string]bool // 菜单中的页面标识集合,模板据此渲染对应页面框架
DisplayName string // 侧边栏显示的用户名
CurrentUser template.JS // JSON 序列化的当前用户信息(id/username/nickname/role),注入前端
CurrentUser template.JS // JSON 序列化的当前用户信息(id/username/nickname/role),注入前端
}
var (
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"log/slog"
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
+43
View File
@@ -0,0 +1,43 @@
package backend
import (
"net/http"
"goblog/database"
"goblog/models"
"github.com/gin-gonic/gin"
)
// GetDashboardStats 返回仪表盘统计数字(按角色过滤:管理员统计全部,普通用户统计自己的)
func GetDashboardStats(c *gin.Context) {
// 文章数:管理员统计全部,普通用户统计自己的(与文章管理页可见范围一致)
postQuery := database.DB.Model(&models.Post{})
if !isAdmin(c) {
userID, _ := c.Get("userID")
postQuery = postQuery.Where("author_id = ?", userID)
}
var postCount int64
postQuery.Count(&postCount)
var categoryCount, tagCount int64
database.DB.Model(&models.Category{}).Count(&categoryCount)
database.DB.Model(&models.Tag{}).Count(&tagCount)
// 待审核评论数:管理员统计全部,普通用户统计自己文章上的
commentQuery := database.DB.Model(&models.Comment{}).Where("status = ?", "pending")
if !isAdmin(c) {
userID, _ := c.Get("userID")
commentQuery = commentQuery.Where("post_id IN (?)",
database.DB.Model(&models.Post{}).Select("id").Where("author_id = ?", userID))
}
var pendingCommentCount int64
commentQuery.Count(&pendingCommentCount)
c.JSON(http.StatusOK, gin.H{
"posts": postCount,
"categories": categoryCount,
"tags": tagCount,
"pending_comments": pendingCommentCount,
})
}
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -1,26 +1,18 @@
package handlers
package backend
import (
"net/http"
"strconv"
"goblog/config"
"goblog/database"
"goblog/models"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
// 站点基础设置键名
const (
OptionSiteName = "site_name"
OptionSiteDesc = "site_desc"
OptionShowComments = "show_comments" // "1" 显示评论 / "0" 隐藏评论
OptionCommentPageSize = "comments_per_page" // 前台评论每页条数
)
// GetSettings 获取站点基础设置(管理员)
func GetSettings(c *gin.Context) {
cfg := getConfig()
cfg := config.GetConfig()
opts, err := models.GetOptionsByUser(database.DB, 0)
if err != nil {
@@ -30,10 +22,10 @@ func GetSettings(c *gin.Context) {
// 默认值:站点信息取配置文件,评论默认显示、每页 10 条
settings := map[string]string{
OptionSiteName: cfg.App.Name,
OptionSiteDesc: cfg.App.Description,
OptionShowComments: "1",
OptionCommentPageSize: "10",
models.OptionSiteName: cfg.App.Name,
models.OptionSiteDesc: cfg.App.Description,
models.OptionShowComments: "1",
models.OptionCommentPageSize: "10",
}
for name := range settings {
if v, ok := opts[name]; ok && v != "" {
@@ -65,10 +57,10 @@ func UpdateSettings(c *gin.Context) {
showComments = "1"
}
options := map[string]string{
OptionSiteName: req.SiteName,
OptionSiteDesc: req.SiteDesc,
OptionShowComments: showComments,
OptionCommentPageSize: strconv.Itoa(req.CommentsPerPage),
models.OptionSiteName: req.SiteName,
models.OptionSiteDesc: req.SiteDesc,
models.OptionShowComments: showComments,
models.OptionCommentPageSize: strconv.Itoa(req.CommentsPerPage),
}
if err := models.BatchSetOptions(database.DB, 0, options); err != nil {
+1 -1
View File
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"fmt"
@@ -1,4 +1,4 @@
package handlers
package backend
import (
"net/http"
+5 -11
View File
@@ -1,4 +1,4 @@
package handlers
package frontend
import (
"math"
@@ -26,10 +26,10 @@ func getConfig() *config.Config {
func loadSiteInfo(cfg *config.Config) (siteName, siteDesc string) {
siteName = cfg.App.Name
siteDesc = cfg.App.Description
if v, err := models.GetOptionValue(database.DB, OptionSiteName, 0); err == nil && v != "" {
if v, err := models.GetOptionValue(database.DB, models.OptionSiteName, 0); err == nil && v != "" {
siteName = v
}
if v, err := models.GetOptionValue(database.DB, OptionSiteDesc, 0); err == nil && v != "" {
if v, err := models.GetOptionValue(database.DB, models.OptionSiteDesc, 0); err == nil && v != "" {
siteDesc = v
}
return
@@ -98,12 +98,6 @@ func IndexView(c *gin.Context) {
db := database.DB.Model(&models.Post{}).Preload("Category").Preload("Tags").Preload("Author").
Where("status = ?", "published")
userId, exists := c.Get("userID")
if exists {
db = db.Or("author_id = ?", userId)
}
if categoryID > 0 {
db = db.Where("category_id = ?", categoryID)
}
@@ -212,11 +206,11 @@ func PostView(c *gin.Context) {
// 读取评论展示设置
showComments := true
if v, optErr := models.GetOptionValue(database.DB, OptionShowComments, 0); optErr == nil {
if v, optErr := models.GetOptionValue(database.DB, models.OptionShowComments, 0); optErr == nil {
showComments = v != "0"
}
commentPageSize := 10
if v, optErr := models.GetOptionValue(database.DB, OptionCommentPageSize, 0); optErr == nil {
if v, optErr := models.GetOptionValue(database.DB, models.OptionCommentPageSize, 0); optErr == nil {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
commentPageSize = n
}
+5 -6
View File
@@ -1,11 +1,10 @@
package middleware
import (
"goblog/handlers/backend"
"net/http"
"strings"
"goblog/handlers"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
@@ -36,10 +35,10 @@ func JWTAuth() gin.HandlerFunc {
return
}
claims := &handlers.Claims{}
claims := &backend.Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return handlers.JWTSecret(), nil
return backend.JWTSecret(), nil
})
if err != nil || !token.Valid {
@@ -77,10 +76,10 @@ func OptionalAuth() gin.HandlerFunc {
return
}
claims := &handlers.Claims{}
claims := &backend.Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return handlers.JWTSecret(), nil
return backend.JWTSecret(), nil
})
if err == nil && token.Valid {
+8
View File
@@ -10,6 +10,14 @@ import (
const PasswordCryptLevel = 12
// 站点基础设置键名
const (
OptionSiteName = "site_name"
OptionSiteDesc = "site_desc"
OptionShowComments = "show_comments" // "1" 显示评论 / "0" 隐藏评论
OptionCommentPageSize = "comments_per_page" // 前台评论每页条数
)
// ==================== 用户模型 ====================
type User struct {
+67 -63
View File
@@ -3,7 +3,8 @@ package routers
import (
"strconv"
"goblog/handlers"
"goblog/handlers/backend"
"goblog/handlers/frontend"
"goblog/middleware"
"goblog/utils"
@@ -18,74 +19,77 @@ func SetupRoutes(r *gin.Engine) {
// 后台管理(放在前面避免冲突)
r.Static("/admin/static", "./static/admin")
// 独立登录页
r.GET("/admin/login", handlers.AdminLoginView)
r.GET("/admin/login", backend.AdminLoginView)
// 后台首页:可选认证中间件注入用户信息,服务端按角色渲染菜单与页面框架
r.GET("/admin", middleware.OptionalAuth(), handlers.AdminView)
r.GET("/admin", middleware.OptionalAuth(), backend.AdminView)
// 前端页面路由(可选认证:侧边栏根据登录态显示系统菜单)
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)
r.GET("/", middleware.OptionalAuth(), frontend.IndexView)
r.GET("/post/:slug", middleware.OptionalAuth(), frontend.PostView)
r.GET("/page/:slug", middleware.OptionalAuth(), frontend.PageView)
r.GET("/search", middleware.OptionalAuth(), frontend.SearchView)
r.POST("/comment", frontend.SubmitComment)
// 前台退出登录:清除 Cookie 后回首页
r.GET("/logout", handlers.WebLogout)
r.GET("/logout", backend.WebLogout)
// API 路由组
api := r.Group("/api")
{
// 公开 API(支持可选认证,以便管理员查看所有文章)
api.GET("/posts", middleware.OptionalAuth(), handlers.GetPosts)
api.GET("/posts/:id", middleware.OptionalAuth(), handlers.GetPost)
api.GET("/categories", handlers.GetCategories)
api.GET("/categories/:id", handlers.GetCategory)
api.GET("/tags", handlers.GetTags)
api.GET("/tags/:id", handlers.GetTag)
api.GET("/pages", handlers.GetPages)
api.GET("/pages/:id", handlers.GetPage)
api.GET("/comments", handlers.GetComments)
api.GET("/posts", middleware.OptionalAuth(), backend.GetPosts)
api.GET("/posts/:id", middleware.OptionalAuth(), backend.GetPost)
api.GET("/categories", backend.GetCategories)
api.GET("/categories/:id", backend.GetCategory)
api.GET("/tags", backend.GetTags)
api.GET("/tags/:id", backend.GetTag)
api.GET("/pages", backend.GetPages)
api.GET("/pages/:id", backend.GetPage)
api.GET("/comments", backend.GetComments)
// 需要认证的 API
api.POST("/comments", middleware.OptionalAuth(), handlers.CreateComment)
api.POST("/comments", middleware.OptionalAuth(), backend.CreateComment)
// 用户认证
api.POST("/auth/login", handlers.Login)
api.POST("/auth/logout", handlers.Logout)
api.POST("/auth/login", backend.Login)
api.POST("/auth/logout", backend.Logout)
// 需要登录的 API
auth := api.Group("/")
auth.Use(middleware.JWTAuth())
{
auth.GET("/auth/me", handlers.GetCurrentUser)
auth.PUT("/auth/me", handlers.UpdateUser)
auth.PUT("/auth/password", handlers.ChangePassword)
auth.GET("/auth/me", backend.GetCurrentUser)
// 仪表盘统计(所有登录用户可访问,handler 内部按角色过滤数据)
auth.GET("/dashboard", middleware.OptionalAuth(), backend.GetDashboardStats)
auth.PUT("/auth/me", backend.UpdateUser)
auth.PUT("/auth/password", backend.ChangePassword)
// 文章管理(所有登录用户可新建文章,修改/删除自己的文章)
auth.POST("/posts", handlers.CreatePost)
auth.PUT("/posts/:id", handlers.UpdatePost)
auth.DELETE("/posts/:id", handlers.DeletePost)
auth.POST("/posts", backend.CreatePost)
auth.PUT("/posts/:id", backend.UpdatePost)
auth.DELETE("/posts/:id", backend.DeletePost)
// Markdown 预览(文章编辑器用)
auth.POST("/markdown/preview", handlers.PreviewMarkdown)
auth.POST("/markdown/preview", backend.PreviewMarkdown)
// 文件上传(文章编辑器封面图等)
auth.POST("/upload", handlers.UploadImage)
auth.POST("/upload", backend.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)
auth.GET("/my/comments", backend.GetComments)
auth.PUT("/my/comments/:id/approve", backend.ApproveComment)
auth.PUT("/my/comments/:id/spam", backend.MarkSpamComment)
auth.DELETE("/my/comments/:id", backend.DeleteComment)
}
// 主题管理(可选认证)
api.GET("/themes", handlers.GetThemes)
api.GET("/theme", handlers.GetCurrentTheme)
api.POST("/theme", middleware.JWTAuth(), handlers.SwitchTheme)
api.POST("/themes/create", middleware.JWTAuth(), handlers.CreateTheme)
api.GET("/themes/:theme/files", middleware.JWTAuth(), handlers.GetThemeFiles)
api.GET("/themes/:theme/files/:file", middleware.JWTAuth(), handlers.GetThemeFile)
api.PUT("/themes/:theme/files/:file", middleware.JWTAuth(), handlers.SaveThemeFile)
api.GET("/themes", backend.GetThemes)
api.GET("/theme", backend.GetCurrentTheme)
api.POST("/theme", middleware.JWTAuth(), backend.SwitchTheme)
api.POST("/themes/create", middleware.JWTAuth(), backend.CreateTheme)
api.GET("/themes/:theme/files", middleware.JWTAuth(), backend.GetThemeFiles)
api.GET("/themes/:theme/files/:file", middleware.JWTAuth(), backend.GetThemeFile)
api.PUT("/themes/:theme/files/:file", middleware.JWTAuth(), backend.SaveThemeFile)
// 管理员 API
admin := api.Group("/admin")
@@ -94,41 +98,41 @@ func SetupRoutes(r *gin.Engine) {
// Markdown 预览(文章编辑器用)已移至登录组
// 分类管理
admin.POST("/categories", handlers.CreateCategory)
admin.PUT("/categories/:id", handlers.UpdateCategory)
admin.DELETE("/categories/:id", handlers.DeleteCategory)
admin.POST("/categories", backend.CreateCategory)
admin.PUT("/categories/:id", backend.UpdateCategory)
admin.DELETE("/categories/:id", backend.DeleteCategory)
// 标签管理
admin.POST("/tags", handlers.CreateTag)
admin.PUT("/tags/:id", handlers.UpdateTag)
admin.DELETE("/tags/:id", handlers.DeleteTag)
admin.POST("/tags", backend.CreateTag)
admin.PUT("/tags/:id", backend.UpdateTag)
admin.DELETE("/tags/:id", backend.DeleteTag)
// 页面管理
admin.POST("/pages", handlers.CreatePage)
admin.PUT("/pages/:id", handlers.UpdatePage)
admin.DELETE("/pages/:id", handlers.DeletePage)
admin.POST("/pages", backend.CreatePage)
admin.PUT("/pages/:id", backend.UpdatePage)
admin.DELETE("/pages/:id", backend.DeletePage)
// 评论管理
admin.GET("/comments", handlers.GetComments)
admin.PUT("/comments/:id/approve", handlers.ApproveComment)
admin.PUT("/comments/:id/spam", handlers.MarkSpamComment)
admin.DELETE("/comments/:id", handlers.DeleteComment)
admin.GET("/comments", backend.GetComments)
admin.PUT("/comments/:id/approve", backend.ApproveComment)
admin.PUT("/comments/:id/spam", backend.MarkSpamComment)
admin.DELETE("/comments/:id", backend.DeleteComment)
// 用户管理
admin.GET("/users", handlers.GetUsers)
admin.POST("/users", handlers.CreateUser)
admin.PUT("/users/:id", handlers.AdminUpdateUser)
admin.PUT("/users/:id/password", handlers.AdminResetPassword)
admin.DELETE("/users/:id", handlers.DeleteUser)
admin.GET("/users", backend.GetUsers)
admin.POST("/users", backend.CreateUser)
admin.PUT("/users/:id", backend.AdminUpdateUser)
admin.PUT("/users/:id/password", backend.AdminResetPassword)
admin.DELETE("/users/:id", backend.DeleteUser)
// 基础设置
admin.GET("/settings", handlers.GetSettings)
admin.PUT("/settings", handlers.UpdateSettings)
admin.GET("/settings", backend.GetSettings)
admin.PUT("/settings", backend.UpdateSettings)
// 文件管理(图片上传)
admin.POST("/upload", handlers.UploadImage)
admin.GET("/uploads", handlers.ListUploads)
admin.DELETE("/uploads/:name", handlers.DeleteUpload)
admin.POST("/upload", backend.UploadImage)
admin.GET("/uploads", backend.ListUploads)
admin.DELETE("/uploads/:name", backend.DeleteUpload)
}
}
}
+63 -16
View File
@@ -4,6 +4,32 @@ const API_BASE = '/api';
// 当前登录用户
let currentUser = null;
// ============ Hash 路由 ============
// 从 URL hash 解析当前页面名(支持 #/posts、#/posts/5 等形式)
function currentHashPage() {
const hash = location.hash.replace(/^#\/?/, '').split('/')[0];
return hash || 'dashboard';
}
// 由 showPage 主动触发的 hash 变更(hashchange 事件中据此跳过,避免重复加载)
let internalHashChange = false;
// 切换页面时同步 URL hash
function setHashPage(pageName) {
if (location.hash !== '#/' + pageName) {
internalHashChange = true;
location.hash = '#/' + pageName;
}
}
// 同步侧边栏菜单高亮
function syncMenuActive(pageName) {
document.querySelectorAll('.menu-item').forEach(i => {
i.classList.toggle('active', i.dataset.page === pageName);
});
}
// 跳转到登录页
function goLogin() {
currentUser = null;
@@ -27,17 +53,38 @@ document.addEventListener('DOMContentLoaded', () => {
// 向服务端校验登录态与角色(不信任本地缓存,防止残留会话导致权限误判)
refreshCurrentUser();
showPage('dashboard');
// 按 URL hash 显示初始页面(默认仪表盘;无权限/不存在时静默回退)
const initialPage = currentHashPage();
if (document.getElementById(initialPage + '-page')) {
showPage(initialPage);
} else {
showPage('dashboard');
}
// Hash 路由:浏览器前进/后退或手动改 hash 时切换页面
window.addEventListener('hashchange', () => {
if (internalHashChange) {
// 由 showPage 主动设置,页面已切换,跳过
internalHashChange = false;
return;
}
const page = currentHashPage();
if (!document.getElementById(page + '-page')) {
// 无权限或不存在时回退仪表盘,避免空白/报错
setHashPage('dashboard');
showPage('dashboard');
return;
}
showPage(page);
});
// 绑定菜单点击事件(菜单由服务端模板渲染,只包含有权限的页面)
document.querySelectorAll('.menu-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
const page = item.dataset.page;
setHashPage(page);
showPage(page);
document.querySelectorAll('.menu-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
});
});
@@ -111,6 +158,9 @@ async function refreshCurrentUser() {
// 切换页面(页面框架由服务端模板渲染,未渲染的页面不存在也不加载数据)
function showPage(pageName) {
setHashPage(pageName);
syncMenuActive(pageName);
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
const targetPage = document.getElementById(pageName + '-page');
if (!targetPage) {
@@ -174,20 +224,17 @@ function getHeaders() {
};
}
// 加载仪表盘
// 加载仪表盘(专用统计接口,只返回数字)
async function loadDashboard() {
try {
const [posts, categories, tags, comments] = await Promise.all([
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(`${commentsAPIBase()}/comments?status=pending`).then(r => r.json())
]);
document.getElementById('stat-posts').textContent = posts.total || 0;
document.getElementById('stat-categories').textContent = categories.data?.length || 0;
document.getElementById('stat-tags').textContent = tags.data?.length || 0;
document.getElementById('stat-comments').textContent = comments.data?.length || 0;
const res = await fetch(`${API_BASE}/dashboard/stats`, { headers: getHeaders() });
if (!res.ok) return;
const data = await res.json();
document.getElementById('stat-posts').textContent = data.posts || 0;
document.getElementById('stat-categories').textContent = data.categories || 0;
document.getElementById('stat-tags').textContent = data.tags || 0;
document.getElementById('stat-comments').textContent = data.pending_comments || 0;
} catch (err) {
console.error('加载仪表盘失败:', err);
}
+9 -9
View File
@@ -51,7 +51,7 @@
{{if index .PageSet "themes"}}{{template "admin_modal_theme" .}}{{end}}
<script>window.__currentUser = {{.CurrentUser}};</script>
<script src="/static/admin/app.js?v=20"></script>
<script src="/static/admin/app.js?v=21"></script>
</body>
</html>
{{end}}
@@ -61,22 +61,22 @@
<div id="dashboard-page" class="page">
<h1>仪表盘</h1>
<div class="stats-grid">
<div class="stat-card">
<a href="#posts" class="stat-card" data-page="posts" onclick="showPage('posts')">
<h3>文章总数</h3>
<p class="stat-number" id="stat-posts">0</p>
</div>
<div class="stat-card">
</a>
<a href="#categories" class="stat-card" data-page="categories" onclick="showPage('categories')">
<h3>分类总数</h3>
<p class="stat-number" id="stat-categories">0</p>
</div>
<div class="stat-card">
</a>
<a href="#tags" class="stat-card" data-page="tags" onclick="showPage('tags')">
<h3>标签总数</h3>
<p class="stat-number" id="stat-tags">0</p>
</div>
<div class="stat-card">
</a>
<a href="#comments" class="stat-card" data-page="comments" onclick="showPage('comments')">
<h3>待审核评论</h3>
<p class="stat-number" id="stat-comments">0</p>
</div>
</a>
</div>
</div>
{{end}}
+10
View File
@@ -199,6 +199,16 @@ body {
gap: 24px;
}
a.stat-card {
text-decoration: none;
color: inherit;
cursor: pointer;
transition: box-shadow 0.2s, transform 0.15s;
}
a.stat-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.13);
transform: translateY(-2px);
}
.stat-card {
background: #fff;
padding: 24px;