feat: 实现内容系统 MVC(Post CRUD + 审核流转)
按 content-system.md 设计文档,实现帖子内容系统的最小可行 MVC。 新增: - Model: Post 模型,含 5 种状态(draft/pending/approved/rejected/locked) - Repository: post_repo.go,分页查询(前台 approved 列表 + 后台全状态筛选) - Service: post_service.go,核心业务逻辑(状态流转、Markdown 渲染 Goldmark) - Controller: post_controller + admin_post_controller,SSR 页面 6 个 + API 13 个 - Template: 列表/详情/编辑器/404 + 管理员列表,各配 CSS/JS - 路由: /posts, /posts/:id, /posts/new, /posts/:id/edit, REST API, Admin API - gomod: + github.com/yuin/goldmark v1.8.2 修改: - Main: AutoMigrate Post 表 - Router: deps 注册 PostService/Controller,路由注册 - Nav: 新增“帖子”导航链接 - Admin Sidebar: 新增“内容管理”入口 设计: - 审核开关复用 audit.enabled,开启时帖子为 draft,关闭后直接 approved - 仅 approved 公开可见,作者/admin 可预览非公开状态帖子 - 严格分层 ISP 接口: Controller → postUseCase, Service → postStore, Repo → *PostRepo - 状态保护: pending/locked 禁止编辑,rejected 编辑后自动重置为 draft
This commit is contained in:
@ -25,7 +25,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}); err != nil {
|
if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}); err != nil {
|
||||||
log.Fatalf("数据库迁移失败: %v", err)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -49,6 +49,7 @@ require (
|
|||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -109,6 +109,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
|||||||
149
internal/controller/admin/admin_post_controller.go
Normal file
149
internal/controller/admin/admin_post_controller.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminPostController 管理后台帖子控制器
|
||||||
|
type AdminPostController struct {
|
||||||
|
postService adminPostUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminPostController 构造函数
|
||||||
|
func NewAdminPostController(ps adminPostUseCase) *AdminPostController {
|
||||||
|
return &AdminPostController{postService: ps}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostsPage 管理后台帖子列表页
|
||||||
|
func (ctrl *AdminPostController) PostsPage(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
status := c.Query("status")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.ListAdmin(keyword, status, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "内容管理",
|
||||||
|
"Error": "加载帖子列表失败",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if posts == nil {
|
||||||
|
posts = []model.Post{}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
|
||||||
|
prevPage := p.Page - 1
|
||||||
|
if prevPage < 1 { prevPage = 1 }
|
||||||
|
nextPage := p.Page + 1
|
||||||
|
if nextPage > totalPages { nextPage = totalPages }
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "admin/posts/index.html", common.BuildAdminPageData(c, gin.H{
|
||||||
|
"Title": "内容管理",
|
||||||
|
"Posts": posts,
|
||||||
|
"Total": total,
|
||||||
|
"Page": p.Page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"PrevPage": prevPage,
|
||||||
|
"NextPage": nextPage,
|
||||||
|
"Keyword": keyword,
|
||||||
|
"Status": status,
|
||||||
|
"ExtraCSS": "/admin/static/css/posts.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve 审核通过
|
||||||
|
func (ctrl *AdminPostController) Approve(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Approve(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "审核通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject 退回
|
||||||
|
func (ctrl *AdminPostController) Reject(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.PostRejectRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "请填写退回理由")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已退回")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock 锁定
|
||||||
|
func (ctrl *AdminPostController) Lock(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Lock(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已锁定")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock 解锁
|
||||||
|
func (ctrl *AdminPostController) Unlock(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Unlock(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已解锁")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 恢复软删除
|
||||||
|
func (ctrl *AdminPostController) Restore(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Restore(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已恢复")
|
||||||
|
}
|
||||||
@ -32,3 +32,13 @@ type siteSettingUseCase interface {
|
|||||||
type auditStatusProvider interface {
|
type auditStatusProvider interface {
|
||||||
FindByID(id uint) (*model.User, error)
|
FindByID(id uint) (*model.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminPostUseCase AdminPostController 对 PostService 的最小依赖(ISP:6 个方法)
|
||||||
|
type adminPostUseCase interface {
|
||||||
|
ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
|
Approve(postID uint) error
|
||||||
|
Reject(postID uint, reason string) error
|
||||||
|
Lock(postID uint) error
|
||||||
|
Unlock(postID uint) error
|
||||||
|
Restore(postID uint) error
|
||||||
|
}
|
||||||
|
|||||||
@ -24,3 +24,14 @@ type rateLimiter interface {
|
|||||||
AllowIP(ip string) (middleware.RateLimitResult, func())
|
AllowIP(ip string) (middleware.RateLimitResult, func())
|
||||||
Clear(email, ip string)
|
Clear(email, ip string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postUseCase PostController 对 PostService 的最小依赖(ISP:8 个方法)
|
||||||
|
type postUseCase interface {
|
||||||
|
Create(userID uint, title, body string) (*model.Post, error)
|
||||||
|
GetByID(id uint) (*model.Post, error)
|
||||||
|
List(keyword string, page, pageSize int) ([]model.Post, int64, error)
|
||||||
|
Update(postID uint, title, body string) error
|
||||||
|
Delete(postID uint) error
|
||||||
|
SubmitForAudit(postID uint) error
|
||||||
|
RenderMarkdown(body string) (string, error)
|
||||||
|
}
|
||||||
|
|||||||
319
internal/controller/post_controller.go
Normal file
319
internal/controller/post_controller.go
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostController 帖子控制器(SSR 页面 + API)
|
||||||
|
type PostController struct {
|
||||||
|
postService postUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostController 构造函数
|
||||||
|
func NewPostController(ps postUseCase) *PostController {
|
||||||
|
return &PostController{postService: ps}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPage 帖子列表页
|
||||||
|
func (ctrl *PostController) ListPage(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "社区帖子",
|
||||||
|
"Error": "加载帖子列表失败",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if posts == nil {
|
||||||
|
posts = []model.Post{}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
|
||||||
|
prevPage := p.Page - 1
|
||||||
|
if prevPage < 1 { prevPage = 1 }
|
||||||
|
nextPage := p.Page + 1
|
||||||
|
if nextPage > totalPages { nextPage = totalPages }
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "社区帖子",
|
||||||
|
"Posts": posts,
|
||||||
|
"Total": total,
|
||||||
|
"Page": p.Page,
|
||||||
|
"TotalPages": totalPages,
|
||||||
|
"PrevPage": prevPage,
|
||||||
|
"NextPage": nextPage,
|
||||||
|
"Keyword": keyword,
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowPage 帖子详情页
|
||||||
|
func (ctrl *PostController) ShowPage(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
var uid uint
|
||||||
|
var role string
|
||||||
|
if uidObj, exists := c.Get("uid"); exists {
|
||||||
|
uid, _ = uidObj.(uint)
|
||||||
|
}
|
||||||
|
if roleObj, exists := c.Get("role"); exists {
|
||||||
|
role, _ = roleObj.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
||||||
|
if post.Status != model.PostStatusApproved {
|
||||||
|
isAuthor := uid == post.UserID
|
||||||
|
isModerator := model.HasMinRole(role, model.RoleModerator)
|
||||||
|
|
||||||
|
if !isAuthor && !isModerator {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": post.Title,
|
||||||
|
"Post": post,
|
||||||
|
"UID": uid,
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPage 发帖页面
|
||||||
|
func (ctrl *PostController) NewPage(c *gin.Context) {
|
||||||
|
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "撰写帖子",
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
"ExtraJS": "/static/js/editor.js",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditPage 编辑页面
|
||||||
|
func (ctrl *PostController) EditPage(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil || post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
|
||||||
|
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "未找到或无法编辑",
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
||||||
|
"Title": "编辑帖子",
|
||||||
|
"Post": post,
|
||||||
|
"ExtraCSS": "/static/css/posts.css",
|
||||||
|
"ExtraJS": "/static/js/editor.js",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create API 发帖
|
||||||
|
func (ctrl *PostController) Create(c *gin.Context) {
|
||||||
|
uidObj, _ := c.Get("uid")
|
||||||
|
uid, ok := uidObj.(uint)
|
||||||
|
if !ok {
|
||||||
|
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.PostCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "发帖失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkWithMessage(c, post, "发布成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update API 编辑帖子
|
||||||
|
func (ctrl *PostController) Update(c *gin.Context) {
|
||||||
|
uidObj, _ := c.Get("uid")
|
||||||
|
uid, _ := uidObj.(uint)
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.PostUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限检查:取帖子归属
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roleObj, _ := c.Get("role")
|
||||||
|
role, _ := roleObj.(string)
|
||||||
|
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权编辑此帖子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "编辑成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete API 删除帖子
|
||||||
|
func (ctrl *PostController) Delete(c *gin.Context) {
|
||||||
|
uidObj, _ := c.Get("uid")
|
||||||
|
uid, _ := uidObj.(uint)
|
||||||
|
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
roleObj, _ := c.Get("role")
|
||||||
|
role, _ := roleObj.(string)
|
||||||
|
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
|
||||||
|
common.Error(c, http.StatusForbidden, "无权删除此帖子")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "删除成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit API 提交审核
|
||||||
|
func (ctrl *PostController) Submit(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.OkMessage(c, "已提交审核")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAPI 帖子列表 JSON API
|
||||||
|
func (ctrl *PostController) ListAPI(c *gin.Context) {
|
||||||
|
keyword := c.Query("keyword")
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if posts == nil {
|
||||||
|
posts = []model.Post{}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
|
||||||
|
|
||||||
|
common.Ok(c, model.PostListResult{
|
||||||
|
Items: posts,
|
||||||
|
Total: total,
|
||||||
|
Page: p.Page,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowAPI 帖子详情 JSON API
|
||||||
|
func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := ctrl.postService.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewAPI Markdown 预览 API
|
||||||
|
func (ctrl *PostController) PreviewAPI(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Body string `json:"body" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
common.Error(c, http.StatusBadRequest, "正文不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := ctrl.postService.RenderMarkdown(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
common.Error(c, http.StatusInternalServerError, "渲染失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
common.Ok(c, gin.H{"html": html})
|
||||||
|
}
|
||||||
76
internal/model/post.go
Normal file
76
internal/model/post.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Post 帖子/文章模型
|
||||||
|
type Post struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
Title string `gorm:"type:varchar(200);not null" json:"title"`
|
||||||
|
Body string `gorm:"type:text" json:"body"`
|
||||||
|
BodyHTML string `gorm:"type:text" json:"body_html"`
|
||||||
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
|
Status string `gorm:"type:varchar(20);index;default:draft;not null" json:"status"`
|
||||||
|
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||||
|
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|
||||||
|
// 非数据库字段(联表查询填充)
|
||||||
|
AuthorName string `gorm:"-:all" json:"author_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 帖子状态常量
|
||||||
|
const (
|
||||||
|
PostStatusDraft = "draft"
|
||||||
|
PostStatusPending = "pending"
|
||||||
|
PostStatusApproved = "approved"
|
||||||
|
PostStatusRejected = "rejected"
|
||||||
|
PostStatusLocked = "locked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostStatusDisplayNames 状态 → 中文名
|
||||||
|
var PostStatusDisplayNames = map[string]string{
|
||||||
|
PostStatusDraft: "草稿",
|
||||||
|
PostStatusPending: "待审核",
|
||||||
|
PostStatusApproved: "已发布",
|
||||||
|
PostStatusRejected: "已退回",
|
||||||
|
PostStatusLocked: "已锁定",
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostListResult 帖子列表查询结果
|
||||||
|
type PostListResult struct {
|
||||||
|
Items []Post `json:"items"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
TotalPages int `json:"total_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostCreateRequest 发帖请求
|
||||||
|
type PostCreateRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||||
|
Body string `json:"body" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostUpdateRequest 编辑请求
|
||||||
|
type PostUpdateRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,min=1,max=200"`
|
||||||
|
Body string `json:"body" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostRejectRequest 退回请求
|
||||||
|
type PostRejectRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required,min=1,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostListQuery 列表查询参数
|
||||||
|
type PostListQuery struct {
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
Status string `form:"status"`
|
||||||
|
Page int `form:"page"`
|
||||||
|
PageSize int `form:"page_size"`
|
||||||
|
}
|
||||||
117
internal/repository/post_repo.go
Normal file
117
internal/repository/post_repo.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PostRepo 帖子数据访问
|
||||||
|
type PostRepo struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostRepo 构造函数
|
||||||
|
func NewPostRepo(db *gorm.DB) *PostRepo {
|
||||||
|
return &PostRepo{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建帖子
|
||||||
|
func (r *PostRepo) Create(post *model.Post) error {
|
||||||
|
return r.db.Create(post).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByID 按 ID 查找帖子
|
||||||
|
func (r *PostRepo) FindByID(id uint) (*model.Post, error) {
|
||||||
|
var post model.Post
|
||||||
|
err := r.db.First(&post, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIDWithAuthor 按 ID 查找帖子(含作者用户名)
|
||||||
|
func (r *PostRepo) FindByIDWithAuthor(id uint) (*model.Post, error) {
|
||||||
|
var post model.Post
|
||||||
|
err := r.db.Table("posts").
|
||||||
|
Select("posts.*, users.username as author_name").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||||
|
Where("posts.id = ?", id).
|
||||||
|
First(&post).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindPageable 分页查询帖子列表(仅 approved 状态,关键词模糊搜索标题)
|
||||||
|
func (r *PostRepo) FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.db.Table("posts").
|
||||||
|
Select("posts.*, users.username as author_name").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||||
|
Where("posts.deleted_at IS NULL").
|
||||||
|
Where("posts.status = ?", model.PostStatusApproved)
|
||||||
|
|
||||||
|
if keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
query = query.Where("posts.title LIKE ?", like)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var posts []model.Post
|
||||||
|
err := query.Order("posts.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&posts).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAdminPageable 管理后台分页查询(全状态筛选)
|
||||||
|
func (r *PostRepo) FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) {
|
||||||
|
query := r.db.Table("posts").
|
||||||
|
Select("posts.*, users.username as author_name").
|
||||||
|
Joins("LEFT JOIN users ON users.uid = posts.user_id").
|
||||||
|
Where("posts.deleted_at IS NULL")
|
||||||
|
|
||||||
|
if keyword != "" {
|
||||||
|
like := "%" + keyword + "%"
|
||||||
|
query = query.Where("posts.title LIKE ?", like)
|
||||||
|
}
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("posts.status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var posts []model.Post
|
||||||
|
err := query.Order("posts.created_at DESC").
|
||||||
|
Offset(offset).Limit(limit).Find(&posts).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return posts, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新帖子
|
||||||
|
func (r *PostRepo) Update(post *model.Post) error {
|
||||||
|
return r.db.Save(post).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete 软删除帖子
|
||||||
|
func (r *PostRepo) SoftDelete(id uint) error {
|
||||||
|
return r.db.Delete(&model.Post{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 恢复软删除
|
||||||
|
func (r *PostRepo) Restore(id uint) error {
|
||||||
|
return r.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
middleware.RequirePageRole(model.RoleAdmin))
|
middleware.RequirePageRole(model.RoleAdmin))
|
||||||
adminPages.GET("/audits", d.auditCtrl.AuditPage,
|
adminPages.GET("/audits", d.auditCtrl.AuditPage,
|
||||||
middleware.RequirePageRole(model.RoleAdmin))
|
middleware.RequirePageRole(model.RoleAdmin))
|
||||||
|
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
|
||||||
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
|
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
|
||||||
middleware.RequirePageRole(model.RoleOwner))
|
middleware.RequirePageRole(model.RoleOwner))
|
||||||
}
|
}
|
||||||
@ -54,4 +55,17 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
|
siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting)
|
||||||
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
|
siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 帖子管理 API(admin+) ---
|
||||||
|
adminPostAPI := r.Group("/api/admin/posts")
|
||||||
|
adminPostAPI.Use(d.authMdw.Required())
|
||||||
|
adminPostAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||||
|
adminPostAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
adminPostAPI.POST("/:id/approve", d.adminPostCtrl.Approve)
|
||||||
|
adminPostAPI.POST("/:id/reject", d.adminPostCtrl.Reject)
|
||||||
|
adminPostAPI.POST("/:id/lock", d.adminPostCtrl.Lock)
|
||||||
|
adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock)
|
||||||
|
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,5 +41,21 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
api.POST("/auth/confirm-restore", d.tokenCtrl.ConfirmRestore)
|
api.POST("/auth/confirm-restore", d.tokenCtrl.ConfirmRestore)
|
||||||
api.POST("/auth/logout", d.tokenCtrl.Logout)
|
api.POST("/auth/logout", d.tokenCtrl.Logout)
|
||||||
api.POST("/auth/refresh", d.tokenCtrl.RefreshToken)
|
api.POST("/auth/refresh", d.tokenCtrl.RefreshToken)
|
||||||
|
|
||||||
|
// 帖子公开 API(无需登录)
|
||||||
|
api.GET("/posts", d.postCtrl.ListAPI)
|
||||||
|
api.GET("/posts/:id", d.postCtrl.ShowAPI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 帖子 API(需登录) ---
|
||||||
|
postsAPI := r.Group("/api/posts")
|
||||||
|
postsAPI.Use(d.authMdw.Required())
|
||||||
|
postsAPI.Use(middleware.CSRF(cfg))
|
||||||
|
{
|
||||||
|
postsAPI.POST("", d.postCtrl.Create)
|
||||||
|
postsAPI.PUT("/:id", d.postCtrl.Update)
|
||||||
|
postsAPI.DELETE("/:id", d.postCtrl.Delete)
|
||||||
|
postsAPI.POST("/:id/submit", d.postCtrl.Submit)
|
||||||
|
postsAPI.POST("/preview", d.postCtrl.PreviewAPI)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ type dependencies struct {
|
|||||||
authMdw *middleware.AuthMiddleware
|
authMdw *middleware.AuthMiddleware
|
||||||
settingsCtrl *controller.SettingsController
|
settingsCtrl *controller.SettingsController
|
||||||
messageCtrl *controller.MessageController
|
messageCtrl *controller.MessageController
|
||||||
|
postCtrl *controller.PostController
|
||||||
|
adminPostCtrl *adminCtrl.AdminPostController
|
||||||
adminCtrl *adminCtrl.AdminController
|
adminCtrl *adminCtrl.AdminController
|
||||||
auditCtrl *adminCtrl.AuditController
|
auditCtrl *adminCtrl.AuditController
|
||||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
siteSettingCtrl *adminCtrl.SiteSettingController
|
||||||
|
|||||||
@ -30,9 +30,15 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
postRepo := repository.NewPostRepo(db)
|
||||||
|
postService := service.NewPostService(postRepo, siteSettings)
|
||||||
|
postCtrl := controller.NewPostController(postService)
|
||||||
|
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
||||||
|
|
||||||
return &dependencies{
|
return &dependencies{
|
||||||
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
|
authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl,
|
||||||
messageCtrl: messageController, adminCtrl: adminController,
|
messageCtrl: messageController, postCtrl: postCtrl, adminPostCtrl: adminPostCtrl,
|
||||||
auditCtrl: auditController, siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
|
adminCtrl: adminController, auditCtrl: auditController,
|
||||||
|
siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,18 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
})
|
})
|
||||||
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
|
pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage)
|
||||||
pages.GET("/messages", d.messageCtrl.MessagesPage)
|
pages.GET("/messages", d.messageCtrl.MessagesPage)
|
||||||
|
|
||||||
|
// 帖子页面
|
||||||
|
pages.GET("/posts", d.postCtrl.ListPage)
|
||||||
|
pages.GET("/posts/:id", d.postCtrl.ShowPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 帖子页面(需登录)
|
||||||
|
postPages := r.Group("/posts")
|
||||||
|
postPages.Use(d.authMdw.Required())
|
||||||
|
{
|
||||||
|
postPages.GET("/new", d.postCtrl.NewPage)
|
||||||
|
postPages.GET("/:id/edit", d.postCtrl.EditPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证页面(已登录自动跳走)
|
// 认证页面(已登录自动跳走)
|
||||||
|
|||||||
212
internal/service/post_service.go
Normal file
212
internal/service/post_service.go
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"metazone.cc/metalab/internal/common"
|
||||||
|
"metazone.cc/metalab/internal/config"
|
||||||
|
"metazone.cc/metalab/internal/model"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postStore 帖子服务所需的最小仓储接口(ISP)
|
||||||
|
type postStore interface {
|
||||||
|
Create(post *model.Post) error
|
||||||
|
FindByID(id uint) (*model.Post, error)
|
||||||
|
FindByIDWithAuthor(id uint) (*model.Post, error)
|
||||||
|
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
|
||||||
|
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
|
||||||
|
Update(post *model.Post) error
|
||||||
|
SoftDelete(id uint) error
|
||||||
|
Restore(id uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostService 帖子业务逻辑
|
||||||
|
type PostService struct {
|
||||||
|
repo postStore
|
||||||
|
ss *config.SiteSettings
|
||||||
|
md goldmark.Markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostService 构造函数
|
||||||
|
func NewPostService(repo postStore, ss *config.SiteSettings) *PostService {
|
||||||
|
return &PostService{
|
||||||
|
repo: repo,
|
||||||
|
ss: ss,
|
||||||
|
md: goldmark.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auditEnabled 审核是否开启(文案审核与全局审核开关一致)
|
||||||
|
func (s *PostService) auditEnabled() bool {
|
||||||
|
return s.ss.IsAuditEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMarkdown 将 Markdown 渲染为 HTML
|
||||||
|
func (s *PostService) renderMarkdown(body string) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := s.md.Convert([]byte(body), &buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建帖子
|
||||||
|
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
|
||||||
|
bodyHTML, err := s.renderMarkdown(body)
|
||||||
|
if err != nil {
|
||||||
|
bodyHTML = body // 渲染失败降级使用纯文本
|
||||||
|
}
|
||||||
|
|
||||||
|
status := model.PostStatusApproved
|
||||||
|
if s.auditEnabled() {
|
||||||
|
status = model.PostStatusDraft
|
||||||
|
}
|
||||||
|
|
||||||
|
post := &model.Post{
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
BodyHTML: bodyHTML,
|
||||||
|
UserID: userID,
|
||||||
|
Status: status,
|
||||||
|
AllowComment: true,
|
||||||
|
}
|
||||||
|
if err := s.repo.Create(post); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID 按 ID 获取帖子(含作者名,仅 approved 公开可见)
|
||||||
|
func (s *PostService) GetByID(id uint) (*model.Post, error) {
|
||||||
|
post, err := s.repo.FindByIDWithAuthor(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, common.ErrUserNotFound // 复用哨兵表示资源不存在
|
||||||
|
}
|
||||||
|
return post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 公开帖子列表(仅 approved)
|
||||||
|
func (s *PostService) List(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
return s.repo.FindPageable(keyword, p.Offset(), p.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAdmin 管理后台帖子列表(全状态)
|
||||||
|
func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]model.Post, int64, error) {
|
||||||
|
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||||
|
p.DefaultPagination()
|
||||||
|
return s.repo.FindAdminPageable(keyword, status, p.Offset(), p.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 编辑帖子(权限在 controller 层检查)
|
||||||
|
func (s *PostService) Update(postID uint, title, body string) error {
|
||||||
|
post, err := s.repo.FindByID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// pending / locked 禁止编辑
|
||||||
|
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
|
||||||
|
return fmt.Errorf("当前状态不允许编辑")
|
||||||
|
}
|
||||||
|
|
||||||
|
post.Title = title
|
||||||
|
post.Body = body
|
||||||
|
|
||||||
|
bodyHTML, err := s.renderMarkdown(body)
|
||||||
|
if err != nil {
|
||||||
|
bodyHTML = body
|
||||||
|
}
|
||||||
|
post.BodyHTML = bodyHTML
|
||||||
|
|
||||||
|
// rejected 状态编辑后自动重置为 draft
|
||||||
|
if post.Status == model.PostStatusRejected {
|
||||||
|
post.Status = model.PostStatusDraft
|
||||||
|
post.RejectReason = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删除(权限在 controller 层检查)
|
||||||
|
func (s *PostService) Delete(postID uint) error {
|
||||||
|
return s.repo.SoftDelete(postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitForAudit 提交审核:draft → pending
|
||||||
|
func (s *PostService) SubmitForAudit(postID uint) error {
|
||||||
|
post, err := s.repo.FindByID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
|
||||||
|
return fmt.Errorf("仅草稿和已退回帖子可提交审核")
|
||||||
|
}
|
||||||
|
post.Status = model.PostStatusPending
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve 审核通过:pending → approved
|
||||||
|
func (s *PostService) Approve(postID uint) error {
|
||||||
|
post, err := s.repo.FindByID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if post.Status != model.PostStatusPending {
|
||||||
|
return fmt.Errorf("仅待审核帖子可通过")
|
||||||
|
}
|
||||||
|
post.Status = model.PostStatusApproved
|
||||||
|
post.RejectReason = ""
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject 退回:pending/approved → rejected
|
||||||
|
func (s *PostService) Reject(postID uint, reason string) error {
|
||||||
|
post, err := s.repo.FindByID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
|
||||||
|
return fmt.Errorf("仅待审核和已发布帖子可退回")
|
||||||
|
}
|
||||||
|
post.Status = model.PostStatusRejected
|
||||||
|
post.RejectReason = reason
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock 锁定:任意状态 → locked
|
||||||
|
func (s *PostService) Lock(postID uint) error {
|
||||||
|
post, err := s.repo.FindByID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
post.Status = model.PostStatusLocked
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock 解锁:locked → 草稿
|
||||||
|
func (s *PostService) Unlock(postID uint) error {
|
||||||
|
post, err := s.repo.FindByID(postID)
|
||||||
|
if err != nil {
|
||||||
|
return common.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if post.Status != model.PostStatusLocked {
|
||||||
|
return fmt.Errorf("仅锁定状态可解锁")
|
||||||
|
}
|
||||||
|
post.Status = model.PostStatusDraft
|
||||||
|
return s.repo.Update(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore 恢复软删除
|
||||||
|
func (s *PostService) Restore(postID uint) error {
|
||||||
|
return s.repo.Restore(postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderMarkdown 公开的 Markdown 渲染(供预览 API 使用,不写 DB)
|
||||||
|
func (s *PostService) RenderMarkdown(body string) (string, error) {
|
||||||
|
return s.renderMarkdown(body)
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class="nav-links" id="navLinks">
|
<ul class="nav-links" id="navLinks">
|
||||||
<li><a href="/">首页</a></li>
|
<li><a href="/">首页</a></li>
|
||||||
|
<li><a href="/posts">帖子</a></li>
|
||||||
{{if .IsLoggedIn}}
|
{{if .IsLoggedIn}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/messages" class="nav-messages" id="messagesBell" title="消息中心">
|
<a href="/messages" class="nav-messages" id="messagesBell" title="消息中心">
|
||||||
|
|||||||
14
templates/MetaLab-2026/html/posts/404.html
Normal file
14
templates/MetaLab-2026/html/posts/404.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{{template "layout/header.html" .}}
|
||||||
|
<body>
|
||||||
|
{{template "layout/nav.html" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="error-404">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>帖子不存在或已被删除</p>
|
||||||
|
<a href="/posts" class="btn btn-primary">返回帖子列表</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{template "layout/footer.html" .}}
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
templates/MetaLab-2026/html/posts/index.html
Normal file
58
templates/MetaLab-2026/html/posts/index.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{{template "layout/header.html" .}}
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{template "layout/nav.html" .}}
|
||||||
|
|
||||||
|
<div class="container posts-container">
|
||||||
|
<div class="posts-header">
|
||||||
|
<h1>社区帖子</h1>
|
||||||
|
{{if .IsLoggedIn}}
|
||||||
|
<a href="/posts/new" class="btn btn-primary">撰写帖子</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-message">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Keyword}}
|
||||||
|
<div class="search-hint">搜索:<strong>{{.Keyword}}</strong></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="posts-list">
|
||||||
|
{{if eq (len .Posts) 0}}
|
||||||
|
<div class="empty-state">暂无帖子</div>
|
||||||
|
{{else}}
|
||||||
|
{{range .Posts}}
|
||||||
|
<article class="post-card">
|
||||||
|
<h2 class="post-card-title">
|
||||||
|
<a href="/posts/{{.ID}}">{{.Title}}</a>
|
||||||
|
</h2>
|
||||||
|
<div class="post-card-meta">
|
||||||
|
<span class="post-author">{{.AuthorName}}</span>
|
||||||
|
<span class="post-time">{{.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
|
</div>
|
||||||
|
<p class="post-card-summary">{{printf "%.200s" .Body}}</p>
|
||||||
|
</article>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if gt .TotalPages 1}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a href="?page={{.PrevPage}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}" class="page-link">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页 (共 {{.Total}} 条)</span>
|
||||||
|
{{if lt .Page .TotalPages}}
|
||||||
|
<a href="?page={{.NextPage}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}" class="page-link">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
templates/MetaLab-2026/html/posts/new.html
Normal file
39
templates/MetaLab-2026/html/posts/new.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{{template "layout/header.html" .}}
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{template "layout/nav.html" .}}
|
||||||
|
|
||||||
|
<div class="container editor-container">
|
||||||
|
<h1 class="editor-title">{{if .Post}}编辑帖子{{else}}撰写帖子{{end}}</h1>
|
||||||
|
|
||||||
|
<form id="postForm" class="post-form">
|
||||||
|
<input type="hidden" id="postId" value="{{if .Post}}{{.Post.ID}}{{end}}">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="postTitle">标题</label>
|
||||||
|
<input type="text" id="postTitle" class="form-input" value="{{if .Post}}{{.Post.Title}}{{end}}" maxlength="200" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="postBody">正文 (Markdown)</label>
|
||||||
|
<textarea id="postBody" class="form-textarea" rows="20" required>{{if .Post}}{{.Post.Body}}{{end}}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button type="button" id="previewBtn" class="btn btn-secondary">预览</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{if .Post}}保存修改{{else}}发布帖子{{end}}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="previewArea" class="post-preview" style="display:none;">
|
||||||
|
<h3>预览</h3>
|
||||||
|
<div id="previewContent" class="post-detail-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
{{if .ExtraJS}}<script src="{{.ExtraJS}}"></script>{{end}}
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
templates/MetaLab-2026/html/posts/show.html
Normal file
50
templates/MetaLab-2026/html/posts/show.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{{template "layout/header.html" .}}
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{template "layout/nav.html" .}}
|
||||||
|
|
||||||
|
<div class="container post-detail">
|
||||||
|
<article class="post-article">
|
||||||
|
<header class="post-detail-header">
|
||||||
|
<h1 class="post-detail-title">{{.Post.Title}}</h1>
|
||||||
|
<div class="post-detail-meta">
|
||||||
|
<span class="post-author">{{.Post.AuthorName}}</span>
|
||||||
|
<span class="post-time">{{.Post.CreatedAt.Format "2006-01-02 15:04"}}</span>
|
||||||
|
{{if ne .Post.Status "approved"}}
|
||||||
|
<span class="post-status post-status-{{.Post.Status}}">{{printf "%s" .Post.Status}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="post-detail-body">
|
||||||
|
{{.Post.BodyHTML}}
|
||||||
|
{{/* 如果 BodyHTML 为空,直接显示原文 */}}
|
||||||
|
</div>
|
||||||
|
{{if not .Post.BodyHTML}}
|
||||||
|
<div class="post-detail-body-raw">{{.Post.Body}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Post.RejectReason}}
|
||||||
|
<div class="post-reject-reason">
|
||||||
|
<strong>退回理由:</strong>{{.Post.RejectReason}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{{if .IsLoggedIn}}
|
||||||
|
<div class="post-actions">
|
||||||
|
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (ne $.Post.Status "locked")}}
|
||||||
|
<a href="/posts/{{$.Post.ID}}/edit" class="btn btn-secondary">编辑</a>
|
||||||
|
{{end}}
|
||||||
|
{{if $.CanAccessAdmin}}
|
||||||
|
<a href="/admin/posts" class="btn btn-secondary">管理</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "layout/footer.html" .}}
|
||||||
|
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
327
templates/MetaLab-2026/static/css/posts.css
Normal file
327
templates/MetaLab-2026/static/css/posts.css
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
/* ========== 帖子列表 ========== */
|
||||||
|
.posts-container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.posts-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:hover {
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-title a {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-title a:hover {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card-summary {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 帖子详情 ========== */
|
||||||
|
.post-detail {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-header {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-body {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-body h1,
|
||||||
|
.post-detail-body h2,
|
||||||
|
.post-detail-body h3 {
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-body p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-body pre {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-body code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-detail-body blockquote {
|
||||||
|
border-left: 4px solid #6366f1;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-status {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-status-draft { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.post-status-pending { background: #fef3c7; color: #d97706; }
|
||||||
|
.post-status-approved { background: #d1fae5; color: #059669; }
|
||||||
|
.post-status-rejected { background: #fef2f2; color: #dc2626; }
|
||||||
|
.post-status-locked { background: #fee2e2; color: #b91c1c; }
|
||||||
|
|
||||||
|
.post-reject-reason {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 编辑器 ========== */
|
||||||
|
.editor-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-form .form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-form label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: 'Menlo', 'Consolas', monospace;
|
||||||
|
line-height: 1.6;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-preview {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-preview h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 404 ========== */
|
||||||
|
.error-404 {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-404 h1 {
|
||||||
|
font-size: 72px;
|
||||||
|
color: #d1d5db;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-404 p {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 12px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 空状态 ========== */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 按钮 ========== */
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: #4f46e5; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover { background: #e5e7eb; }
|
||||||
|
|
||||||
|
/* ========== 分页 ========== */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #374151;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link:hover { background: #f3f4f6; }
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 错误消息 ========== */
|
||||||
|
.error-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #b91c1c;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
85
templates/MetaLab-2026/static/js/editor.js
Normal file
85
templates/MetaLab-2026/static/js/editor.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
(function() {
|
||||||
|
var form = document.getElementById('postForm');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
var postId = document.getElementById('postId');
|
||||||
|
var titleEl = document.getElementById('postTitle');
|
||||||
|
var bodyEl = document.getElementById('postBody');
|
||||||
|
var previewBtn = document.getElementById('previewBtn');
|
||||||
|
var previewArea = document.getElementById('previewArea');
|
||||||
|
var previewContent = document.getElementById('previewContent');
|
||||||
|
|
||||||
|
var isEdit = postId && postId.value;
|
||||||
|
|
||||||
|
// 获取 CSRF token
|
||||||
|
function getCSRFToken() {
|
||||||
|
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return meta ? meta.getAttribute('content') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览
|
||||||
|
if (previewBtn) {
|
||||||
|
previewBtn.addEventListener('click', function() {
|
||||||
|
var body = bodyEl.value.trim();
|
||||||
|
if (!body) { alert('请输入正文'); return; }
|
||||||
|
|
||||||
|
fetch('/api/posts/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': getCSRFToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body: body })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
previewContent.innerHTML = data.data.html;
|
||||||
|
previewArea.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert(data.message || '预览失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { alert('预览请求失败'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var title = titleEl.value.trim();
|
||||||
|
var body = bodyEl.value.trim();
|
||||||
|
|
||||||
|
if (!title) { alert('请输入标题'); return; }
|
||||||
|
if (!body) { alert('请输入正文'); return; }
|
||||||
|
|
||||||
|
var url = '/api/posts';
|
||||||
|
var method = 'POST';
|
||||||
|
if (isEdit) {
|
||||||
|
url = '/api/posts/' + postId.value;
|
||||||
|
method = 'PUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': getCSRFToken()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title: title, body: body })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
if (data.data && data.data.id) {
|
||||||
|
window.location.href = '/posts/' + data.data.id;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/posts';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(data.message || '操作失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { alert('请求失败'); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
@ -39,6 +39,10 @@
|
|||||||
审核管理
|
审核管理
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<a href="/admin/posts" class="sidebar-item {{if eq .CurrentPath "/admin/posts"}}active{{end}}">
|
||||||
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||||||
|
内容管理
|
||||||
|
</a>
|
||||||
{{if .CanManageSettings}}
|
{{if .CanManageSettings}}
|
||||||
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
|
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
|
||||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||||
|
|||||||
83
templates/admin/html/posts/index.html
Normal file
83
templates/admin/html/posts/index.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{{template "admin/layout/base.html" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>内容管理</h1>
|
||||||
|
<p class="page-desc">管理所有社区帖子</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-filter-bar">
|
||||||
|
<form class="filter-form" method="GET" action="/admin/posts">
|
||||||
|
<input type="text" name="keyword" value="{{.Keyword}}" placeholder="搜索标题..." class="form-input filter-input">
|
||||||
|
<select name="status" class="form-select filter-select">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="draft" {{if eq .Status "draft"}}selected{{end}}>草稿</option>
|
||||||
|
<option value="pending" {{if eq .Status "pending"}}selected{{end}}>待审核</option>
|
||||||
|
<option value="approved" {{if eq .Status "approved"}}selected{{end}}>已发布</option>
|
||||||
|
<option value="rejected" {{if eq .Status "rejected"}}selected{{end}}>已退回</option>
|
||||||
|
<option value="locked" {{if eq .Status "locked"}}selected{{end}}>已锁定</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">搜索</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-message">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>作者</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{if eq (len .Posts) 0}}
|
||||||
|
<tr><td colspan="6" class="empty">暂无帖子</td></tr>
|
||||||
|
{{end}}
|
||||||
|
{{range .Posts}}
|
||||||
|
<tr class="post-row">
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td><a href="/posts/{{.ID}}" target="_blank" class="post-link">{{.Title}}</a></td>
|
||||||
|
<td>{{.AuthorName}}</td>
|
||||||
|
<td><span class="status-badge status-{{.Status}}">{{.Status}}</span></td>
|
||||||
|
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<div class="action-btns">
|
||||||
|
{{if eq .Status "pending"}}
|
||||||
|
<button class="btn btn-sm btn-success post-approve" data-id="{{.ID}}">通过</button>
|
||||||
|
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回</button>
|
||||||
|
{{end}}
|
||||||
|
{{if eq .Status "approved"}}
|
||||||
|
<button class="btn btn-sm btn-warning post-reject" data-id="{{.ID}}">退回</button>
|
||||||
|
{{end}}
|
||||||
|
{{if and (ne .Status "locked") (ne .Status "pending")}}
|
||||||
|
<button class="btn btn-sm btn-danger post-lock" data-id="{{.ID}}">锁定</button>
|
||||||
|
{{end}}
|
||||||
|
{{if eq .Status "locked"}}
|
||||||
|
<button class="btn btn-sm btn-success post-unlock" data-id="{{.ID}}">解锁</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{{if gt .TotalPages 1}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a href="?page={{.PrevPage}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}{{if .Status}}&status={{.Status}}{{end}}" class="page-link">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span class="page-info">第 {{.Page}} / {{.TotalPages}} 页 (共 {{.Total}} 条)</span>
|
||||||
|
{{if lt .Page .TotalPages}}
|
||||||
|
<a href="?page={{.NextPage}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}{{if .Status}}&status={{.Status}}{{end}}" class="page-link">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<script src="/admin/static/js/posts.js"></script>
|
||||||
|
{{template "admin/layout/footer.html" .}}
|
||||||
131
templates/admin/static/css/posts.css
Normal file
131
templates/admin/static/css/posts.css
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/* ========== 管理后台帖子列表 ========== */
|
||||||
|
|
||||||
|
.admin-filter-bar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table .empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-draft { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.status-pending { background: #fef3c7; color: #d97706; }
|
||||||
|
.status-approved { background: #d1fae5; color: #059669; }
|
||||||
|
.status-rejected { background: #fee2e2; color: #dc2626; }
|
||||||
|
.status-locked { background: #fee2e2; color: #b91c1c; }
|
||||||
|
|
||||||
|
.actions-cell { min-width: 180px; }
|
||||||
|
|
||||||
|
.action-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.btn-success { background: #10b981; color: #fff; }
|
||||||
|
.btn-warning { background: #f59e0b; color: #fff; }
|
||||||
|
.btn-danger { background: #ef4444; color: #fff; }
|
||||||
|
|
||||||
|
/* 分页 */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #374151;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-link:hover { background: #f3f4f6; }
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
61
templates/admin/static/js/posts.js
Normal file
61
templates/admin/static/js/posts.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
(function() {
|
||||||
|
function getCSRFToken() {
|
||||||
|
var meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return meta ? meta.getAttribute('content') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(url, method, body) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': getCSRFToken()
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
}).then(function(r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPage() { window.location.reload(); }
|
||||||
|
|
||||||
|
function handleClick(sel, actionFn) {
|
||||||
|
var btns = document.querySelectorAll(sel);
|
||||||
|
btns.forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
if (confirm('确定执行此操作吗?')) {
|
||||||
|
actionFn(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审核通过
|
||||||
|
handleClick('.post-approve', function(id) {
|
||||||
|
api('/api/admin/posts/' + id + '/approve', 'POST')
|
||||||
|
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||||
|
.catch(function() { alert('操作失败'); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 退回
|
||||||
|
handleClick('.post-reject', function(id) {
|
||||||
|
var reason = prompt('请输入退回理由(必填):');
|
||||||
|
if (!reason || !reason.trim()) return;
|
||||||
|
api('/api/admin/posts/' + id + '/reject', 'POST', { reason: reason.trim() })
|
||||||
|
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||||
|
.catch(function() { alert('操作失败'); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 锁定
|
||||||
|
handleClick('.post-lock', function(id) {
|
||||||
|
api('/api/admin/posts/' + id + '/lock', 'POST')
|
||||||
|
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||||
|
.catch(function() { alert('操作失败'); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 解锁
|
||||||
|
handleClick('.post-unlock', function(id) {
|
||||||
|
api('/api/admin/posts/' + id + '/unlock', 'POST')
|
||||||
|
.then(function(d) { if (d.success) refreshPage(); else alert(d.message); })
|
||||||
|
.catch(function() { alert('操作失败'); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user