Files
hfb_sys/frontend/src/features/admin/views/AdminManualEntryView.vue
T
yml2213 cca40940d5 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。
2026-10-12 14:52:24 +08:00

306 lines
9.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>