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:
1 parent
b9781f7d3f
commit
32b7940067
17 files changed
+917
-512
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user