feat(admin): 客服手动录单支持平台与操作人,并可后台配置可选项

- 录单列表新增「平台」「操作人」两列,录单表单与编辑弹窗新增同名字段下拉
- 新增 cs_manual_entry_options 表与 4 个接口(读取/新增/重命名/删除),页面右上角弹窗维护平台与操作人
- 迁移 000087:cs_manual_entries 增加 platform/operator 列与索引,注册 cs_manual_entry:option_manage 权限并授予 ops/super_admin
- 记录保存名称快照,删除选项不影响历史记录;同类型重名返回 409,跨类型同名允许
- 顶部关键词查询支持命中平台/操作人
- 新增单测:选项 CRUD 边界与录单平台/操作人读写
This commit is contained in:
yml2213
2026-10-13 16:22:57 +08:00
parent 9a52dbdb7b
commit f33dc97f6f
12 changed files with 1017 additions and 30 deletions
@@ -2,9 +2,14 @@ import { apiClient } from '@/shared/api/client'
import type { ApiResponse, PaginatedResult } from '@/shared/types/types'
/** 录单可选项类型:平台 / 操作人 */
export type ManualEntryOptionType = 'platform' | 'operator'
export interface ManualEntryItem {
id: number
entry_no: string
platform: string
operator: string
admin_id: number
admin_name: string
admin_username: string
@@ -12,6 +17,19 @@ export interface ManualEntryItem {
created_at: string
}
export interface ManualEntryOption {
id: number
type: ManualEntryOptionType
name: string
sort_order: number
created_at: string
}
export interface ManualEntryOptions {
platforms: ManualEntryOption[]
operators: ManualEntryOption[]
}
export interface ManualEntryAgentStats {
admin_id: number
admin_name: string
@@ -20,11 +38,15 @@ export interface ManualEntryAgentStats {
export interface CreateManualEntryRequest {
entry_no: string
platform?: string
operator?: string
remark?: string
}
export interface UpdateManualEntryRequest {
entry_no: string
platform?: string
operator?: string
remark?: string
}
@@ -79,3 +101,37 @@ export async function fetchManualEntryTodayStats() {
)
return data.data
}
/** 获取平台 / 操作人可选项(录单下拉数据源) */
export async function fetchManualEntryOptions() {
const { data } = await apiClient.get<ApiResponse<ManualEntryOptions>>(
'/admin/manual-entries/options'
)
return data.data
}
export async function createManualEntryOption(payload: {
type: ManualEntryOptionType
name: string
}) {
const { data } = await apiClient.post<ApiResponse<ManualEntryOption>>(
'/admin/manual-entries/options',
payload
)
return data.data
}
export async function updateManualEntryOption(id: number, name: string) {
const { data } = await apiClient.put<ApiResponse<ManualEntryOption>>(
`/admin/manual-entries/options/${id}`,
{ name }
)
return data.data
}
export async function deleteManualEntryOption(id: number) {
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
`/admin/manual-entries/options/${id}`
)
return data.data
}
@@ -13,6 +13,12 @@
margin-top: 20px;
}
.page-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.manual-entry-form-head {
display: flex;
align-items: baseline;
@@ -34,19 +40,22 @@
.manual-entry-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: end;
}
.manual-entry-form-grid :deep(.el-form-item) {
margin-bottom: 0;
}
.manual-entry-submit {
.full-control {
width: 100%;
}
.manual-entry-submit-row {
display: flex;
align-items: center;
height: 32px;
justify-content: flex-end;
margin-top: 18px;
}
.manual-entry-filter-row {
@@ -62,7 +71,7 @@
}
.filter-keyword {
width: 260px;
width: 320px;
}
.filter-date {
@@ -76,8 +85,87 @@
color: #1e3a8a;
}
/* 平台 / 操作人配置弹窗 */
.option-config-tip {
margin: 0 0 16px;
font-size: 12px;
color: #94a3b8;
}
.option-config-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.option-config-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.option-config-title {
font-size: 14px;
font-weight: 700;
color: #1e293b;
}
.option-config-add {
display: flex;
gap: 8px;
}
.option-config-list {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
max-height: 320px;
overflow-y: auto;
}
.option-config-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-radius: 8px;
background: #ffffff;
border: 1px solid #e2e8f0;
}
.option-config-name {
font-size: 13px;
color: #0f172a;
word-break: break-all;
}
.option-config-actions {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.option-config-actions :deep(.el-button) {
margin: 0;
}
.option-config-empty {
margin: 0;
font-size: 12px;
color: #94a3b8;
}
@media (max-width: 1100px) {
.manual-entry-form-grid {
.manual-entry-form-grid,
.option-config-grid {
grid-template-columns: 1fr;
}
}
@@ -1,13 +1,20 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createManualEntry,
createManualEntryOption,
deleteManualEntry,
deleteManualEntryOption,
fetchManualEntries,
fetchManualEntryOptions,
updateManualEntry,
updateManualEntryOption,
type ManualEntryItem,
type ManualEntryOption,
type ManualEntryOptionType,
type ManualEntryOptions,
} from '@/features/admin/api/adminManualEntry'
import { useAdminPaginatedTable } from '@/features/admin/composables/useAdminPaginatedTable'
import { useAdminSessionStore } from '@/stores/adminSession'
@@ -21,6 +28,7 @@ const currentAdminName = computed(() => adminSession.nickname || adminSession.us
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 canManageOptions = computed(() => adminSession.hasPermission('cs_manual_entry:option_manage'))
const filters = reactive({
keyword: '',
@@ -28,8 +36,13 @@ const filters = reactive({
dateRange: null as string[] | null,
})
const options = ref<ManualEntryOptions>({ platforms: [], operators: [] })
const optionsLoading = ref(false)
const entryForm = reactive({
entry_no: '',
platform: '',
operator: '',
remark: '',
})
@@ -54,6 +67,21 @@ const {
},
})
onMounted(() => {
void loadOptions()
})
async function loadOptions() {
optionsLoading.value = true
try {
options.value = await fetchManualEntryOptions()
} catch (error) {
ElMessage.error(readError(error, '获取平台与操作人选项失败'))
} finally {
optionsLoading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.entry_no = ''
@@ -86,10 +114,14 @@ async function handleCreate() {
try {
const result = await createManualEntry({
entry_no: entryNo,
platform: entryForm.platform,
operator: entryForm.operator,
remark: entryForm.remark,
})
ElMessage.success(`编号 ${result.entry.entry_no} 已登记到【${result.entry.admin_name}】`)
entryForm.entry_no = ''
entryForm.platform = ''
entryForm.operator = ''
entryForm.remark = ''
currentPage.value = 1
await loadEntries()
@@ -104,11 +136,35 @@ async function handleCreate() {
const editVisible = ref(false)
const editSubmitting = ref(false)
const editingEntry = ref<ManualEntryItem | null>(null)
const editForm = reactive({ entry_no: '', remark: '' })
const editForm = reactive({ entry_no: '', platform: '', operator: '', remark: '' })
/**
* 历史记录里的平台/操作人可能已被后台删除选项,
* 这里把当前值补进下拉,保证编辑时能正常显示与保留。
*/
function withCurrentValue(list: ManualEntryOption[], current: string) {
const items: { id: number | string; name: string }[] = list.map(item => ({
id: item.id,
name: item.name,
}))
if (current && !items.some(item => item.name === current)) {
items.unshift({ id: `current-${current}`, name: current })
}
return items
}
const editPlatformOptions = computed(() =>
withCurrentValue(options.value.platforms, editForm.platform)
)
const editOperatorOptions = computed(() =>
withCurrentValue(options.value.operators, editForm.operator)
)
function openEdit(row: ManualEntryItem) {
editingEntry.value = row
editForm.entry_no = row.entry_no
editForm.platform = row.platform || ''
editForm.operator = row.operator || ''
editForm.remark = row.remark
editVisible.value = true
}
@@ -124,6 +180,8 @@ async function handleUpdate() {
try {
await updateManualEntry(editingEntry.value.id, {
entry_no: entryNo,
platform: editForm.platform,
operator: editForm.operator,
remark: editForm.remark,
})
ElMessage.success('录单已更新')
@@ -154,6 +212,86 @@ async function handleDelete(row: ManualEntryItem) {
ElMessage.error(readError(error, '删除失败'))
}
}
// ---------- 平台 / 操作人配置 ----------
const configVisible = ref(false)
const optionSubmitting = ref(false)
const newPlatformName = ref('')
const newOperatorName = ref('')
function openConfig() {
configVisible.value = true
if (!options.value.platforms.length && !options.value.operators.length) {
void loadOptions()
}
}
async function handleAddOption(type: ManualEntryOptionType) {
const name = (type === 'platform' ? newPlatformName.value : newOperatorName.value).trim()
if (!name) {
ElMessage.warning('请输入名称')
return
}
optionSubmitting.value = true
try {
await createManualEntryOption({ type, name })
if (type === 'platform') newPlatformName.value = ''
else newOperatorName.value = ''
ElMessage.success('已添加')
await loadOptions()
} catch (error) {
ElMessage.error(readError(error, '添加失败'))
} finally {
optionSubmitting.value = false
}
}
async function handleRenameOption(item: ManualEntryOption) {
let input: string
try {
const result = await ElMessageBox.prompt('请输入新的名称', '重命名', {
inputValue: item.name,
inputPattern: /\S/,
inputErrorMessage: '名称不能为空',
confirmButtonText: '保存',
cancelButtonText: '取消',
})
input = result.value
} catch {
return // 用户取消
}
const name = input.trim()
if (!name || name === item.name) return
optionSubmitting.value = true
try {
await updateManualEntryOption(item.id, name)
ElMessage.success('已重命名')
await loadOptions()
} catch (error) {
ElMessage.error(readError(error, '重命名失败'))
} finally {
optionSubmitting.value = false
}
}
async function handleRemoveOption(item: ManualEntryOption) {
try {
await ElMessageBox.confirm(
`确定删除${item.type === 'platform' ? '平台' : '操作人'}【${item.name}】吗?历史录单记录不受影响。`,
'删除确认',
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return // 用户取消
}
try {
await deleteManualEntryOption(item.id)
ElMessage.success('已删除')
await loadOptions()
} catch (error) {
ElMessage.error(readError(error, '删除失败'))
}
}
</script>
<template>
@@ -165,9 +303,14 @@ async function handleDelete(row: ManualEntryItem) {
<p>
当前登录客服:
<strong>{{ currentAdminName }}</strong>
· 输入编号登记到本人名下,记录登记时间,支持查询与修正。
· 输入编号登记到本人名下,可选平台与操作人,支持查询与修正。
</p>
</div>
<div class="page-header-actions">
<el-button v-if="canManageOptions" :loading="optionsLoading" @click="openConfig">
配置平台/操作人
</el-button>
</div>
</div>
<!-- 录单表单 -->
@@ -186,6 +329,40 @@ async function handleDelete(row: ManualEntryItem) {
@keyup.enter="handleCreate"
/>
</el-form-item>
<el-form-item label="平台(可选)">
<el-select
v-model="entryForm.platform"
placeholder="请选择平台"
clearable
filterable
class="full-control"
:loading="optionsLoading"
>
<el-option
v-for="item in options.platforms"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="操作人(可选)">
<el-select
v-model="entryForm.operator"
placeholder="请选择操作人"
clearable
filterable
class="full-control"
:loading="optionsLoading"
>
<el-option
v-for="item in options.operators"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="备注(可选)">
<el-input
v-model="entryForm.remark"
@@ -195,11 +372,9 @@ async function handleDelete(row: ManualEntryItem) {
@keyup.enter="handleCreate"
/>
</el-form-item>
<div class="manual-entry-submit">
<el-button type="primary" :loading="submitting" @click="handleCreate">
提交登记
</el-button>
</div>
</div>
<div class="manual-entry-submit-row">
<el-button type="primary" :loading="submitting" @click="handleCreate"> 提交登记 </el-button>
</div>
</div>
@@ -215,7 +390,7 @@ async function handleDelete(row: ManualEntryItem) {
/>
<el-input
v-model="filters.keyword"
placeholder="按编号 / 客服名 / 登录名模糊查询"
placeholder="按编号 / 客服名 / 登录名 / 平台 / 操作人模糊查询"
clearable
class="filter-keyword"
@keyup.enter="applySearch"
@@ -235,11 +410,17 @@ async function handleDelete(row: ManualEntryItem) {
<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">
<el-table-column prop="entry_no" label="编号" width="160">
<template #default="{ row }">
<span class="mono-entry-no">{{ row.entry_no }}</span>
</template>
</el-table-column>
<el-table-column prop="platform" label="平台" min-width="120">
<template #default="{ row }">{{ row.platform || '-' }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" min-width="120">
<template #default="{ row }">{{ row.operator || '-' }}</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>
@@ -285,6 +466,38 @@ async function handleDelete(row: ManualEntryItem) {
clearable
/>
</el-form-item>
<el-form-item label="平台">
<el-select
v-model="editForm.platform"
placeholder="请选择平台"
clearable
filterable
class="full-control"
>
<el-option
v-for="item in editPlatformOptions"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="操作人">
<el-select
v-model="editForm.operator"
placeholder="请选择操作人"
clearable
filterable
class="full-control"
>
<el-option
v-for="item in editOperatorOptions"
:key="item.id"
:label="item.name"
:value="item.name"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="editForm.remark"
@@ -299,6 +512,91 @@ async function handleDelete(row: ManualEntryItem) {
<el-button type="primary" :loading="editSubmitting" @click="handleUpdate">保存</el-button>
</template>
</el-dialog>
<!-- 平台 / 操作人配置弹窗 -->
<el-dialog
v-model="configVisible"
title="配置平台与操作人"
width="720px"
destroy-on-close
class="manual-entry-config-dialog"
>
<p class="option-config-tip">
这里维护的可选项会出现在录单表单的下拉中;删除选项不会影响已登记的历史记录。
</p>
<div class="option-config-grid">
<section class="option-config-panel">
<header class="option-config-title">平台</header>
<div class="option-config-add">
<el-input
v-model="newPlatformName"
placeholder="新增平台名称"
maxlength="64"
clearable
@keyup.enter="handleAddOption('platform')"
/>
<el-button
type="primary"
:loading="optionSubmitting"
@click="handleAddOption('platform')"
>
添加
</el-button>
</div>
<ul class="option-config-list">
<li v-for="item in options.platforms" :key="item.id">
<span class="option-config-name">{{ item.name }}</span>
<span class="option-config-actions">
<el-button link type="primary" size="small" @click="handleRenameOption(item)">
重命名
</el-button>
<el-button link type="danger" size="small" @click="handleRemoveOption(item)">
删除
</el-button>
</span>
</li>
</ul>
<p v-if="!options.platforms.length" class="option-config-empty">暂无平台,请先添加</p>
</section>
<section class="option-config-panel">
<header class="option-config-title">操作人</header>
<div class="option-config-add">
<el-input
v-model="newOperatorName"
placeholder="新增操作人名称"
maxlength="64"
clearable
@keyup.enter="handleAddOption('operator')"
/>
<el-button
type="primary"
:loading="optionSubmitting"
@click="handleAddOption('operator')"
>
添加
</el-button>
</div>
<ul class="option-config-list">
<li v-for="item in options.operators" :key="item.id">
<span class="option-config-name">{{ item.name }}</span>
<span class="option-config-actions">
<el-button link type="primary" size="small" @click="handleRenameOption(item)">
重命名
</el-button>
<el-button link type="danger" size="small" @click="handleRemoveOption(item)">
删除
</el-button>
</span>
</li>
</ul>
<p v-if="!options.operators.length" class="option-config-empty">暂无操作人,请先添加</p>
</section>
</div>
<template #footer>
<el-button type="primary" @click="configVisible = false">关闭</el-button>
</template>
</el-dialog>
</section>
</template>