Mimingguang ee078b61bc feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 Naive UI 组件库,统一 UI 规范
- 前台 usePublicApi 切换到 API 调用(GET /api/content/[key])
- 前后台数据源统一(site_content 表)
- 产品线统一管理(tour/camp/course 三套编辑器)
- 产品数据归一化(itinerary/faq/pricing 格式统一)
- 表单提交 API 改写入 submissions 表
- 新增 submissions 标记已读 API
- site-content PUT 支持 upsert
- 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb)
- 消除 PageHero 类型警告(useSEO reactive 修复)
- 25 个前台页面全部 HTTP 200,展示效果不变

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:48:02 +08:00

194 行
7.1 KiB
Vue

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

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

<template>
<div style="max-width: 1200px">
<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">管理所有产品线 · 支持动态增删</p>
</div>
<n-button type="primary" @click="showAddModal = true">新增产品线</n-button>
</n-space>
<n-spin :show="loading">
<n-grid :cols="1" :y-gap="12">
<n-gi v-for="(item, index) in productLines" :key="item.key">
<n-card hoverable>
<n-space justify="space-between" align="center">
<n-space align="center" :size="16">
<span style="font-size: 14px; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: #f0f7f1; border-radius: 8px; font-weight: 600; color: #3a7d44">{{ item.name?.charAt(0) || 'P' }}</span>
<div>
<div style="font-size: 16px; font-weight: 600; color: #111827">
{{ item.name }}
<n-tag v-if="item.version" size="small" type="info" :bordered="false" style="margin-left: 8px">{{ item.version }}</n-tag>
<n-tag size="small" :type="seasonType(item.season)" :bordered="false" style="margin-left: 4px">{{ seasonLabel(item.season) }}</n-tag>
<n-tag size="small" :bordered="false" style="margin-left: 4px">{{ item.type === 'camp' ? '营地' : '线路' }}</n-tag>
</div>
<div style="font-size: 13px; color: #9ca3af; margin-top: 2px">
key: {{ item.key }} · 排序: {{ item.sortOrder }}
</div>
</div>
</n-space>
<n-space>
<n-button size="small" @click="moveUp(index)" :disabled="index === 0">上移</n-button>
<n-button size="small" @click="moveDown(index)" :disabled="index === productLines.length - 1">下移</n-button>
<n-button size="small" type="primary" @click="navigateTo(`/admin/products/${item.key}`)">编辑内容</n-button>
<n-popconfirm @positive-click="handleDelete(index)">
<template #trigger>
<n-button size="small" type="error" quaternary>删除</n-button>
</template>
确定删除{{ item.name }}产品线
</n-popconfirm>
</n-space>
</n-space>
</n-card>
</n-gi>
</n-grid>
<n-empty v-if="!loading && productLines.length === 0" description="暂无产品线" style="padding: 60px 0" />
</n-spin>
<!-- 新增弹窗 -->
<n-modal v-model:show="showAddModal" preset="dialog" title="新增产品线" positive-text="确定" negative-text="取消" @positive-click="handleAdd">
<n-form label-placement="top" style="margin-top: 16px">
<n-form-item label="产品线名称" required>
<n-input v-model:value="newItem.name" placeholder="如:额吉的故乡" />
</n-form-item>
<n-grid :cols="2" :x-gap="12">
<n-gi>
<n-form-item label="Key唯一标识" required>
<n-input v-model:value="newItem.key" placeholder="如spring-products" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="版本号">
<n-input v-model:value="newItem.version" placeholder="如V1" />
</n-form-item>
</n-gi>
</n-grid>
<n-grid :cols="2" :x-gap="12">
<n-gi>
<n-form-item label="季节">
<n-select v-model:value="newItem.season" :options="seasonOptions" />
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="类型">
<n-select v-model:value="newItem.type" :options="[{ label: '线路产品', value: 'tour' }, { label: '营地/研学', value: 'camp' }]" />
</n-form-item>
</n-gi>
</n-grid>
</n-form>
</n-modal>
</div>
</template>
<script setup>
import { NButton, NCard, NSpace, NTag, NGrid, NGi, NSpin, NEmpty, NModal, NForm, NFormItem, NInput, NSelect, NPopconfirm } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const loading = ref(false)
const productLines = ref([])
const showAddModal = ref(false)
const seasonOptions = [
{ label: '春季', value: 'spring' },
{ label: '夏季', value: 'summer' },
{ label: '秋季', value: 'autumn' },
{ label: '冬季', value: 'winter' },
{ label: '全年', value: 'all' },
]
const newItem = ref({ name: '', key: '', version: '', season: 'summer', type: 'tour' })
function seasonLabel(s) {
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬', all: '全年' }[s] || s
}
function seasonType(s) {
return { spring: 'success', summer: 'success', autumn: 'warning', winter: 'info', all: 'default' }[s] || 'default'
}
async function loadData() {
loading.value = true
try {
const res = await adminFetch('/api/admin/site-content?key=product-lines')
productLines.value = typeof res.data === 'string' ? JSON.parse(res.data) : (res.data || [])
} catch { message.error('加载失败') }
finally { loading.value = false }
}
async function saveList() {
try {
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: { key: 'product-lines', data: productLines.value },
})
} catch { message.error('保存失败') }
}
async function handleAdd() {
if (!newItem.value.name?.trim() || !newItem.value.key?.trim()) {
message.warning('名称和 Key 不能为空')
return false
}
if (productLines.value.some(p => p.key === newItem.value.key)) {
message.warning('Key 已存在')
return false
}
// Add to list
productLines.value.push({
...newItem.value,
sortOrder: productLines.value.length + 1,
})
await saveList()
// Create empty product data entry in site_content
await adminFetch('/api/admin/site-content', {
method: 'PUT',
body: {
key: newItem.value.key,
label: newItem.value.name,
data: {
narrative: '',
highlights: [],
versions: [],
faq: [],
},
},
})
message.success('已添加')
newItem.value = { name: '', key: '', version: '', season: 'summer', type: 'tour', icon: '🌿' }
showAddModal.value = false
}
function moveUp(index) {
if (index <= 0) return
const arr = productLines.value
;[arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]
arr.forEach((item, i) => { item.sortOrder = i + 1 })
saveList()
}
function moveDown(index) {
if (index >= productLines.value.length - 1) return
const arr = productLines.value
;[arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]
arr.forEach((item, i) => { item.sortOrder = i + 1 })
saveList()
}
async function handleDelete(index) {
productLines.value.splice(index, 1)
productLines.value.forEach((item, i) => { item.sortOrder = i + 1 })
await saveList()
message.success('已删除')
}
onMounted(loadData)
</script>