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

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

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

View File

@ -0,0 +1,127 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 客户列表项(对应后端 `CustomerOut`)。
*
* owner_personnel_name 由联表带出:归属人员不可考(被物理删除)时为 null,
* owner_personnel_id 仍保留原值供追溯。
*/
export interface CustomerItem {
id: number
company_name: string
/** 客户公司的负责人姓名(非系统内营销人员) */
owner_name: string
contact_person: string | null
contact_phone: string | null
address: string | null
industry: string | null
/** personal 个人户 / manager_personal 经理个人户 / enterprise 企业户(计提规则锚点) */
customer_type: string
owner_personnel_id: number | null
owner_personnel_name: string | null
remark: string | null
}
export interface CustomerQueryParams {
pageNum?: number
pageSize?: number
/** 公司名称或客户负责人,模糊匹配 */
keyword?: string
customer_type?: string
owner_personnel_id?: number
}
/** 新增客户(owner_personnel_id 为初始归属,可空:先建档后派归属也合法) */
export interface CustomerCreateParams {
company_name: string
owner_name: string
contact_person?: string | null
contact_phone?: string | null
address?: string | null
industry?: string | null
customer_type: string
owner_personnel_id?: number | null
remark?: string | null
}
/**
* 编辑客户:不含归属字段 —— 归属调整统一走 `PUT /{id}/ownership`
* (唯一写归属留痕的入口),编辑表单即使提交归属也会被后端忽略。
*/
export type CustomerUpdateParams = Omit<CustomerCreateParams, 'owner_personnel_id'>
/** 归属变更入参(new_owner_personnel_id 空 = 清空归属) */
export interface CustomerOwnershipParams {
new_owner_personnel_id?: number | null
reason?: string | null
}
/** 归属变更留痕条目(对应后端 `OwnershipChangeLogOut`;姓名可随人员消失,ID 固化) */
export interface OwnershipLogItem {
id: number
customer_id: number
old_owner_personnel_id: number | null
new_owner_personnel_id: number | null
old_owner_name: string | null
new_owner_name: string | null
change_reason: string | null
operator_id: number | null
operator_name: string
create_time: string
}
/** 客户分类下拉项(与后端 `core/constants.py` 的值域一致) */
export const CUSTOMER_TYPE_OPTIONS = [
{ label: '个人户', value: 'personal' },
{ label: '经理个人户', value: 'manager_personal' },
{ label: '企业户', value: 'enterprise' },
] as const
/** 分类编码 → 展示名(表格列展示用,未知编码原样回显) */
export function customerTypeLabel(code: string): string {
return CUSTOMER_TYPE_OPTIONS.find((o) => o.value === code)?.label ?? code
}
/** 分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名) */
export function listCustomers(params: CustomerQueryParams): Promise<PageData<CustomerItem>> {
return request({ url: '/v1/base/customers', method: 'get', params })
}
/** 新增客户(公司名称+负责人组合唯一,冲突由后端拦截) */
export function createCustomer(data: CustomerCreateParams): Promise<CustomerItem> {
return request({ url: '/v1/base/customers', method: 'post', data })
}
/** 编辑客户档案(不含归属;全量提交) */
export function updateCustomer(
customerId: number,
data: CustomerUpdateParams,
): Promise<CustomerItem> {
return request({ url: `/v1/base/customers/${customerId}`, method: 'put', data })
}
/** 删除客户(物理删除) */
export function deleteCustomer(customerId: number): Promise<null> {
return request({ url: `/v1/base/customers/${customerId}`, method: 'delete' })
}
/** 归属变更(唯一写归属留痕的入口;新旧相同后端报「归属未变化」) */
export function changeOwnership(
customerId: number,
data: CustomerOwnershipParams,
): Promise<CustomerItem> {
return request({ url: `/v1/base/customers/${customerId}/ownership`, method: 'put', data })
}
/** 查某客户的归属变更留痕(分页,按变更时间倒序) */
export function listOwnershipLogs(
customerId: number,
params: { pageNum?: number; pageSize?: number },
): Promise<PageData<OwnershipLogItem>> {
return request({
url: `/v1/base/customers/${customerId}/ownership-logs`,
method: 'get',
params,
})
}

View File

