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
This commit is contained in:
liuwenxin committed 2026-10-23 10:59:33 +08:00
1 parent b9781f7d3f
commit 32b7940067
17 files changed
+917 -512

No files matched your search

+21
View File
@@ -21,6 +21,10 @@ export interface CustomerItem {
owner_personnel_id: number | null
owner_personnel_name: string | null
remark: string | null
/** 评级总分(0-100,评级计算器保存;null=未评分) */
rating_score: number | null
/** 最近评级时间(评级计算器保存评分时刷新;null=从未评级) */
rating_time: string | null
}
export interface CustomerQueryParams {
@@ -30,6 +34,10 @@ export interface CustomerQueryParams {
keyword?: string
customer_type?: string
owner_personnel_id?: number
/** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */
order_by?: 'id' | 'rating_score'
/** 排序方向,默认 desc */
order?: 'asc' | 'desc'
}
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
@@ -57,6 +65,11 @@ export interface CustomerOwnershipParams {
reason?: string | null
}
/** 保存评级入参:只存总分(五项明细不落库,评分是档案属性而非审计事件) */
export interface CustomerRatingParams {
score: number
}
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
export interface OwnershipLogItem {
id: number
@@ -114,6 +127,14 @@ export function changeOwnership(
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
}
/** 保存评级(评级计算器「保存评分」;只存总分与评级时间,评分只走本端点) */
export function rateCustomer(
customerId: number,
data: CustomerRatingParams,
): Promise<CustomerItem> {
return request({ url: `/v1/base/customers/${customerId}/rating`, method: 'put', data })
}
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
export function listOwnershipLogs(
customerId: number,
@@ -17,7 +17,6 @@ import {
SetUp,
Shop,
Stamp,
Star,
Tickets,
TrendCharts,
User,
@@ -54,7 +53,6 @@ const ICONS = {
businesses: Briefcase,
shipments: Van,
receipts: Money,
rating: Star,
ledger: Notebook,
settlements: Calendar,
'report-personal': DataLine,
+1 -8
View File
@@ -170,14 +170,7 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/business/receipts/index.vue'),
meta: { title: '回款管理', icon: 'receipts', group: '业务数据', order: 15 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', icon: 'rating', group: '业务数据', order: 16 },
},
// 客户评级计算器已并入客户管理(行内弹窗),独立页移除
{
// 绩效工资组(M3 ④):核算台账读登录即可,月结写限管理员与营销后勤
// (api 层 require_roles 兜底,页面层不再叠加 requiresAdmin)
@@ -0,0 +1,553 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import {
createRatingGrade,
deleteRatingGrade,
listRatingGrades,
updateRatingGrade,
type RatingGradeItem,
type RatingGradeParams,
} from '@/api/ratings'
import { rateCustomer, type CustomerItem } from '@/api/customers'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { formatDateTime } from '@/utils/format'
import { handledError } from '@/utils/error'
/**
* 客户评级计算器(需求 2.2.5 / 对照表 #25)· 客户管理行内弹窗版。
*
* 原独立页(/business/rating)并入客户管理:在客户行上点「评级计算」打开,
* 五项打分实时判定等级,「保存评分」把总分写入客户档案(rating_score +
* rating_time)。与独立页时代的差异:评分不再是过眼云烟 —— 保存为档案
* 属性;五项明细与命中等级仍不落库(等级映射是可变判定表,回溯「当时
* 命中的等级」需要快照设计,超出档案属性的范围)。
*
* 等级映射的增删改走 /v1/business/rating-grades 维护接口,配置变更后
* 重载判定用列表 —— 判定与抽屉表格共用同一份数据。
*/
const visible = defineModel<boolean>('visible', { default: false })
const props = defineProps<{
/** 目标客户;弹窗打开期间必非空(由父页在 open 时赋值) */
customer: CustomerItem | null
}>()
const emit = defineEmits<{
/** 保存成功,带回最新 CustomerItem(父页原位更新行,不打乱当前浏览位置) */
saved: [item: CustomerItem]
}>()
// ==================== 等级判定(纯函数) ====================
/**
* 等级判定:按 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))
// ==================== 弹窗打开 / 关闭 ====================
/** 打开时重置五项并重载等级配置,避免上一次打分的残留 */
watch(visible, (isOpen) => {
if (!isOpen) return
resetScores()
void loadGrades()
})
// ==================== 保存评分 ====================
const saving = ref(false)
/** 档案里已保存的评分(打开时的快照,仅展示用;保存成功后由父页更新行数据) */
const archivedScore = computed(() => {
const target = props.customer
if (!target || target.rating_score === null) return null
return {
score: target.rating_score,
time: target.rating_time ? formatDateTime(target.rating_time) : '',
}
})
async function handleSave(): Promise<void> {
const target = props.customer
if (!target) return
saving.value = true
try {
const item = await rateCustomer(target.id, { score: totalScore.value })
ElMessage.success('评级已保存')
emit('saved', item)
visible.value = false
} catch (error) {
handledError(error) // 失败提示由请求拦截器统一完成
} finally {
saving.value = false
}
}
// ==================== 等级映射设置抽屉 ====================
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(),
})
}
</script>
<template>
<el-dialog
v-model="visible"
:title="`客户评级 · ${customer?.company_name ?? ''}`"
width="680px"
destroy-on-close
>
<!-- 对照表 #25:口径提示改写 —— 评分现在会落库,但仍不参与绩效金额 -->
<el-alert
class="dialog-alert"
title="评分保存为客户档案,只用于客户评级展示,不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<div v-if="archivedScore" class="archived-line">
档案评分:<b>{{ archivedScore.score }}</b> 分
<span v-if="archivedScore.time" class="archived-time">({{ archivedScore.time }} 保存)</span>
<span class="archived-tip">重新保存将覆盖</span>
</div>
<div class="calc-grid">
<div class="calc-form">
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
<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 class="section-title">判定结果</div>
<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>
<template #footer>
<el-button @click="resetScores">重置</el-button>
<el-button @click="openDrawer">等级映射设置</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存评分</el-button>
</template>
</el-dialog>
<!-- 等级映射设置抽屉(评级弹窗的二级层,append-to-body 保证遮罩层级正确) -->
<el-drawer v-model="drawerVisible" title="等级映射设置" size="640px" append-to-body>
<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 }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-drawer>
<!-- 新增 / 编辑等级 -->
<el-dialog
v-model="editVisible"
:title="isCreate ? '新增评级等级' : '编辑评级等级'"
width="440px"
destroy-on-close
append-to-body
@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>
</template>
<style scoped>
/* 版式皮肤(.card/.section-title/按钮/表格)由全局 index.css 提供,这里只留弹窗特有排版 */
.dialog-alert {
margin-bottom: 12px;
}
/* 档案评分快照行:一眼看出这是覆盖式保存 */
.archived-line {
font-size: 13px;
color: var(--ys-ink-2);
margin-bottom: 12px;
}
.archived-time {
color: var(--ys-ink-3);
}
.archived-tip {
margin-left: 10px;
font-size: 12px;
color: var(--ys-ink-3);
}
.calc-grid {
display: flex;
gap: 28px;
align-items: stretch;
flex-wrap: wrap;
}
.calc-form {
flex: 1 1 320px;
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 220px;
min-width: 200px;
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: 36px;
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: 26px;
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>
+75 -2
View File
@@ -11,6 +11,7 @@ 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:客户档案 + 归属人员 + 归属变更留痕)。
@@ -21,6 +22,8 @@ import { handledError } from '@/utils/error'
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
* (对照表 #19 的业务口径)
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
* - 评级计算器并入本页(原 /business/rating 独立页已移除):行内弹窗打分,
* 保存评分写入档案字段 rating_score/rating_time
*/
const { confirmAndRun } = useConfirmAction()
@@ -39,6 +42,8 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
keyword: filters.keyword,
customer_type: filters.customer_type,
owner_personnel_id: filters.owner_personnel_id,
order_by: sortState.order_by,
order: sortState.order,
}),
)
@@ -49,6 +54,54 @@ function resetFilters(): void {
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 {
@@ -322,7 +375,7 @@ onMounted(() => {
<div class="page-head">
<h1 class="page-title">客户管理</h1>
<p class="page-desc">
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,每次调整都在「变更记录」留痕
客户档案与归属人员;归属变更只影响变更后的核算、不回溯历史,评分由「评级计算」维护并保存进档案
</p>
<span class="waterline"></span>
</div>
@@ -385,8 +438,20 @@ onMounted(() => {
: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 }">
@@ -423,9 +488,10 @@ onMounted(() => {
<span v-else class="cell-muted">(无归属)</span>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="center">
<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)">
归属变更
@@ -585,6 +651,13 @@ onMounted(() => {
@size-change="onLogsSizeChange"
/>
</el-drawer>
<!-- 评级计算器(原 /business/rating 独立页并入;保存评分回写档案) -->
<RatingCalculatorDialog
v-model:visible="ratingVisible"
:customer="ratingTarget"
@saved="onRatingSaved"
/>
</div>
</template>
@@ -1,493 +0,0 @@
<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">
<!-- 页面头:标题 + 一句说明 + 签名水线(每页只出现一次) -->
<div class="page-head">
<h1 class="page-title">客户评级计算器</h1>
<p class="page-desc">
五项各 0-20 分、总分 0-100,按配置的分值下限实时判定等级;评分只决定评级,不参与绩效金额计算
</p>
<span class="waterline"></span>
</div>
<!-- 对照表 #25:常驻口径提示,防止误认为评分会影响绩效金额 -->
<el-alert
class="page-alert"
title="本页为评分计算工具,评分结果不参与绩效金额计算"
type="warning"
:closable="false"
show-icon
/>
<!-- 计算器是一张卡:卡头放标题与操作,卡体内分「评分项 / 判定结果」两块 -->
<div class="card">
<div class="card-head">
<span class="card-title">评分计算</span>
<span class="card-head__actions">
<el-button @click="resetScores">重置</el-button>
<el-button type="primary" @click="openDrawer">等级映射设置</el-button>
</span>
</div>
<div class="card-body">
<div class="calc-grid">
<div class="calc-form">
<div class="section-title">评分项(各 0-20 分,步进 1)</div>
<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 class="section-title">判定结果</div>
<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>
</div>
</div>
<!-- 等级映射设置抽屉 -->
<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 }">
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</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-head/.page-title/.page-desc/.waterline)、.card 与 .section-title
都由全局 index.css 提供,此处只保留本页特有的排版 */
.page-alert {
margin-bottom: 14px;
}
/* 卡头右侧操作组:.card-head 已是 flex + justify-between,这里只补按钮之间的间距 */
.card-head__actions {
display: flex;
gap: 10px;
}
.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>