- 引入 CodeMirror 6 (MIT) 替代 contenteditable WYSIWYG 编辑器,消除 execCommand 废弃 API 风险 - 全新全宽三栏布局:标题栏、工具栏、编辑区+可切换预览、状态栏 - 工具栏新增删除线/H1/H4/任务列表/表格/预览切换/全屏按钮 - 支持粘贴图片自动上传 (Ctrl+V)、拖拽上传、上传进度反馈 - 代码块语言下拉选择器替代 prompt() 弹窗,支持搜索和键盘导航 - 自动保存草稿 (30s 防抖,localStorage + 后端静默保存) - 分屏实时预览 (Ctrl+Shift+P),编辑器与预览区双向滚动同步 - 字数/行数/阅读时间统计、全屏编辑 (F11) - 快捷键:Ctrl+B/I/K/`/Shift+I/S/Shift+P + Tab/Shift+Tab - 删除旧的 htmlToMarkdown、syncMarkdownToWysiwyg、mode-tab 等废弃代码 - 后端 API 零改动,仅移除控制器中不再需要的 ExtraJS 字段
407 lines
10 KiB
Go
407 lines
10 KiB
Go
package controller
|
||
|
||
import (
|
||
"fmt"
|
||
"html/template"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"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,
|
||
"PostBodyHTML": template.HTML(post.BodyHTML),
|
||
"UID": uid,
|
||
"StatusNames": model.PostStatusDisplayNames,
|
||
"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",
|
||
}))
|
||
}
|
||
|
||
// 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",
|
||
}))
|
||
}
|
||
|
||
// 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})
|
||
}
|
||
|
||
// allowedImageExt 允许的图片扩展名
|
||
var allowedImageExt = map[string]bool{
|
||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||
}
|
||
|
||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||
uidObj, _ := c.Get("uid")
|
||
uid, ok := uidObj.(uint)
|
||
if !ok {
|
||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||
return
|
||
}
|
||
|
||
file, header, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// 检查扩展名
|
||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||
if !allowedImageExt[ext] {
|
||
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
|
||
return
|
||
}
|
||
|
||
// 限制 5MB
|
||
maxSize := int64(5 << 20)
|
||
if header.Size > maxSize {
|
||
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||
return
|
||
}
|
||
|
||
// 存储路径
|
||
storageDir := "storage/uploads/posts"
|
||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||
return
|
||
}
|
||
|
||
// 唯一文件名:uid_timestamp.ext
|
||
ts := time.Now().UnixMilli()
|
||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||
savePath := filepath.Join(storageDir, filename)
|
||
|
||
if err := saveUploadedFile(file, savePath); err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||
return
|
||
}
|
||
|
||
url := "/uploads/posts/" + filename
|
||
common.Ok(c, gin.H{"url": url})
|
||
}
|
||
|
||
// saveUploadedFile 将 multipart.File 写入目标路径
|
||
func saveUploadedFile(file multipart.File, dst string) error {
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer out.Close()
|
||
|
||
// 限制读取 5MB 防止内存放大
|
||
buf := make([]byte, 32*1024)
|
||
for {
|
||
n, err := file.Read(buf)
|
||
if n > 0 {
|
||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||
return writeErr
|
||
}
|
||
}
|
||
if err != nil {
|
||
break
|
||
}
|
||
}
|
||
return nil
|
||
}
|