179 lines
4.6 KiB
Vue
179 lines
4.6 KiB
Vue
<template>
|
||
<div class="page-builder-editor">
|
||
<div class="pbe-split">
|
||
<div class="pbe-editor-col">
|
||
<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>
|
||
<aside class="pbe-preview-col">
|
||
<div class="pbe-preview-head">
|
||
<span>实时预览</span>
|
||
<el-text size="small" type="info">与前台样式接近,保存后线上一致</el-text>
|
||
</div>
|
||
<div class="pbe-preview-body">
|
||
<BlockRenderer v-if="blocks.length" :blocks="blocks" />
|
||
<el-empty v-else description="添加模块后此处显示效果" :image-size="80" />
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import PageBuilderBlocks from './PageBuilderBlocks.vue'
|
||
import BlockRenderer from '@yh-web/components/blocks/BlockRenderer.vue'
|
||
import '@yh-web/styles/page-animations.css'
|
||
|
||
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 newBlockId() {
|
||
return 'b-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
||
}
|
||
|
||
function normalizeBlock(b) {
|
||
if (!b.id) b.id = newBlockId()
|
||
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))
|
||
blocks.value.forEach(normalizeBlock)
|
||
syncingFromParent = false
|
||
emit('update:modelValue', stringify())
|
||
ElMessage.success('已应用')
|
||
} catch (e) {
|
||
ElMessage.error('JSON 格式错误')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-builder-editor {
|
||
width: 100%;
|
||
}
|
||
.pbe-split {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pbe-editor-col {
|
||
flex: 1 1 420px;
|
||
min-width: 320px;
|
||
}
|
||
.pbe-preview-col {
|
||
flex: 0 1 380px;
|
||
width: 100%;
|
||
max-width: 420px;
|
||
border: 1px solid #dcdfe6;
|
||
border-radius: 8px;
|
||
background: #0a0a12;
|
||
color: #e8e8ef;
|
||
overflow: hidden;
|
||
position: sticky;
|
||
top: 8px;
|
||
}
|
||
.pbe-preview-head {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
}
|
||
.pbe-preview-body {
|
||
padding: 16px;
|
||
max-height: min(62vh, 640px);
|
||
overflow: auto;
|
||
}
|
||
.pbe-preview-body :deep(.builder-heading),
|
||
.pbe-preview-body :deep(.builder-text),
|
||
.pbe-preview-body :deep(.builder-links a),
|
||
.pbe-preview-body :deep(.builder-btn) {
|
||
color: inherit;
|
||
}
|
||
.pbe-preview-body :deep(.builder-text) {
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
.pbe-preview-body :deep(.builder-links a) {
|
||
color: #7eb8ff;
|
||
}
|
||
.pbe-preview-body :deep(hr) {
|
||
border-color: rgba(255, 255, 255, 0.15) !important;
|
||
}
|
||
.adv-collapse {
|
||
margin-top: 12px;
|
||
}
|
||
</style>
|