- 人员页:编辑抽屉内区域/计划子表编辑;产品页:类别阈值提示条; 客户页:归属变更二次确认含「不回溯历史」+变更记录抽屉 - 评级页:五项评分实时出等级(纯前端判定,不落库 #25)+ 等级映射维护抽屉 - 菜单两组:基础数据(8-10)/业务数据(14);金额字符串直出禁 float Co-Authored-By: Claude Code <noreply@anthropic.com>
563 lines
20 KiB
Vue
563 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||
|
||
import * as customerApi from '@/api/customers'
|
||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||
import * as personnelApi from '@/api/personnel'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { formatDateTime } from '@/utils/format'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||
*
|
||
* 与后端约定对齐:
|
||
* - 编辑弹窗不含归属字段 —— 归属调整是独立操作(「归属变更」按钮),
|
||
* 走 PUT /{id}/ownership,是唯一写归属留痕的入口
|
||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||
* (对照表 #19 的业务口径)
|
||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||
*/
|
||
const { confirmAndRun } = useConfirmAction()
|
||
|
||
// ==================== 列表 ====================
|
||
|
||
const filters = reactive({
|
||
keyword: '',
|
||
customer_type: undefined as string | undefined,
|
||
owner_personnel_id: undefined as number | undefined,
|
||
})
|
||
|
||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
|
||
customerApi.listCustomers,
|
||
() => ({
|
||
keyword: filters.keyword,
|
||
customer_type: filters.customer_type,
|
||
owner_personnel_id: filters.owner_personnel_id,
|
||
}),
|
||
)
|
||
|
||
function resetFilters(): void {
|
||
filters.keyword = ''
|
||
filters.customer_type = undefined
|
||
filters.owner_personnel_id = undefined
|
||
search()
|
||
}
|
||
|
||
// ==================== 归属人员下拉数据源 ====================
|
||
|
||
interface PersonnelOption {
|
||
id: number
|
||
name: string
|
||
status: number
|
||
}
|
||
|
||
const ownerOptions = ref<PersonnelOption[]>([])
|
||
|
||
async function loadOwnerOptions(): Promise<void> {
|
||
try {
|
||
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||
// 极端场景由后端对归属合法性的校验兜底
|
||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||
ownerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||
} catch (error) {
|
||
handledError(error) // 下拉为空不阻断列表使用
|
||
}
|
||
}
|
||
|
||
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
|
||
function ownerLabel(option: PersonnelOption): string {
|
||
return option.status === 1 ? option.name : `${option.name}(离职)`
|
||
}
|
||
|
||
function personnelName(id: number): string {
|
||
return ownerOptions.value.find((o) => o.id === id)?.name ?? `人员#${id}`
|
||
}
|
||
|
||
// ==================== 新增 / 编辑 ====================
|
||
|
||
const dialogVisible = ref(false)
|
||
const editingId = ref<number | null>(null)
|
||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||
const isEdit = computed(() => editingId.value !== null)
|
||
const formRef = ref<FormInstance>()
|
||
const submitting = ref(false)
|
||
|
||
const form = reactive({
|
||
company_name: '',
|
||
owner_name: '',
|
||
contact_person: '',
|
||
contact_phone: '',
|
||
address: '',
|
||
industry: '',
|
||
customer_type: '',
|
||
owner_personnel_id: undefined as number | undefined,
|
||
remark: '',
|
||
})
|
||
|
||
const rules = computed<FormRules>(() => ({
|
||
company_name: [
|
||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||
{ max: 128, message: '公司名称最长 128 个字符', trigger: 'blur' },
|
||
],
|
||
owner_name: [
|
||
{ required: true, message: '请输入客户负责人', trigger: 'blur' },
|
||
{ max: 64, message: '负责人最长 64 个字符', trigger: 'blur' },
|
||
],
|
||
contact_person: [{ max: 64, message: '联系人最长 64 个字符', trigger: 'blur' }],
|
||
contact_phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
|
||
address: [{ max: 255, message: '地址最长 255 个字符', trigger: 'blur' }],
|
||
industry: [{ max: 64, message: '行业最长 64 个字符', trigger: 'blur' }],
|
||
customer_type: [{ required: true, message: '请选择客户分类', trigger: 'change' }],
|
||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||
}))
|
||
|
||
function openCreate(): void {
|
||
editingId.value = null
|
||
Object.assign(form, {
|
||
company_name: '',
|
||
owner_name: '',
|
||
contact_person: '',
|
||
contact_phone: '',
|
||
address: '',
|
||
industry: '',
|
||
customer_type: '',
|
||
owner_personnel_id: undefined,
|
||
remark: '',
|
||
})
|
||
dialogVisible.value = true
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
function openEdit(row: CustomerItem): void {
|
||
editingId.value = row.id
|
||
Object.assign(form, {
|
||
company_name: row.company_name,
|
||
owner_name: row.owner_name,
|
||
contact_person: row.contact_person ?? '',
|
||
contact_phone: row.contact_phone ?? '',
|
||
address: row.address ?? '',
|
||
industry: row.industry ?? '',
|
||
customer_type: row.customer_type,
|
||
owner_personnel_id: undefined, // 编辑弹窗不含归属,归属走「归属变更」按钮
|
||
remark: row.remark ?? '',
|
||
})
|
||
dialogVisible.value = true
|
||
formRef.value?.clearValidate()
|
||
}
|
||
|
||
async function handleSubmit(): Promise<void> {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
|
||
submitting.value = true
|
||
try {
|
||
if (editingId.value !== null) {
|
||
// 编辑端点不含归属:归属调整统一走「归属变更」,保证留痕完整
|
||
const { owner_personnel_id: _ignored, ...rest } = form
|
||
await customerApi.updateCustomer(editingId.value, {
|
||
...rest,
|
||
company_name: rest.company_name.trim(),
|
||
owner_name: rest.owner_name.trim(),
|
||
contact_person: rest.contact_person.trim() || null,
|
||
contact_phone: rest.contact_phone.trim() || null,
|
||
address: rest.address.trim() || null,
|
||
industry: rest.industry.trim() || null,
|
||
remark: rest.remark.trim() || null,
|
||
})
|
||
ElMessage.success('已保存')
|
||
} else {
|
||
await customerApi.createCustomer({
|
||
company_name: form.company_name.trim(),
|
||
owner_name: form.owner_name.trim(),
|
||
contact_person: form.contact_person.trim() || null,
|
||
contact_phone: form.contact_phone.trim() || null,
|
||
address: form.address.trim() || null,
|
||
industry: form.industry.trim() || null,
|
||
customer_type: form.customer_type,
|
||
owner_personnel_id: form.owner_personnel_id ?? null,
|
||
remark: form.remark.trim() || null,
|
||
})
|
||
ElMessage.success('客户已创建')
|
||
}
|
||
dialogVisible.value = false
|
||
await load()
|
||
} catch (error) {
|
||
handledError(error) // 失败提示由请求拦截器统一完成
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
// ==================== 归属变更 ====================
|
||
|
||
const ownershipVisible = ref(false)
|
||
const ownershipTarget = ref<CustomerItem | null>(null)
|
||
const ownershipSubmitting = ref(false)
|
||
const ownershipForm = reactive({
|
||
new_owner_personnel_id: undefined as number | undefined,
|
||
reason: '',
|
||
})
|
||
|
||
const ownershipCurrentLabel = computed(() => {
|
||
const target = ownershipTarget.value
|
||
if (!target) return '—'
|
||
return target.owner_personnel_name ?? '(无归属)'
|
||
})
|
||
|
||
function openOwnership(row: CustomerItem): void {
|
||
ownershipTarget.value = row
|
||
ownershipForm.new_owner_personnel_id = row.owner_personnel_id ?? undefined
|
||
ownershipForm.reason = ''
|
||
ownershipVisible.value = true
|
||
}
|
||
|
||
async function handleOwnershipSubmit(): Promise<void> {
|
||
const target = ownershipTarget.value
|
||
if (!target) return
|
||
|
||
const nextId = ownershipForm.new_owner_personnel_id ?? null
|
||
const currentId = target.owner_personnel_id ?? null
|
||
if (nextId === currentId) {
|
||
ElMessage.warning('归属未变化,请选择新的归属人员,或清空下拉后提交以清空归属')
|
||
return
|
||
}
|
||
|
||
const nextName = nextId === null ? '(清空归属)' : personnelName(nextId)
|
||
await confirmAndRun({
|
||
// 业务口径(对照表 #19):仅影响变更后核算,不回溯历史 —— 文案必须保留
|
||
confirmText: `确定调整客户「${target.company_name}」的归属为「${nextName}」?归属变更仅影响变更后核算,不回溯历史。`,
|
||
title: '归属变更确认',
|
||
confirmButtonText: '确认变更',
|
||
successText: '归属已调整',
|
||
run: async () => {
|
||
ownershipSubmitting.value = true
|
||
try {
|
||
await customerApi.changeOwnership(target.id, {
|
||
new_owner_personnel_id: nextId,
|
||
reason: ownershipForm.reason.trim() || null,
|
||
})
|
||
} finally {
|
||
ownershipSubmitting.value = false
|
||
}
|
||
},
|
||
after: async () => {
|
||
ownershipVisible.value = false
|
||
await load()
|
||
// 归属记录抽屉开着且正是该客户时同步刷新
|
||
if (logsVisible.value && logsCustomerId.value === target.id) await loadLogs()
|
||
},
|
||
})
|
||
}
|
||
|
||
// ==================== 归属变更记录 ====================
|
||
|
||
const logsVisible = ref(false)
|
||
const logsCustomerId = ref<number | null>(null)
|
||
const logsCustomerName = ref('')
|
||
const logsTitle = computed(() =>
|
||
logsCustomerId.value === null ? '归属变更记录' : `「${logsCustomerName.value}」· 归属变更记录`,
|
||
)
|
||
|
||
const {
|
||
records: logRecords,
|
||
total: logTotal,
|
||
pageNum: logPageNum,
|
||
pageSize: logPageSize,
|
||
loading: logsLoading,
|
||
load: loadLogs,
|
||
onPageChange: onLogsPageChange,
|
||
onSizeChange: onLogsSizeChange,
|
||
} = usePagedList<OwnershipLogItem, object>(
|
||
(params) => {
|
||
const id = logsCustomerId.value
|
||
// 抽屉未指定客户时兜底空数据,避免发出无意义请求
|
||
return id === null
|
||
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
|
||
: customerApi.listOwnershipLogs(id, params)
|
||
},
|
||
() => ({}),
|
||
)
|
||
|
||
function openLogs(row: CustomerItem): void {
|
||
logsCustomerId.value = row.id
|
||
logsCustomerName.value = row.company_name
|
||
logPageNum.value = 1
|
||
logsVisible.value = true
|
||
void loadLogs()
|
||
}
|
||
|
||
/** 留痕展示:新旧归属任一为 null 表示该时点无归属(ID 固化、姓名随联表) */
|
||
function ownerTransition(row: OwnershipLogItem): string {
|
||
const oldName = row.old_owner_name ?? '(无)'
|
||
const newName = row.new_owner_name ?? '(无)'
|
||
return `${oldName} → ${newName}`
|
||
}
|
||
|
||
// ==================== 删除 ====================
|
||
|
||
async function handleDelete(row: CustomerItem): Promise<void> {
|
||
await confirmAndRun({
|
||
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
|
||
title: '删除确认',
|
||
confirmButtonText: '删除',
|
||
successText: '客户已删除',
|
||
run: () => customerApi.deleteCustomer(row.id),
|
||
after: reloadAfterDelete,
|
||
})
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
onMounted(() => {
|
||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||
void load()
|
||
void loadOwnerOptions()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<el-card shadow="never">
|
||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||
<el-form-item label="关键词">
|
||
<el-input
|
||
v-model="filters.keyword"
|
||
placeholder="公司名称或客户负责人"
|
||
clearable
|
||
style="width: 190px"
|
||
@keyup.enter="search"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="分类">
|
||
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
|
||
<el-option
|
||
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="归属人员">
|
||
<el-select
|
||
v-model="filters.owner_personnel_id"
|
||
placeholder="全部"
|
||
clearable
|
||
filterable
|
||
style="width: 160px"
|
||
>
|
||
<el-option
|
||
v-for="item in ownerOptions"
|
||
:key="item.id"
|
||
:label="ownerLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div class="toolbar">
|
||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||
</div>
|
||
|
||
<el-table v-loading="loading" :data="records" stripe>
|
||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
||
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
||
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="分类" width="110" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag
|
||
:type="
|
||
row.customer_type === 'personal'
|
||
? 'info'
|
||
: row.customer_type === 'manager_personal'
|
||
? 'warning'
|
||
: 'primary'
|
||
"
|
||
size="small"
|
||
disable-transitions
|
||
>
|
||
{{ customerApi.customerTypeLabel(row.customer_type) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="归属人员" min-width="110">
|
||
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="230" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||
<el-button link type="primary" size="small" @click="openOwnership(row)">归属变更</el-button>
|
||
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</el-card>
|
||
|
||
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
||
<el-dialog
|
||
v-model="dialogVisible"
|
||
:title="isEdit ? '编辑客户' : '新增客户'"
|
||
width="560px"
|
||
destroy-on-close
|
||
>
|
||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||
<el-form-item label="公司名称" prop="company_name">
|
||
<el-input v-model="form.company_name" maxlength="128" placeholder="与负责人构成组合唯一键" />
|
||
</el-form-item>
|
||
<el-form-item label="负责人" prop="owner_name">
|
||
<el-input v-model="form.owner_name" maxlength="64" placeholder="客户公司的负责人姓名" />
|
||
</el-form-item>
|
||
<el-form-item label="联系人" prop="contact_person">
|
||
<el-input v-model="form.contact_person" maxlength="64" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="电话" prop="contact_phone">
|
||
<el-input v-model="form.contact_phone" maxlength="20" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="地址" prop="address">
|
||
<el-input v-model="form.address" maxlength="255" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="行业" prop="industry">
|
||
<el-input v-model="form.industry" maxlength="64" placeholder="选填" />
|
||
</el-form-item>
|
||
<el-form-item label="客户分类" prop="customer_type">
|
||
<el-select v-model="form.customer_type" placeholder="决定计提规则匹配" style="width: 100%">
|
||
<el-option
|
||
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="!isEdit" label="初始归属" prop="owner_personnel_id">
|
||
<el-select
|
||
v-model="form.owner_personnel_id"
|
||
placeholder="不选择=暂无归属(可先建档后派归属)"
|
||
clearable
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="item in ownerOptions"
|
||
:key="item.id"
|
||
:label="ownerLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="备注" prop="remark">
|
||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 归属变更(唯一写归属留痕的入口) -->
|
||
<el-dialog v-model="ownershipVisible" title="归属变更" width="480px" destroy-on-close>
|
||
<el-form label-width="110px">
|
||
<el-form-item label="客户">
|
||
<span>{{ ownershipTarget?.company_name }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="当前归属">
|
||
<span>{{ ownershipCurrentLabel }}</span>
|
||
</el-form-item>
|
||
<el-form-item label="新归属人员">
|
||
<el-select
|
||
v-model="ownershipForm.new_owner_personnel_id"
|
||
placeholder="不选择并提交=清空归属"
|
||
clearable
|
||
filterable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="item in ownerOptions"
|
||
:key="item.id"
|
||
:label="ownerLabel(item)"
|
||
:value="item.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="变更原因">
|
||
<el-input
|
||
v-model="ownershipForm.reason"
|
||
type="textarea"
|
||
:rows="3"
|
||
maxlength="255"
|
||
placeholder="选填,将写入归属变更留痕"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="ownership-warn">
|
||
归属变更仅影响变更后核算,不回溯历史。
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="ownershipVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit">
|
||
提交变更
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
||
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
||
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
|
||
<el-table-column label="变更时间" width="160">
|
||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="原归属 → 新归属" min-width="220">
|
||
<template #default="{ row }">{{ ownerTransition(row) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="变更原因" min-width="180">
|
||
<template #default="{ row }">{{ row.change_reason ?? '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="operator_name" label="操作人" width="100" />
|
||
</el-table>
|
||
<AppPagination
|
||
:total="logTotal"
|
||
:page-num="logPageNum"
|
||
:page-size="logPageSize"
|
||
@page-change="onLogsPageChange"
|
||
@size-change="onLogsSizeChange"
|
||
/>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.ownership-warn {
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
background: var(--ys-indigo-wash);
|
||
color: var(--ys-ink-2);
|
||
font-size: 12px;
|
||
line-height: 1.7;
|
||
}
|
||
</style>
|