feat(base): 前端基础数据三页与客户评级计算器(人员/产品/客户/评级)
- 人员页:编辑抽屉内区域/计划子表编辑;产品页:类别阈值提示条; 客户页:归属变更二次确认含「不回溯历史」+变更记录抽屉 - 评级页:五项评分实时出等级(纯前端判定,不落库 #25)+ 等级映射维护抽屉 - 菜单两组:基础数据(8-10)/业务数据(14);金额字符串直出禁 float Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
09831dbb7f
commit
5207d14daa
127
frontend/src/api/customers.ts
Normal file
127
frontend/src/api/customers.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
150
frontend/src/api/personnel.ts
Normal file
150
frontend/src/api/personnel.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
91
frontend/src/api/products.ts
Normal file
91
frontend/src/api/products.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
60
frontend/src/api/ratings.ts
Normal file
60
frontend/src/api/ratings.ts
Normal 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' })
|
||||
}
|
||||
@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 未匹配路由回首页;若未登录会被守卫拦到登录页
|
||||
|
||||
@ -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 的展示一致) */
|
||||
|
||||
562
frontend/src/views/base/customers/index.vue
Normal file
562
frontend/src/views/base/customers/index.vue
Normal file
@ -0,0 +1,562 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import * as customerApi from '@/api/customers'
|
||||
import type { CustomerItem, OwnershipLogItem } from '@/api/customers'
|
||||
import * as personnelApi from '@/api/personnel'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 客户管理(需求 2.1.3:客户档案 + 归属人员 + 归属变更留痕)。
|
||||
*
|
||||
* 与后端约定对齐:
|
||||
* - 编辑弹窗不含归属字段 —— 归属调整是独立操作(「归属变更」按钮),
|
||||
* 走 PUT /{id}/ownership,是唯一写归属留痕的入口
|
||||
* - 归属变更提交前二次确认,文案必须包含「仅影响变更后核算,不回溯历史」
|
||||
* (对照表 #19 的业务口径)
|
||||
* - 归属变更记录为分页留痕表(抽屉内展示,按变更时间倒序)
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
customer_type: undefined as string | undefined,
|
||||
owner_personnel_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
|
||||
customerApi.listCustomers,
|
||||
() => ({
|
||||
keyword: filters.keyword,
|
||||
customer_type: filters.customer_type,
|
||||
owner_personnel_id: filters.owner_personnel_id,
|
||||
}),
|
||||
)
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.customer_type = undefined
|
||||
filters.owner_personnel_id = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
// ==================== 归属人员下拉数据源 ====================
|
||||
|
||||
interface PersonnelOption {
|
||||
id: number
|
||||
name: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const ownerOptions = ref<PersonnelOption[]>([])
|
||||
|
||||
async function loadOwnerOptions(): Promise<void> {
|
||||
try {
|
||||
// 归属下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||
// 极端场景由后端对归属合法性的校验兜底
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
ownerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
|
||||
/** 下拉项展示名:离职人员加后缀标注(仍可被选作归属吗?由后端校验兜底) */
|
||||
function ownerLabel(option: PersonnelOption): string {
|
||||
return option.status === 1 ? option.name : `${option.name}(离职)`
|
||||
}
|
||||
|
||||
function personnelName(id: number): string {
|
||||
return ownerOptions.value.find((o) => o.id === id)?.name ?? `人员#${id}`
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑 ====================
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
const isEdit = computed(() => editingId.value !== null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
company_name: '',
|
||||
owner_name: '',
|
||||
contact_person: '',
|
||||
contact_phone: '',
|
||||
address: '',
|
||||
industry: '',
|
||||
customer_type: '',
|
||||
owner_personnel_id: undefined as number | undefined,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
company_name: [
|
||||
{ required: true, message: '请输入公司名称', trigger: 'blur' },
|
||||
{ max: 128, message: '公司名称最长 128 个字符', trigger: 'blur' },
|
||||
],
|
||||
owner_name: [
|
||||
{ required: true, message: '请输入客户负责人', trigger: 'blur' },
|
||||
{ max: 64, message: '负责人最长 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
contact_person: [{ max: 64, message: '联系人最长 64 个字符', trigger: 'blur' }],
|
||||
contact_phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
|
||||
address: [{ max: 255, message: '地址最长 255 个字符', trigger: 'blur' }],
|
||||
industry: [{ max: 64, message: '行业最长 64 个字符', trigger: 'blur' }],
|
||||
customer_type: [{ required: true, message: '请选择客户分类', trigger: 'change' }],
|
||||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
company_name: '',
|
||||
owner_name: '',
|
||||
contact_person: '',
|
||||
contact_phone: '',
|
||||
address: '',
|
||||
industry: '',
|
||||
customer_type: '',
|
||||
owner_personnel_id: undefined,
|
||||
remark: '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openEdit(row: CustomerItem): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
company_name: row.company_name,
|
||||
owner_name: row.owner_name,
|
||||
contact_person: row.contact_person ?? '',
|
||||
contact_phone: row.contact_phone ?? '',
|
||||
address: row.address ?? '',
|
||||
industry: row.industry ?? '',
|
||||
customer_type: row.customer_type,
|
||||
owner_personnel_id: undefined, // 编辑弹窗不含归属,归属走「归属变更」按钮
|
||||
remark: row.remark ?? '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value !== null) {
|
||||
// 编辑端点不含归属:归属调整统一走「归属变更」,保证留痕完整
|
||||
const { owner_personnel_id: _ignored, ...rest } = form
|
||||
await customerApi.updateCustomer(editingId.value, {
|
||||
...rest,
|
||||
company_name: rest.company_name.trim(),
|
||||
owner_name: rest.owner_name.trim(),
|
||||
contact_person: rest.contact_person.trim() || null,
|
||||
contact_phone: rest.contact_phone.trim() || null,
|
||||
address: rest.address.trim() || null,
|
||||
industry: rest.industry.trim() || null,
|
||||
remark: rest.remark.trim() || null,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await customerApi.createCustomer({
|
||||
company_name: form.company_name.trim(),
|
||||
owner_name: form.owner_name.trim(),
|
||||
contact_person: form.contact_person.trim() || null,
|
||||
contact_phone: form.contact_phone.trim() || null,
|
||||
address: form.address.trim() || null,
|
||||
industry: form.industry.trim() || null,
|
||||
customer_type: form.customer_type,
|
||||
owner_personnel_id: form.owner_personnel_id ?? null,
|
||||
remark: form.remark.trim() || null,
|
||||
})
|
||||
ElMessage.success('客户已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 归属变更 ====================
|
||||
|
||||
const ownershipVisible = ref(false)
|
||||
const ownershipTarget = ref<CustomerItem | null>(null)
|
||||
const ownershipSubmitting = ref(false)
|
||||
const ownershipForm = reactive({
|
||||
new_owner_personnel_id: undefined as number | undefined,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const ownershipCurrentLabel = computed(() => {
|
||||
const target = ownershipTarget.value
|
||||
if (!target) return '—'
|
||||
return target.owner_personnel_name ?? '(无归属)'
|
||||
})
|
||||
|
||||
function openOwnership(row: CustomerItem): void {
|
||||
ownershipTarget.value = row
|
||||
ownershipForm.new_owner_personnel_id = row.owner_personnel_id ?? undefined
|
||||
ownershipForm.reason = ''
|
||||
ownershipVisible.value = true
|
||||
}
|
||||
|
||||
async function handleOwnershipSubmit(): Promise<void> {
|
||||
const target = ownershipTarget.value
|
||||
if (!target) return
|
||||
|
||||
const nextId = ownershipForm.new_owner_personnel_id ?? null
|
||||
const currentId = target.owner_personnel_id ?? null
|
||||
if (nextId === currentId) {
|
||||
ElMessage.warning('归属未变化,请选择新的归属人员,或清空下拉后提交以清空归属')
|
||||
return
|
||||
}
|
||||
|
||||
const nextName = nextId === null ? '(清空归属)' : personnelName(nextId)
|
||||
await confirmAndRun({
|
||||
// 业务口径(对照表 #19):仅影响变更后核算,不回溯历史 —— 文案必须保留
|
||||
confirmText: `确定调整客户「${target.company_name}」的归属为「${nextName}」?归属变更仅影响变更后核算,不回溯历史。`,
|
||||
title: '归属变更确认',
|
||||
confirmButtonText: '确认变更',
|
||||
successText: '归属已调整',
|
||||
run: async () => {
|
||||
ownershipSubmitting.value = true
|
||||
try {
|
||||
await customerApi.changeOwnership(target.id, {
|
||||
new_owner_personnel_id: nextId,
|
||||
reason: ownershipForm.reason.trim() || null,
|
||||
})
|
||||
} finally {
|
||||
ownershipSubmitting.value = false
|
||||
}
|
||||
},
|
||||
after: async () => {
|
||||
ownershipVisible.value = false
|
||||
await load()
|
||||
// 归属记录抽屉开着且正是该客户时同步刷新
|
||||
if (logsVisible.value && logsCustomerId.value === target.id) await loadLogs()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 归属变更记录 ====================
|
||||
|
||||
const logsVisible = ref(false)
|
||||
const logsCustomerId = ref<number | null>(null)
|
||||
const logsCustomerName = ref('')
|
||||
const logsTitle = computed(() =>
|
||||
logsCustomerId.value === null ? '归属变更记录' : `「${logsCustomerName.value}」· 归属变更记录`,
|
||||
)
|
||||
|
||||
const {
|
||||
records: logRecords,
|
||||
total: logTotal,
|
||||
pageNum: logPageNum,
|
||||
pageSize: logPageSize,
|
||||
loading: logsLoading,
|
||||
load: loadLogs,
|
||||
onPageChange: onLogsPageChange,
|
||||
onSizeChange: onLogsSizeChange,
|
||||
} = usePagedList<OwnershipLogItem, object>(
|
||||
(params) => {
|
||||
const id = logsCustomerId.value
|
||||
// 抽屉未指定客户时兜底空数据,避免发出无意义请求
|
||||
return id === null
|
||||
? Promise.resolve({ list: [], total: 0, pageNum: 1, pageSize: 10 })
|
||||
: customerApi.listOwnershipLogs(id, params)
|
||||
},
|
||||
() => ({}),
|
||||
)
|
||||
|
||||
function openLogs(row: CustomerItem): void {
|
||||
logsCustomerId.value = row.id
|
||||
logsCustomerName.value = row.company_name
|
||||
logPageNum.value = 1
|
||||
logsVisible.value = true
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
/** 留痕展示:新旧归属任一为 null 表示该时点无归属(ID 固化、姓名随联表) */
|
||||
function ownerTransition(row: OwnershipLogItem): string {
|
||||
const oldName = row.old_owner_name ?? '(无)'
|
||||
const newName = row.new_owner_name ?? '(无)'
|
||||
return `${oldName} → ${newName}`
|
||||
}
|
||||
|
||||
// ==================== 删除 ====================
|
||||
|
||||
async function handleDelete(row: CustomerItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定删除客户「${row.company_name}」?删除为物理删除、不可恢复;归属变更留痕将一并失去展示主体。`,
|
||||
title: '删除确认',
|
||||
confirmButtonText: '删除',
|
||||
successText: '客户已删除',
|
||||
run: () => customerApi.deleteCustomer(row.id),
|
||||
after: reloadAfterDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||||
void load()
|
||||
void loadOwnerOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="公司名称或客户负责人"
|
||||
clearable
|
||||
style="width: 190px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
|
||||
<el-option
|
||||
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="归属人员">
|
||||
<el-select
|
||||
v-model="filters.owner_personnel_id"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in ownerOptions"
|
||||
:key="item.id"
|
||||
:label="ownerLabel(item)"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增客户</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||||
<el-table-column prop="owner_name" label="负责人" min-width="90" />
|
||||
<el-table-column prop="contact_person" label="联系人" min-width="90">
|
||||
<template #default="{ row }">{{ row.contact_person ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact_phone" label="电话" min-width="120">
|
||||
<template #default="{ row }">{{ row.contact_phone ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="
|
||||
row.customer_type === 'personal'
|
||||
? 'info'
|
||||
: row.customer_type === 'manager_personal'
|
||||
? 'warning'
|
||||
: 'primary'
|
||||
"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
{{ customerApi.customerTypeLabel(row.customer_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属人员" min-width="110">
|
||||
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openOwnership(row)">归属变更</el-button>
|
||||
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑客户' : '新增客户'"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="公司名称" prop="company_name">
|
||||
<el-input v-model="form.company_name" maxlength="128" placeholder="与负责人构成组合唯一键" />
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人" prop="owner_name">
|
||||
<el-input v-model="form.owner_name" maxlength="64" placeholder="客户公司的负责人姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="联系人" prop="contact_person">
|
||||
<el-input v-model="form.contact_person" maxlength="64" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话" prop="contact_phone">
|
||||
<el-input v-model="form.contact_phone" maxlength="20" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址" prop="address">
|
||||
<el-input v-model="form.address" maxlength="255" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="行业" prop="industry">
|
||||
<el-input v-model="form.industry" maxlength="64" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="客户分类" prop="customer_type">
|
||||
<el-select v-model="form.customer_type" placeholder="决定计提规则匹配" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isEdit" label="初始归属" prop="owner_personnel_id">
|
||||
<el-select
|
||||
v-model="form.owner_personnel_id"
|
||||
placeholder="不选择=暂无归属(可先建档后派归属)"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in ownerOptions"
|
||||
:key="item.id"
|
||||
:label="ownerLabel(item)"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 归属变更(唯一写归属留痕的入口) -->
|
||||
<el-dialog v-model="ownershipVisible" title="归属变更" width="480px" destroy-on-close>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="客户">
|
||||
<span>{{ ownershipTarget?.company_name }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前归属">
|
||||
<span>{{ ownershipCurrentLabel }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新归属人员">
|
||||
<el-select
|
||||
v-model="ownershipForm.new_owner_personnel_id"
|
||||
placeholder="不选择并提交=清空归属"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in ownerOptions"
|
||||
:key="item.id"
|
||||
:label="ownerLabel(item)"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="变更原因">
|
||||
<el-input
|
||||
v-model="ownershipForm.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="255"
|
||||
placeholder="选填,将写入归属变更留痕"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="ownership-warn">
|
||||
归属变更仅影响变更后核算,不回溯历史。
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="ownershipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="ownershipSubmitting" @click="handleOwnershipSubmit">
|
||||
提交变更
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 归属变更记录(分页留痕,按变更时间倒序) -->
|
||||
<el-drawer v-model="logsVisible" :title="logsTitle" size="760px" destroy-on-close>
|
||||
<el-table v-loading="logsLoading" :data="logRecords" stripe size="small">
|
||||
<el-table-column label="变更时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原归属 → 新归属" min-width="220">
|
||||
<template #default="{ row }">{{ ownerTransition(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变更原因" min-width="180">
|
||||
<template #default="{ row }">{{ row.change_reason ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator_name" label="操作人" width="100" />
|
||||
</el-table>
|
||||
<AppPagination
|
||||
:total="logTotal"
|
||||
:page-num="logPageNum"
|
||||
:page-size="logPageSize"
|
||||
@page-change="onLogsPageChange"
|
||||
@size-change="onLogsSizeChange"
|
||||
/>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ownership-warn {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--ys-indigo-wash);
|
||||
color: var(--ys-ink-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
659
frontend/src/views/base/personnel/index.vue
Normal file
659
frontend/src/views/base/personnel/index.vue
Normal file
@ -0,0 +1,659 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import * as businessRoleApi from '@/api/businessRoles'
|
||||
import * as personnelApi from '@/api/personnel'
|
||||
import type { PersonnelItem } from '@/api/personnel'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import StatusTag from '@/components/table/StatusTag.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 人员管理(需求 2.1.1:人员档案 + 辐射区域 + 拓展计划)。
|
||||
*
|
||||
* 与后端约定对齐:
|
||||
* - 基本信息新增/编辑走弹窗(全量提交、不含状态);在职/离职切换、
|
||||
* 删除均为独立操作,删除被引用时后端拒绝,此时应改为离职
|
||||
* - 编辑抽屉承载子表:辐射区域与拓展计划行内增删,保存走
|
||||
* PUT /{id}/regions、PUT /{id}/plans 全量替换(空列表即清空)
|
||||
* - 工资标准为字符串直出(金额红线:不经 float、不 toFixed)
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
/** 在职/离职筛选项(与产品页「启用/禁用」语义不同,页面内定义) */
|
||||
const EMPLOYMENT_OPTIONS = [
|
||||
{ label: '在职', value: 1 },
|
||||
{ label: '离职', value: 0 },
|
||||
] as const
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
business_role_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<PersonnelItem, personnelApi.PersonnelQueryParams>(
|
||||
personnelApi.listPersonnel,
|
||||
() => ({
|
||||
keyword: filters.keyword,
|
||||
status: filters.status,
|
||||
business_role_id: filters.business_role_id,
|
||||
}),
|
||||
)
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.status = undefined
|
||||
filters.business_role_id = undefined
|
||||
search()
|
||||
}
|
||||
|
||||
// ==================== 岗位 / 经理下拉数据源 ====================
|
||||
|
||||
interface SelectOption {
|
||||
id: number
|
||||
name: string
|
||||
status: number
|
||||
}
|
||||
|
||||
const roleOptions = ref<SelectOption[]>([])
|
||||
const managerOptions = ref<SelectOption[]>([])
|
||||
|
||||
async function loadRoleOptions(): Promise<void> {
|
||||
try {
|
||||
// 岗位规模有限(内置 3 个 + 自定义),一页足够
|
||||
const data = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
|
||||
roleOptions.value = data.list.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManagerOptions(): Promise<void> {
|
||||
try {
|
||||
// 经理下拉的近似全量数据源:内部系统人员规模有限,200 条足够;
|
||||
// 超出规模的极端场景由后端对经理合法性的校验兜底
|
||||
const data = await personnelApi.listPersonnel({ pageNum: 1, pageSize: 200 })
|
||||
managerOptions.value = data.list.map((p) => ({ id: p.id, name: p.personnel_name, status: p.status }))
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 新增 / 编辑(基本信息) ====================
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 单一状态源:编辑中 ⇔ editingId 非空(避免双状态漂移) */
|
||||
const isEdit = computed(() => editingId.value !== null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
personnel_name: '',
|
||||
business_role_id: undefined as number | undefined,
|
||||
manager_id: undefined as number | undefined,
|
||||
phone: '',
|
||||
hire_date: '',
|
||||
is_probation: 0,
|
||||
salary_standard: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
/** 编辑中的行(用于停用岗位/离职经理的回显兜底) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
|
||||
/** 搜索下拉显示全部岗位(含停用,便于筛出仍绑定停用岗位的人员) */
|
||||
const searchRoleOptions = computed(() =>
|
||||
roleOptions.value.map((r) => ({ ...r, name: r.status === 1 ? r.name : `${r.name}(已停用)` })),
|
||||
)
|
||||
|
||||
/** 表单下拉只列启用岗位;编辑时若原岗位已停用则保留一项供回显 */
|
||||
const formRoleOptions = computed(() => {
|
||||
const enabled = roleOptions.value.filter((r) => r.status === 1)
|
||||
const row = editingRow.value
|
||||
if (row && !enabled.some((r) => r.id === row.business_role_id)) {
|
||||
enabled.unshift({ id: row.business_role_id, name: `${row.role_name ?? ''}(已停用)`, status: 0 })
|
||||
}
|
||||
return enabled
|
||||
})
|
||||
|
||||
/** 经理下拉:排除编辑中的人员自己(后端校验「经理不能是自己」) */
|
||||
const formManagerOptions = computed(() =>
|
||||
managerOptions.value.filter((m) => m.id !== editingId.value),
|
||||
)
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
personnel_name: [
|
||||
{ required: true, message: '请输入姓名', trigger: 'blur' },
|
||||
{ max: 64, message: '姓名最长 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
business_role_id: [{ required: true, message: '请选择岗位', trigger: 'change' }],
|
||||
phone: [{ max: 20, message: '联系电话最长 20 个字符', trigger: 'blur' }],
|
||||
salary_standard: [
|
||||
{ pattern: /^(\d+)(\.\d{1,2})?$/, message: '工资标准须为非负数字,最多两位小数', trigger: 'blur' },
|
||||
],
|
||||
remark: [{ max: 255, message: '备注最长 255 个字符', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
personnel_name: '',
|
||||
business_role_id: undefined,
|
||||
manager_id: undefined,
|
||||
phone: '',
|
||||
hire_date: '',
|
||||
is_probation: 0,
|
||||
salary_standard: '',
|
||||
remark: '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openEdit(row: PersonnelItem): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
personnel_name: row.personnel_name,
|
||||
business_role_id: row.business_role_id,
|
||||
manager_id: row.manager_id ?? undefined,
|
||||
phone: row.phone ?? '',
|
||||
hire_date: row.hire_date ?? '',
|
||||
is_probation: row.is_probation,
|
||||
salary_standard: row.salary_standard ?? '',
|
||||
remark: row.remark ?? '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
// 空串归一为 null(与后端「空串归一 NULL」口径一致);金额原串提交
|
||||
const payload: personnelApi.PersonnelSaveParams = {
|
||||
personnel_name: form.personnel_name.trim(),
|
||||
business_role_id: form.business_role_id as number,
|
||||
manager_id: form.manager_id ?? null,
|
||||
phone: form.phone.trim() || null,
|
||||
hire_date: form.hire_date || null,
|
||||
is_probation: form.is_probation,
|
||||
salary_standard: form.salary_standard.trim() || null,
|
||||
remark: form.remark.trim() || null,
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingId.value !== null) {
|
||||
await personnelApi.updatePersonnel(editingId.value, payload)
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await personnelApi.createPersonnel(payload)
|
||||
ElMessage.success('人员已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 编辑抽屉:辐射区域 / 拓展计划 ====================
|
||||
|
||||
interface RegionDraft {
|
||||
region_name: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface PlanDraft {
|
||||
plan_period: string
|
||||
plan_content: string
|
||||
target_desc: string
|
||||
}
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const drawerRow = ref<PersonnelItem | null>(null)
|
||||
const drawerTitle = computed(() =>
|
||||
drawerRow.value ? `「${drawerRow.value.personnel_name}」· 区域与拓展计划` : '区域与拓展计划',
|
||||
)
|
||||
const detailLoading = ref(false)
|
||||
const regionSaving = ref(false)
|
||||
const planSaving = ref(false)
|
||||
|
||||
/** 本地草稿行(全量替换:保存即以后端返回为准,无需保留行 id) */
|
||||
const regionRows = ref<RegionDraft[]>([])
|
||||
const planRows = ref<PlanDraft[]>([])
|
||||
|
||||
async function openSubTables(row: PersonnelItem): Promise<void> {
|
||||
drawerRow.value = row
|
||||
drawerVisible.value = true
|
||||
detailLoading.value = true
|
||||
regionRows.value = []
|
||||
planRows.value = []
|
||||
try {
|
||||
const detail = await personnelApi.getPersonnelDetail(row.id)
|
||||
regionRows.value = detail.regions.map((r) => ({
|
||||
region_name: r.region_name,
|
||||
remark: r.remark ?? '',
|
||||
}))
|
||||
planRows.value = detail.plans.map((p) => ({
|
||||
plan_period: p.plan_period,
|
||||
plan_content: p.plan_content,
|
||||
target_desc: p.target_desc ?? '',
|
||||
}))
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成,抽屉显示空表
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addRegionRow(): void {
|
||||
regionRows.value.push({ region_name: '', remark: '' })
|
||||
}
|
||||
|
||||
function removeRegionRow(index: number): void {
|
||||
regionRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function addPlanRow(): void {
|
||||
planRows.value.push({ plan_period: '', plan_content: '', target_desc: '' })
|
||||
}
|
||||
|
||||
function removePlanRow(index: number): void {
|
||||
planRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
/** 保存辐射区域:全量替换,未填名称的行不能混入 */
|
||||
async function saveRegions(): Promise<void> {
|
||||
const row = drawerRow.value
|
||||
if (!row) return
|
||||
const blank = regionRows.value.findIndex((r) => !r.region_name.trim())
|
||||
if (blank >= 0) {
|
||||
ElMessage.warning(`第 ${blank + 1} 行的区域名称未填写`)
|
||||
return
|
||||
}
|
||||
|
||||
regionSaving.value = true
|
||||
try {
|
||||
await personnelApi.replacePersonnelRegions(
|
||||
row.id,
|
||||
regionRows.value.map((r) => ({
|
||||
region_name: r.region_name.trim(),
|
||||
remark: r.remark.trim() || null,
|
||||
})),
|
||||
)
|
||||
ElMessage.success('辐射区域已保存')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
regionSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存拓展计划:期间与内容必填,指标说明可空 */
|
||||
async function savePlans(): Promise<void> {
|
||||
const row = drawerRow.value
|
||||
if (!row) return
|
||||
const blank = planRows.value.findIndex((r) => !r.plan_period.trim() || !r.plan_content.trim())
|
||||
if (blank >= 0) {
|
||||
ElMessage.warning(`第 ${blank + 1} 行的计划期间或内容未填写`)
|
||||
return
|
||||
}
|
||||
|
||||
planSaving.value = true
|
||||
try {
|
||||
await personnelApi.replacePersonnelPlans(
|
||||
row.id,
|
||||
planRows.value.map((r) => ({
|
||||
plan_period: r.plan_period.trim(),
|
||||
plan_content: r.plan_content.trim(),
|
||||
target_desc: r.target_desc.trim() || null,
|
||||
})),
|
||||
)
|
||||
ElMessage.success('拓展计划已保存')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
planSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 在职切换 / 删除 ====================
|
||||
|
||||
async function handleToggleStatus(row: PersonnelItem): Promise<void> {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
const action = next === 1 ? '设为在职' : '设为离职'
|
||||
await confirmAndRun({
|
||||
confirmText:
|
||||
next === 0
|
||||
? `确定将「${row.personnel_name}」设为离职?离职人员不可再被业务与客户归属引用。`
|
||||
: `确定将「${row.personnel_name}」设为在职?`,
|
||||
title: '状态确认',
|
||||
confirmButtonText: action,
|
||||
successText: `已${action}`,
|
||||
run: () => personnelApi.setPersonnelStatus(row.id, next),
|
||||
after: load,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row: PersonnelItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定删除人员「${row.personnel_name}」?删除为物理删除、不可恢复;若该人员已被团队成员或客户归属引用将无法删除,此时应改为离职。`,
|
||||
title: '删除确认',
|
||||
confirmButtonText: '删除',
|
||||
successText: '人员已删除',
|
||||
run: () => personnelApi.deletePersonnel(row.id),
|
||||
after: reloadAfterDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
// 三个请求各自容错(load 内部已兜底),互不拖累
|
||||
void load()
|
||||
void loadRoleOptions()
|
||||
void loadManagerOptions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||||
<el-form-item label="姓名">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="姓名,模糊匹配"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option
|
||||
v-for="item in EMPLOYMENT_OPTIONS"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位">
|
||||
<el-select
|
||||
v-model="filters.business_role_id"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in searchRoleOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate">新增人员</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe>
|
||||
<el-table-column prop="personnel_name" label="姓名" min-width="100" />
|
||||
<el-table-column prop="role_name" label="岗位" min-width="100">
|
||||
<template #default="{ row }">{{ row.role_name ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="manager_name" label="直属经理" min-width="100">
|
||||
<template #default="{ row }">{{ row.manager_name ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phone" label="联系电话" min-width="120">
|
||||
<template #default="{ row }">{{ row.phone ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hire_date" label="入职日期" min-width="110">
|
||||
<template #default="{ row }">{{ row.hire_date ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="试用期" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_probation === 1" type="warning" size="small" disable-transitions>
|
||||
试用期
|
||||
</el-tag>
|
||||
<span v-else class="cell-muted">正式</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="工资标准(元)" min-width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-amount">{{ row.salary_standard ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="openSubTables(row)">区域与计划</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
|
||||
{{ row.status === 1 ? '设为离职' : '设为在职' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增 / 编辑(基本信息;不含状态与子表) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑人员' : '新增人员'"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="姓名" prop="personnel_name">
|
||||
<el-input v-model="form.personnel_name" placeholder="营销人员姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位" prop="business_role_id">
|
||||
<el-select
|
||||
v-model="form.business_role_id"
|
||||
placeholder="选择岗位(计提公式随岗位绑定)"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in formRoleOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="直属经理" prop="manager_id">
|
||||
<el-select
|
||||
v-model="form.manager_id"
|
||||
placeholder="不选择=无直属经理"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in formManagerOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="phone">
|
||||
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="入职日期" prop="hire_date">
|
||||
<el-date-picker
|
||||
v-model="form.hire_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="试用期" prop="is_probation">
|
||||
<el-switch v-model="form.is_probation" :active-value="1" :inactive-value="0" />
|
||||
<span class="switch-hint">试用期绩效照算但不发放,年累计超 18000 元部分年底一次性结算</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="工资标准" prop="salary_standard">
|
||||
<el-input v-model="form.salary_standard" placeholder="如 5000.50(月,元),选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" maxlength="255" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑抽屉:辐射区域 / 拓展计划(行内增删,全量替换保存) -->
|
||||
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="720px" destroy-on-close>
|
||||
<div v-loading="detailLoading">
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-head">
|
||||
<span class="sub-section-title">辐射区域</span>
|
||||
<span>
|
||||
<el-button size="small" @click="addRegionRow">添加区域</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="regionSaving"
|
||||
@click="saveRegions"
|
||||
>
|
||||
保存区域
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="regionRows" stripe size="small">
|
||||
<el-table-column label="区域名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.region_name" maxlength="128" placeholder="如 沈阳市铁西区" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.remark" maxlength="255" placeholder="选填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removeRegionRow($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-head">
|
||||
<span class="sub-section-title">拓展计划</span>
|
||||
<span>
|
||||
<el-button size="small" @click="addPlanRow">添加计划</el-button>
|
||||
<el-button size="small" type="primary" :loading="planSaving" @click="savePlans">
|
||||
保存计划
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<el-table :data="planRows" stripe size="small">
|
||||
<el-table-column label="期间" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.plan_period" maxlength="32" placeholder="如 2026Q1" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划内容" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.plan_content" maxlength="500" placeholder="计划内容" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="指标说明" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.target_desc" maxlength="255" placeholder="选填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="removePlanRow($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cell-muted {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cell-amount {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.switch-hint {
|
||||
margin-left: 10px;
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sub-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.sub-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sub-section-title {
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
</style>
|
||||
351
frontend/src/views/base/products/index.vue
Normal file
351
frontend/src/views/base/products/index.vue
Normal 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>
|
||||
482
frontend/src/views/business/rating/index.vue
Normal file
482
frontend/src/views/business/rating/index.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user