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

273 行
7.6 KiB
Vue

<template>
<div style="max-width: 1200px;">
<div style="margin-bottom: 20px;">
<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>
<NSpace vertical :size="16">
<NSpace align="center" justify="space-between">
<NButtonGroup>
<NButton
v-for="tab in tabs" :key="tab.value"
:type="filterType === tab.value ? 'primary' : 'default'"
@click="onFilterChange(tab.value)"
>{{ tab.label }}</NButton>
</NButtonGroup>
<NInput
v-model:value="search"
placeholder="搜索姓名、手机..."
clearable
style="width: 240px;"
@update:value="onSearch"
/>
</NSpace>
<NDataTable
:columns="columns"
:data="list"
:loading="loading"
:row-key="row => row.id"
:bordered="false"
:single-line="false"
/>
<NSpace justify="end" v-if="totalPages > 1">
<NPagination
:page="page"
:page-count="totalPages"
:page-size="20"
@update:page="onPageChange"
/>
</NSpace>
</NSpace>
<!-- 详情抽屉 -->
<NDrawer v-model:show="drawerVisible" :width="480">
<NDrawerContent title="提交详情" closable>
<template v-if="currentItem">
<NDescriptions bordered :column="1" label-placement="left">
<NDescriptionsItem label="ID">{{ currentItem.id }}</NDescriptionsItem>
<NDescriptionsItem label="类型">
<NTag :type="currentItem.type === 'contact' ? 'info' : 'warning'" size="small">
{{ currentItem.type === 'contact' ? '留言' : '定制' }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem label="姓名">{{ currentItem.data?.name }}</NDescriptionsItem>
<NDescriptionsItem label="手机">{{ currentItem.data?.phone }}</NDescriptionsItem>
<NDescriptionsItem label="邮箱" v-if="currentItem.data?.email">{{ currentItem.data?.email }}</NDescriptionsItem>
<NDescriptionsItem label="内容">
<div style="white-space: pre-wrap;">{{ currentItem.data?.message }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="状态">
<NTag :type="currentItem.read ? 'success' : 'warning'" size="small">
{{ currentItem.read ? '已读' : '未读' }}
</NTag>
</NDescriptionsItem>
<NDescriptionsItem label="提交时间">{{ formatTime(currentItem.createdAt) }}</NDescriptionsItem>
</NDescriptions>
<!-- 显示 data 中除 name/phone/email/message 外的其他字段 -->
<template v-if="extraFields.length > 0">
<h4 style="margin: 16px 0 8px;">其他信息</h4>
<NDescriptions bordered :column="1" label-placement="left">
<NDescriptionsItem v-for="field in extraFields" :key="field.key" :label="field.key">
{{ field.value }}
</NDescriptionsItem>
</NDescriptions>
</template>
</template>
<template #footer>
<NButton
v-if="currentItem && !currentItem.read"
type="primary"
@click="markRead(currentItem)"
>标记已读</NButton>
</template>
</NDrawerContent>
</NDrawer>
</div>
</template>
<script setup>
import { NButton, NTag, NDataTable, NInput, NSpace, NDrawer, NDrawerContent, NDescriptions, NDescriptionsItem, NBadge, NButtonGroup, NPagination } from 'naive-ui'
import { useMessage } from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const list = ref([])
const total = ref(0)
const page = ref(1)
const totalPages = ref(1)
const loading = ref(false)
const search = ref('')
const filterType = ref('')
const drawerVisible = ref(false)
const currentItem = ref(null)
const tabs = [
{ label: '全部', value: '' },
{ label: '留言', value: 'contact' },
{ label: '定制', value: 'customize' },
]
const extraFields = computed(() => {
if (!currentItem.value?.data) return []
const knownKeys = ['name', 'phone', 'email', 'message']
return Object.entries(currentItem.value.data)
.filter(([k]) => !knownKeys.includes(k))
.map(([key, value]) => ({ key, value: typeof value === 'object' ? JSON.stringify(value) : String(value) }))
})
function formatTime(t) {
if (!t) return ''
return t.slice(0, 16).replace('T', ' ')
}
const columns = [
{
title: 'ID',
key: 'id',
width: 60,
render(row) {
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
}
},
{
title: '类型',
key: 'type',
width: 80,
render(row) {
return h(NTag, {
type: row.type === 'contact' ? 'info' : 'warning',
size: 'small',
}, () => row.type === 'contact' ? '留言' : '定制')
}
},
{
title: '姓名',
key: 'name',
width: 100,
render(row) {
return row.data?.name || ''
}
},
{
title: '手机',
key: 'phone',
width: 130,
render(row) {
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
}
},
{
title: '内容',
key: 'message',
ellipsis: { tooltip: true },
render(row) {
const msg = row.data?.message || ''
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, msg.length > 50 ? msg.slice(0, 50) + '...' : msg)
}
},
{
title: '提交时间',
key: 'createdAt',
width: 150,
render(row) {
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
}
},
{
title: '状态',
key: 'read',
width: 70,
render(row) {
return h(NTag, {
type: row.read ? 'success' : 'warning',
size: 'small',
}, () => row.read ? '已读' : '未读')
}
},
{
title: '操作',
key: 'actions',
width: 160,
render(row) {
return h(NSpace, { size: 8 }, () => [
h(NButton, {
size: 'small',
quaternary: true,
type: 'info',
onClick: () => openDetail(row),
}, () => '查看'),
!row.read
? h(NButton, {
size: 'small',
quaternary: true,
type: 'primary',
onClick: () => markRead(row),
}, () => '标记已读')
: null,
])
}
},
]
let searchTimer = null
async function loadData() {
loading.value = true
try {
let url = `/api/admin/submissions?page=${page.value}&limit=20&search=${encodeURIComponent(search.value)}`
if (filterType.value) url += `&type=${filterType.value}`
const d = await adminFetch(url)
list.value = d.items
total.value = d.total
totalPages.value = d.totalPages
} catch (e) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
page.value = 1
loadData()
}, 300)
}
function onPageChange(p) {
page.value = p
loadData()
}
function onFilterChange(type) {
filterType.value = type
page.value = 1
loadData()
}
function openDetail(row) {
currentItem.value = row
drawerVisible.value = true
}
async function markRead(row) {
try {
await adminFetch(`/api/admin/submissions/${row.id}`, {
method: 'PUT',
body: { read: true },
})
row.read = true
message.success('已标记为已读')
} catch (e) {
message.error('操作失败')
}
}
onMounted(loadData)
</script>