feat(admin): 积木页面可视化编辑与链接选择器(站内页/可下载文件)

Made-with: Cursor
This commit is contained in:
whm
2026-03-19 16:33:54 +08:00
parent ea163dbf8e
commit 6df5cf029d
6 changed files with 641 additions and 5 deletions

View File

@@ -0,0 +1,143 @@
<template>
<el-dialog
:model-value="modelValue"
title="选择链接"
width="640px"
destroy-on-close
@update:model-value="$emit('update:modelValue', $event)"
>
<el-tabs v-model="tab">
<el-tab-pane label="站内页面" name="pages">
<el-table
v-loading="loadingPages"
:data="pageRows"
highlight-current-row
max-height="360"
@row-click="(row) => confirm(row.path)"
>
<el-table-column prop="title" label="标题" min-width="140" />
<el-table-column prop="path" label="路径" min-width="160" />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="primary" link @click.stop="confirm(row.path)">选择</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingPages && !pageRows.length" description="暂无页面" />
</el-tab-pane>
<el-tab-pane label="可下载文件" name="files">
<el-table
v-loading="loadingFiles"
:data="files"
max-height="360"
@row-click="(row) => confirm(fileUrl(row))"
>
<el-table-column prop="name" label="文件名" min-width="160" />
<el-table-column prop="file_path" label="存储路径" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button type="primary" link @click.stop="confirm(fileUrl(row))">选择</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingFiles && !files.length" description="无标记为可下载的文件请到文件管理上传并勾选允许下载" />
</el-tab-pane>
<el-tab-pane label="自定义地址" name="custom">
<el-input v-model="customUrl" placeholder="https:// 或 /path" clearable />
<div style="margin-top: 16px">
<el-button type="primary" @click="confirmCustom">使用此地址</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getPages, getDownloadableAssets } from '../api/admin'
const props = defineProps({
modelValue: { type: Boolean, default: false },
siteId: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue', 'select'])
const tab = ref('pages')
const pageRows = ref([])
const files = ref([])
const loadingPages = ref(false)
const loadingFiles = ref(false)
const customUrl = ref('')
function effectivePath(p) {
if (p.route_path) {
const r = String(p.route_path).trim()
return r.startsWith('/') ? r : '/' + r
}
if (!p.slug || p.slug === 'index') return '/'
return '/' + p.slug
}
async function loadPages() {
if (!props.siteId) return
loadingPages.value = true
try {
const res = await getPages({ site_id: props.siteId })
const list = res.list || []
pageRows.value = list.map((p) => ({
title: p.title || p.slug,
path: effectivePath(p),
slug: p.slug
}))
} catch {
pageRows.value = []
} finally {
loadingPages.value = false
}
}
async function loadFiles() {
if (!props.siteId) return
loadingFiles.value = true
try {
const res = await getDownloadableAssets(props.siteId)
files.value = res.list || []
} catch {
files.value = []
} finally {
loadingFiles.value = false
}
}
function fileUrl(row) {
return `/api/web/sites/${props.siteId}/assets/${row.id}/download`
}
function confirm(url) {
if (!url) return
emit('select', url)
emit('update:modelValue', false)
}
function confirmCustom() {
const u = (customUrl.value || '').trim()
if (!u) {
ElMessage.warning('请输入地址')
return
}
confirm(u)
}
watch(
() => props.modelValue,
(v) => {
if (v) {
customUrl.value = ''
tab.value = 'pages'
loadPages()
loadFiles()
}
}
)
</script>

View File

@@ -0,0 +1,53 @@
<template>
<el-form-item label="入场动画">
<el-select v-model="enter" style="width: 130px" @change="sync">
<el-option label="无" value="none" />
<el-option label="淡入" value="fadeIn" />
<el-option label="上滑" value="slideUp" />
<el-option label="左滑" value="slideLeft" />
<el-option label="缩放" value="zoomIn" />
</el-select>
<el-input-number v-model="delay" :min="0" :max="5000" style="width: 110px; margin-left: 8px" @change="sync" />
<span class="hint">延迟ms</span>
<el-input-number v-model="duration" :min="100" :max="3000" style="width: 110px; margin-left: 8px" @change="sync" />
<span class="hint">时长ms</span>
</el-form-item>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
const enter = ref('fadeIn')
const delay = ref(0)
const duration = ref(600)
function fromModel() {
const a = props.modelValue || {}
enter.value = a.enter || 'fadeIn'
delay.value = a.delay_ms ?? 0
duration.value = a.duration_ms ?? 600
}
function sync() {
emit('update:modelValue', {
enter: enter.value,
delay_ms: delay.value,
duration_ms: duration.value
})
}
watch(() => props.modelValue, fromModel, { immediate: true, deep: true })
</script>
<style scoped>
.hint {
font-size: 12px;
color: #909399;
margin-left: 4px;
}
</style>

View File

@@ -0,0 +1,320 @@
<template>
<div class="page-builder-blocks">
<div v-for="(block, idx) in modelBlocks" :key="block.id || idx" class="block-card">
<div class="block-head">
<span class="block-type">{{ typeLabel(block.type) }}</span>
<div class="block-actions">
<el-button link type="primary" :disabled="idx === 0" @click="move(idx, -1)">上移</el-button>
<el-button link type="primary" :disabled="idx >= modelBlocks.length - 1" @click="move(idx, 1)">下移</el-button>
<el-button link type="danger" @click="remove(idx)">删除</el-button>
</div>
</div>
<template v-if="block.type === 'heading'">
<el-form label-width="88px" size="small">
<el-form-item label="标题文字">
<el-input v-model="block.props.text" placeholder="标题" />
</el-form-item>
<el-form-item label="级别 (1-6)">
<el-input-number v-model="block.props.level" :min="1" :max="6" />
</el-form-item>
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'text'">
<el-form label-width="88px" size="small">
<el-form-item label="HTML 模式">
<el-switch v-model="block.props.html" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="block.props.text" type="textarea" :rows="4" placeholder="纯文本或 HTML" />
</el-form-item>
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'link_list'">
<div class="link-items">
<div v-for="(it, j) in block.props.items" :key="j" class="link-row">
<el-input v-model="it.label" placeholder="显示文字" style="width: 110px" />
<el-input v-model="it.url" placeholder="链接" style="flex: 1; min-width: 120px" />
<el-select v-model="it.target" style="width: 95px">
<el-option label="当前页" value="_self" />
<el-option label="新窗口" value="_blank" />
</el-select>
<el-button type="primary" link @click="openPicker(it)">选择</el-button>
<el-button link type="danger" @click="block.props.items.splice(j, 1)"></el-button>
</div>
<el-button link type="primary" @click="block.props.items.push({ label: '', url: '', target: '_self' })">
+ 添加链接
</el-button>
</div>
<el-form label-width="88px" size="small" style="margin-top: 8px">
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'button'">
<el-form label-width="88px" size="small">
<el-form-item label="按钮文字">
<el-input v-model="block.props.text" />
</el-form-item>
<el-form-item label="链接">
<div class="url-row">
<el-input v-model="block.props.url" placeholder="#" />
<el-button type="primary" @click="openPicker(block.props, 'url')">选择链接</el-button>
</div>
</el-form-item>
<el-form-item label="样式">
<el-radio-group v-model="block.props.variant">
<el-radio-button label="primary">主色</el-radio-button>
<el-radio-button label="ghost">线框</el-radio-button>
</el-radio-group>
</el-form-item>
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'html'">
<el-input v-model="block.props.html" type="textarea" :rows="6" placeholder="HTML 片段" />
<el-form label-width="88px" size="small" style="margin-top: 8px">
<PageBuilderAnimFields v-model="block.animation" />
</el-form>
</template>
<template v-else-if="block.type === 'spacer'">
<el-form label-width="88px" size="small">
<el-form-item label="高度(px)">
<el-input-number v-model="block.props.height" :min="0" :max="500" />
</el-form-item>
</el-form>
</template>
<template v-else-if="block.type === 'divider'">
<span class="muted">分割线</span>
</template>
<template v-else-if="block.type === 'section'">
<el-form label-width="88px" size="small">
<el-form-item label="内边距">
<el-input v-model="block.props.padding" placeholder="24px 16px" />
</el-form-item>
<el-form-item label="最大宽度">
<el-input v-model="block.props.maxWidth" placeholder="960px" />
</el-form-item>
<el-form-item label="背景色">
<el-input v-model="block.props.background" placeholder="transparent" />
</el-form-item>
</el-form>
<div class="nested-label">区块内模块可继续嵌套</div>
<PageBuilderBlocks
:blocks="block.children || []"
:site-id="siteId"
@update:blocks="(v) => patchSectionChildren(idx, v)"
/>
</template>
<template v-else>
<span class="muted">未知类型 {{ block.type }}</span>
</template>
</div>
<div class="add-bar">
<el-dropdown trigger="click" @command="addBlock">
<el-button type="primary">
+ 添加模块 <el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="heading">标题</el-dropdown-item>
<el-dropdown-item command="text">段落文字</el-dropdown-item>
<el-dropdown-item command="link_list">链接组</el-dropdown-item>
<el-dropdown-item command="button">按钮</el-dropdown-item>
<el-dropdown-item command="html">HTML</el-dropdown-item>
<el-dropdown-item command="spacer">留白</el-dropdown-item>
<el-dropdown-item command="divider">分割线</el-dropdown-item>
<el-dropdown-item command="section">区块嵌套</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<LinkPickerDialog v-model="pickerVisible" :site-id="siteId" @select="onPicked" />
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import LinkPickerDialog from './LinkPickerDialog.vue'
import PageBuilderAnimFields from './PageBuilderAnimFields.vue'
defineOptions({ name: 'PageBuilderBlocks' })
const props = defineProps({
blocks: { type: Array, default: () => [] },
siteId: { type: String, default: '' }
})
const emit = defineEmits(['update:blocks'])
const modelBlocks = computed(() => props.blocks)
function typeLabel(t) {
const m = {
heading: '标题',
text: '段落',
link_list: '链接组',
button: '按钮',
html: 'HTML',
spacer: '留白',
divider: '分割线',
section: '区块'
}
return m[t] || t
}
function newId() {
return 'b-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8)
}
function addBlock(cmd) {
const list = [...props.blocks]
const b = {
id: newId(),
type: cmd,
props: {},
animation: { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
}
switch (cmd) {
case 'heading':
b.props = { text: '新标题', level: 2 }
break
case 'text':
b.props = { text: '段落内容', html: false }
break
case 'link_list':
b.props = { items: [{ label: '链接', url: '/', target: '_self' }] }
break
case 'button':
b.props = { text: '按钮', url: '#', variant: 'primary' }
break
case 'html':
b.props = { html: '<p>内容</p>' }
break
case 'spacer':
b.props = { height: 24 }
break
case 'divider':
b.props = {}
break
case 'section':
b.props = { padding: '24px 0', maxWidth: '960px', background: 'transparent' }
b.children = []
break
default:
b.props = {}
}
list.push(b)
emit('update:blocks', list)
}
function remove(idx) {
const list = [...props.blocks]
list.splice(idx, 1)
emit('update:blocks', list)
}
function move(idx, delta) {
const list = [...props.blocks]
const j = idx + delta
if (j < 0 || j >= list.length) return
const t = list[idx]
list[idx] = list[j]
list[j] = t
emit('update:blocks', list)
}
const pickerVisible = ref(false)
const pickTarget = ref(null)
function openPicker(target, key) {
if (key === 'url') pickTarget.value = { obj: target, key: 'url' }
else pickTarget.value = { item: target }
pickerVisible.value = true
}
function onPicked(url) {
const t = pickTarget.value
if (t?.item) t.item.url = url
if (t?.obj && t?.key) t.obj[t.key] = url
pickTarget.value = null
}
function patchSectionChildren(idx, children) {
const list = props.blocks.map((b, i) => (i === idx ? { ...b, children: [...children] } : b))
emit('update:blocks', list)
}
</script>
<style scoped>
.page-builder-blocks {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 12px;
background: #fafafa;
max-height: 62vh;
overflow-y: auto;
}
.block-card {
background: #fff;
border: 1px solid #dcdfe6;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.block-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.block-type {
font-weight: 600;
color: #409eff;
}
.link-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.url-row {
display: flex;
gap: 8px;
width: 100%;
align-items: center;
}
.url-row .el-input {
flex: 1;
}
.nested-label {
font-size: 13px;
color: #606266;
margin: 8px 0;
}
.add-bar {
margin-top: 8px;
}
.muted {
color: #909399;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<div class="page-builder-editor">
<PageBuilderBlocks v-model:blocks="blocks" :site-id="siteId" />
<el-collapse class="adv-collapse" accordion>
<el-collapse-item title="高级:直接编辑 JSON慎用" name="json">
<el-input
v-model="jsonDraft"
type="textarea"
:rows="10"
placeholder="修改后会覆盖上方可视化内容"
/>
<el-button type="warning" style="margin-top: 8px" @click="applyJsonDraft">应用 JSON</el-button>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import PageBuilderBlocks from './PageBuilderBlocks.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
siteId: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const blocks = ref([])
const jsonDraft = ref('')
let syncingFromParent = false
function normalizeBlock(b) {
if (!b.props) b.props = {}
if (!b.animation) b.animation = { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
if (b.type === 'link_list' && !Array.isArray(b.props.items)) b.props.items = []
if (b.type === 'section' && !Array.isArray(b.children)) b.children = []
if (Array.isArray(b.children)) b.children.forEach(normalizeBlock)
}
function parseFromString(s) {
syncingFromParent = true
try {
const j = JSON.parse(s || '{}')
const raw = Array.isArray(j.blocks) ? JSON.parse(JSON.stringify(j.blocks)) : []
raw.forEach(normalizeBlock)
blocks.value = raw
jsonDraft.value = JSON.stringify({ version: j.version || 1, blocks: blocks.value }, null, 2)
} catch {
blocks.value = []
jsonDraft.value = '{"version":1,"blocks":[]}'
}
syncingFromParent = false
}
function stringify() {
return JSON.stringify({ version: 1, blocks: blocks.value }, null, 2)
}
watch(
() => props.modelValue,
(v) => {
if (syncingFromParent) return
parseFromString(v)
},
{ immediate: true }
)
watch(
blocks,
() => {
if (syncingFromParent) return
const s = stringify()
jsonDraft.value = s
emit('update:modelValue', s)
},
{ deep: true }
)
function applyJsonDraft() {
try {
const j = JSON.parse(jsonDraft.value || '{}')
if (!Array.isArray(j.blocks)) {
ElMessage.error('JSON 须包含 blocks 数组')
return
}
syncingFromParent = true
blocks.value = JSON.parse(JSON.stringify(j.blocks))
syncingFromParent = false
emit('update:modelValue', stringify())
ElMessage.success('已应用')
} catch (e) {
ElMessage.error('JSON 格式错误')
}
}
</script>
<style scoped>
.page-builder-editor {
width: 100%;
}
.adv-collapse {
margin-top: 12px;
}
</style>

View File

@@ -50,7 +50,13 @@
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editId ? '编辑网页' : '新增网页'" width="720px" @close="resetForm">
<el-dialog
v-model="dialogVisible"
:title="editId ? '编辑网页' : '新增网页'"
:width="form.content_mode === 'builder' ? '960px' : '720px'"
top="4vh"
@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、indexindex 为首页数据,一般不单独走路由)" :disabled="!!editId" />
@@ -70,15 +76,18 @@
<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-option label="积木组装(可视化" 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="12" placeholder="HTML 模式直接写 HTML;积木模式为 JSON见项目 docs/PAGE_BUILDER.md" />
<el-form-item v-if="form.content_mode === 'html'" label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="14" placeholder="直接写 HTML" />
</el-form-item>
<el-form-item v-else label="页面积木" class="builder-form-item">
<PageBuilderEditor v-model="form.content" :site-id="siteId" />
</el-form-item>
</el-form>
<template #footer>
@@ -94,6 +103,7 @@ import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSites } from '../../api/admin'
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
import PageBuilderEditor from '../../components/PageBuilderEditor.vue'
const siteId = ref('')
const sites = ref([])
@@ -289,4 +299,8 @@ onMounted(() => {
justify-content: space-between;
align-items: center;
}
.builder-form-item :deep(.el-form-item__content) {
display: block;
margin-left: 0 !important;
}
</style>

View File

@@ -4,7 +4,8 @@
-**网页管理** 中创建页面,设置 **前台路径**`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
- **HTML**`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
- **积木builder**`content` 为 JSON结构如下前台按模块渲染并支持入场动画
- **积木builder**后台使用 **可视化编辑器**(添加模块、拖拽顺序、配置动画);链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON
- 存储仍为 JSON结构如下前台按模块渲染并支持入场动画。
## 动态路由