feat(base): 前端基础数据三页与客户评级计算器(人员/产品/客户/评级)

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-13 12:25:45 +08:00
1 parent 09831dbb7f
commit 5207d14daa
10 files changed
+2518 -1

No files matched your search

+562
View File
@@ -0,0 +1,562 @@
<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>
+659
View File
@@ -0,0 +1,659 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as businessRoleApi from '@/api/businessRoles'
import * as personnelApi from '@/api/personnel'
import type { PersonnelItem } from '@/api/personnel'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
/**
* 人员管理(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
*
* 与后端约定对齐:
* - 基本信息新增/编辑走弹窗(全量提交、不含状态);在职/离职切换、
* 删除均为独立操作,删除被引用时后端拒绝,此时应改为离职
* - 编辑抽屉承载子表:辐射区域与拓展计划行内增删,保存走
* PUT /{id}/regions、PUT /{id}/plans 全量替换(空列表即清空)
* - 工资标准为字符串直出(金额红线:不经 float、不 toFixed)
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
/** 在职/离职筛选项(与产品页「启用/禁用」语义不同,页面内定义) */
const EMPLOYMENT_OPTIONS = [
{ label: '在职', value: 1 },
{ label: '离职', value: 0 },
] as const
const filters = reactive({
keyword: '',
status: undefined as number | undefined,
business_role_id: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
personnelApi.listPersonnel,
() => ({
keyword: filters.keyword,
status: filters.status,
business_role_id: filters.business_role_id,
}),
)
function resetFilters(): void {
filters.keyword = ''
filters.status = undefined
filters.business_role_id = undefined
search()
}
// ==================== 岗位 / 经理下拉数据源 ====================
interface SelectOption {
id: number
name: string
status: number
}
const roleOptions = ref<SelectOption[]>([])
const managerOptions = ref<SelectOption[]>([])
async function loadRoleOptions(): Promise<void> {
try {
// 岗位规模有限(内置 3 个 + 自定义),一页足够
const data = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
roleOptions.value = data.list.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
} catch (error) {
handledError(error) // 下拉为空不阻断列表使用
}
}
async function loadManagerOptions(): Promise<void> {
try {
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
// 超出规模的极端场景由后端对经理合法性的校验兜底
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
} catch (error) {
handledError(error)
}
}
// ==================== 新增 / 编辑(基本信息) ====================
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({
personnel_name: '',
business_role_id: undefined as number | undefined,
manager_id: undefined as number | undefined,
phone: '',
hire_date: '',
is_probation: 0,
salary_standard: '',
remark: '',
})
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
const editingRow = computed(() =>
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
)
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
const searchRoleOptions = computed(() =>
roleOptions.value.map((r) => ({ ...r, name: r.status === 1 ? r.name : `${r.name}(已停用)` })),
)
/** 表单下拉只列启用岗位;编辑时若原岗位已停用则保留一项供回显 */
const formRoleOptions = computed(() => {
const enabled = roleOptions.value.filter((r) => r.status === 1)
const row = editingRow.value
if (row && !enabled.some((r) => r.id === row.business_role_id)) {
enabled.unshift({ id: row.business_role_id, name: `${row.role_name ?? ''}(已停用)`, status: 0 })
}
return enabled
})
/** 经理下拉:排除编辑中的人员自己(后端校验「经理不能是自己」) */
const formManagerOptions = computed(() =>
managerOptions.value.filter((m) => m.id !== editingId.value),
)
const rules = computed<FormRules>(() => ({
personnel_name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ max: 64, message: '姓名最长 64 个字符', trigger: 'blur' },
],
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
salary_standard: [
{ pattern: /^(\d+)(\.\d{1,2})?$/, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
personnel_name: '',
business_role_id: undefined,
manager_id: undefined,
phone: '',
hire_date: '',
is_probation: 0,
salary_standard: '',
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: PersonnelItem): void {
editingId.value = row.id
Object.assign(form, {
personnel_name: row.personnel_name,
business_role_id: row.business_role_id,
manager_id: row.manager_id ?? undefined,
phone: row.phone ?? '',
hire_date: row.hire_date ?? '',
is_probation: row.is_probation,
salary_standard: row.salary_standard ?? '',
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
// 空串归一为 null(与后端「空串归一 NULL」口径一致);金额原串提交
const payload: personnelApi.PersonnelSaveParams = {
personnel_name: form.personnel_name.trim(),
business_role_id: form.business_role_id as number,
manager_id: form.manager_id ?? null,
phone: form.phone.trim() || null,
hire_date: form.hire_date || null,
is_probation: form.is_probation,
salary_standard: form.salary_standard.trim() || null,
remark: form.remark.trim() || null,
}
submitting.value = true
try {
if (editingId.value !== null) {
await personnelApi.updatePersonnel(editingId.value, payload)
ElMessage.success('已保存')
} else {
await personnelApi.createPersonnel(payload)
ElMessage.success('人员已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 编辑抽屉:辐射区域 / 拓展计划 ====================
interface RegionDraft {
region_name: string
remark: string
}
interface PlanDraft {
plan_period: string
plan_content: string
target_desc: string
}
const drawerVisible = ref(false)
const drawerRow = ref<PersonnelItem | null>(null)
const drawerTitle = computed(() =>
drawerRow.value ? `「${drawerRow.value.personnel_name}」· 区域与拓展计划` : '区域与拓展计划',
)
const detailLoading = ref(false)
const regionSaving = ref(false)
const planSaving = ref(false)
/** 本地草稿行(全量替换:保存即以后端返回为准,无需保留行 id) */
const regionRows = ref<RegionDraft[]>([])
const planRows = ref<PlanDraft[]>([])
async function openSubTables(row: PersonnelItem): Promise<void> {
drawerRow.value = row
drawerVisible.value = true
detailLoading.value = true
regionRows.value = []
planRows.value = []
try {
const detail = await personnelApi.getPersonnelDetail(row.id)
regionRows.value = detail.regions.map((r) => ({
region_name: r.region_name,
remark: r.remark ?? '',
}))
planRows.value = detail.plans.map((p) => ({
plan_period: p.plan_period,
plan_content: p.plan_content,
target_desc: p.target_desc ?? '',
}))
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成,抽屉显示空表
} finally {
detailLoading.value = false
}
}
function addRegionRow(): void {
regionRows.value.push({ region_name: '', remark: '' })
}
function removeRegionRow(index: number): void {
regionRows.value.splice(index, 1)
}
function addPlanRow(): void {
planRows.value.push({ plan_period: '', plan_content: '', target_desc: '' })
}
function removePlanRow(index: number): void {
planRows.value.splice(index, 1)
}
/** 保存辐射区域:全量替换,未填名称的行不能混入 */
async function saveRegions(): Promise<void> {
const row = drawerRow.value
if (!row) return
const blank = regionRows.value.findIndex((r) => !r.region_name.trim())
if (blank >= 0) {
ElMessage.warning(`第 ${blank + 1} 行的区域名称未填写`)
return
}
regionSaving.value = true
try {
await personnelApi.replacePersonnelRegions(
row.id,
regionRows.value.map((r) => ({
region_name: r.region_name.trim(),
remark: r.remark.trim() || null,
})),
)
ElMessage.success('辐射区域已保存')
} catch (error) {
handledError(error)
} finally {
regionSaving.value = false
}
}
/** 保存拓展计划:期间与内容必填,指标说明可空 */
async function savePlans(): Promise<void> {
const row = drawerRow.value
if (!row) return
const blank = planRows.value.findIndex((r) => !r.plan_period.trim() || !r.plan_content.trim())
if (blank >= 0) {
ElMessage.warning(`第 ${blank + 1} 行的计划期间或内容未填写`)
return
}
planSaving.value = true
try {
await personnelApi.replacePersonnelPlans(
row.id,
planRows.value.map((r) => ({
plan_period: r.plan_period.trim(),
plan_content: r.plan_content.trim(),
target_desc: r.target_desc.trim() || null,
})),
)
ElMessage.success('拓展计划已保存')
} catch (error) {
handledError(error)
} finally {
planSaving.value = false
}
}
// ==================== 在职切换 / 删除 ====================
async function handleToggleStatus(row: PersonnelItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '设为在职' : '设为离职'
await confirmAndRun({
confirmText:
next === 0
? `确定将「${row.personnel_name}」设为离职?离职人员不可再被业务与客户归属引用。`
: `确定将「${row.personnel_name}」设为在职?`,
title: '状态确认',
confirmButtonText: action,
successText: `已${action}`,
run: () => personnelApi.setPersonnelStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: PersonnelItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除人员「${row.personnel_name}」?删除为物理删除、不可恢复;若该人员已被团队成员或客户归属引用将无法删除,此时应改为离职。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '人员已删除',
run: () => personnelApi.deletePersonnel(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
// 三个请求各自容错(load 内部已兜底),互不拖累
void load()
void loadRoleOptions()
void loadManagerOptions()
})
</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: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in EMPLOYMENT_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="岗位">
<el-select
v-model="filters.business_role_id"
placeholder="全部"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="item in searchRoleOptions"
:key="item.id"
:label="item.name"
: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="personnel_name" label="姓名" min-width="100" />
<el-table-column prop="role_name" label="岗位" min-width="100">
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="manager_name" label="直属经理" min-width="100">
<template #default="{ row }">{{ row.manager_name ?? '—' }}</template>
</el-table-column>
<el-table-column prop="phone" label="联系电话" min-width="120">
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
</el-table-column>
<el-table-column prop="hire_date" label="入职日期" min-width="110">
<template #default="{ row }">{{ row.hire_date ?? '—' }}</template>
</el-table-column>
<el-table-column label="试用期" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_probation === 1" type="warning" size="small" disable-transitions>
试用期
</el-tag>
<span v-else class="cell-muted">正式</span>
</template>
</el-table-column>
<el-table-column label="工资标准(元)" min-width="120" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</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="openSubTables(row)">区域与计划</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '设为离职' : '设为在职' }}
</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="90px">
<el-form-item label="姓名" prop="personnel_name">
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
</el-form-item>
<el-form-item label="岗位" prop="business_role_id">
<el-select
v-model="form.business_role_id"
placeholder="选择岗位(计提公式随岗位绑定)"
filterable
style="width: 100%"
>
<el-option
v-for="item in formRoleOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="直属经理" prop="manager_id">
<el-select
v-model="form.manager_id"
placeholder="不选择=无直属经理"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="item in formManagerOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item>
<el-form-item label="入职日期" prop="hire_date">
<el-date-picker
v-model="form.hire_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="试用期" prop="is_probation">
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span>
</el-form-item>
<el-form-item label="工资标准" prop="salary_standard">
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
</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-drawer v-model="drawerVisible" :title="drawerTitle" size="720px" destroy-on-close>
<div v-loading="detailLoading">
<div class="sub-section">
<div class="sub-section-head">
<span class="sub-section-title">辐射区域</span>
<span>
<el-button size="small" @click="addRegionRow">添加区域</el-button>
<el-button
size="small"
type="primary"
:loading="regionSaving"
@click="saveRegions"
>
保存区域
</el-button>
</span>
</div>
<el-table :data="regionRows" stripe size="small">
<el-table-column label="区域名称" min-width="200">
<template #default="{ row }">
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="255" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRegionRow($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="sub-section">
<div class="sub-section-head">
<span class="sub-section-title">拓展计划</span>
<span>
<el-button size="small" @click="addPlanRow">添加计划</el-button>
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans">
保存计划
</el-button>
</span>
</div>
<el-table :data="planRows" stripe size="small">
<el-table-column label="期间" min-width="110">
<template #default="{ row }">
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
</template>
</el-table-column>
<el-table-column label="计划内容" min-width="240">
<template #default="{ row }">
<el-input v-model="row.plan_content" maxlength="500" placeholder="计划内容" />
</template>
</el-table-column>
<el-table-column label="指标说明" min-width="160">
<template #default="{ row }">
<el-input v-model="row.target_desc" maxlength="255" placeholder="选填" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removePlanRow($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.cell-muted {
color: var(--ys-ink-3);
font-size: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
.switch-hint {
margin-left: 10px;
color: var(--ys-ink-3);
font-size: 12px;
}
.sub-section {
margin-bottom: 20px;
}
.sub-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.sub-section-title {
font-weight: 600;
color: var(--ys-ink);
}
</style>
+351
View File
@@ -0,0 +1,351 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import * as productApi from '@/api/products'
import type { ProductItem } from '@/api/products'
import AppPagination from '@/components/table/AppPagination.vue'
import StatusTag from '@/components/table/StatusTag.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { STATUS_OPTIONS } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
* 产品管理(需求 2.1.2:产品主数据维护)。
*
* 与后端约定对齐:
* - product_category 是折算阈值的行级锚点(市政水利 30% / 轨道交通 20%,
* 阈值本身在「系统设置-计提参数」维护),类别下拉为受控枚举
* - 数量/单价以字符串进出(金额红线:禁 float、禁 toFixed),
* 前端只做格式提示,有效性与精度由后端校验兜底
* - 编辑全量提交、不含状态;启用/停用与删除为独立操作
*/
const { confirmAndRun } = useConfirmAction()
// ==================== 列表 ====================
const filters = reactive({
keyword: '',
product_category: undefined as string | undefined,
status: undefined as number | undefined,
})
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
usePagedList<ProductItem, productApi.ProductQueryParams>(
productApi.listProducts,
() => ({
keyword: filters.keyword,
product_category: filters.product_category,
status: filters.status,
}),
)
function resetFilters(): void {
filters.keyword = ''
filters.product_category = undefined
filters.status = undefined
search()
}
// ==================== 新增 / 编辑 ====================
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({
product_name: '',
spec_model: '',
unit: '',
stock_quantity: '',
sale_price: '',
product_category: '',
remark: '',
})
const rules = computed<FormRules>(() => ({
product_name: [
{ required: true, message: '请输入产品名称', trigger: 'blur' },
{ max: 128, message: '产品名称最长 128 个字符', trigger: 'blur' },
],
spec_model: [{ max: 128, message: '规格型号最长 128 个字符', trigger: 'blur' }],
unit: [{ max: 16, message: '计量单位最长 16 个字符', trigger: 'blur' }],
stock_quantity: [
{ pattern: /^(\d+)(\.\d+)?$/, message: '数量须为非负数字', trigger: 'blur' },
],
sale_price: [
{ pattern: /^(\d+)(\.\d+)?$/, message: '单价须为非负数字', trigger: 'blur' },
],
product_category: [{ required: true, message: '请选择产品类别', trigger: 'change' }],
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
}))
function openCreate(): void {
editingId.value = null
Object.assign(form, {
product_name: '',
spec_model: '',
unit: '',
stock_quantity: '',
sale_price: '',
product_category: '',
remark: '',
})
dialogVisible.value = true
formRef.value?.clearValidate()
}
function openEdit(row: ProductItem): void {
editingId.value = row.id
Object.assign(form, {
product_name: row.product_name,
spec_model: row.spec_model ?? '',
unit: row.unit ?? '',
stock_quantity: row.stock_quantity ?? '',
sale_price: row.sale_price ?? '',
product_category: row.product_category,
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
// 可空字段空串归一为 null;数量/单价保持原串提交(金额红线)
const payload: productApi.ProductSaveParams = {
product_name: form.product_name.trim(),
spec_model: form.spec_model.trim(),
unit: form.unit.trim() || null,
stock_quantity: form.stock_quantity.trim() || null,
sale_price: form.sale_price.trim() || null,
product_category: form.product_category,
remark: form.remark.trim() || null,
}
submitting.value = true
try {
if (editingId.value !== null) {
await productApi.updateProduct(editingId.value, payload)
ElMessage.success('已保存')
} else {
await productApi.createProduct(payload)
ElMessage.success('产品已创建')
}
dialogVisible.value = false
await load()
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
submitting.value = false
}
}
// ==================== 启停 / 删除 ====================
async function handleToggleStatus(row: ProductItem): Promise<void> {
const next = row.status === 1 ? 0 : 1
const action = next === 1 ? '启用' : '停用'
await confirmAndRun({
confirmText:
next === 0
? `确定停用产品「${row.product_name}」?停用后新业务不再选用该产品。`
: `确定启用产品「${row.product_name}」?`,
title: `${action}确认`,
confirmButtonText: action,
successText: `已${action}`,
run: () => productApi.setProductStatus(row.id, next),
after: load,
})
}
async function handleDelete(row: ProductItem): Promise<void> {
await confirmAndRun({
confirmText: `确定删除产品「${row.product_name}」?删除为物理删除、不可恢复;常规下架请改用停用。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '产品已删除',
run: () => productApi.deleteProduct(row.id),
after: reloadAfterDelete,
})
}
// ==================== 初始化 ====================
onMounted(() => {
void load()
})
</script>
<template>
<div>
<el-alert
class="page-alert"
type="info"
show-icon
:closable="false"
title="产品类别决定折算阈值:市政水利 30% / 轨道交通 20%;阈值在「系统设置-计提参数」维护。"
/>
<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: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="类别">
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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="product_name" label="产品名称" min-width="150" />
<el-table-column prop="spec_model" label="规格型号" min-width="140">
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="80" align="center">
<template #default="{ row }">{{ row.unit ?? '—' }}</template>
</el-table-column>
<el-table-column label="数量" min-width="100" align="right">
<template #default="{ row }">
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
<span class="cell-amount">{{ row.stock_quantity ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" min-width="110" align="right">
<template #default="{ row }">
<span class="cell-amount">{{ row.sale_price ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="类别" width="110" align="center">
<template #default="{ row }">
<el-tag
:type="row.product_category === 'municipal_water' ? 'primary' : 'success'"
size="small"
disable-transitions
>
{{ productApi.productCategoryLabel(row.product_category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</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="540px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="产品名称" prop="product_name">
<el-input v-model="form.product_name" maxlength="128" placeholder="与规格构成组合唯一键" />
</el-form-item>
<el-form-item label="规格型号" prop="spec_model">
<el-input v-model="form.spec_model" maxlength="128" placeholder="选填" />
</el-form-item>
<el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" maxlength="16" placeholder="如 台 / 套 / 米" />
</el-form-item>
<el-form-item label="数量" prop="stock_quantity">
<el-input v-model="form.stock_quantity" placeholder="非负数字,选填" />
</el-form-item>
<el-form-item label="单价(元)" prop="sale_price">
<el-input v-model="form.sale_price" placeholder="非负数字,选填" />
</el-form-item>
<el-form-item label="产品类别" prop="product_category">
<el-select
v-model="form.product_category"
placeholder="决定折算阈值(市政 30% / 轨交 20%)"
style="width: 100%"
>
<el-option
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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>
</div>
</template>
<style scoped>
.page-alert {
margin-bottom: 12px;
}
.cell-amount {
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
font-size: 12px;
color: var(--ys-ink-2);
}
</style>
@@ -0,0 +1,482 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
createRatingGrade,
deleteRatingGrade,
listRatingGrades,
updateRatingGrade,
type RatingGradeItem,
type RatingGradeParams,
} from '@/api/ratings'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { handledError } from '@/utils/error'
/**
* 客户评级计算器(需求 2.2.5 / 对照表 #25)。
*
* 评级计算器仅是**打分工具**:五项打分求总分、按等级映射实时判定等级,
* 评分记录不落库、不参与绩效金额计算,因此页面没有保存动作(仅重置)。
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表——判定与抽屉表格共用同一份数据。
*/
// ==================== 等级判定(纯函数) ====================
/**
* 等级判定:按 sort_order 升序取首个 min_score ≤ 总分的等级。
*
* 后端 min_score 是字符串直出(分值红线),比较前必须 parseFloat 转数值,
* 禁止字符串直接比大小("90.0" 这类字典序比较会得出错误结果)。
* 无命中(总分低于全部下限)返回 null,由调用方展示未命中提示。
*/
function resolveGrade(grades: RatingGradeItem[], totalScore: number): RatingGradeItem | null {
const ordered = [...grades].sort((a, b) => a.sort_order - b.sort_order)
for (const grade of ordered) {
if (totalScore >= parseFloat(grade.min_score)) {
return grade
}
}
return null
}
// ==================== 评分计算(纯前端实时计算) ====================
/** 五项评分项:各 0-20 分、步进 1,总分 = 五项之和(0-100) */
const SCORE_ITEMS = [
{ key: 'ship_amount', label: '发货额' },
{ key: 'receipt_rate', label: '回款率' },
{ key: 'profit_rate', label: '毛利率' },
{ key: 'contract_cooperation', label: '合同履约配合能力' },
{ key: 'site_construction', label: '现场规范施工能力' },
] as const
type ScoreKey = (typeof SCORE_ITEMS)[number]['key']
/** el-input-number 清空后值为 undefined,字段用宽松类型,求和处 ?? 0 兜底 */
const scores = reactive<Record<ScoreKey, number | undefined>>({
ship_amount: 0,
receipt_rate: 0,
profit_rate: 0,
contract_cooperation: 0,
site_construction: 0,
})
const totalScore = computed(() =>
SCORE_ITEMS.reduce((sum, item) => sum + (scores[item.key] ?? 0), 0),
)
/** 无保存动作(不落库),重置即把五项清零 */
function resetScores(): void {
for (const item of SCORE_ITEMS) {
scores[item.key] = 0
}
}
// ==================== 等级映射(判定用列表) ====================
const { confirmAndRun } = useConfirmAction()
const grades = ref<RatingGradeItem[]>([])
const gradesLoading = ref(false)
/** 判定与抽屉表格共用同一份全量列表,配置变更后重载一次即同时生效 */
async function loadGrades(): Promise<void> {
gradesLoading.value = true
try {
grades.value = await listRatingGrades()
} catch (error) {
handledError(error)
} finally {
gradesLoading.value = false
}
}
const matchedGrade = computed(() => resolveGrade(grades.value, totalScore.value))
// ==================== 等级映射设置抽屉 ====================
const drawerVisible = ref(false)
/** 打开时重新拉取,避免展示陈旧数据 */
function openDrawer(): void {
drawerVisible.value = true
void loadGrades()
}
// ==================== 新增 / 编辑等级弹窗 ====================
const editVisible = ref(false)
/** null = 新增,否则为编辑目标 id */
const editingId = ref<number | null>(null)
const editSaving = ref(false)
const isCreate = computed(() => editingId.value === null)
const editForm = reactive({
grade_name: '',
min_score: undefined as number | undefined,
max_score: undefined as number | undefined,
sort_order: 0,
})
function openCreate(): void {
editingId.value = null
editForm.grade_name = ''
editForm.min_score = undefined
editForm.max_score = undefined
editForm.sort_order = 0
editVisible.value = true
}
function openEdit(row: RatingGradeItem): void {
editingId.value = row.id
editForm.grade_name = row.grade_name
// 后端字符串直出,回填 parseFloat 转数值,保存时再 String() 转回字符串
editForm.min_score = parseFloat(row.min_score)
editForm.max_score = parseFloat(row.max_score)
editForm.sort_order = row.sort_order
editVisible.value = true
}
/** 前端值域校验(0≤min≤max≤100);后端在审计上下文内兜底同规则与区间重叠 */
function editFormError(minScore: number | undefined, maxScore: number | undefined): string | null {
if (!editForm.grade_name.trim()) return '请输入等级名称'
if (minScore === undefined || maxScore === undefined) return '请输入分值区间'
if (minScore < 0 || maxScore > 100 || minScore > maxScore) {
return '分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100'
}
return null
}
async function saveGrade(): Promise<void> {
// 局部变量收窄类型:reactive 属性在异步流程中不保证保持窄化
const minScore = editForm.min_score
const maxScore = editForm.max_score
const error = editFormError(minScore, maxScore)
if (error) {
ElMessage.warning(error)
return
}
const payload: RatingGradeParams = {
grade_name: editForm.grade_name.trim(),
min_score: String(minScore),
max_score: String(maxScore),
sort_order: editForm.sort_order ?? 0,
}
// 后端为全量替换语义;remark 本页不维护,编辑时原样带回避免误清
if (editingId.value !== null) {
payload.remark = grades.value.find((g) => g.id === editingId.value)?.remark ?? undefined
}
editSaving.value = true
try {
if (editingId.value === null) {
await createRatingGrade(payload)
} else {
await updateRatingGrade(editingId.value, payload)
}
editVisible.value = false
ElMessage.success(isCreate.value ? '评级等级已创建' : '已保存')
await loadGrades()
} catch (requestError) {
handledError(requestError)
} finally {
editSaving.value = false
}
}
/** 删除走确认动作流;文案明确后果(该区间不再能映射等级) */
function removeGrade(row: RatingGradeItem): void {
void confirmAndRun({
confirmText: `确定删除等级「${row.grade_name}」(${row.min_score} ~ ${row.max_score})?删除后该分数区间将无法映射等级。`,
title: '删除确认',
confirmButtonText: '删除',
successText: '评级等级已删除',
run: () => deleteRatingGrade(row.id),
after: () => loadGrades(),
})
}
onMounted(() => {
void loadGrades()
})
</script>
<template>
<div class="rating-page">
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="page-alert"
title="本页为评分计算工具,评分结果不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<!-- 评分计算区:无保存按钮,纯前端实时计算 -->
<el-card shadow="never">
<div class="page-toolbar">
<span class="page-desc">五项各 0-20 分,总分 0-100;评级等级按配置的下限实时判定。</span>
<span>
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<div class="calc-grid">
<div class="calc-form">
<div v-for="item in SCORE_ITEMS" :key="item.key" class="score-row">
<span class="score-label">{{ item.label }}</span>
<el-input-number
v-model="scores[item.key]"
:min="0"
:max="20"
:step="1"
controls-position="right"
/>
<span class="score-unit">分</span>
</div>
</div>
<div class="calc-result">
<div>
<span class="result-label">总分</span>
<b class="total-num">{{ totalScore }}</b>
<span class="total-max">/ 100</span>
</div>
<div>
<span class="result-label">评级等级</span>
<template v-if="grades.length === 0">
<div class="grade-name">—</div>
<div class="grade-hint">未配置等级映射</div>
</template>
<template v-else-if="matchedGrade">
<div class="grade-name">{{ matchedGrade.grade_name }}</div>
<div class="grade-hint">
分值区间 {{ matchedGrade.min_score }} ~ {{ matchedGrade.max_score }}
</div>
</template>
<template v-else>
<div class="grade-name">—</div>
<div class="grade-hint">总分低于全部等级下限,未命中等级</div>
</template>
</div>
</div>
</div>
</el-card>
<!-- 等级映射设置抽屉 -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px">
<div class="drawer-hint">
判定规则:按排序升序取首个「下限 ≤ 总分」的等级,上限仅作展示;修改后评级区立即按新配置判定。
</div>
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="openCreate">新增等级</el-button>
</div>
<el-table v-loading="gradesLoading" :data="grades" size="small">
<el-table-column prop="grade_name" label="等级名称" min-width="110" />
<el-table-column prop="min_score" label="下限(含)" width="100" align="center" />
<el-table-column prop="max_score" label="上限" width="90" align="center" />
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<!-- 新增 / 编辑等级 -->
<el-dialog
v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px"
destroy-on-close
@closed="editingId = null"
>
<el-form label-width="90px" @submit.prevent>
<el-form-item label="等级名称" required>
<el-input v-model="editForm.grade_name" maxlength="32" placeholder="如:A / 优秀" />
</el-form-item>
<el-form-item label="下限(含)" required>
<el-input-number
v-model="editForm.min_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="上限" required>
<el-input-number
v-model="editForm.max_score"
:min="0"
:max="100"
:step="5"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number
v-model="editForm.sort_order"
:min="0"
:step="10"
controls-position="right"
/>
<span class="form-tip">升序判定,越小越先判定</span>
</el-form-item>
</el-form>
<div class="form-tip-block">
分值区间须满足 0 ≤ 下限 ≤ 上限 ≤ 100;区间重叠、名称重复由后端校验兜底。
</div>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveGrade">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-alert {
margin-bottom: 14px;
}
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.page-desc {
font-size: 13px;
color: var(--ys-ink-2);
}
.calc-grid {
display: flex;
gap: 28px;
align-items: stretch;
flex-wrap: wrap;
}
.calc-form {
flex: 1 1 380px;
min-width: 280px;
}
.score-row {
display: flex;
align-items: center;
gap: 12px;
line-height: 2.7;
}
.score-label {
width: 140px;
font-size: 13px;
color: var(--ys-ink);
text-align: right;
}
.score-unit {
font-size: 12px;
color: var(--ys-ink-3);
}
.calc-result {
flex: 0 0 250px;
min-width: 220px;
border: 1px solid var(--ys-line);
border-radius: 10px;
padding: 18px 20px;
background: linear-gradient(180deg, #ffffff, #fcfcfe);
display: flex;
flex-direction: column;
justify-content: center;
gap: 18px;
}
.result-label {
display: block;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--ys-ink-3);
}
.total-num {
font-size: 40px;
color: var(--ys-indigo);
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.total-max {
font-size: 13px;
color: var(--ys-ink-3);
}
.grade-name {
font-family: var(--ys-font-brand);
font-size: 28px;
font-weight: 600;
color: var(--ys-ink);
line-height: 1.3;
margin-top: 2px;
}
.grade-hint {
font-size: 12px;
color: var(--ys-ink-3);
margin-top: 4px;
}
.drawer-hint {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 12px;
}
.drawer-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.form-tip {
font-size: 12px;
color: var(--ys-ink-3);
margin-left: 8px;
}
.form-tip-block {
font-size: 12px;
color: var(--ys-ink-3);
background: var(--ys-mist);
border-radius: 6px;
padding: 8px 12px;
}
/* 移动端:标签左对齐、结果块自然换行到表单下方 */
@media (max-width: 768px) {
.score-label {
width: auto;
text-align: left;
}
.calc-result {
flex: 1 1 100%;
}
}
</style>