454 行
15 KiB
Vue
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>
|