- 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
554 lines
16 KiB
Vue
554 lines
16 KiB
Vue
<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>
|