feat(admin): 积木页面可视化编辑与链接选择器(站内页/可下载文件)
Made-with: Cursor
This commit is contained in:
143
admin/src/components/LinkPickerDialog.vue
Normal file
143
admin/src/components/LinkPickerDialog.vue
Normal 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>
|
||||
53
admin/src/components/PageBuilderAnimFields.vue
Normal file
53
admin/src/components/PageBuilderAnimFields.vue
Normal 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>
|
||||
320
admin/src/components/PageBuilderBlocks.vue
Normal file
320
admin/src/components/PageBuilderBlocks.vue
Normal 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>
|
||||
105
admin/src/components/PageBuilderEditor.vue
Normal file
105
admin/src/components/PageBuilderEditor.vue
Normal 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>
|
||||
@@ -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、index(index 为首页数据,一般不单独走路由)" :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>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
|
||||
- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
|
||||
- **积木(builder)**:`content` 为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
||||
- **积木(builder)**:后台使用 **可视化编辑器**(添加模块、拖拽顺序、配置动画);链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
|
||||
- 存储仍为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
||||
|
||||
## 动态路由
|
||||
|
||||
|
||||
Reference in New Issue
Block a user