feat(admin): 新增客服手动录单功能与仪表盘今日录单统计

- 做了什么:
  - 新增客服手动录单数据库迁移 000084 及对应模型,支持编号唯一校验、客服快照绑定与 CRUD 操作;
  - 后端提供 csmanualentry 业务模块及相关 API,并在仪表盘摘要中聚合今日各客服录单数量;
  - 前端增加客服手动录单视图页面、路由与侧边栏导航,支持录单二次确认与细粒度权限控制;
  - 管理后台仪表盘增加今日客服手动录单卡片展示。
- 为什么:
  - 满足客服在后台手动登记外部业务编号需求,并便于运营按客服统计今日录单业绩和追溯对账。
- 验证方式:
  - 执行后端全量测试 go test ./... 与静态检查 go vet ./... 全部通过;
  - 执行前端代码门禁 npm run check 与 vite build 全部通过;
  - 执行 goose down/up 迁移回滚与应用测试通过,角色权限分配正确;
  - 本地 dev 联调 POST /api/admin/manual-entries 录单成功返回 201。
This commit is contained in:
yml2213
2026-10-12 14:52:24 +08:00
parent 5840b7da40
commit cca40940d5
20 changed files with 1267 additions and 12 deletions
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createManualEntry,
deleteManualEntry,
fetchManualEntries,
updateManualEntry,
type ManualEntryItem,
} from '@/features/admin/api/adminManualEntry'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime } from '@/shared/utils/time'
import { readError } from '@/shared/utils/error'
import AdminTablePagination from '../components/AdminTablePagination.vue'
const adminSession = useAdminSessionStore()
const currentAdminName = computed(() => adminSession.nickname || adminSession.username || '管理员')
const canCreate = computed(() => adminSession.hasPermission('cs_manual_entry:create'))
const canUpdate = computed(() => adminSession.hasPermission('cs_manual_entry:update'))
const canDelete = computed(() => adminSession.hasPermission('cs_manual_entry:delete'))
const filters = reactive({
keyword: '',
entry_no: '',
dateRange: null as string[] | null,
})
const entryForm = reactive({
entry_no: '',
remark: '',
})
const submitting = ref(false)
const {
loading,
data: entries,
total,
currentPage,
currentPageSize,
load: loadEntries,
} = useAdminPaginatedTable<ManualEntryItem>({
fetchFn: (page, pageSize) => {
const [startDate, endDate] = filters.dateRange || []
return fetchManualEntries(page, pageSize, {
keyword: filters.keyword || undefined,
entry_no: filters.entry_no || undefined,
start_date: startDate,
end_date: endDate,
})
},
})
function resetFilters() {
filters.keyword = ''
filters.entry_no = ''
filters.dateRange = null
currentPage.value = 1
void loadEntries()
}
function applySearch() {
currentPage.value = 1
void loadEntries()
}
async function handleCreate() {
const entryNo = entryForm.entry_no.trim()
if (!entryNo) {
ElMessage.warning('请输入编号')
return
}
try {
await ElMessageBox.confirm(
`确认将编号 ${entryNo} 登记到【${currentAdminName.value}】名下吗?`,
'录单确认',
{ confirmButtonText: '确认登记', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
}
submitting.value = true
try {
const result = await createManualEntry({
entry_no: entryNo,
remark: entryForm.remark,
})
ElMessage.success(`编号 ${result.entry.entry_no} 已登记到【${result.entry.admin_name}】`)
entryForm.entry_no = ''
entryForm.remark = ''
currentPage.value = 1
await loadEntries()
} catch (error) {
ElMessage.error(readError(error, '录单失败'))
} finally {
submitting.value = false
}
}
// 编辑(管理员修正录单错误)
const editVisible = ref(false)
const editSubmitting = ref(false)
const editingEntry = ref<ManualEntryItem | null>(null)
const editForm = reactive({ entry_no: '', remark: '' })
function openEdit(row: ManualEntryItem) {
editingEntry.value = row
editForm.entry_no = row.entry_no
editForm.remark = row.remark
editVisible.value = true
}
async function handleUpdate() {
if (!editingEntry.value) return
const entryNo = editForm.entry_no.trim()
if (!entryNo) {
ElMessage.warning('请输入编号')
return
}
editSubmitting.value = true
try {
await updateManualEntry(editingEntry.value.id, {
entry_no: entryNo,
remark: editForm.remark,
})
ElMessage.success('录单已更新')
editVisible.value = false
await loadEntries()
} catch (error) {
ElMessage.error(readError(error, '更新失败'))
} finally {
editSubmitting.value = false
}
}
async function handleDelete(row: ManualEntryItem) {
try {
await ElMessageBox.confirm(
`确定删除编号 ${row.entry_no} 的录单记录吗?此操作不可恢复。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
}
try {
await deleteManualEntry(row.id)
ElMessage.success('录单已删除')
await loadEntries()
} catch (error) {
ElMessage.error(readError(error, '删除失败'))
}
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Manual Entry</p>
<h1>客服手动录单</h1>
<p>
当前登录客服:
<strong>{{ currentAdminName }}</strong>
· 输入编号登记到本人名下,记录登记时间,支持查询与修正。
</p>
</div>
</div>
<!-- 录单表单 -->
<div v-if="canCreate" class="manual-entry-form-card">
<div class="manual-entry-form-head">
<span class="form-title">登记编号</span>
<span class="form-hint">提交前会弹出确认提示,请核对编号</span>
</div>
<div class="manual-entry-form-grid">
<el-form-item label="编号">
<el-input
v-model="entryForm.entry_no"
placeholder="例如:202610120013"
maxlength="64"
clearable
@keyup.enter="handleCreate"
/>
</el-form-item>
<el-form-item label="备注(可选)">
<el-input
v-model="entryForm.remark"
placeholder="选填备注信息"
maxlength="255"
clearable
@keyup.enter="handleCreate"
/>
</el-form-item>
<div class="manual-entry-submit">
<el-button type="primary" :loading="submitting" @click="handleCreate">
提交登记
</el-button>
</div>
</div>
</div>
<!-- 查询与列表 -->
<div class="manual-entry-list-card">
<div class="manual-entry-filter-row">
<el-input
v-model="filters.entry_no"
placeholder="按编号精确查询"
clearable
class="filter-entry-no"
@keyup.enter="applySearch"
/>
<el-input
v-model="filters.keyword"
placeholder="按编号 / 客服名 / 登录名模糊查询"
clearable
class="filter-keyword"
@keyup.enter="applySearch"
/>
<el-date-picker
v-model="filters.dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="filter-date"
/>
<el-button type="primary" :loading="loading" @click="applySearch"> 查询 </el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<el-table v-loading="loading" class="table-panel" :data="entries">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="entry_no" label="编号" min-width="160">
<template #default="{ row }">
<span class="mono-entry-no">{{ row.entry_no }}</span>
</template>
</el-table-column>
<el-table-column prop="admin_name" label="客服" min-width="120">
<template #default="{ row }">{{ row.admin_name || row.admin_username || '-' }}</template>
</el-table-column>
<el-table-column prop="admin_username" label="登录账号" min-width="140">
<template #default="{ row }">{{ row.admin_username || '-' }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column label="登记时间" min-width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column v-if="canUpdate || canDelete" label="操作" width="130" fixed="right">
<template #default="{ row }">
<el-button v-if="canUpdate" size="small" type="primary" link @click="openEdit(row)">
编辑
</el-button>
<el-button v-if="canDelete" size="small" type="danger" link @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<AdminTablePagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:loading="loading"
@page-change="loadEntries"
/>
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑录单" width="480px" destroy-on-close>
<el-form label-position="top" @submit.prevent>
<el-form-item label="编号">
<el-input
v-model="editForm.entry_no"
placeholder="例如:202610120013"
maxlength="64"
clearable
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="editForm.remark"
placeholder="选填备注信息"
maxlength="255"
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="handleUpdate">保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped src="./AdminManualEntryView.css"></style>