@ -0,0 +1,150 @@
import { request } from './request'
import type { PageData } from './types'
/** 辐射区域条目(对应后端 `PersonnelRegionOut`) */
export interface PersonnelRegionItem {
id: number
region_name: string
remark: string | null
}
/** 拓展计划条目(对应后端 `PersonnelPlanOut`) */
export interface PersonnelPlanItem {
id: number
plan_period: string
plan_content: string
target_desc: string | null
}
/** 人员列表项(对应后端 `PersonnelOut`) */
export interface PersonnelItem {
id: number
personnel_name: string
business_role_id: number
/** 岗位名称由联表带出 */
role_name: string | null
manager_id: number | null
manager_name: string | null
phone: string | null
/** 入职日期,ISO 串(YYYY-MM-DD) */
hire_date: string | null
/** 1试用期 0正式 */
is_probation: number
/** 月工资标准(元),字符串直出 —— 金额红线:不经 float */
salary_standard: string | null
/** 1在职 0离职 */
status: number
remark: string | null
}
/** 人员详情:附辐射区域与拓展计划,供编辑抽屉一次回显 */
export interface PersonnelDetail extends PersonnelItem {
regions: PersonnelRegionItem[]
plans: PersonnelPlanItem[]
}
export interface PersonnelQueryParams {
pageNum?: number
pageSize?: number
/** 姓名,模糊匹配 */
keyword?: string
/** 1在职 0离职 */
status?: number
/** 岗位角色ID */
business_role_id?: number
}
/**
* 新增/编辑人员(全量提交)。
*
* 不含状态 —— 在职/离职是独立操作(PUT /{id}/status),避免编辑表单
* 误触离职。可空字段传 `null` 表示清空,与后端 `PersonnelCreateIn` 对齐。
*/
export interface PersonnelSaveParams {
personnel_name: string
business_role_id: number
/** 直属经理ID,空=无;后端校验不能是自己且须在职 */
manager_id?: number | null
phone?: string | null
/** ISO 串(YYYY-MM-DD) */
hire_date?: string | null
/** 1试用期 0正式 */
is_probation?: number
/** 月工资标准(元),字符串形式的数字,最多两位小数 */
salary_standard?: string | null
remark?: string | null
}
/** 辐射区域条目入参(全量替换用) */
export interface RegionItemParams {
region_name: string
remark?: string | null
}
/** 拓展计划条目入参(全量替换用) */
export interface PlanItemParams {
plan_period: string
plan_content: string
target_desc?: string | null
}
/** 分页查询人员(含离职),姓名模糊匹配 */
export function listPersonnel(params: PersonnelQueryParams): Promise<PageData<PersonnelItem>> {
return request({ url: '/v1/base/personnel', method: 'get', params })
}
/** 新增人员(默认在职;岗位须存在且启用) */
export function createPersonnel(data: PersonnelSaveParams): Promise<PersonnelItem> {
return request({ url: '/v1/base/personnel', method: 'post', data })
}
/** 人员详情(附辐射区域与拓展计划) */
export function getPersonnelDetail(personnelId: number): Promise<PersonnelDetail> {
return request({ url: `/v1/base/personnel/${personnelId}`, method: 'get' })
}
/** 编辑人员(全量提交,不含状态) */
export function updatePersonnel(
personnelId: number,
data: PersonnelSaveParams,
): Promise<PersonnelItem> {
return request({ url: `/v1/base/personnel/${personnelId}`, method: 'put', data })
}
/** 删除人员(被团队成员或客户归属引用时后端拒绝,应改为离职) */
export function deletePersonnel(personnelId: number): Promise<null> {
return request({ url: `/v1/base/personnel/${personnelId}`, method: 'delete' })
}
/** 设为在职 / 离职(离职人员不可再被业务与客户归属引用) */
export function setPersonnelStatus(personnelId: number, status: number): Promise<PersonnelItem> {
return request({
url: `/v1/base/personnel/${personnelId}/status`,
method: 'put',
data: { status },
})
}
/** 全量替换辐射区域(空列表即清空该人员的全部区域) */
export function replacePersonnelRegions(
personnelId: number,
items: RegionItemParams[],
): Promise<null> {
return request({
url: `/v1/base/personnel/${personnelId}/regions`,
method: 'put',
data: { items },
})
}
/** 全量替换拓展计划(空列表即清空该人员的全部计划) */
export function replacePersonnelPlans(
personnelId: number,
items: PlanItemParams[],
): Promise<null> {
return request({
url: `/v1/base/personnel/${personnelId}/plans`,
method: 'put',
data: { items },
})
}

View File

