- 后台全面引入 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>
273 行
7.6 KiB
Vue
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>
|