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
@@ -72,10 +72,17 @@ export interface DashboardRecentDispute {
created_at: string
}
export interface ManualEntryTodayStats {
admin_id: number
admin_name: string
count: number
}
export interface AdminDashboard {
metrics: DashboardMetrics
pending: DashboardPending
listing_daily: ListingDailyOverview
manual_entry_today: ManualEntryTodayStats[]
recent_orders: DashboardRecentOrder[]
recent_disputes: DashboardRecentDispute[]
generated_at: string
@@ -104,6 +111,7 @@ export async function fetchAdminDashboard() {
days: listingDaily?.days ?? 7,
timezone: listingDaily?.timezone ?? 'Asia/Shanghai',
},
manual_entry_today: payload.manual_entry_today ?? [],
recent_orders: payload.recent_orders ?? [],
recent_disputes: payload.recent_disputes ?? [],
}
@@ -0,0 +1,81 @@
import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
export interface ManualEntryItem {
id: number
entry_no: string
admin_id: number
admin_name: string
admin_username: string
remark: string
created_at: string
}
export interface ManualEntryAgentStats {
admin_id: number
admin_name: string
count: number
}
export interface CreateManualEntryRequest {
entry_no: string
remark?: string
}
export interface UpdateManualEntryRequest {
entry_no: string
remark?: string
}
export interface ManualEntryListQuery {
entry_no?: string
keyword?: string
admin_id?: number
start_date?: string
end_date?: string
}
export async function fetchManualEntries(
page = 1,
pageSize = 20,
query: ManualEntryListQuery = {}
) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ManualEntryItem>>>(
'/admin/manual-entries',
{
params: { page, page_size: pageSize, ...query },
}
)
return data.data
}
export async function createManualEntry(payload: CreateManualEntryRequest) {
const { data } = await apiClient.post<ApiResponse<{ entry: ManualEntryItem }>>(
'/admin/manual-entries',
payload
)
return data.data
}
export async function updateManualEntry(id: number, payload: UpdateManualEntryRequest) {
const { data } = await apiClient.put<ApiResponse<ManualEntryItem>>(
`/admin/manual-entries/${id}`,
payload
)
return data.data
}
export async function deleteManualEntry(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
`/admin/manual-entries/${id}`
)
return data.data
}
export async function fetchManualEntryTodayStats() {
const { data } = await apiClient.get<ApiResponse<ManualEntryAgentStats[]>>(
'/admin/manual-entries/today-stats'
)
return data.data
}
+1
View File
@@ -2,6 +2,7 @@
export * from './api/adminDashboard'
export * from './api/adminUsers'
export * from './api/adminMgr'
export * from './api/adminManualEntry'
export * from './api/adminWallet'
export * from './api/adminPayments'
export * from './api/adminFinance'
@@ -557,6 +557,20 @@
white-space: nowrap;
}
/* 今日客服手动录单统计:与外部上传区分开,用绿色系 */
.listing-uploader-item.manual-entry-item {
border-color: #bbf7d0;
background: #f0fdf4;
}
.listing-uploader-item.manual-entry-item .uploader-name {
color: #14532d;
}
.listing-uploader-item.manual-entry-item .uploader-total {
color: #16a34a;
}
.channel-badge {
font-weight: 600;
}
@@ -26,6 +26,7 @@ import {
type ListingChannelDayStats,
type ListingDayStats,
type ListingUploaderDayStats,
type ManualEntryTodayStats,
} from '@/features/admin/api/adminDashboard'
import { useAdminTable } from '@/features/admin/composables/useAdminTable'
import { formatCentWithSymbol } from '@/shared/utils/money'
@@ -78,6 +79,12 @@ const listingTodayUploaders = computed<ListingUploaderDayStats[]>(() => {
)
})
const manualEntryToday = computed<ManualEntryTodayStats[]>(() =>
[...(dashboard.value?.manual_entry_today ?? [])].sort(
(a, b) => b.count - a.count || a.admin_name.localeCompare(b.admin_name)
)
)
function leaveCount(row: ListingChannelDayStats | ListingDayStats) {
return Number(row.active_offline_count || 0) + Number(row.trade_leave_count || 0)
}
@@ -336,6 +343,21 @@ function shortDate(date: string) {
</div>
</div>
<div v-if="manualEntryToday.length" class="listing-uploader-block">
<div class="channel-summary-title">今日客服手动录单统计</div>
<div class="listing-uploader-summary">
<div
v-for="row in manualEntryToday"
:key="`today-manual-${row.admin_id}-${row.admin_name}`"
class="listing-uploader-item manual-entry-item"
>
<span class="uploader-name">{{ row.admin_name || '未填写' }}</span>
<strong class="uploader-total">{{ row.count }}</strong>
<span class="uploader-unit">个</span>
</div>
</div>
</div>
<!-- 7日上下架趋势明细表 -->
<div class="table-container trend-table-wrap">
<el-table class="custom-enterprise-table" :data="listingTrend" size="small" stripe>
@@ -0,0 +1,83 @@
.manual-entry-form-card,
.manual-entry-list-card {
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 16px;
padding: 22px 24px;
box-shadow:
0 1px 3px rgba(15, 23, 42, 0.04),
0 4px 12px rgba(15, 23, 42, 0.02);
}
.manual-entry-list-card {
margin-top: 20px;
}
.manual-entry-form-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.form-title {
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.form-hint {
font-size: 12px;
color: #94a3b8;
}
.manual-entry-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
gap: 16px;
align-items: end;
}
.manual-entry-form-grid :deep(.el-form-item) {
margin-bottom: 0;
}
.manual-entry-submit {
display: flex;
align-items: center;
height: 32px;
}
.manual-entry-filter-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.filter-entry-no {
width: 200px;
}
.filter-keyword {
width: 260px;
}
.filter-date {
width: 260px;
}
.mono-entry-no {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
font-weight: 600;
color: #1e3a8a;
}
@media (max-width: 1100px) {
.manual-entry-form-grid {
grid-template-columns: 1fr;
}
}
@@ -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>
+7
View File
@@ -7,6 +7,7 @@ import {
DataLine,
Document,
DocumentChecked,
EditPen,
Expand,
Fold,
House,
@@ -204,6 +205,12 @@ const allNavGroups: NavGroup[] = [
{ label: '客服群聊', to: adminPath('chats'), icon: ChatDotRound, permission: 'chat:view' },
{ label: '二维码池', to: adminPath('qrcode-pool'), icon: Picture, permission: 'qrcode:view' },
{ label: '客服分组', to: adminPath('support-groups'), icon: User, permission: 'chat:manage' },
{
label: '手动录单',
to: adminPath('manual-entries'),
icon: EditPen,
permission: 'cs_manual_entry:view',
},
],
},
{
+6
View File
@@ -120,6 +120,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/features/admin/views/AdminSupportGroupsView.vue'),
meta: adminMeta,
},
{
path: adminPath('manual-entries'),
name: 'admin-manual-entries',
component: () => import('@/features/admin/views/AdminManualEntryView.vue'),
meta: adminMeta,
},
{
path: adminPath('finance/dashboard'),
name: 'admin-finance-dashboard',