PayrollManagementSystem/frontend/src/views/base/customers/index.vue
liuwenxin 5207d14daa feat(base): 前端基础数据三页与客户评级计算器(人员/产品/客户/评级)
- 人员页:编辑抽屉内区域/计划子表编辑;产品页:类别阈值提示条;
  客户页:归属变更二次确认含「不回溯历史」+变更记录抽屉
- 评级页:五项评分实时出等级(纯前端判定,不落库 #25)+ 等级映射维护抽屉
- 菜单两组:基础数据(8-10)/业务数据(14);金额字符串直出禁 float

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 12:25:45 +08:00

563 lines
20 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 { 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>