feat: 官网代码全量更新 (2026-04-18)
这个提交包含在:
+275
-94
@@ -2,18 +2,27 @@
|
||||
<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>
|
||||
<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>
|
||||
<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="搜索姓名、手机..."
|
||||
@@ -25,7 +34,7 @@
|
||||
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:data="filteredList"
|
||||
:loading="loading"
|
||||
:row-key="row => row.id"
|
||||
:bordered="false"
|
||||
@@ -43,10 +52,10 @@
|
||||
</NSpace>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<NDrawer v-model:show="drawerVisible" :width="480">
|
||||
<NDrawer v-model:show="drawerVisible" :width="520">
|
||||
<NDrawerContent title="提交详情" closable>
|
||||
<template v-if="currentItem">
|
||||
<NDescriptions bordered :column="1" label-placement="left">
|
||||
<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">
|
||||
@@ -56,20 +65,24 @@
|
||||
<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 }}</div>
|
||||
<div style="white-space: pre-wrap;">{{ currentItem.data?.message || currentItem.data?.remarks || '(无)' }}</div>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="状态">
|
||||
<NTag :type="currentItem.read ? 'success' : 'warning'" size="small">
|
||||
{{ currentItem.read ? '已读' : '未读' }}
|
||||
</NTag>
|
||||
<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>
|
||||
<!-- 显示 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">
|
||||
<NDescriptions bordered :column="1" label-placement="left" size="small">
|
||||
<NDescriptionsItem v-for="field in extraFields" :key="field.key" :label="field.key">
|
||||
{{ field.value }}
|
||||
</NDescriptionsItem>
|
||||
@@ -77,24 +90,77 @@
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<NButton
|
||||
v-if="currentItem && !currentItem.read"
|
||||
type="primary"
|
||||
@click="markRead(currentItem)"
|
||||
>标记已读</NButton>
|
||||
<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, NBadge, NButtonGroup, NPagination } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
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)
|
||||
@@ -103,18 +169,53 @@ 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: '' },
|
||||
{ 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', 'message']
|
||||
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) }))
|
||||
@@ -125,90 +226,60 @@ function formatTime(t) {
|
||||
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) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.id)
|
||||
}
|
||||
title: 'ID', key: 'id', width: 60,
|
||||
render: (row) => 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: '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) {
|
||||
return row.data?.name || ''
|
||||
}
|
||||
title: '姓名', key: 'name', width: 100,
|
||||
render: (row) => row.data?.name || ''
|
||||
},
|
||||
{
|
||||
title: '手机',
|
||||
key: 'phone',
|
||||
width: 130,
|
||||
render(row) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, row.data?.phone || '')
|
||||
}
|
||||
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 || ''
|
||||
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) {
|
||||
return h('span', { style: 'color: #9ca3af; font-size: 12px;' }, formatTime(row.createdAt))
|
||||
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: 'read',
|
||||
width: 70,
|
||||
title: '操作', key: 'actions', width: 260,
|
||||
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,
|
||||
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) }, () => '删除'),
|
||||
])
|
||||
}
|
||||
},
|
||||
@@ -250,23 +321,133 @@ function onFilterChange(type) {
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onStatusChange(status) {
|
||||
filterStatus.value = status
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
currentItem.value = row
|
||||
drawerVisible.value = true
|
||||
// 查看即标记已读
|
||||
if (!row.read) markRead(row, { silent: true })
|
||||
}
|
||||
|
||||
async function markRead(row) {
|
||||
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
|
||||
message.success('已标记为已读')
|
||||
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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户