PayrollManagementSystem/frontend/src/views/base/customers/index.vue
liuwenxin 32b7940067 feat(customer): 客户评级并入客户管理并落库
- customers 表新增 rating_score/rating_time(V13 迁移 + V10 SQL + init-dev 同步)
- 新端点 PUT /customers/{id}/rating:评分是档案属性,只存总分与评级时间(五项明细不落库)
- 列表排序 order_by=rating_score,未评分客户升序/降序都垫底,id desc 兜底稳定分页
- 前端 RatingCalculatorDialog 并入客户管理(评分列 + 评级计算按钮),删除独立页与路由
- 测试:评级保存/越界/排序垫底 3 例,test_customers.py 18 passed;schema 同步 216 passed
2026-10-23 10:59:33 +08:00

674 lines
24 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, 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 { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
/**
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
*
* 与后端约定对齐:
* - 编辑弹窗不含归属字段 —— 归属调整是独立操作(「归属变更」按钮),
* 走 PUT /{id}/ownership,是唯一写归属留痕的入口
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
* 保存评分写入档案字段 rating_score/rating_time
*/
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,
order_by: sortState.order_by,
order: sortState.order,
}),
)
function resetFilters(): void {
filters.keyword = ''
filters.customer_type = undefined
filters.owner_personnel_id = undefined
search()
}
// ==================== 排序(评分列) ====================
/**
* 表头排序是查询参数而非本地排序:分页下本地排序只能排当前页。
* 目前唯一可排序列是评分;el-table 第三次点击取消排序,回到建档序(id 倒序)。
*/
const sortState = reactive<{ order_by: 'id' | 'rating_score'; order: 'asc' | 'desc' }>({
order_by: 'id',
order: 'desc',
})
function onSortChange({
prop,
order,
}: {
prop: string
order: 'ascending' | 'descending' | null
}): void {
if (prop === 'rating_score' && order) {
sortState.order_by = 'rating_score'
sortState.order = order === 'ascending' ? 'asc' : 'desc'
} else {
sortState.order_by = 'id'
sortState.order = 'desc'
}
search()
}
// ==================== 评级计算 ====================
const ratingVisible = ref(false)
const ratingTarget = ref<CustomerItem | null>(null)
function openRating(row: CustomerItem): void {
ratingTarget.value = row
ratingVisible.value = true
}
/** 保存评分后回写列表:按评分排序时刷新(位置随分变化),否则原位更新不打乱浏览 */
function onRatingSaved(item: CustomerItem): void {
if (sortState.order_by === 'rating_score') {
void load()
return
}
const index = records.value.findIndex((record) => record.id === item.id)
if (index !== -1) records.value[index] = item
}
// ==================== 归属人员下拉数据源 ====================
interface PersonnelOption {
id: number
name: string
status: number
}
const { options: ownerOptions, load: loadOwnerOptions } = useDropdownOptions<PersonnelOption>(
async () => {
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 极端场景由后端对归属合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
return data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
},
)
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
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>
<div class="page-head">
<h1 class="page-title">客户管理</h1>
<p class="page-desc">
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案
</p>
<span class="waterline"></span>
</div>
<!--
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
-->
<el-form class="filter-bar" @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>
<div class="filter-bar__actions">
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreate">新增客户</el-button>
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
</div>
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
<div class="card ys-rise">
<el-table
v-loading="loading"
:data="records"
stripe
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
@sort-change="onSortChange"
>
<el-table-column prop="company_name" label="公司名称" min-width="180" />
<el-table-column prop="rating_score" label="评分" width="88" align="center" sortable="custom">
<template #default="{ row }">
<span
v-if="row.rating_score !== null"
:title="row.rating_time ? `评级时间:${formatDateTime(row.rating_time)}` : undefined"
>
{{ row.rating_score }}
</span>
<span v-else class="cell-muted" title="尚未评级">—</span>
</template>
</el-table-column>
<el-table-column prop="owner_name" label="负责人" min-width="90" />
<el-table-column prop="contact_person" label="联系人" min-width="90">
<template #default="{ row }">
<span v-if="row.contact_person">{{ row.contact_person }}</span>
<span v-else class="cell-muted">—</span>
</template>
</el-table-column>
<el-table-column prop="contact_phone" label="电话" min-width="120">
<template #default="{ row }">
<span v-if="row.contact_phone">{{ row.contact_phone }}</span>
<span v-else class="cell-muted">—</span>
</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 }">
<span v-if="row.owner_personnel_name">{{ row.owner_personnel_name }}</span>
<span v-else class="cell-muted">(无归属)</span>
</template>
</el-table-column>
<el-table-column label="操作" width="340" fixed="right" align="center">
<template #default="{ row }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openRating(row)">评级计算</el-button>
<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>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="card-foot">
<AppPagination
:total="total"
:page-num="pageNum"
:page-size="pageSize"
@page-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</div>
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
<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"
empty-text="这个客户还没有归属变更记录 —— 调整一次归属后这里就会出现留痕"
>
<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>
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
<RatingCalculatorDialog
v-model:visible="ratingVisible"
:customer="ratingTarget"
@saved="onRatingSaved"
/>
</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>