feat: 前台动态路由与积木页面、网页路径/发布/模式、PAGE_BUILDER 文档

Made-with: Cursor
This commit is contained in:
whm
2026-03-19 16:20:48 +08:00
parent b17e99eb93
commit ea163dbf8e
11 changed files with 732 additions and 29 deletions

View File

@@ -17,9 +17,24 @@
<el-table-column label="ID" width="240">
<template #default="{ row }">{{ row.id }}</template>
</el-table-column>
<el-table-column prop="slug" label="Slug" width="120" />
<el-table-column prop="title" label="标题" width="160" />
<el-table-column prop="type" label="类型" width="100">
<el-table-column prop="slug" label="Slug" width="100" />
<el-table-column label="前台路径" min-width="120" show-overflow-tooltip>
<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 }">
<el-tag v-if="row.type === 'homepage'" type="success" size="small">首页</el-tag>
<el-tag v-else size="small">页面</el-tag>
@@ -35,10 +50,13 @@
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="560px" @close="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="720px" @close="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<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、indexindex 为首页数据,一般不单独走路由)" :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 label="标题" prop="title">
<el-input v-model="form.title" placeholder="页面标题" />
@@ -49,8 +67,18 @@
<el-option label="首页" value="homepage" />
</el-select>
</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-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>
<template #footer>
@@ -107,7 +135,80 @@ const dialogVisible = ref(false)
const editId = ref('')
const submitting = ref(false)
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 = {
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

52
docs/PAGE_BUILDER.md Normal file
View 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` (16) |
| `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`,无需改库表结构。

View File

@@ -72,6 +72,9 @@ type CreatePageInput struct {
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()})
@@ -112,6 +124,9 @@ type UpdatePageInput struct {
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()

View 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,
})
}

View File

@@ -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")

View File

@@ -18,7 +18,10 @@ type Page struct {
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 字符串
Content string `bson:"content" json:"content"` // html 模式为 HTMLbuilder 模式为 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"`
}

16
web/src/api/webPages.js Normal file
View 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()
}

View 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>

View File

@@ -3,7 +3,29 @@ import App from './App.vue'
import router from './router'
import './utils/disable-debug'
import './assets/landing-dynamics.css'
import './styles/page-animations.css'
import { fetchWebRoutes } from './api/webPages'
const app = createApp(App)
app.use(router)
app.mount('#app')
async function bootstrap() {
const app = createApp(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()

View 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;
}

View 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>