feat: 前台动态路由与积木页面、网页路径/发布/模式、PAGE_BUILDER 文档
Made-with: Cursor
This commit is contained in:
@@ -17,9 +17,24 @@
|
|||||||
<el-table-column label="ID" width="240">
|
<el-table-column label="ID" width="240">
|
||||||
<template #default="{ row }">{{ row.id }}</template>
|
<template #default="{ row }">{{ row.id }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="slug" label="Slug" width="120" />
|
<el-table-column prop="slug" label="Slug" width="100" />
|
||||||
<el-table-column prop="title" label="标题" width="160" />
|
<el-table-column label="前台路径" min-width="120" show-overflow-tooltip>
|
||||||
<el-table-column prop="type" label="类型" width="100">
|
<template #default="{ row }">{{ row.route_path || '/' + (row.slug || '') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="标题" width="140" />
|
||||||
|
<el-table-column label="模式" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.content_mode === 'builder'" type="warning" size="small">积木</el-tag>
|
||||||
|
<el-tag v-else type="info" size="small">HTML</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="发布" width="70">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.published === false" type="danger" size="small">否</el-tag>
|
||||||
|
<el-tag v-else type="success" size="small">是</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="type" label="类型" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-if="row.type === 'homepage'" type="success" size="small">首页</el-tag>
|
<el-tag v-if="row.type === 'homepage'" type="success" size="small">首页</el-tag>
|
||||||
<el-tag v-else size="small">页面</el-tag>
|
<el-tag v-else size="small">页面</el-tag>
|
||||||
@@ -35,10 +50,13 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="560px" @close="resetForm">
|
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="720px" @close="resetForm">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
<el-form-item label="Slug" prop="slug">
|
<el-form-item label="Slug" prop="slug">
|
||||||
<el-input v-model="form.slug" placeholder="如 about、index" :disabled="!!editId" />
|
<el-input v-model="form.slug" placeholder="如 about、index(index 为首页数据,一般不单独走路由)" :disabled="!!editId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="前台路径">
|
||||||
|
<el-input v-model="form.route_path" placeholder="留空则自动为 /{slug},可填如 /download 或 /about/us" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="标题" prop="title">
|
<el-form-item label="标题" prop="title">
|
||||||
<el-input v-model="form.title" placeholder="页面标题" />
|
<el-input v-model="form.title" placeholder="页面标题" />
|
||||||
@@ -49,8 +67,18 @@
|
|||||||
<el-option label="首页" value="homepage" />
|
<el-option label="首页" value="homepage" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="内容模式">
|
||||||
|
<el-select v-model="form.content_mode" placeholder="模式" style="width: 200px">
|
||||||
|
<el-option label="HTML 富文本" value="html" />
|
||||||
|
<el-option label="积木组装(JSON)" value="builder" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" link style="margin-left: 12px" @click="insertBuilderTemplate">插入积木模板</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="发布到前台">
|
||||||
|
<el-switch v-model="form.published" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="内容" prop="content">
|
<el-form-item label="内容" prop="content">
|
||||||
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="HTML 或 JSON" />
|
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="HTML 模式直接写 HTML;积木模式为 JSON,见项目 docs/PAGE_BUILDER.md" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -107,7 +135,80 @@ const dialogVisible = ref(false)
|
|||||||
const editId = ref('')
|
const editId = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const formRef = ref(null)
|
const formRef = ref(null)
|
||||||
const form = reactive({ site_id: '', slug: '', title: '', type: 'page', content: '' })
|
const builderTemplate = () =>
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'h1',
|
||||||
|
type: 'heading',
|
||||||
|
props: { text: '页面标题', level: 2 },
|
||||||
|
animation: { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
type: 'text',
|
||||||
|
props: { text: '在此编辑说明文字,可在后台修改 JSON 调整模块与动画。' },
|
||||||
|
animation: { enter: 'slideUp', delay_ms: 100, duration_ms: 500 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'links',
|
||||||
|
type: 'link_list',
|
||||||
|
props: {
|
||||||
|
items: [
|
||||||
|
{ label: '回首页', url: '/' },
|
||||||
|
{ label: '示例外链', url: '#', target: '_blank' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'btn',
|
||||||
|
type: 'button',
|
||||||
|
props: { text: '主要按钮', url: '#', variant: 'primary' }
|
||||||
|
},
|
||||||
|
{ id: 'sp', type: 'spacer', props: { height: 24 } },
|
||||||
|
{
|
||||||
|
id: 'sec',
|
||||||
|
type: 'section',
|
||||||
|
props: { padding: '24px 0', maxWidth: '720px' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'sub',
|
||||||
|
type: 'text',
|
||||||
|
props: { html: '<p>区块内可嵌套子模块(<strong>section → children</strong>)。</p>' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
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 = {
|
const rules = {
|
||||||
slug: [{ required: true, message: '请输入 slug', trigger: 'blur' }],
|
slug: [{ required: true, message: '请输入 slug', trigger: 'blur' }],
|
||||||
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
|
||||||
@@ -120,6 +221,9 @@ const openDialog = (row) => {
|
|||||||
form.title = row ? row.title : ''
|
form.title = row ? row.title : ''
|
||||||
form.type = row ? row.type || 'page' : 'page'
|
form.type = row ? row.type || 'page' : 'page'
|
||||||
form.content = row ? row.content || '' : ''
|
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
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +232,9 @@ const resetForm = () => {
|
|||||||
form.title = ''
|
form.title = ''
|
||||||
form.type = 'page'
|
form.type = 'page'
|
||||||
form.content = ''
|
form.content = ''
|
||||||
|
form.content_mode = 'html'
|
||||||
|
form.route_path = ''
|
||||||
|
form.published = true
|
||||||
editId.value = ''
|
editId.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,11 +242,20 @@ const submitForm = async () => {
|
|||||||
await formRef.value?.validate()
|
await formRef.value?.validate()
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
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) {
|
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('更新成功')
|
ElMessage.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createPage({ ...form, site_id: siteId.value })
|
await createPage({ ...payload, site_id: siteId.value })
|
||||||
ElMessage.success('创建成功')
|
ElMessage.success('创建成功')
|
||||||
}
|
}
|
||||||
dialogVisible.value = false
|
dialogVisible.value = false
|
||||||
|
|||||||
52
docs/PAGE_BUILDER.md
Normal file
52
docs/PAGE_BUILDER.md
Normal file
@@ -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`,无需改库表结构。
|
||||||
@@ -72,6 +72,9 @@ type CreatePageInput struct {
|
|||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
Type string `json:"type"` // homepage, page
|
Type string `json:"type"` // homepage, page
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
ContentMode string `json:"content_mode"` // html | builder
|
||||||
|
RoutePath string `json:"route_path"`
|
||||||
|
Published *bool `json:"published"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePage 创建网页
|
// CreatePage 创建网页
|
||||||
@@ -98,6 +101,15 @@ func CreatePage(c *gin.Context) {
|
|||||||
"content": input.Content,
|
"content": input.Content,
|
||||||
"updated_at": now,
|
"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)
|
res, err := coll.InsertOne(ctx, doc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -112,6 +124,9 @@ type UpdatePageInput struct {
|
|||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Content *string `json:"content"`
|
Content *string `json:"content"`
|
||||||
|
ContentMode *string `json:"content_mode"`
|
||||||
|
RoutePath *string `json:"route_path"`
|
||||||
|
Published *bool `json:"published"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePage 更新网页
|
// UpdatePage 更新网页
|
||||||
@@ -142,6 +157,15 @@ func UpdatePage(c *gin.Context) {
|
|||||||
if input.Content != nil {
|
if input.Content != nil {
|
||||||
set["content"] = *input.Content
|
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
140
server/handlers/web_routes.go
Normal file
140
server/handlers/web_routes.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -206,6 +206,8 @@ func main() {
|
|||||||
|
|
||||||
// 官网站点首页(前台,无需鉴权)
|
// 官网站点首页(前台,无需鉴权)
|
||||||
r.GET("/api/web/homepage", handlers.GetWebHomepage)
|
r.GET("/api/web/homepage", handlers.GetWebHomepage)
|
||||||
|
r.GET("/api/web/routes", handlers.GetWebRoutes)
|
||||||
|
r.GET("/api/web/page", handlers.GetWebPageByPath)
|
||||||
|
|
||||||
// 前台 API 路由组
|
// 前台 API 路由组
|
||||||
web := r.Group("/api/web")
|
web := r.Group("/api/web")
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ type Page struct {
|
|||||||
Slug string `bson:"slug" json:"slug"` // index, about, ...
|
Slug string `bson:"slug" json:"slug"` // index, about, ...
|
||||||
Title string `bson:"title" json:"title"`
|
Title string `bson:"title" json:"title"`
|
||||||
Type string `bson:"type" json:"type"` // homepage, page
|
Type string `bson:"type" json:"type"` // homepage, page
|
||||||
Content string `bson:"content" json:"content"` // HTML 或 JSON 字符串
|
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"`
|
UpdatedAt string `bson:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
web/src/api/webPages.js
Normal file
16
web/src/api/webPages.js
Normal file
@@ -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()
|
||||||
|
}
|
||||||
111
web/src/components/blocks/BlockRenderer.vue
Normal file
111
web/src/components/blocks/BlockRenderer.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div class="block-renderer">
|
||||||
|
<template v-for="(block, i) in blocks" :key="block.id || i">
|
||||||
|
<div
|
||||||
|
class="yh-block"
|
||||||
|
:class="animClass(block)"
|
||||||
|
:style="animStyle(block)"
|
||||||
|
>
|
||||||
|
<template v-if="block.type === 'heading'">
|
||||||
|
<component :is="headingTag(block)" class="builder-heading">{{ block.props?.text }}</component>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="block.type === 'text'">
|
||||||
|
<p v-if="!block.props?.html" class="builder-text">{{ block.props?.text }}</p>
|
||||||
|
<div v-else class="builder-text" v-html="block.props?.html || block.props?.text"></div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="block.type === 'link_list'">
|
||||||
|
<nav class="builder-links">
|
||||||
|
<a
|
||||||
|
v-for="(item, j) in (block.props?.items || [])"
|
||||||
|
:key="j"
|
||||||
|
:href="item.url || '#'"
|
||||||
|
:target="item.target || '_self'"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ item.label }}</a>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="block.type === 'button'">
|
||||||
|
<a
|
||||||
|
:href="block.props?.url || '#'"
|
||||||
|
class="builder-btn"
|
||||||
|
:class="block.props?.variant === 'ghost' ? 'builder-btn--ghost' : 'builder-btn--primary'"
|
||||||
|
:target="block.props?.target || '_self'"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ block.props?.text }}</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="block.type === 'html'">
|
||||||
|
<div class="builder-html" v-html="block.props?.html"></div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="block.type === 'spacer'">
|
||||||
|
<div :style="{ height: (block.props?.height || 16) + 'px' }" aria-hidden="true"></div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="block.type === 'divider'">
|
||||||
|
<hr style="border: none; border-top: 1px solid #e4e7ed; margin: 24px 0" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="block.type === 'section'">
|
||||||
|
<section
|
||||||
|
class="builder-section"
|
||||||
|
:style="sectionStyle(block.props)"
|
||||||
|
>
|
||||||
|
<BlockRenderer v-if="block.children?.length" :blocks="block.children" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="builder-unknown">未知模块: {{ block.type }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineOptions({ name: 'BlockRenderer' })
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
blocks: { type: Array, default: () => [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
function animClass(block) {
|
||||||
|
const enter = block.animation?.enter || 'fadeIn'
|
||||||
|
if (enter === 'none') return 'yh-anim-none'
|
||||||
|
const map = {
|
||||||
|
fadeIn: 'yh-anim-fadeIn',
|
||||||
|
slideUp: 'yh-anim-slideUp',
|
||||||
|
slideLeft: 'yh-anim-slideLeft',
|
||||||
|
zoomIn: 'yh-anim-zoomIn'
|
||||||
|
}
|
||||||
|
return map[enter] || 'yh-anim-fadeIn'
|
||||||
|
}
|
||||||
|
|
||||||
|
function animStyle(block) {
|
||||||
|
const a = block.animation || {}
|
||||||
|
const delay = a.delay_ms != null ? a.delay_ms : 0
|
||||||
|
const duration = a.duration_ms != null ? a.duration_ms : 600
|
||||||
|
return {
|
||||||
|
animationDelay: `${delay}ms`,
|
||||||
|
animationDuration: `${duration}ms`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingTag(block) {
|
||||||
|
const lv = Math.min(6, Math.max(1, block.props?.level || 2))
|
||||||
|
return `h${lv}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionStyle(props) {
|
||||||
|
if (!props) return {}
|
||||||
|
return {
|
||||||
|
padding: props.padding || '16px 0',
|
||||||
|
maxWidth: props.maxWidth || '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
background: props.background || 'transparent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.builder-unknown {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,29 @@ import App from './App.vue'
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import './utils/disable-debug'
|
import './utils/disable-debug'
|
||||||
import './assets/landing-dynamics.css'
|
import './assets/landing-dynamics.css'
|
||||||
|
import './styles/page-animations.css'
|
||||||
|
import { fetchWebRoutes } from './api/webPages'
|
||||||
|
|
||||||
const app = createApp(App)
|
async function bootstrap() {
|
||||||
app.use(router)
|
const app = createApp(App)
|
||||||
app.mount('#app')
|
app.use(router)
|
||||||
|
try {
|
||||||
|
const data = await fetchWebRoutes()
|
||||||
|
const DynamicPage = () => import('./views/DynamicPage.vue')
|
||||||
|
for (const r of data.routes || []) {
|
||||||
|
const path = r.path
|
||||||
|
if (!path || path === '/') continue
|
||||||
|
router.addRoute({
|
||||||
|
path,
|
||||||
|
name: `web-page-${r.id}`,
|
||||||
|
component: DynamicPage,
|
||||||
|
meta: { title: r.title || '', pagePath: path }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* 动态路由失败时仍展示首页 */
|
||||||
|
}
|
||||||
|
app.mount('#app')
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
|
|||||||
132
web/src/styles/page-animations.css
Normal file
132
web/src/styles/page-animations.css
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/* 积木页面动画:与后台 block.animation.enter 对应 */
|
||||||
|
.yh-block {
|
||||||
|
opacity: 0;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yh-block.yh-anim-none {
|
||||||
|
opacity: 1;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yh-block.yh-anim-fadeIn {
|
||||||
|
animation-name: yhFadeIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yh-block.yh-anim-slideUp {
|
||||||
|
animation-name: yhSlideUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yh-block.yh-anim-slideLeft {
|
||||||
|
animation-name: yhSlideLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yh-block.yh-anim-zoomIn {
|
||||||
|
animation-name: yhZoomIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes yhFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes yhSlideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(24px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes yhSlideLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-24px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes yhZoomIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-page {
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-page .html-content :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-heading {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-text {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-links a {
|
||||||
|
color: #409eff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 8px 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-btn--primary {
|
||||||
|
background: linear-gradient(135deg, #00d4ff, #ff6b9d);
|
||||||
|
color: #0a0a12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-btn--ghost {
|
||||||
|
border: 1px solid #409eff;
|
||||||
|
color: #409eff;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
85
web/src/views/DynamicPage.vue
Normal file
85
web/src/views/DynamicPage.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dynamic-page">
|
||||||
|
<p v-if="loading" class="state">加载中…</p>
|
||||||
|
<p v-else-if="error" class="state error">{{ error }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<h1 v-if="pageTitle" class="page-title">{{ pageTitle }}</h1>
|
||||||
|
<BlockRenderer v-if="mode === 'builder' && builderBlocks.length" :blocks="builderBlocks" />
|
||||||
|
<div v-else class="html-content" v-html="htmlContent"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import BlockRenderer from '../components/blocks/BlockRenderer.vue'
|
||||||
|
import { fetchWebPageByPath } from '../api/webPages'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
const pageTitle = ref('')
|
||||||
|
const mode = ref('html')
|
||||||
|
const htmlContent = ref('')
|
||||||
|
const builderBlocks = ref([])
|
||||||
|
|
||||||
|
function parsePath() {
|
||||||
|
const p = route.path || '/'
|
||||||
|
return p === '' ? '/' : p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const path = parsePath()
|
||||||
|
const data = await fetchWebPageByPath(path)
|
||||||
|
pageTitle.value = data.title || ''
|
||||||
|
document.title = data.title ? `${data.title} - 官网` : '官网'
|
||||||
|
const cm = (data.content_mode || 'html').toLowerCase()
|
||||||
|
mode.value = cm === 'builder' ? 'builder' : 'html'
|
||||||
|
if (mode.value === 'builder' && data.content) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data.content)
|
||||||
|
builderBlocks.value = Array.isArray(json.blocks) ? json.blocks : []
|
||||||
|
if (!builderBlocks.value.length) {
|
||||||
|
mode.value = 'html'
|
||||||
|
htmlContent.value = data.content
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
mode.value = 'html'
|
||||||
|
htmlContent.value = data.content || ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
htmlContent.value = data.content || ''
|
||||||
|
builderBlocks.value = []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message || '页面加载失败'
|
||||||
|
pageTitle.value = ''
|
||||||
|
builderBlocks.value = []
|
||||||
|
htmlContent.value = ''
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.path, load, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.state.error {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user