454 行
15 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">
<NSpace :size="8" align="center">
<NButtonGroup>
<NButton
v-for="tab in tabs" :key="tab.value"
:type="filterType === tab.value ? 'primary' : 'default'"
@click="onFilterChange(tab.value)"
>{{ tab.label }}</NButton>
</NButtonGroup>
<NButtonGroup>
<NButton
v-for="tab in statusTabs" :key="tab.value"
:type="filterStatus === tab.value ? 'primary' : 'default'"
@click="onStatusChange(tab.value)"
>{{ tab.label }}</NButton>
</NButtonGroup>
</NSpace>
<NInput
v-model:value="search"
placeholder="搜索姓名、手机..."
clearable
style="width: 240px;"
@update:value="onSearch"
/>
</NSpace>
<NDataTable
:columns="columns"
:data="filteredList"
: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="520">
<NDrawerContent title="提交详情" closable>
<template v-if="currentItem">
<NDescriptions bordered :column="1" label-placement="left" size="small">
<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="微信" v-if="currentItem.data?.wechat">{{ currentItem.data?.wechat }}</NDescriptionsItem>
<NDescriptionsItem label="内容">
<div style="white-space: pre-wrap;">{{ currentItem.data?.message || currentItem.data?.remarks || '(无)' }}</div>
</NDescriptionsItem>
<NDescriptionsItem label="状态">
<NSpace :size="4">
<NTag :type="statusTag(currentItem).type" size="small">{{ statusTag(currentItem).label }}</NTag>
<NTag v-if="currentItem.repliedAt" type="default" size="small">
回复于 {{ formatTime(currentItem.repliedAt) }}
</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem label="提交时间">{{ formatTime(currentItem.createdAt) }}</NDescriptionsItem>
<NDescriptionsItem label="IP" v-if="currentItem.ip">{{ currentItem.ip }}</NDescriptionsItem>
</NDescriptions>
<template v-if="extraFields.length > 0">
<h4 style="margin: 16px 0 8px;">其他信息</h4>
<NDescriptions bordered :column="1" label-placement="left" size="small">
<NDescriptionsItem v-for="field in extraFields" :key="field.key" :label="field.key">
{{ field.value }}
</NDescriptionsItem>
</NDescriptions>
</template>
</template>
<template #footer>
<NSpace>
<NButton
v-if="currentItem && !currentItem.read"
@click="markRead(currentItem)"
>标记已读</NButton>
<NButton
v-if="currentItem && !currentItem.replied"
type="primary"
@click="markReplied(currentItem)"
>标记已回复</NButton>
<NButton
v-if="currentItem && currentItem.replied"
@click="unmarkReplied(currentItem)"
>撤销已回复</NButton>
<NButton @click="openEdit(currentItem)">编辑</NButton>
</NSpace>
</template>
</NDrawerContent>
</NDrawer>
<!-- 编辑弹窗 -->
<NModal v-model:show="editVisible" preset="card" title="编辑提交" style="width: 540px;">
<NForm
ref="editFormRef"
:model="editForm"
:rules="editRules"
label-placement="left"
label-width="70"
>
<NFormItem label="姓名" path="name">
<NInput v-model:value="editForm.name" placeholder="姓名" />
</NFormItem>
<NFormItem label="手机" path="phone">
<NInput v-model:value="editForm.phone" placeholder="11 位手机号" />
</NFormItem>
<NFormItem label="邮箱">
<NInput v-model:value="editForm.email" placeholder="可选" />
</NFormItem>
<NFormItem label="微信">
<NInput v-model:value="editForm.wechat" placeholder="可选" />
</NFormItem>
<NFormItem label="内容">
<NInput
v-model:value="editForm.message"
type="textarea"
:rows="4"
placeholder="留言内容 / 备注"
/>
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="editVisible = false">取消</NButton>
<NButton type="primary" :loading="saving" @click="saveEdit">保存</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
<script setup>
import {
NButton, NTag, NDataTable, NInput, NSpace, NDrawer, NDrawerContent,
NDescriptions, NDescriptionsItem, NButtonGroup, NPagination,
NModal, NForm, NFormItem, useDialog, useMessage
} from 'naive-ui'
definePageMeta({ layout: 'admin', middleware: 'admin' })
const { adminFetch } = useAdmin()
const message = useMessage()
const dialog = useDialog()
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 filterStatus = ref('')
const drawerVisible = ref(false)
const currentItem = ref(null)
const editVisible = ref(false)
const editFormRef = ref(null)
const saving = ref(false)
const editingId = ref(null)
const editForm = reactive({ name: '', phone: '', email: '', wechat: '', message: '' })
const editRules = {
name: { required: true, message: '请输入姓名', trigger: 'blur' },
phone: {
required: true,
trigger: 'blur',
validator(_rule, v) {
if (!v) return new Error('请输入手机号')
if (!/^\d{6,15}$/.test(v)) return new Error('手机号格式不正确')
return true
}
}
}
const tabs = [
{ label: '全部类型', value: '' },
{ label: '留言', value: 'contact' },
{ label: '定制', value: 'customize' },
]
const statusTabs = [
{ label: '全部状态', value: '' },
{ label: '未读', value: 'unread' },
{ label: '已读未回', value: 'read' },
{ label: '已回复', value: 'replied' },
]
const filteredList = computed(() => {
if (!filterStatus.value) return list.value
return list.value.filter(r => {
if (filterStatus.value === 'unread') return !r.read && !r.replied
if (filterStatus.value === 'read') return r.read && !r.replied
if (filterStatus.value === 'replied') return r.replied
return true
})
})
const extraFields = computed(() => {
if (!currentItem.value?.data) return []
const knownKeys = ['name', 'phone', 'email', 'wechat', 'message', 'remarks']
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', ' ')
}
function statusTag(row) {
if (row.replied) return { type: 'success', label: '已回复' }
if (row.read) return { type: 'info', label: '已读' }
return { type: 'warning', label: '未读' }
}
const columns = [
{
title: 'ID', key: 'id', width: 60,
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
},
{
title: '类型', key: 'type', width: 70,
render: (row) => h(NTag, {
type: row.type === 'contact' ? 'info' : 'warning',
size: 'small',
}, () => row.type === 'contact' ? '留言' : '定制')
},
{
title: '姓名', key: 'name', width: 100,
render: (row) => row.data?.name || ''
},
{
title: '手机', key: 'phone', width: 130,
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
},
{
title: '内容', key: 'message', ellipsis: { tooltip: true },
render: (row) => {
const msg = row.data?.message || row.data?.remarks || ''
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, msg.length > 50 ? msg.slice(0, 50) + '...' : msg)
}
},
{
title: '提交时间', key: 'createdAt', width: 150,
render: (row) => h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
},
{
title: '状态', key: 'status', width: 80,
render: (row) => {
const s = statusTag(row)
return h(NTag, { type: s.type, size: 'small' }, () => s.label)
}
},
{
title: '操作', key: 'actions', width: 260,
render(row) {
return h(NSpace, { size: 4 }, () => [
h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '查看'),
h(NButton, { size: 'small', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
!row.replied
? h(NButton, { size: 'small', quaternary: true, type: 'primary', onClick: () => markReplied(row) }, () => '标记已回复')
: h(NButton, { size: 'small', quaternary: true, onClick: () => unmarkReplied(row) }, () => '撤销回复'),
h(NButton, { size: 'small', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, () => '删除'),
])
}
},
]
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 onStatusChange(status) {
filterStatus.value = status
}
function openDetail(row) {
currentItem.value = row
drawerVisible.value = true
// 查看即标记已读
if (!row.read) markRead(row, { silent: true })
}
function openEdit(row) {
editingId.value = row.id
editForm.name = row.data?.name || ''
editForm.phone = row.data?.phone || ''
editForm.email = row.data?.email || ''
editForm.wechat = row.data?.wechat || ''
editForm.message = row.data?.message || row.data?.remarks || ''
editVisible.value = true
}
async function saveEdit() {
if (!editFormRef.value) return
try {
await editFormRef.value.validate()
} catch (e) {
return
}
saving.value = true
try {
const row = list.value.find(r => r.id === editingId.value)
if (!row) {
message.error('记录不存在')
editVisible.value = false
return
}
// 合并:保留其它 data 字段,只覆盖已编辑字段
const nextData = { ...(row.data || {}) }
nextData.name = editForm.name
nextData.phone = editForm.phone
if (editForm.email !== undefined) nextData.email = editForm.email
if (editForm.wechat !== undefined) nextData.wechat = editForm.wechat
// message 字段:原始用 message 或 remarks,按原来有哪个更新哪个
if ('remarks' in nextData && !('message' in nextData)) {
nextData.remarks = editForm.message
} else {
nextData.message = editForm.message
}
const updated = await adminFetch(`/api/admin/submissions/${editingId.value}`, {
method: 'PUT',
body: { data: nextData },
})
Object.assign(row, updated)
if (currentItem.value?.id === editingId.value) currentItem.value = updated
message.success('已保存')
editVisible.value = false
} catch (e) {
message.error('保存失败:' + (e.data?.message || e.message || '未知错误'))
} finally {
saving.value = false
}
}
async function markRead(row, opts = {}) {
try {
await adminFetch(`/api/admin/submissions/${row.id}`, {
method: 'PUT',
body: { read: true },
})
row.read = true
if (!opts.silent) message.success('已标记为已读')
} catch (e) {
if (!opts.silent) message.error('操作失败')
}
}
async function markReplied(row) {
try {
const updated = await adminFetch(`/api/admin/submissions/${row.id}`, {
method: 'PUT',
body: { replied: true },
})
Object.assign(row, updated)
if (currentItem.value?.id === row.id) currentItem.value = updated
message.success('已标记为已回复')
} catch (e) {
message.error('操作失败')
}
}
async function unmarkReplied(row) {
try {
const updated = await adminFetch(`/api/admin/submissions/${row.id}`, {
method: 'PUT',
body: { replied: false },
})
Object.assign(row, updated)
if (currentItem.value?.id === row.id) currentItem.value = updated
message.success('已撤销回复')
} catch (e) {
message.error('操作失败')
}
}
function confirmDelete(row) {
dialog.warning({
title: '确认删除',
content: `确定要删除来自「${row.data?.name || '匿名'}」(ID ${row.id}) 的提交记录吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await adminFetch(`/api/admin/submissions/${row.id}`, { method: 'DELETE' })
list.value = list.value.filter(r => r.id !== row.id)
if (currentItem.value?.id === row.id) {
drawerVisible.value = false
currentItem.value = null
}
total.value = Math.max(0, total.value - 1)
message.success('已删除')
} catch (e) {
message.error('删除失败')
}
}
})
}
onMounted(loadData)
</script>