284 行
12 KiB
Vue
284 行
12 KiB
Vue
<template>
|
||
<div style="max-width: 1100px">
|
||
<n-space justify="space-between" align="center" style="margin-bottom: 20px">
|
||
<div>
|
||
<h1 style="margin: 0 0 4px; font-size: 20px; font-weight: 600; color: #111827">定制游怎么选 (文章)</h1>
|
||
<p style="margin: 0; font-size: 13px; color: #9ca3af">编辑官网「/guide-custom」定制游对比文章内容</p>
|
||
</div>
|
||
<n-space>
|
||
<n-button tag="a" href="/guide-custom" target="_blank" secondary>查看前台 ↗</n-button>
|
||
<n-button type="primary" :loading="saving" @click="save">保存</n-button>
|
||
</n-space>
|
||
</n-space>
|
||
|
||
<n-spin :show="!loaded">
|
||
<n-space vertical :size="16">
|
||
<!-- 标题 & TL;DR -->
|
||
<n-card title="基础信息">
|
||
<n-form label-placement="top">
|
||
<n-form-item label="文章标题(Hero 大标题)">
|
||
<n-input v-model:value="data.title" />
|
||
</n-form-item>
|
||
<n-form-item label="TL;DR 摘要(顶部绿色边框内的一段话)">
|
||
<n-input v-model:value="data.tldr" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" />
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-card>
|
||
|
||
<!-- SEO -->
|
||
<n-card title="SEO 配置">
|
||
<n-form label-placement="top">
|
||
<n-form-item label="页面 Title (<title>)">
|
||
<n-input v-model:value="data.seo.title" />
|
||
</n-form-item>
|
||
<n-form-item label="Meta Description">
|
||
<n-input v-model:value="data.seo.description" type="textarea" :autosize="{ minRows: 2, maxRows: 4 }" />
|
||
</n-form-item>
|
||
<n-form-item label="Meta Keywords">
|
||
<n-input v-model:value="data.seo.keywords" />
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-card>
|
||
|
||
<!-- 文章章节 -->
|
||
<n-card title="文章章节">
|
||
<template #header-extra>
|
||
<n-space>
|
||
<n-button size="small" @click="addSection('text')">+ 段落</n-button>
|
||
<n-button size="small" @click="addSection('list')">+ 列表</n-button>
|
||
<n-button size="small" @click="addSection('table')">+ 对比表</n-button>
|
||
</n-space>
|
||
</template>
|
||
|
||
<n-space vertical :size="16">
|
||
<div
|
||
v-for="(sec, sIdx) in data.sections"
|
||
:key="sIdx"
|
||
style="border: 1px solid #e5e7eb; border-radius: 6px; padding: 14px; background: #fafafa"
|
||
>
|
||
<n-space justify="space-between" align="center" style="margin-bottom: 10px">
|
||
<n-tag :bordered="false" size="small">{{ typeLabel(sec.type) }} #{{ sIdx + 1 }}</n-tag>
|
||
<n-space :size="4">
|
||
<n-button size="tiny" :disabled="sIdx === 0" @click="moveSection(sIdx, -1)">↑</n-button>
|
||
<n-button size="tiny" :disabled="sIdx === data.sections.length - 1" @click="moveSection(sIdx, 1)">↓</n-button>
|
||
<n-button size="tiny" type="error" quaternary @click="data.sections.splice(sIdx, 1)">删除</n-button>
|
||
</n-space>
|
||
</n-space>
|
||
|
||
<n-form label-placement="top">
|
||
<n-form-item label="h2 标题">
|
||
<n-input v-model:value="sec.heading" />
|
||
</n-form-item>
|
||
|
||
<!-- 文本段落 -->
|
||
<template v-if="sec.type === 'text'">
|
||
<n-form-item label="段落(每行一段)">
|
||
<n-space vertical :size="8" style="width: 100%">
|
||
<div v-for="(_, pIdx) in sec.paragraphs" :key="pIdx" style="display: flex; gap: 8px">
|
||
<n-input
|
||
v-model:value="sec.paragraphs[pIdx]"
|
||
type="textarea"
|
||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||
style="flex: 1"
|
||
/>
|
||
<n-button size="small" type="error" quaternary @click="sec.paragraphs.splice(pIdx, 1)">删</n-button>
|
||
</div>
|
||
<n-button size="small" dashed @click="sec.paragraphs.push('')">+ 添加段落</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
</template>
|
||
|
||
<!-- 列表 -->
|
||
<template v-else-if="sec.type === 'list'">
|
||
<n-form-item label="引言(列表上方的文字,可空)">
|
||
<n-input v-model:value="sec.intro" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
|
||
</n-form-item>
|
||
<n-form-item label="列表项">
|
||
<n-space vertical :size="6" style="width: 100%">
|
||
<div v-for="(_, iIdx) in sec.items" :key="iIdx" style="display: flex; gap: 8px">
|
||
<n-input v-model:value="sec.items[iIdx]" style="flex: 1" />
|
||
<n-button size="small" type="error" quaternary @click="sec.items.splice(iIdx, 1)">删</n-button>
|
||
</div>
|
||
<n-button size="small" dashed @click="sec.items.push('')">+ 添加条目</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
<n-form-item label="结语(列表下方的文字,可空)">
|
||
<n-input v-model:value="sec.outro" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
|
||
</n-form-item>
|
||
</template>
|
||
|
||
<!-- 对比表 -->
|
||
<template v-else-if="sec.type === 'table'">
|
||
<n-form-item label="列名(从左到右,首列会被高亮绿色)">
|
||
<n-space vertical :size="6" style="width: 100%">
|
||
<div v-for="(_, cIdx) in sec.columns" :key="cIdx" style="display: flex; gap: 8px">
|
||
<n-input v-model:value="sec.columns[cIdx]" style="flex: 1" />
|
||
<n-button size="small" type="error" quaternary @click="removeColumn(sec, cIdx)">删列</n-button>
|
||
</div>
|
||
<n-button size="small" dashed @click="addColumn(sec)">+ 添加列</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
<n-form-item label="行">
|
||
<n-space vertical :size="10" style="width: 100%">
|
||
<div
|
||
v-for="(row, rIdx) in sec.rows"
|
||
:key="rIdx"
|
||
style="display: flex; flex-direction: column; gap: 6px; border: 1px dashed #d1d5db; padding: 10px; border-radius: 4px"
|
||
>
|
||
<div style="display: flex; gap: 8px; align-items: center">
|
||
<span style="font-size: 12px; color: #6b7280; min-width: 40px">行标:</span>
|
||
<n-input v-model:value="row.label" style="flex: 1" />
|
||
<n-button size="tiny" type="error" quaternary @click="sec.rows.splice(rIdx, 1)">删行</n-button>
|
||
</div>
|
||
<div
|
||
v-for="(_, vIdx) in row.values"
|
||
:key="vIdx"
|
||
style="display: flex; gap: 8px; align-items: center"
|
||
>
|
||
<span style="font-size: 12px; color: #6b7280; min-width: 40px">{{ sec.columns[vIdx] || `列${vIdx + 1}` }}:</span>
|
||
<n-input v-model:value="row.values[vIdx]" style="flex: 1" />
|
||
</div>
|
||
</div>
|
||
<n-button size="small" dashed @click="addRow(sec)">+ 添加行</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
</template>
|
||
</n-form>
|
||
</div>
|
||
</n-space>
|
||
</n-card>
|
||
|
||
<!-- CTA -->
|
||
<n-card title="底部 CTA">
|
||
<n-form label-placement="top">
|
||
<n-form-item label="标题">
|
||
<n-input v-model:value="data.cta.heading" />
|
||
</n-form-item>
|
||
<n-form-item label="按钮">
|
||
<n-space vertical :size="10" style="width: 100%">
|
||
<div
|
||
v-for="(link, lIdx) in data.cta.links"
|
||
:key="lIdx"
|
||
style="border: 1px dashed #d1d5db; padding: 10px; border-radius: 4px"
|
||
>
|
||
<n-grid :cols="2" :x-gap="10">
|
||
<n-gi>
|
||
<n-form-item label="按钮文字" :show-feedback="false">
|
||
<n-input v-model:value="link.text" />
|
||
</n-form-item>
|
||
</n-gi>
|
||
<n-gi>
|
||
<n-form-item label="跳转路径" :show-feedback="false">
|
||
<n-input v-model:value="link.to" />
|
||
</n-form-item>
|
||
</n-gi>
|
||
</n-grid>
|
||
<n-space style="margin-top: 8px" justify="space-between" align="center">
|
||
<n-checkbox v-model:checked="link.primary">主按钮(绿色填充)</n-checkbox>
|
||
<n-button size="tiny" type="error" quaternary @click="data.cta.links.splice(lIdx, 1)">删除</n-button>
|
||
</n-space>
|
||
</div>
|
||
<n-button size="small" dashed @click="data.cta.links.push({ text: '', to: '/', primary: false })">+ 添加按钮</n-button>
|
||
</n-space>
|
||
</n-form-item>
|
||
</n-form>
|
||
</n-card>
|
||
</n-space>
|
||
</n-spin>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { NButton, NCard, NSpace, NInput, NForm, NFormItem, NSpin, NGrid, NGi, NCheckbox, NTag } from 'naive-ui'
|
||
import { useMessage } from 'naive-ui'
|
||
|
||
definePageMeta({ layout: 'admin', middleware: 'admin' })
|
||
const { adminFetch } = useAdmin()
|
||
const message = useMessage()
|
||
|
||
const defaultData = () => ({
|
||
title: '',
|
||
tldr: '',
|
||
seo: { title: '', description: '', keywords: '' },
|
||
sections: [],
|
||
cta: { heading: '', links: [] },
|
||
})
|
||
|
||
const data = ref(defaultData())
|
||
const loaded = ref(false)
|
||
const saving = ref(false)
|
||
|
||
function typeLabel(type) {
|
||
return { text: '段落', list: '列表', table: '对比表' }[type] || type
|
||
}
|
||
|
||
function addSection(type) {
|
||
const templates = {
|
||
text: { type: 'text', heading: '', paragraphs: [''] },
|
||
list: { type: 'list', heading: '', intro: '', items: [''], outro: '' },
|
||
table: { type: 'table', heading: '', columns: ['列1', '列2'], rows: [{ label: '', values: ['', ''] }] },
|
||
}
|
||
data.value.sections.push(templates[type])
|
||
}
|
||
|
||
function moveSection(idx, delta) {
|
||
const target = idx + delta
|
||
if (target < 0 || target >= data.value.sections.length) return
|
||
const arr = data.value.sections
|
||
;[arr[idx], arr[target]] = [arr[target], arr[idx]]
|
||
}
|
||
|
||
function addColumn(sec) {
|
||
sec.columns.push('')
|
||
for (const row of sec.rows) row.values.push('')
|
||
}
|
||
|
||
function removeColumn(sec, cIdx) {
|
||
sec.columns.splice(cIdx, 1)
|
||
for (const row of sec.rows) row.values.splice(cIdx, 1)
|
||
}
|
||
|
||
function addRow(sec) {
|
||
sec.rows.push({ label: '', values: sec.columns.map(() => '') })
|
||
}
|
||
|
||
async function load() {
|
||
try {
|
||
const res = await adminFetch('/api/admin/site-content?key=guide-custom')
|
||
const d = res.data || {}
|
||
// 合并默认结构,防止字段缺失
|
||
data.value = {
|
||
title: d.title || '',
|
||
tldr: d.tldr || '',
|
||
seo: { title: '', description: '', keywords: '', ...(d.seo || {}) },
|
||
sections: d.sections || [],
|
||
cta: {
|
||
heading: d.cta?.heading || '',
|
||
links: d.cta?.links || [],
|
||
},
|
||
}
|
||
} catch (e) {
|
||
data.value = defaultData()
|
||
} finally {
|
||
loaded.value = true
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
saving.value = true
|
||
try {
|
||
await adminFetch('/api/admin/site-content', {
|
||
method: 'PUT',
|
||
body: { key: 'guide-custom', data: data.value },
|
||
})
|
||
message.success('已保存')
|
||
} catch (e) {
|
||
message.error('保存失败:' + (e.data?.message || ''))
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|