区块内可嵌套子模块(section → children)。
' } + } + ] + } + ] + }, + null, + 2 + ) + +const form = reactive({ + site_id: '', + slug: '', + title: '', + type: 'page', + content: '', + content_mode: 'html', + route_path: '', + published: true +}) + +function insertBuilderTemplate() { + form.content_mode = 'builder' + if (!form.content?.trim()) { + form.content = builderTemplate() + } else { + ElMessageBox.confirm('将用模板覆盖当前内容?', '提示', { type: 'warning' }) + .then(() => { + form.content = builderTemplate() + }) + .catch(() => {}) + } +} const rules = { slug: [{ required: true, message: '请输入 slug', trigger: 'blur' }], title: [{ required: true, message: '请输入标题', trigger: 'blur' }] @@ -120,6 +221,9 @@ const openDialog = (row) => { form.title = row ? row.title : '' form.type = row ? row.type || 'page' : 'page' form.content = row ? row.content || '' : '' + form.content_mode = row?.content_mode || 'html' + form.route_path = row?.route_path || '' + form.published = row?.published !== false dialogVisible.value = true } @@ -128,6 +232,9 @@ const resetForm = () => { form.title = '' form.type = 'page' form.content = '' + form.content_mode = 'html' + form.route_path = '' + form.published = true editId.value = '' } @@ -135,11 +242,20 @@ const submitForm = async () => { await formRef.value?.validate() submitting.value = true try { + const payload = { + slug: form.slug, + title: form.title, + type: form.type, + content: form.content, + content_mode: form.content_mode, + route_path: form.route_path || undefined, + published: form.published + } if (editId.value) { - await updatePage(editId.value, { slug: form.slug, title: form.title, type: form.type, content: form.content }) + await updatePage(editId.value, payload) ElMessage.success('更新成功') } else { - await createPage({ ...form, site_id: siteId.value }) + await createPage({ ...payload, site_id: siteId.value }) ElMessage.success('创建成功') } dialogVisible.value = false diff --git a/docs/PAGE_BUILDER.md b/docs/PAGE_BUILDER.md new file mode 100644 index 0000000..3b8b65f --- /dev/null +++ b/docs/PAGE_BUILDER.md @@ -0,0 +1,52 @@ +# 前台积木页面(动态路由) + +## 概念 + +- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。 +- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。 +- **积木(builder)**:`content` 为 JSON,结构如下,前台按模块渲染并支持入场动画。 + +## 动态路由 + +- 前台启动时请求 `GET /api/web/routes`,按已发布页面注册 Vue Router。 +- `slug` 为 `index` 的页面不参与动态路由(仍由首页 `Home.vue` + 首页数据驱动)。 +- 单页数据:`GET /api/web/page?path=/your-path`(`site_id` 可选,默认官网站点)。 + +## 积木 JSON 结构 + +```json +{ + "version": 1, + "blocks": [ + { + "id": "唯一可选", + "type": "heading", + "props": { "text": "标题", "level": 2 }, + "animation": { "enter": "fadeIn", "delay_ms": 0, "duration_ms": 600 } + } + ] +} +``` + +### 模块类型 `type` + +| type | props 说明 | +|------|------------| +| `heading` | `text`, `level` (1–6) | +| `text` | `text` 纯文本;或 `html: true` 时用 `html` / `text` 作为 HTML | +| `link_list` | `items: [{ label, url, target? }]` | +| `button` | `text`, `url`, `variant`: `primary` \| `ghost`, `target?` | +| `html` | `html` 原始 HTML 片段 | +| `spacer` | `height` 像素 | +| `divider` | 无 | +| `section` | `padding`, `maxWidth`, `background`;`children` 为子 `blocks` 数组 | + +### 动画 `animation.enter` + +- `none` | `fadeIn` | `slideUp` | `slideLeft` | `zoomIn` +- `delay_ms`、`duration_ms` 控制延迟与时长(毫秒) + +## 扩展新模块 + +1. 在 `web/src/components/blocks/BlockRenderer.vue` 增加 `type` 分支与样式。 +2. 后台仍通过 JSON 配置 `props`,无需改库表结构。 diff --git a/server/handlers/page.go b/server/handlers/page.go index 07a8a10..8b401fd 100644 --- a/server/handlers/page.go +++ b/server/handlers/page.go @@ -67,11 +67,14 @@ func GetPageByID(c *gin.Context) { // CreatePageInput 创建网页 type CreatePageInput struct { - SiteID string `json:"site_id" binding:"required"` - Slug string `json:"slug" binding:"required"` - Title string `json:"title" binding:"required"` - Type string `json:"type"` // homepage, page - Content string `json:"content"` + SiteID string `json:"site_id" binding:"required"` + Slug string `json:"slug" binding:"required"` + Title string `json:"title" binding:"required"` + Type string `json:"type"` // homepage, page + Content string `json:"content"` + ContentMode string `json:"content_mode"` // html | builder + RoutePath string `json:"route_path"` + Published *bool `json:"published"` } // CreatePage 创建网页 @@ -98,6 +101,15 @@ func CreatePage(c *gin.Context) { "content": input.Content, "updated_at": now, } + if input.ContentMode != "" { + doc["content_mode"] = input.ContentMode + } + if input.RoutePath != "" { + doc["route_path"] = input.RoutePath + } + if input.Published != nil { + doc["published"] = *input.Published + } res, err := coll.InsertOne(ctx, doc) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -108,10 +120,13 @@ func CreatePage(c *gin.Context) { // UpdatePageInput 更新网页 type UpdatePageInput struct { - Slug *string `json:"slug"` - Title *string `json:"title"` - Type *string `json:"type"` - Content *string `json:"content"` + Slug *string `json:"slug"` + Title *string `json:"title"` + Type *string `json:"type"` + Content *string `json:"content"` + ContentMode *string `json:"content_mode"` + RoutePath *string `json:"route_path"` + Published *bool `json:"published"` } // UpdatePage 更新网页 @@ -142,6 +157,15 @@ func UpdatePage(c *gin.Context) { if input.Content != nil { set["content"] = *input.Content } + if input.ContentMode != nil { + set["content_mode"] = *input.ContentMode + } + if input.RoutePath != nil { + set["route_path"] = *input.RoutePath + } + if input.Published != nil { + set["published"] = *input.Published + } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/server/handlers/web_routes.go b/server/handlers/web_routes.go new file mode 100644 index 0000000..2df4a33 --- /dev/null +++ b/server/handlers/web_routes.go @@ -0,0 +1,140 @@ +package handlers + +import ( + "context" + "net/http" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" + + "yh_web/server/config" + "yh_web/server/models" + + "github.com/gin-gonic/gin" +) + +// effectivePagePath 对外访问路径:优先 route_path,否则 /{slug};index 且无 route_path 时返回空(由首页单独处理) +func effectivePagePath(p models.Page) string { + if p.RoutePath != "" { + path := strings.TrimSpace(p.RoutePath) + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path + } + if p.Slug == "" || p.Slug == homepageSlug { + return "" + } + return "/" + p.Slug +} + +// GetWebRoutes 前台:获取站点已发布页面的动态路由列表(无需鉴权) +func GetWebRoutes(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + siteID := c.Query("site_id") + if siteID == "" { + siteID = getOfficialSiteID(ctx) + } + if siteID == "" { + c.JSON(http.StatusOK, gin.H{"site_id": "", "routes": []any{}}) + return + } + + coll := config.GetDB(config.DBName).Collection("pages") + opts := options.Find().SetSort(bson.D{{Key: "updated_at", Value: -1}}) + cursor, err := coll.Find(ctx, bson.M{"site_id": siteID}, opts) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer cursor.Close(ctx) + + var pages []models.Page + if err = cursor.All(ctx, &pages); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + routes := make([]gin.H, 0) + for _, p := range pages { + if p.Published != nil && !*p.Published { + continue + } + path := effectivePagePath(p) + if path == "" { + continue + } + routes = append(routes, gin.H{ + "path": path, + "title": p.Title, + "slug": p.Slug, + "id": p.ID.Hex(), + "mode": p.ContentMode, + }) + } + c.JSON(http.StatusOK, gin.H{"site_id": siteID, "routes": routes}) +} + +// GetWebPageByPath 前台:按路径取单页内容(无需鉴权) +func GetWebPageByPath(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + siteID := c.Query("site_id") + if siteID == "" { + siteID = getOfficialSiteID(ctx) + } + path := strings.TrimSpace(c.Query("path")) + if siteID == "" || path == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 site_id 或 path"}) + return + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + coll := config.GetDB(config.DBName).Collection("pages") + + var page models.Page + tryDecode := func(filter bson.M) bool { + page = models.Page{} + err := coll.FindOne(ctx, filter).Decode(&page) + return err == nil && !page.ID.IsZero() + } + if !tryDecode(bson.M{"site_id": siteID, "route_path": path}) { + alt := strings.TrimPrefix(path, "/") + if alt != "" { + tryDecode(bson.M{"site_id": siteID, "route_path": alt}) + } + } + if page.ID.IsZero() { + slug := strings.TrimPrefix(path, "/") + if slug != "" { + tryDecode(bson.M{"site_id": siteID, "slug": slug}) + } + } + if page.ID.IsZero() { + c.JSON(http.StatusNotFound, gin.H{"error": "页面不存在"}) + return + } + if page.Published != nil && !*page.Published { + c.JSON(http.StatusNotFound, gin.H{"error": "页面未发布"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": page.ID.Hex(), + "site_id": page.SiteID, + "slug": page.Slug, + "title": page.Title, + "type": page.Type, + "content": page.Content, + "content_mode": page.ContentMode, + "route_path": page.RoutePath, + "updated_at": page.UpdatedAt, + }) +} diff --git a/server/main.go b/server/main.go index d761432..7911c13 100644 --- a/server/main.go +++ b/server/main.go @@ -206,6 +206,8 @@ func main() { // 官网站点首页(前台,无需鉴权) r.GET("/api/web/homepage", handlers.GetWebHomepage) + r.GET("/api/web/routes", handlers.GetWebRoutes) + r.GET("/api/web/page", handlers.GetWebPageByPath) // 前台 API 路由组 web := r.Group("/api/web") diff --git a/server/models/site.go b/server/models/site.go index 647a402..de91afb 100644 --- a/server/models/site.go +++ b/server/models/site.go @@ -13,13 +13,16 @@ type Site struct { // Page 网页(属于某站点) type Page struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"id"` - SiteID string `bson:"site_id" json:"site_id"` - Slug string `bson:"slug" json:"slug"` // index, about, ... - Title string `bson:"title" json:"title"` - Type string `bson:"type" json:"type"` // homepage, page - Content string `bson:"content" json:"content"` // HTML 或 JSON 字符串 - UpdatedAt string `bson:"updated_at" json:"updated_at"` + ID bson.ObjectID `bson:"_id,omitempty" json:"id"` + SiteID string `bson:"site_id" json:"site_id"` + Slug string `bson:"slug" json:"slug"` // index, about, ... + Title string `bson:"title" json:"title"` + Type string `bson:"type" json:"type"` // homepage, page + Content string `bson:"content" json:"content"` // html 模式为 HTML;builder 模式为 JSON(见文档) + ContentMode string `bson:"content_mode,omitempty" json:"content_mode"` // html | builder,空视为 html + RoutePath string `bson:"route_path,omitempty" json:"route_path"` // 自定义前台路径,如 /about;空则用 /{slug} + Published *bool `bson:"published,omitempty" json:"published"` // nil 或未设视为已发布 + UpdatedAt string `bson:"updated_at" json:"updated_at"` } // HomepageData 首页可编辑数据(与 yuheng-download-space 对应) diff --git a/web/src/api/webPages.js b/web/src/api/webPages.js new file mode 100644 index 0000000..c796874 --- /dev/null +++ b/web/src/api/webPages.js @@ -0,0 +1,16 @@ +import { apiBase } from '../config' + +const prefix = () => (apiBase ? `${apiBase}/api` : '/api') + +export async function fetchWebRoutes() { + const res = await fetch(`${prefix()}/web/routes`) + if (!res.ok) return { site_id: '', routes: [] } + return res.json() +} + +export async function fetchWebPageByPath(path) { + const q = new URLSearchParams({ path: path || '/' }) + const res = await fetch(`${prefix()}/web/page?${q}`) + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || '加载失败') + return res.json() +} diff --git a/web/src/components/blocks/BlockRenderer.vue b/web/src/components/blocks/BlockRenderer.vue new file mode 100644 index 0000000..2e606d9 --- /dev/null +++ b/web/src/components/blocks/BlockRenderer.vue @@ -0,0 +1,111 @@ + +{{ block.props?.text }}
+ + + + + + + {{ block.props?.text }} + + + + + + + + +未知模块: {{ block.type }}
+ +加载中…
+{{ error }}
+ +