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-table>
|
||||||
</el-card>
|
</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 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(index 为首页数据,一般不单独走路由)" :disabled="!!editId" />
|
<el-input v-model="form.slug" placeholder="如 about、index(index 为首页数据,一般不单独走路由)" :disabled="!!editId" />
|
||||||
@@ -70,15 +76,18 @@
|
|||||||
<el-form-item label="内容模式">
|
<el-form-item label="内容模式">
|
||||||
<el-select v-model="form.content_mode" placeholder="模式" style="width: 200px">
|
<el-select v-model="form.content_mode" placeholder="模式" style="width: 200px">
|
||||||
<el-option label="HTML 富文本" value="html" />
|
<el-option label="HTML 富文本" value="html" />
|
||||||
<el-option label="积木组装(JSON)" value="builder" />
|
<el-option label="积木组装(可视化)" value="builder" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button type="primary" link style="margin-left: 12px" @click="insertBuilderTemplate">插入积木模板</el-button>
|
<el-button type="primary" link style="margin-left: 12px" @click="insertBuilderTemplate">插入积木模板</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="发布到前台">
|
<el-form-item label="发布到前台">
|
||||||
<el-switch v-model="form.published" />
|
<el-switch v-model="form.published" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="内容" prop="content">
|
<el-form-item v-if="form.content_mode === 'html'" label="内容" prop="content">
|
||||||
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="HTML 模式直接写 HTML;积木模式为 JSON,见项目 docs/PAGE_BUILDER.md" />
|
<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-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -94,6 +103,7 @@ import { ref, reactive, onMounted, watch } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getSites } from '../../api/admin'
|
import { getSites } from '../../api/admin'
|
||||||
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
|
import { getPages, createPage, updatePage, deletePage } from '../../api/admin'
|
||||||
|
import PageBuilderEditor from '../../components/PageBuilderEditor.vue'
|
||||||
|
|
||||||
const siteId = ref('')
|
const siteId = ref('')
|
||||||
const sites = ref([])
|
const sites = ref([])
|
||||||
@@ -289,4 +299,8 @@ onMounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.builder-form-item :deep(.el-form-item__content) {
|
||||||
|
display: block;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
|
- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
|
||||||
- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
|
- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
|
||||||
- **积木(builder)**:`content` 为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
- **积木(builder)**:后台使用 **可视化编辑器**(添加模块、拖拽顺序、配置动画);链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
|
||||||
|
- 存储仍为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
||||||
|
|
||||||
## 动态路由
|
## 动态路由
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user