+ │
+ ▼
+VDitor.preview() ← 前端:MD → HTML,占位 div 原样保留
+ │
+ ▼
+renderShortcodes() ← 前端 shortcode.js:扫描 .zone-card,渲染 UI 卡片
+```
+
+### 文件清单
+
+| 文件 | 职责 |
+|------|------|
+| `internal/model/shortcode.go` | 类型常量 + 正则 + DTO |
+| `internal/service/shortcode_service.go` | 解析器 + 占位 HTML 生成器 |
+| `internal/controller/post_controller.go` | ShowPage/ShowAPI 调用 Process() |
+| `internal/router/deps_extra.go` | DI 注入 ShortcodeService |
+| `templates/.../js/shortcode.js` | 前端卡片渲染器 |
+| `templates/.../html/posts/show.html` | 引入 shortcode.js + 调用 renderShortcodes() |
+| `templates/.../static/css/posts.css` | 卡片样式 |
+| `templates/.../static/js/editor.js` | hint 自动补全提示 |
+
+### 如何添加新类型
+
+1. `internal/model/shortcode.go`:注册 `ShortcodeType` 常量
+2. `internal/service/shortcode_service.go`:在 `renderPlaceholder()` 添加 case
+3. `templates/.../js/shortcode.js`:在 `cardRenderers` 注册渲染函数
+4. `templates/.../static/js/editor.js`:在 `hint.extend` 添加补全提示
+
+### 注意事项
+
+- 未注册或格式错误的 shortcode **不报错**,原文保留,避免破坏用户内容
+- 占位 div 结构为 `
`
+- 编辑器输入 `[zone:` 时自动弹出补全列表(Vditor hint.extend)
+- 后端 API 就绪后,将 `shortcode.js` 中的静态卡片替换为 `fetch()` 动态数据即可
+
+---
+
+## 不涉及的部分
+
+- `internal/router/api.go` — 路由不变(`POST /api/posts/upload-image` 端点不变)
+- `internal/router/frontend.go` — SSR 页面路由不变
+- `internal/repository/post_repo.go` — 仓储不变(Model 字段变更由 GORM 自动映射)
+- `internal/middleware/` — CSRF / Auth 中间件不变
+- `internal/config/` — 配置不变
+- `cmd/server/main.go` — `Static()` 映射不变(`/static` → `templates/.../static`)
+
+---
+
+## 注意事项
+
+1. **DB Schema**:GORM AutoMigrate 自动添加 `excerpt` 列;旧的 `body_html` 列残留但不影响运行(GORM 不会 DROP,可手动清理)
+2. **已有帖子**:旧 HTML body 在 MD 预览中会显示为 HTML 源码,可手动清理或通过 SQL 迁移
+3. **CSRF**:`upload.setHeaders` 为函数,每次上传前动态读取最新 token,不受 token 过期影响
+4. **时序**:Vditor 主文件先加载,lute/highlight.js/i18n 等由 Vditor 内部按需动态加载,无需手动控制
+5. **bluemonday**:已从 `go.mod` 中移除(`go mod tidy` 自动清理)
diff --git a/go.mod b/go.mod
index e90c669..714e34a 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,6 @@ require (
github.com/chai2010/webp v1.4.0
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
- github.com/microcosm-cc/bluemonday v1.0.27
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.52.0
golang.org/x/image v0.41.0
@@ -15,7 +14,6 @@ require (
)
require (
- github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -29,7 +27,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
- github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
diff --git a/go.sum b/go.sum
index 38c7e64..1fddb81 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,3 @@
-github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
-github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -42,8 +40,6 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
-github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -68,8 +64,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
-github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
diff --git a/internal/common/response.go b/internal/common/response.go
index 5d17f65..186ff48 100644
--- a/internal/common/response.go
+++ b/internal/common/response.go
@@ -22,3 +22,16 @@ func OkWithMessage(c *gin.Context, data interface{}, message string) {
func Error(c *gin.Context, code int, message string) {
c.JSON(code, gin.H{"success": false, "message": message})
}
+
+// VditorUploadOk Vditor 图片上传成功响应
+// succMap: key=原始文件名, value=文件 URL
+func VditorUploadOk(c *gin.Context, succMap map[string]string) {
+ c.JSON(http.StatusOK, gin.H{
+ "code": 0,
+ "msg": "",
+ "data": gin.H{
+ "errFiles": []string{},
+ "succMap": succMap,
+ },
+ })
+}
diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go
index 4b9d65c..7fb09e8 100644
--- a/internal/controller/post_controller.go
+++ b/internal/controller/post_controller.go
@@ -3,7 +3,6 @@ package controller
import (
"errors"
"fmt"
- "html/template"
"mime/multipart"
"net/http"
"os"
@@ -14,18 +13,20 @@ import (
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
+ "metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// PostController 帖子控制器(SSR 页面 + API)
type PostController struct {
- postService postUseCase
+ postService postUseCase
+ shortcodeSvc *service.ShortcodeService
}
// NewPostController 构造函数
-func NewPostController(ps postUseCase) *PostController {
- return &PostController{postService: ps}
+func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostController {
+ return &PostController{postService: ps, shortcodeSvc: scs}
}
// ListPage 帖子列表页
@@ -105,13 +106,19 @@ func (ctrl *PostController) ShowPage(c *gin.Context) {
}
}
+ // 处理 shortcode:[zone:type:params] → HTML 占位符
+ // 占位 div 会被 Vditor.preview() 保留,由前端 shortcode.js 渲染为卡片
+ if ctrl.shortcodeSvc != nil {
+ result := ctrl.shortcodeSvc.Process(post.Body)
+ post.Body = result.ProcessedBody
+ }
+
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",
+ "Title": post.Title,
+ "Post": post,
+ "UID": uid,
+ "StatusNames": model.PostStatusDisplayNames,
+ "ExtraCSS": "/static/css/posts.css",
}))
}
@@ -367,6 +374,12 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
}
}
+ // 处理 shortcode:[zone:type:params] → HTML 占位符
+ if ctrl.shortcodeSvc != nil {
+ result := ctrl.shortcodeSvc.Process(post.Body)
+ post.Body = result.ProcessedBody
+ }
+
common.Ok(c, post)
}
@@ -423,7 +436,7 @@ func (ctrl *PostController) UploadImage(c *gin.Context) {
}
url := "/uploads/posts/" + filename
- common.Ok(c, gin.H{"url": url})
+ common.VditorUploadOk(c, map[string]string{header.Filename: url})
}
// saveUploadedFile 将 multipart.File 写入目标路径
diff --git a/internal/model/post.go b/internal/model/post.go
index 8960a72..b5ca2b9 100644
--- a/internal/model/post.go
+++ b/internal/model/post.go
@@ -8,17 +8,17 @@ import (
// 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"`
+ ID uint `gorm:"primarykey" json:"id"`
+ Title string `gorm:"type:varchar(200);not null" json:"title"`
+ Body string `gorm:"type:text" json:"body"` // Markdown content
+ Excerpt string `gorm:"type:varchar(500)" json:"excerpt"` // plain text summary for list
+ 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"`
// 非数据库字段(联表查询填充)
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
diff --git a/internal/model/shortcode.go b/internal/model/shortcode.go
new file mode 100644
index 0000000..07b2edf
--- /dev/null
+++ b/internal/model/shortcode.go
@@ -0,0 +1,92 @@
+package model
+
+// =============================================================================
+// Shortcode — Markdown 扩展语法
+//
+// 用法:在 MD 正文中插入 [zone:类型:参数],渲染时替换为对应 UI 卡片。
+//
+// 支持的 shortcode:
+// [zone:event:活动ID] → 官方活动卡片(标题、时间、封面、链接)
+// [zone:game:游戏slug] → 游戏信息卡片(名称、封面、类型)
+// [zone:poll:投票ID] → 投票组件(预留)
+// [zone:resource:资源ID] → 资源卡片(工具/素材推荐,预留)
+//
+// 扩展现有类型:在 ShortcodeRegistry 中注册新类型 + 实现 Renderer 即可。
+// =============================================================================
+
+import "regexp"
+
+// =============================================================================
+// 语法常量
+// =============================================================================
+
+// ShortcodePattern 匹配所有 [zone:type:params] 模式的 shortcode
+// 捕获组:$1=类型, $2=参数
+var ShortcodePattern = regexp.MustCompile(`\[zone:(\w+):([^\]]+)\]`)
+
+// =============================================================================
+// 已注册的 Shortcode 类型
+// =============================================================================
+
+// ShortcodeType 定义一种 shortcode 的类型标识
+type ShortcodeType string
+
+const (
+ ShortcodeEvent ShortcodeType = "event" // [zone:event:活动ID]
+ ShortcodeGame ShortcodeType = "game" // [zone:game:游戏slug]
+ ShortcodePoll ShortcodeType = "poll" // [zone:poll:投票ID] ※预留(后端API未实现)
+ ShortcodeResource ShortcodeType = "resource" // [zone:resource:资源ID] ※预留(后端API未实现)
+)
+
+// allTypes 所有已定义的 shortcode 类型,添加新类型时在这里追加
+var allTypes = []ShortcodeType{
+ ShortcodeEvent,
+ ShortcodeGame,
+ ShortcodePoll,
+ ShortcodeResource,
+}
+
+// IsValidType 检查给定类型是否已注册
+func IsValidType(t string) bool {
+ for _, st := range allTypes {
+ if string(st) == t {
+ return true
+ }
+ }
+ return false
+}
+
+// =============================================================================
+// 解析结果
+// =============================================================================
+
+// Shortcode 一次解析的结果
+type Shortcode struct {
+ Raw string // 原始文本,如 "[zone:event:abc123]"
+ Type ShortcodeType // 类型
+ Params string // 参数(ID/slug 等)
+}
+
+// ShortcodeResult 整个 body 的解析结果
+type ShortcodeResult struct {
+ // ProcessedBody 替换后的 body(shortcode → HTML 占位符),可直接传给模板渲染
+ ProcessedBody string
+ // Shortcodes 本次解析到的所有 shortcode 列表
+ Shortcodes []Shortcode
+}
+
+// =============================================================================
+// 卡片渲染数据 DTO
+// =============================================================================
+
+// ShortcodeCard 渲染一张卡片的通用数据
+// 前端根据 Type 决定具体 UI 组件,字段按需填充
+type ShortcodeCard struct {
+ Type ShortcodeType `json:"type"`
+ ID string `json:"id"` // 业务 ID(活动ID / 游戏slug)
+ Title string `json:"title,omitempty"`
+ Cover string `json:"cover,omitempty"`
+ Desc string `json:"desc,omitempty"`
+ URL string `json:"url,omitempty"`
+ Extra string `json:"extra,omitempty"` // 扩展字段(时间、标签等,JSON string)
+}
diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go
index b941dcf..fdb7f0c 100644
--- a/internal/router/deps_extra.go
+++ b/internal/router/deps_extra.go
@@ -32,7 +32,8 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
postRepo := repository.NewPostRepo(db)
postService := service.NewPostService(postRepo, siteSettings)
- postCtrl := controller.NewPostController(postService)
+ shortcodeSvc := service.NewShortcodeService()
+ postCtrl := controller.NewPostController(postService, shortcodeSvc)
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
return &dependencies{
diff --git a/internal/service/post_service.go b/internal/service/post_service.go
index 7214ef7..ec46915 100644
--- a/internal/service/post_service.go
+++ b/internal/service/post_service.go
@@ -4,28 +4,74 @@ import (
"errors"
"fmt"
"regexp"
+ "strings"
+ "unicode/utf8"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/config"
"metazone.cc/metalab/internal/model"
- "github.com/microcosm-cc/bluemonday"
"gorm.io/gorm"
)
-// sanitizePolicy Tiptap 输出 HTML 的消毒策略
-// 前端 WYSIWYG 编辑器输出 HTML,由 bluemonday UGC 策略消毒
-// 放行 code/pre 的 class/data-language 属性以保留代码高亮和语言标签
-// 放行 span 元素及其 hljs-* class 以保留 highlight.js 语法高亮
-// 放行 img 的 src/alt/title 属性以保留图片
-var sanitizePolicy = func() *bluemonday.Policy {
- p := bluemonday.UGCPolicy()
- p.AllowElements("span")
- p.AllowAttrs("class", "data-language").OnElements("code", "pre")
- p.AllowAttrs("class").Matching(regexp.MustCompile("^hljs-")).OnElements("span")
- p.AllowAttrs("src", "alt", "title").OnElements("img")
- return p
-}()
+// mdPatterns Markdown 语法正则(用于生成纯文本摘要)
+var mdPatterns = []*regexp.Regexp{
+ // 代码块 ```...```
+ regexp.MustCompile("(?s)```[^`]*```"),
+ // 行内代码 `...`
+ regexp.MustCompile("`[^`]+`"),
+ // 图片 
+ regexp.MustCompile(`!\[.*?\]\(.*?\)`),
+ // 链接 [text](url)
+ regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`),
+ // 标题标记 #
+ regexp.MustCompile(`^#{1,6}\s+`),
+ // 粗体/斜体/删除线标记
+ regexp.MustCompile(`\*{1,3}|_{1,3}|~~`),
+ // 引用 >
+ regexp.MustCompile(`^>\s?`),
+ // 列表标记
+ regexp.MustCompile(`^[\s]*[-*+]\s+`),
+ regexp.MustCompile(`^[\s]*\d+\.\s+`),
+ // 分割线
+ regexp.MustCompile(`^[-*_]{3,}\s*$`),
+}
+
+// generateExcerpt 从 Markdown 生成纯文本摘要(最多 300 字符)
+// MD 作为纯文本存储无 XSS 风险,前端由 Vditor 渲染
+func generateExcerpt(md string) string {
+ text := md
+
+ // 按行处理,保留行结构
+ lines := strings.Split(text, "\n")
+ var cleaned []string
+ for _, line := range lines {
+ cleanedLine := line
+ for _, re := range mdPatterns {
+ if re == mdPatterns[2] {
+ // 链接保留文字: [text](url) → text
+ cleanedLine = re.ReplaceAllString(cleanedLine, "$1")
+ } else {
+ cleanedLine = re.ReplaceAllString(cleanedLine, "")
+ }
+ }
+ cleanedLine = strings.TrimSpace(cleanedLine)
+ if cleanedLine != "" {
+ cleaned = append(cleaned, cleanedLine)
+ }
+ }
+
+ result := strings.Join(cleaned, " ")
+
+ // 按 rune 截断,不切中文字符
+ const maxChars = 300
+ if utf8.RuneCountInString(result) > maxChars {
+ runes := []rune(result)
+ result = string(runes[:maxChars]) + "..."
+ }
+
+ return result
+}
// PostService 帖子业务逻辑
type PostService struct {
@@ -70,16 +116,10 @@ func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
return post, nil
}
-// sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒
-// 前端 WYSIWYG 编辑器直接输出 HTML,后端仅做安全消毒
-func (s *PostService) sanitizeHTML(body string) string {
- return sanitizePolicy.Sanitize(body)
-}
-
// Create 创建帖子
+// body 为 Vditor 输出的 Markdown,后端直接存储不做 HTML 消毒
+// MD 是纯文本,无 XSS 注入风险;前端渲染时由 Vditor 转 HTML
func (s *PostService) Create(userID uint, title, body string) (*model.Post, error) {
- bodyHTML := s.sanitizeHTML(body)
-
status := model.PostStatusApproved
if s.auditEnabled() {
status = model.PostStatusDraft
@@ -88,7 +128,7 @@ func (s *PostService) Create(userID uint, title, body string) (*model.Post, erro
post := &model.Post{
Title: title,
Body: body,
- BodyHTML: bodyHTML,
+ Excerpt: generateExcerpt(body),
UserID: userID,
Status: status,
AllowComment: true,
@@ -131,7 +171,7 @@ func (s *PostService) Update(postID uint, title, body string) error {
post.Title = title
post.Body = body
- post.BodyHTML = s.sanitizeHTML(body)
+ post.Excerpt = generateExcerpt(body)
// rejected 状态编辑后自动重置为 draft
if post.Status == model.PostStatusRejected {
diff --git a/internal/service/shortcode_service.go b/internal/service/shortcode_service.go
new file mode 100644
index 0000000..2153c37
--- /dev/null
+++ b/internal/service/shortcode_service.go
@@ -0,0 +1,143 @@
+package service
+
+import (
+ "fmt"
+ "html"
+ "strings"
+
+ "metazone.cc/metalab/internal/model"
+)
+
+// =============================================================================
+// ShortcodeService — Markdown shortcode 解析与渲染
+//
+// 职责:
+// 1. 从 Markdown body 中提取所有 [zone:type:params] 语法
+// 2. 将合法的 shortcode 替换为前端可识别的占位 HTML(
)
+// 3. 未注册的 shortcode 保持不变(不报错,避免破坏用户输入)
+//
+// 扩展新类型:
+// 1. 在 model/shortcode.go 中注册新 ShortcodeType 常量
+// 2. 在本文件的 renderPlaceholder() 中添加 case 分支
+// 3. 在 templates/.../shortcode.js 中添加前端卡片渲染逻辑
+// =============================================================================
+
+// ShortcodeService 无状态,纯函数集合
+type ShortcodeService struct{}
+
+// NewShortcodeService 构造函数
+func NewShortcodeService() *ShortcodeService {
+ return &ShortcodeService{}
+}
+
+// =============================================================================
+// 主入口:解析 + 替换
+// =============================================================================
+
+// Process 从 MD body 中提取 shortcode 并替换为占位 HTML
+// 返回的 ProcessedBody 可直接传给模板渲染(Go template 会自动对 HTML 实体转义,
+// 因此占位 div 需要用 template.HTML 类型标记为安全)
+func (s *ShortcodeService) Process(body string) model.ShortcodeResult {
+ result := model.ShortcodeResult{
+ ProcessedBody: body,
+ }
+
+ // 一次遍历替换所有合法的 shortcode
+ result.ProcessedBody = model.ShortcodePattern.ReplaceAllStringFunc(body, func(match string) string {
+ sc, ok := parseShortcode(match)
+ if !ok {
+ return match // 格式不合法,保留原文
+ }
+
+ if !model.IsValidType(string(sc.Type)) {
+ return match // 类型未注册,保留原文
+ }
+
+ // 生成占位 HTML
+ placeholder := renderPlaceholder(sc)
+ if placeholder == "" {
+ return match
+ }
+
+ result.Shortcodes = append(result.Shortcodes, sc)
+ return placeholder
+ })
+
+ return result
+}
+
+// =============================================================================
+// 解析
+// =============================================================================
+
+// parseShortcode 从 "[zone:type:params]" 字符串中解析出 Shortcode
+// 返回 false 表示格式不合法
+func parseShortcode(raw string) (model.Shortcode, bool) {
+ matches := model.ShortcodePattern.FindStringSubmatch(raw)
+ if len(matches) != 3 {
+ return model.Shortcode{}, false
+ }
+
+ sc := model.Shortcode{
+ Raw: raw,
+ Type: model.ShortcodeType(matches[1]),
+ Params: strings.TrimSpace(matches[2]),
+ }
+
+ if sc.Params == "" {
+ return model.Shortcode{}, false
+ }
+
+ return sc, true
+}
+
+// =============================================================================
+// 占位 HTML 渲染器(每个类型一个 case)
+// =============================================================================
+
+// renderPlaceholder 根据 shortcode 类型生成前端占位 HTML
+//
+// 占位 div 结构约定:
+//
+// 卡片加载中...
+//
+//
+// 前端 shortcode.js 根据 data-zone-* 属性决定如何渲染。
+// "加载中..." 文本会被 shortcode.js 在渲染时替换,仅作降级显示。
+func renderPlaceholder(sc model.Shortcode) string {
+ switch sc.Type {
+ case model.ShortcodeEvent:
+ // [zone:event:活动ID]
+ // 前端根据活动ID获取标题、时间、封面等信息
+ return fmt.Sprintf(
+ `
活动卡片加载中...
`,
+ html.EscapeString(sc.Params),
+ )
+
+ case model.ShortcodeGame:
+ // [zone:game:游戏slug]
+ // 前端根据游戏slug获取名称、封面、标签等信息
+ return fmt.Sprintf(
+ `
游戏卡片加载中...
`,
+ html.EscapeString(sc.Params),
+ )
+
+ case model.ShortcodePoll:
+ // [zone:poll:投票ID] ※预留
+ return fmt.Sprintf(
+ `
投票组件加载中...
`,
+ html.EscapeString(sc.Params),
+ )
+
+ case model.ShortcodeResource:
+ // [zone:resource:资源ID] ※预留
+ return fmt.Sprintf(
+ `
资源卡片加载中...
`,
+ html.EscapeString(sc.Params),
+ )
+
+ default:
+ return ""
+ }
+}
diff --git a/templates/MetaLab-2026/html/posts/index.html b/templates/MetaLab-2026/html/posts/index.html
index ffe1c5d..af22e0b 100644
--- a/templates/MetaLab-2026/html/posts/index.html
+++ b/templates/MetaLab-2026/html/posts/index.html
@@ -32,7 +32,7 @@
{{if .AuthorName}}{{.AuthorName}}({{.UserID}}){{else}}UID{{.UserID}}{{end}}
{{.CreatedAt.Format "2006-01-02 15:04"}}
-
{{printf "%.200s" .Body}}
+
{{if .Excerpt}}{{.Excerpt}}{{else}}{{printf "%.200s" .Body}}{{end}}
{{end}}
{{end}}
diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html
index 5a62938..4f072db 100644
--- a/templates/MetaLab-2026/html/posts/new.html
+++ b/templates/MetaLab-2026/html/posts/new.html
@@ -6,6 +6,10 @@
@@ -87,18 +38,9 @@
{{template "layout/footer.html" .}}
-
+
+
+