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'),
|
component: () => import('@/views/system/logs/index.vue'),
|
||||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
|
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` 的
|
* 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的
|
||||||
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
|
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
|
||||||
*/
|
*/
|
||||||
export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const
|
export const MODULE_OPTIONS = [
|
||||||
|
'用户管理',
|
||||||
|
'角色管理',
|
||||||
|
'岗位角色',
|
||||||
|
'计提参数',
|
||||||
|
'人员管理',
|
||||||
|
'产品管理',
|
||||||
|
'客户管理',
|
||||||
|
] as const
|
||||||
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
|
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
|
||||||
|
|
||||||
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
|
/** 启用/禁用筛选选项(文案与 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