hulai-website/pages/admin/content/guide-custom.vue

284 行
12 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<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>