@ -0,0 +1,91 @@
import { request } from './request'
import type { PageData } from './types'
/**
* 产品列表项(对应后端 `ProductOut`)。
*
* 数量/单价为字符串直出 —— 金额红线:前端禁用 float 解析与 toFixed,
* 展示原串、提交原串,Decimal 转换收敛在后端 service 层。
*/
export interface ProductItem {
id: number
product_name: string
/** 规格型号,可空串(与名称构成组合唯一键) */
spec_model: string
unit: string | null
stock_quantity: string | null
sale_price: string | null
/** municipal_water 市政水利 / rail_transit 轨道交通 —— 折算阈值的行级锚点 */
product_category: string
/** 1启用 0停用 */
status: number
remark: string | null
create_time: string
update_time: string
}
export interface ProductQueryParams {
pageNum?: number
pageSize?: number
/** 名称或规格,模糊匹配 */
keyword?: string
product_category?: string
status?: number
}
/** 新增/编辑产品(全量提交;不含状态,启停走独立端点) */
export interface ProductSaveParams {
product_name: string
/** 空串与 None 同义(后端归一为空串以保组合唯一键生效) */
spec_model?: string | null
unit?: string | null
/** 非负数字字符串 */
stock_quantity?: string | null
/** 非负数字字符串 */
sale_price?: string | null
product_category: string
remark?: string | null
}
/** 产品类别下拉项(与后端 `core/constants.py` 的值域一致) */
export const PRODUCT_CATEGORY_OPTIONS = [
{ label: '市政水利', value: 'municipal_water' },
{ label: '轨道交通', value: 'rail_transit' },
] as const
/** 类别编码 → 展示名(表格列展示用,未知编码原样回显) */
export function productCategoryLabel(code: string): string {
return PRODUCT_CATEGORY_OPTIONS.find((o) => o.value === code)?.label ?? code
}
/** 分页查询产品(含停用) */
export function listProducts(params: ProductQueryParams): Promise<PageData<ProductItem>> {
return request({ url: '/v1/base/products', method: 'get', params })
}
/** 新增产品(默认启用;类别须在合法值域内) */
export function createProduct(data: ProductSaveParams): Promise<ProductItem> {
return request({ url: '/v1/base/products', method: 'post', data })
}
/** 编辑产品(全量提交,不含状态;名称+规格组合唯一,冲突由后端拦截) */
export function updateProduct(
productId: number,
data: ProductSaveParams,
): Promise<ProductItem> {
return request({ url: `/v1/base/products/${productId}`, method: 'put', data })
}
/** 删除产品(物理删除,仅限误录数据;常规下架用停用) */
export function deleteProduct(productId: number): Promise<null> {
return request({ url: `/v1/base/products/${productId}`, method: 'delete' })
}
/** 启用 / 停用产品 */
export function setProductStatus(productId: number, status: number): Promise<ProductItem> {
return request({
url: `/v1/base/products/${productId}/status`,
method: 'put',
data: { status },
})
}

View File

@ -0,0 +1,60 @@
import { request } from './request'
/**
* 评级等级配置项(对应后端 `RatingGradeOut`)。
*
* min_score/max_score 按分值红线以**字符串**出参(后端 str 直出),
* 前端参与数值比较前必须显式 parseFloat 转数值,禁止直接做字符串比大小。
*/
export interface RatingGradeItem {
id: number
grade_name: string
min_score: string
max_score: string
/** 判定顺序,升序;判定取首个 min_score ≤ 总分的等级 */
sort_order: number
remark: string | null
create_time: string
update_time: string
}
/**
* 新增 / 编辑等级入参(编辑为全量替换语义)。
*
* 分值以字符串承载(如 "90.0"):number 无法精确表示全部十进制小数,
* 字符串是分值红线的入口闸门,Decimal 转换统一收敛在后端 service 层。
*/
export interface RatingGradeParams {
grade_name: string
min_score: string
max_score: string
sort_order?: number
remark?: string
}
/**
* 全量等级列表(按 sort_order 升序返回,不分页)。
*
* 等级映射是评级计算器的判定表,数量个位数,分页无意义。
*/
export function listRatingGrades(): Promise<RatingGradeItem[]> {
return request({ url: '/v1/business/rating-grades', method: 'get' })
}
/** 新增评级等级(区间合法与不重叠由后端校验兜底,前端校验仅是展示约束) */
export function createRatingGrade(data: RatingGradeParams): Promise<RatingGradeItem> {
return request({ url: '/v1/business/rating-grades', method: 'post', data })
}
/** 编辑评级等级(全量替换,字段与新增一致) */
export function updateRatingGrade(
gradeId: number,
data: RatingGradeParams,
): Promise<RatingGradeItem> {
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'put', data })
}
/** 删除评级等级(物理删除,等级表无下游引用,允许清空后由列表空态提示) */
export function deleteRatingGrade(gradeId: number): Promise<null> {
return request({ url: `/v1/business/rating-grades/${gradeId}`, method: 'delete' })
}

View File

@ -70,6 +70,33 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/system/logs/index.vue'),
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
},
{
// 基础数据属业务操作:登录即可访问,不设 requiresAdmin(对照后端 api/v1/base)
path: '/base/personnel',
name: 'base-personnel',
component: () => import('@/views/base/personnel/index.vue'),
meta: { title: '人员管理', group: '基础数据', order: 8 },
},
{
path: '/base/products',
name: 'base-products',
component: () => import('@/views/base/products/index.vue'),
meta: { title: '产品管理', group: '基础数据', order: 9 },
},
{
path: '/base/customers',
name: 'base-customers',
component: () => import('@/views/base/customers/index.vue'),
meta: { title: '客户管理', group: '基础数据', order: 10 },
},
{
// 业务数据组首个页面:评级计算器是打分工具,不影响绩效金额,
// 登录用户即可用,不设 requiresAdmin(区别于系统管理组)
path: '/business/rating',
name: 'business-rating',
component: () => import('@/views/business/rating/index.vue'),
meta: { title: '客户评级计算器', group: '业务数据', order: 14 },
},
],
},
// 未匹配路由回首页;若未登录会被守卫拦到登录页

View File

@ -7,7 +7,15 @@ export const ROLE_ADMIN = 'admin'
* 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
*/
export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const
export const MODULE_OPTIONS = [
'用户管理',
'角色管理',
'岗位角色',
'计提参数',
'人员管理',
'产品管理',
'客户管理',
] as const
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */

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>

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>

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>

View File

@ -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>