feat(system): 前端岗位角色与计提参数配置页面
- 岗位角色页:列表/新增/编辑/启停/删除,内置岗位保护、模板受控下拉(含声明参数引导) - 计提参数页:分组目录(多维度生效值)→ 修改入「待提交」→ 批次提交(立即/指定日期 + 二次确认); 版本管理抽屉(记录分页/明细旧值→新值/两日期对比/撤回未生效/xlsx 导出);计算预览抽屉(按回款到账日取参数) - request.ts 对 responseType=blob 直通(导出文件流非统一信封) - 路由注册岗位角色/计提参数(系统管理组 order 5/6);日志模块词表补两处 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
fd801455f0
commit
4c442b8cf2
90
frontend/src/api/businessRoles.ts
Normal file
90
frontend/src/api/businessRoles.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 公式模板下拉项(含声明参数,供岗位表单展示) */
|
||||
export interface TemplateOption {
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
params: { code: string; name: string }[]
|
||||
}
|
||||
|
||||
/** 岗位角色列表项(对应后端 `BusinessRoleOut`) */
|
||||
export interface BusinessRoleItem {
|
||||
id: number
|
||||
role_code: string
|
||||
role_name: string
|
||||
calc_template: string
|
||||
description: string | null
|
||||
sort_order: number
|
||||
status: number
|
||||
/** 1内置(禁删禁停) 0自定义 */
|
||||
is_builtin: number
|
||||
}
|
||||
|
||||
export interface BusinessRoleQueryParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export interface BusinessRoleCreateParams {
|
||||
role_code: string
|
||||
role_name: string
|
||||
calc_template: string
|
||||
description?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
/** 编辑:不含 role_code(创建后不可修改,编码是参数维度与模板绑定的锚点) */
|
||||
export interface BusinessRoleUpdateParams {
|
||||
role_name: string
|
||||
calc_template: string
|
||||
description?: string
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
/** 分页查询岗位角色(含停用) */
|
||||
export function listBusinessRoles(
|
||||
params: BusinessRoleQueryParams,
|
||||
): Promise<PageData<BusinessRoleItem>> {
|
||||
return request({ url: '/v1/system/business-roles', method: 'get', params })
|
||||
}
|
||||
|
||||
/** 新增岗位 */
|
||||
export function createBusinessRole(
|
||||
data: BusinessRoleCreateParams,
|
||||
): Promise<BusinessRoleItem> {
|
||||
return request({ url: '/v1/system/business-roles', method: 'post', data })
|
||||
}
|
||||
|
||||
/** 编辑岗位 */
|
||||
export function updateBusinessRole(
|
||||
roleId: number,
|
||||
data: BusinessRoleUpdateParams,
|
||||
): Promise<BusinessRoleItem> {
|
||||
return request({ url: `/v1/system/business-roles/${roleId}`, method: 'put', data })
|
||||
}
|
||||
|
||||
/** 删除岗位(内置岗位后端拒绝,前端同步禁用按钮) */
|
||||
export function deleteBusinessRole(roleId: number): Promise<null> {
|
||||
return request({ url: `/v1/system/business-roles/${roleId}`, method: 'delete' })
|
||||
}
|
||||
|
||||
/** 启用 / 禁用(内置岗位后端拒绝停用) */
|
||||
export function setBusinessRoleStatus(
|
||||
roleId: number,
|
||||
status: number,
|
||||
): Promise<BusinessRoleItem> {
|
||||
return request({
|
||||
url: `/v1/system/business-roles/${roleId}/status`,
|
||||
method: 'put',
|
||||
data: { status },
|
||||
})
|
||||
}
|
||||
|
||||
/** 公式模板下拉(含声明参数说明) */
|
||||
export function listTemplateOptions(): Promise<TemplateOption[]> {
|
||||
return request({ url: '/v1/system/business-roles/meta/templates', method: 'get' })
|
||||
}
|
||||
208
frontend/src/api/params.ts
Normal file
208
frontend/src/api/params.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import service, { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 参数目录项(含各维度截至查询时点的生效值) */
|
||||
export interface ParamDimensionValue {
|
||||
dimension_value: string
|
||||
value_text: string
|
||||
effective_date: string | null
|
||||
}
|
||||
|
||||
export interface ParamCatalogItem {
|
||||
param_code: string
|
||||
param_name: string
|
||||
param_group: string
|
||||
value_type: string
|
||||
unit: string | null
|
||||
dimension_type: string
|
||||
options: string[]
|
||||
default_value: string | null
|
||||
value_min: string | null
|
||||
value_max: string | null
|
||||
description: string | null
|
||||
values: ParamDimensionValue[]
|
||||
}
|
||||
|
||||
/** 参数目录(asOf 缺省为今日) */
|
||||
export function getCatalog(asOf?: string): Promise<ParamCatalogItem[]> {
|
||||
return request({ url: '/v1/system/params', method: 'get', params: asOf ? { as_of: asOf } : {} })
|
||||
}
|
||||
|
||||
/** 变更条目:参数 + 维度 + 新值 */
|
||||
export interface ParamChangeItem {
|
||||
param_code: string
|
||||
dimension_value: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ParamChangePayload {
|
||||
/** 生效方式:1立即生效 2指定日期生效 */
|
||||
change_type: number
|
||||
effective_date?: string
|
||||
remark?: string
|
||||
items: ParamChangeItem[]
|
||||
}
|
||||
|
||||
export interface ParamChangeBatch {
|
||||
id: number
|
||||
version_no: string
|
||||
effective_date: string
|
||||
change_type: number
|
||||
remark: string | null
|
||||
operator_name: string
|
||||
item_count: number
|
||||
create_time: string
|
||||
}
|
||||
|
||||
/** 提交变更批次 */
|
||||
export function submitChanges(data: ParamChangePayload): Promise<ParamChangeBatch> {
|
||||
return request({ url: '/v1/system/params/changes', method: 'post', data })
|
||||
}
|
||||
|
||||
export interface ParamBatchItem {
|
||||
param_code: string
|
||||
param_name: string
|
||||
dimension_value: string
|
||||
old_value_text: string | null
|
||||
value_text: string
|
||||
effective_date: string
|
||||
}
|
||||
|
||||
export interface ParamBatchDetail {
|
||||
id: number
|
||||
version_no: string
|
||||
effective_date: string
|
||||
change_type: number
|
||||
remark: string | null
|
||||
operator_name: string
|
||||
item_count: number
|
||||
create_time: string
|
||||
items: ParamBatchItem[]
|
||||
}
|
||||
|
||||
/** 批次明细(旧值 → 新值) */
|
||||
export function getBatchDetail(batchId: number): Promise<ParamBatchDetail> {
|
||||
return request({ url: `/v1/system/params/changes/${batchId}`, method: 'get' })
|
||||
}
|
||||
|
||||
export interface ParamBatch {
|
||||
id: number
|
||||
version_no: string
|
||||
effective_date: string
|
||||
change_type: number
|
||||
remark: string | null
|
||||
operator_name: string
|
||||
item_count: number
|
||||
create_time: string
|
||||
}
|
||||
|
||||
/** 变更记录分页 */
|
||||
export function listBatches(params: {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}): Promise<PageData<ParamBatch>> {
|
||||
return request({ url: '/v1/system/params/changes', method: 'get', params })
|
||||
}
|
||||
|
||||
/** 撤回未生效批次 */
|
||||
export function withdrawBatch(batchId: number): Promise<null> {
|
||||
return request({ url: `/v1/system/params/changes/${batchId}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export interface ParamDiff {
|
||||
param_code: string
|
||||
param_name: string
|
||||
param_group: string
|
||||
dimension_value: string
|
||||
old_value: string | null
|
||||
new_value: string | null
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/** 两日期快照对比 */
|
||||
export function compareParams(fromDate: string, toDate: string): Promise<ParamDiff[]> {
|
||||
return request({
|
||||
url: '/v1/system/params/compare',
|
||||
method: 'get',
|
||||
params: { from_date: fromDate, to_date: toDate },
|
||||
})
|
||||
}
|
||||
|
||||
export interface ParamHistory {
|
||||
version_no: string
|
||||
dimension_value: string
|
||||
old_value_text: string | null
|
||||
value_text: string
|
||||
effective_date: string
|
||||
operator_name: string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
/** 单参数版本历史 */
|
||||
export function getParamHistory(
|
||||
paramCode: string,
|
||||
params: { pageNum?: number; pageSize?: number; dimension_value?: string },
|
||||
): Promise<PageData<ParamHistory>> {
|
||||
return request({ url: `/v1/system/params/${paramCode}/history`, method: 'get', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出变更记录(文件流)。
|
||||
*
|
||||
* 走 axios 实例(带令牌)而非 request 封装:响应是 xlsx 文件流,
|
||||
* 不是统一信封,拦截器已对 responseType=blob 直通。
|
||||
*/
|
||||
export async function exportChanges(params: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}): Promise<Blob> {
|
||||
const response = await service.get('/v1/system/params/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data as Blob
|
||||
}
|
||||
|
||||
/** 计算预览入参(金额为字符串,Decimal 承载) */
|
||||
export interface ParamPreviewPayload {
|
||||
business_role?: string
|
||||
customer_type?: string
|
||||
product_category?: string
|
||||
ship_date: string
|
||||
receipt_date: string
|
||||
revenue: string
|
||||
profit: string
|
||||
received_amount: string
|
||||
barter: boolean
|
||||
probation: boolean
|
||||
}
|
||||
|
||||
export interface ParamUsed {
|
||||
param_code: string
|
||||
value_text: string
|
||||
effective_date: string | null
|
||||
}
|
||||
|
||||
export interface ParamPreviewResult {
|
||||
payment_days: number
|
||||
rate: string
|
||||
rate_text: string
|
||||
profit_rate: string
|
||||
converted_profit: string
|
||||
performance_amount: string
|
||||
barter_applied: boolean
|
||||
performance_after_barter: string | null
|
||||
start_payment_ok: boolean
|
||||
payment_weight_r: number
|
||||
payment_weight: string
|
||||
payable_amount: string
|
||||
probation_note: string | null
|
||||
params_used: ParamUsed[]
|
||||
}
|
||||
|
||||
/** 计算预览 */
|
||||
export function previewCalc(data: ParamPreviewPayload): Promise<ParamPreviewResult> {
|
||||
return request({ url: '/v1/system/params/preview', method: 'post', data })
|
||||
}
|
||||
@ -51,6 +51,10 @@ function handleUnauthorized(message: string): void {
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResult>) => {
|
||||
// 文件流(导出下载)不是统一信封,直通给调用方处理
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response
|
||||
}
|
||||
const body = response.data
|
||||
if (body.code !== 200) {
|
||||
// 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况
|
||||
|
||||
@ -52,11 +52,23 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/system/roles/index.vue'),
|
||||
meta: { title: '角色管理', group: '系统管理', requiresAdmin: true, order: 3 },
|
||||
},
|
||||
{
|
||||
path: '/system/business-roles',
|
||||
name: 'system-business-roles',
|
||||
component: () => import('@/views/system/business-roles/index.vue'),
|
||||
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
|
||||
},
|
||||
{
|
||||
path: '/system/params',
|
||||
name: 'system-params',
|
||||
component: () => import('@/views/system/params/index.vue'),
|
||||
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
|
||||
},
|
||||
{
|
||||
path: '/system/logs',
|
||||
name: 'system-logs',
|
||||
component: () => import('@/views/system/logs/index.vue'),
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 4 },
|
||||
meta: { title: '日志查询', group: '系统管理', requiresAdmin: true, order: 7 },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -7,7 +7,7 @@ export const ROLE_ADMIN = 'admin'
|
||||
* 操作日志的模块 / 操作词表 —— 与后端 `core/constants.py` 的
|
||||
* `MODULE_*` / `ACTION_*` 保持一致;新增模块时两处同步(后端是权威来源)。
|
||||
*/
|
||||
export const MODULE_OPTIONS = ['用户管理', '角色管理'] as const
|
||||
export const MODULE_OPTIONS = ['用户管理', '角色管理', '岗位角色', '计提参数'] as const
|
||||
export const ACTION_OPTIONS = ['新增', '修改', '删除', '重置密码', '启用', '禁用'] as const
|
||||
|
||||
/** 启用/禁用筛选选项(文案与 StatusTag 的展示一致) */
|
||||
|
||||
374
frontend/src/views/system/business-roles/index.vue
Normal file
374
frontend/src/views/system/business-roles/index.vue
Normal file
@ -0,0 +1,374 @@
|
||||
<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 type { BusinessRoleItem, TemplateOption } from '@/api/businessRoles'
|
||||
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'
|
||||
|
||||
/**
|
||||
* 岗位角色管理(需求 #28)。
|
||||
*
|
||||
* 与后端约定对齐:
|
||||
* - 内置岗位(营销员/营销经理/信息员)禁删禁停 —— 前端禁用按钮,后端仍会拦截
|
||||
* - 岗位编码创建后不可修改(它是参数维度值与模板绑定的锚点)
|
||||
* - 公式模板为受控下拉(代码内注册表的有限集合);新增岗位 = 选模板 + 配参数,
|
||||
* 无需改代码
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<BusinessRoleItem, businessRoleApi.BusinessRoleQueryParams>(
|
||||
businessRoleApi.listBusinessRoles,
|
||||
() => ({
|
||||
keyword: filters.keyword,
|
||||
status: filters.status,
|
||||
}),
|
||||
)
|
||||
|
||||
const templateOptions = ref<TemplateOption[]>([])
|
||||
|
||||
async function loadTemplateOptions(): Promise<void> {
|
||||
try {
|
||||
templateOptions.value = await businessRoleApi.listTemplateOptions()
|
||||
} catch (error) {
|
||||
handledError(error) // 拦截器已提示;下拉为空不阻断列表使用
|
||||
}
|
||||
}
|
||||
|
||||
/** 模板编码 → 名称(表格列展示用,下拉未加载完成时回退显示编码) */
|
||||
function templateName(code: string): string {
|
||||
return templateOptions.value.find((tpl) => tpl.code === code)?.name ?? code
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
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({
|
||||
role_code: '',
|
||||
role_name: '',
|
||||
calc_template: '',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
/** 编辑中的行(内置岗位的模板仍可改,但编码与删除/停用受保护) */
|
||||
const editingRow = computed(() =>
|
||||
editingId.value === null ? null : records.value.find((r) => r.id === editingId.value) ?? null,
|
||||
)
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
role_code: isEdit.value
|
||||
? []
|
||||
: [
|
||||
{ required: true, message: '请输入岗位编码', trigger: 'blur' },
|
||||
{ pattern: /^[a-z][a-z0-9_]{1,31}$/, message: '编码须以小写字母开头,仅可含小写字母、数字与下划线', trigger: 'blur' },
|
||||
],
|
||||
role_name: [
|
||||
{ required: true, message: '请输入岗位名称', trigger: 'blur' },
|
||||
{ max: 64, message: '岗位名称最长 64 个字符', trigger: 'blur' },
|
||||
],
|
||||
calc_template: [{ required: true, message: '请选择计提公式模板', trigger: 'change' }],
|
||||
description: [{ max: 255, message: '说明最长 255 个字符', trigger: 'blur' }],
|
||||
}))
|
||||
|
||||
const selectedTemplate = computed(() =>
|
||||
templateOptions.value.find((tpl) => tpl.code === form.calc_template) ?? null,
|
||||
)
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null
|
||||
Object.assign(form, {
|
||||
role_code: '',
|
||||
role_name: '',
|
||||
calc_template: '',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
})
|
||||
dialogVisible.value = true
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openEdit(row: BusinessRoleItem): void {
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
role_code: row.role_code,
|
||||
role_name: row.role_name,
|
||||
calc_template: row.calc_template,
|
||||
description: row.description ?? '',
|
||||
sort_order: row.sort_order,
|
||||
})
|
||||
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) {
|
||||
await businessRoleApi.updateBusinessRole(editingId.value, {
|
||||
role_name: form.role_name,
|
||||
calc_template: form.calc_template,
|
||||
description: form.description,
|
||||
sort_order: form.sort_order,
|
||||
})
|
||||
ElMessage.success('已保存')
|
||||
} else {
|
||||
await businessRoleApi.createBusinessRole({
|
||||
role_code: form.role_code,
|
||||
role_name: form.role_name,
|
||||
calc_template: form.calc_template,
|
||||
description: form.description || undefined,
|
||||
sort_order: form.sort_order,
|
||||
})
|
||||
ElMessage.success('岗位已创建')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 启停 / 删除 ====================
|
||||
|
||||
/** 内置岗位禁删禁停(按钮禁用 + title 说明原因) */
|
||||
function isBuiltin(row: BusinessRoleItem): boolean {
|
||||
return row.is_builtin === 1
|
||||
}
|
||||
|
||||
function builtinHint(row: BusinessRoleItem): string {
|
||||
return isBuiltin(row) ? '内置岗位不可删除或停用' : ''
|
||||
}
|
||||
|
||||
async function handleToggleStatus(row: BusinessRoleItem): Promise<void> {
|
||||
const next = row.status === 1 ? 0 : 1
|
||||
const action = next === 1 ? '启用' : '禁用'
|
||||
await confirmAndRun({
|
||||
confirmText:
|
||||
next === 0
|
||||
? `确定禁用岗位「${row.role_name}」?禁用后新核算不再匹配该岗位。`
|
||||
: `确定启用岗位「${row.role_name}」?`,
|
||||
title: `${action}确认`,
|
||||
confirmButtonText: action,
|
||||
successText: `已${action}`,
|
||||
run: () => businessRoleApi.setBusinessRoleStatus(row.id, next),
|
||||
after: load,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(row: BusinessRoleItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定删除岗位「${row.role_name}」?删除为物理删除、不可恢复;该岗位编码相关的参数维度值不会随之清理。`,
|
||||
title: '删除确认',
|
||||
confirmButtonText: '删除',
|
||||
successText: '岗位已删除',
|
||||
run: () => businessRoleApi.deleteBusinessRole(row.id),
|
||||
after: reloadAfterDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||||
void load()
|
||||
void loadTemplateOptions()
|
||||
})
|
||||
</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 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="role_code" label="岗位编码" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="role-code">{{ row.role_code }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role_name" label="岗位名称" min-width="110" />
|
||||
<el-table-column label="公式模板" min-width="170">
|
||||
<template #default="{ row }">{{ templateName(row.calc_template) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="200">
|
||||
<template #default="{ row }">{{ row.description || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<span :title="builtinHint(row)">
|
||||
<el-button
|
||||
link
|
||||
type="warning"
|
||||
size="small"
|
||||
:disabled="isBuiltin(row)"
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</span>
|
||||
<span :title="builtinHint(row)">
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="isBuiltin(row)"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</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="role_code">
|
||||
<el-input
|
||||
v-model="form.role_code"
|
||||
:disabled="isEdit"
|
||||
:placeholder="editingRow ? '' : '如 senior_sales,创建后不可修改'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位名称" prop="role_name">
|
||||
<el-input v-model="form.role_name" placeholder="如 高级业务员" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公式模板" prop="calc_template">
|
||||
<el-select
|
||||
v-model="form.calc_template"
|
||||
placeholder="选择计提公式模板"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="tpl in templateOptions"
|
||||
:key="tpl.code"
|
||||
:label="tpl.name"
|
||||
:value="tpl.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="selectedTemplate" label-width="90px">
|
||||
<div class="tpl-info">
|
||||
<div>{{ selectedTemplate.description }}</div>
|
||||
<div v-if="selectedTemplate.params.length" class="tpl-params">
|
||||
涉及参数:{{ selectedTemplate.params.map((p) => p.name).join('、') }}(在「计提参数」中维护)
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位说明" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort_order">
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="9999" />
|
||||
</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>
|
||||
.role-code {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.tpl-info {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--ys-indigo-wash);
|
||||
color: var(--ys-ink-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.tpl-params {
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
</style>
|
||||
973
frontend/src/views/system/params/index.vue
Normal file
973
frontend/src/views/system/params/index.vue
Normal file
@ -0,0 +1,973 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import * as businessRoleApi from '@/api/businessRoles'
|
||||
import * as paramsApi from '@/api/params'
|
||||
import type {
|
||||
ParamBatch,
|
||||
ParamBatchDetail,
|
||||
ParamCatalogItem,
|
||||
ParamDiff,
|
||||
ParamHistory,
|
||||
ParamPreviewResult,
|
||||
} from '@/api/params'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useConfirmAction } from '@/composables/useConfirmAction'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 计提参数配置(需求 #28)。
|
||||
*
|
||||
* 与后端约定对齐:
|
||||
* - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」,
|
||||
* 以批次提交(一次一批,逐项留痕旧值/新值)
|
||||
* - 支持立即生效或指定日期生效;未生效批次可整批撤回
|
||||
* - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
// ==================== 目录 ====================
|
||||
|
||||
const catalog = ref<ParamCatalogItem[]>([])
|
||||
const catalogLoading = ref(false)
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
catalogLoading.value = true
|
||||
try {
|
||||
catalog.value = await paramsApi.getCatalog()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
catalogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 分组后的目录(保持后端 sort_order 顺序) */
|
||||
const groupedCatalog = computed(() => {
|
||||
const groups = new Map<string, ParamCatalogItem[]>()
|
||||
for (const item of catalog.value) {
|
||||
groups.set(item.param_group, [...(groups.get(item.param_group) ?? []), item])
|
||||
}
|
||||
return [...groups.entries()].map(([name, items]) => ({ name, items }))
|
||||
})
|
||||
|
||||
/** 维度值 → 展示名(NONE 维度为空串) */
|
||||
const DIMENSION_LABELS: Record<string, string> = {
|
||||
'': '默认',
|
||||
municipal_water: '市政水利',
|
||||
rail_transit: '轨道交通',
|
||||
}
|
||||
|
||||
function dimensionLabel(dimension: string): string {
|
||||
return DIMENSION_LABELS[dimension] ?? dimension
|
||||
}
|
||||
|
||||
/** 带单位的值展示 */
|
||||
function formatValue(item: ParamCatalogItem, value: string): string {
|
||||
return item.unit ? `${value} ${item.unit}` : value
|
||||
}
|
||||
|
||||
// ==================== 修改(待提交) ====================
|
||||
|
||||
interface PendingChange {
|
||||
item: ParamCatalogItem
|
||||
dimension_value: string
|
||||
old_value: string
|
||||
new_value: string
|
||||
}
|
||||
|
||||
const pending = reactive<Record<string, PendingChange>>({})
|
||||
const pendingList = computed(() => Object.entries(pending).map(([key, change]) => ({ key, ...change })))
|
||||
|
||||
function pendingKey(paramCode: string, dimension: string): string {
|
||||
return `${paramCode}|${dimension}`
|
||||
}
|
||||
|
||||
function pendingOf(item: ParamCatalogItem, dimension: string): PendingChange | undefined {
|
||||
return pending[pendingKey(item.param_code, dimension)]
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editTarget = ref<{ item: ParamCatalogItem; dimension: string } | null>(null)
|
||||
const editValue = ref('')
|
||||
|
||||
/** 可选维度:该参数已有维度值(无则视为默认) */
|
||||
const editDimensions = computed(() => {
|
||||
if (!editTarget.value) return []
|
||||
const dims = new Set<string>(editTarget.value.item.values.map((v) => v.dimension_value))
|
||||
if (dims.size === 0) dims.add('')
|
||||
return [...dims]
|
||||
})
|
||||
|
||||
function currentValue(item: ParamCatalogItem, dimension: string): string | undefined {
|
||||
return item.values.find((v) => v.dimension_value === dimension)?.value_text
|
||||
}
|
||||
|
||||
function openEdit(item: ParamCatalogItem, dimension: string): void {
|
||||
editTarget.value = { item, dimension }
|
||||
editValue.value = currentValue(item, dimension) ?? item.default_value ?? ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function confirmEdit(): void {
|
||||
if (!editTarget.value) return
|
||||
const { item, dimension } = editTarget.value
|
||||
const value = editValue.value.trim()
|
||||
if (!value) {
|
||||
ElMessage.warning('请输入新值')
|
||||
return
|
||||
}
|
||||
const old = currentValue(item, dimension) ?? item.default_value ?? ''
|
||||
if (value === old) {
|
||||
ElMessage.info('新值与当前值相同,未加入待提交')
|
||||
return
|
||||
}
|
||||
pending[pendingKey(item.param_code, dimension)] = {
|
||||
item,
|
||||
dimension_value: dimension,
|
||||
old_value: old,
|
||||
new_value: value,
|
||||
}
|
||||
editVisible.value = false
|
||||
ElMessage.success('已加入待提交,提交前不会生效')
|
||||
}
|
||||
|
||||
function removePending(key: string): void {
|
||||
delete pending[key]
|
||||
}
|
||||
|
||||
// ==================== 提交批次 ====================
|
||||
|
||||
const changeType = ref<1 | 2>(1)
|
||||
const effectiveDate = ref<string>('')
|
||||
const remark = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
const effectiveText = computed(() =>
|
||||
changeType.value === 1 ? '立即生效' : `${effectiveDate.value} 起生效`,
|
||||
)
|
||||
|
||||
async function submitAll(): Promise<void> {
|
||||
if (changeType.value === 2 && !effectiveDate.value) {
|
||||
ElMessage.warning('请选择生效日期')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`共 ${pendingList.value.length} 项变更,${effectiveText.value}。提交后逐项留痕,未生效前可整批撤回。`,
|
||||
'提交确认',
|
||||
{ confirmButtonText: '提交', cancelButtonText: '再想想', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const batch = await paramsApi.submitChanges({
|
||||
change_type: changeType.value,
|
||||
effective_date: changeType.value === 2 ? effectiveDate.value : undefined,
|
||||
remark: remark.value || undefined,
|
||||
items: pendingList.value.map((p) => ({
|
||||
param_code: p.item.param_code,
|
||||
dimension_value: p.dimension_value,
|
||||
value: p.new_value,
|
||||
})),
|
||||
})
|
||||
ElMessage.success(`已提交,版本 ${batch.version_no}`)
|
||||
Object.keys(pending).forEach((key) => delete pending[key])
|
||||
remark.value = ''
|
||||
await loadCatalog()
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 版本管理抽屉 ====================
|
||||
|
||||
const versionVisible = ref(false)
|
||||
const batches = ref<ParamBatch[]>([])
|
||||
const batchesTotal = ref(0)
|
||||
const batchesPage = ref(1)
|
||||
const batchesLoading = ref(false)
|
||||
const detailCache = reactive<Record<number, ParamBatchDetail>>({})
|
||||
|
||||
async function loadBatches(): Promise<void> {
|
||||
batchesLoading.value = true
|
||||
try {
|
||||
const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: 10 })
|
||||
batches.value = page.list
|
||||
batchesTotal.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
batchesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function detailOf(batch: ParamBatch): Promise<ParamBatchDetail> {
|
||||
if (detailCache[batch.id]) return detailCache[batch.id]
|
||||
const detail = await paramsApi.getBatchDetail(batch.id)
|
||||
detailCache[batch.id] = detail
|
||||
return detail
|
||||
}
|
||||
|
||||
function changeTypeText(changeType: number): string {
|
||||
return changeType === 1 ? '立即生效' : '指定日期'
|
||||
}
|
||||
|
||||
async function handleWithdraw(batch: ParamBatch): Promise<void> {
|
||||
await confirmAndRun({
|
||||
confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`,
|
||||
title: '撤回确认',
|
||||
confirmButtonText: '撤回',
|
||||
successText: '批次已撤回',
|
||||
run: () => paramsApi.withdrawBatch(batch.id),
|
||||
after: () => {
|
||||
loadBatches()
|
||||
void loadCatalog()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 对比 ----------
|
||||
|
||||
const compareFrom = ref('')
|
||||
const compareTo = ref('')
|
||||
const comparing = ref(false)
|
||||
const diffResult = ref<ParamDiff[]>([])
|
||||
const diffSearched = ref(false)
|
||||
|
||||
async function doCompare(): Promise<void> {
|
||||
if (!compareFrom.value || !compareTo.value) {
|
||||
ElMessage.warning('请选择两个对比日期')
|
||||
return
|
||||
}
|
||||
comparing.value = true
|
||||
try {
|
||||
diffResult.value = await paramsApi.compareParams(compareFrom.value, compareTo.value)
|
||||
diffSearched.value = true
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
comparing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 导出 ----------
|
||||
|
||||
const exporting = ref(false)
|
||||
|
||||
async function doExport(): Promise<void> {
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await paramsApi.exportChanges({
|
||||
start_date: compareFrom.value || undefined,
|
||||
end_date: compareTo.value || undefined,
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `param_changes_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('已导出')
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openVersionDrawer(): void {
|
||||
versionVisible.value = true
|
||||
batchesPage.value = 1
|
||||
void loadBatches()
|
||||
}
|
||||
|
||||
// ==================== 历史抽屉 ====================
|
||||
|
||||
const historyVisible = ref(false)
|
||||
const historyItem = ref<ParamCatalogItem | null>(null)
|
||||
const historyRows = ref<ParamHistory[]>([])
|
||||
const historyTotal = ref(0)
|
||||
const historyPage = ref(1)
|
||||
const historyLoading = ref(false)
|
||||
|
||||
function openHistory(item: ParamCatalogItem): void {
|
||||
historyItem.value = item
|
||||
historyVisible.value = true
|
||||
historyPage.value = 1
|
||||
void loadHistory()
|
||||
}
|
||||
|
||||
async function loadHistory(): Promise<void> {
|
||||
if (!historyItem.value) return
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
|
||||
pageNum: historyPage.value,
|
||||
pageSize: 10,
|
||||
})
|
||||
historyRows.value = page.list
|
||||
historyTotal.value = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 计算预览抽屉 ====================
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewResult = ref<ParamPreviewResult | null>(null)
|
||||
const roleOptions = ref<businessRoleApi.BusinessRoleItem[]>([])
|
||||
|
||||
const previewForm = reactive({
|
||||
business_role: 'salesperson',
|
||||
customer_type: 'personal',
|
||||
product_category: 'municipal_water',
|
||||
ship_date: '',
|
||||
receipt_date: '',
|
||||
revenue: '',
|
||||
profit: '',
|
||||
received_amount: '',
|
||||
barter: false,
|
||||
probation: false,
|
||||
})
|
||||
|
||||
const PRODUCT_OPTIONS = [
|
||||
{ label: '市政水利', value: 'municipal_water' },
|
||||
{ label: '轨道交通', value: 'rail_transit' },
|
||||
]
|
||||
|
||||
const CUSTOMER_OPTIONS = [
|
||||
{ label: '个人户', value: 'personal' },
|
||||
{ label: '企业户', value: 'enterprise' },
|
||||
]
|
||||
|
||||
async function loadRoleOptions(): Promise<void> {
|
||||
try {
|
||||
const page = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
|
||||
roleOptions.value = page.list
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function openPreviewDrawer(): void {
|
||||
previewVisible.value = true
|
||||
if (roleOptions.value.length === 0) void loadRoleOptions()
|
||||
}
|
||||
|
||||
async function runPreview(): Promise<void> {
|
||||
if (!previewForm.ship_date || !previewForm.receipt_date) {
|
||||
ElMessage.warning('请选择发货日与回款到账日')
|
||||
return
|
||||
}
|
||||
previewLoading.value = true
|
||||
try {
|
||||
previewResult.value = await paramsApi.previewCalc({
|
||||
business_role: previewForm.business_role || undefined,
|
||||
customer_type: previewForm.customer_type || undefined,
|
||||
product_category: previewForm.product_category || undefined,
|
||||
ship_date: previewForm.ship_date,
|
||||
receipt_date: previewForm.receipt_date,
|
||||
revenue: previewForm.revenue,
|
||||
profit: previewForm.profit,
|
||||
received_amount: previewForm.received_amount,
|
||||
barter: previewForm.barter,
|
||||
probation: previewForm.probation,
|
||||
})
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
onMounted(() => {
|
||||
void loadCatalog()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="page-toolbar">
|
||||
<span class="page-desc">
|
||||
计提比例、系数、阈值、分成与起付比例在此统一维护;变更逐项留痕并支持按日期生效。
|
||||
</span>
|
||||
<span>
|
||||
<el-button @click="openPreviewDrawer">计算预览</el-button>
|
||||
<el-button @click="openVersionDrawer">版本管理</el-button>
|
||||
<el-button @click="doExport" :loading="exporting">导出记录</el-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-collapse v-loading="catalogLoading">
|
||||
<el-collapse-item v-for="group in groupedCatalog" :key="group.name" :name="group.name">
|
||||
<template #title>
|
||||
<span class="group-title">{{ group.name }}</span>
|
||||
</template>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 190px">参数</th>
|
||||
<th>当前生效值</th>
|
||||
<th style="width: 110px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in group.items" :key="item.param_code">
|
||||
<td>
|
||||
<div class="param-name">{{ item.param_name }}</div>
|
||||
<div class="param-desc">{{ item.description || item.param_code }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="item.values.length">
|
||||
<div v-for="v in item.values" :key="v.dimension_value" class="value-row">
|
||||
<span class="dim-label">{{ dimensionLabel(v.dimension_value) }}</span>
|
||||
<b class="num">{{ formatValue(item, v.value_text) }}</b>
|
||||
<span class="eff">{{ v.effective_date }} 起</span>
|
||||
<el-tag
|
||||
v-if="pendingOf(item, v.dimension_value)"
|
||||
size="small"
|
||||
type="warning"
|
||||
disable-transitions
|
||||
>
|
||||
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="no-value">
|
||||
未设置(默认 {{ item.default_value ?? '—' }})
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<el-button link type="primary" size="small" @click="openEdit(item, item.values[0]?.dimension_value ?? '')">
|
||||
修改
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
|
||||
<!-- 待提交批次 -->
|
||||
<el-card v-if="pendingList.length" shadow="never" class="pending-card">
|
||||
<template #header>
|
||||
<div class="pending-head">
|
||||
<b>待提交变更({{ pendingList.length }} 项)</b>
|
||||
<span class="pending-hint">提交前不会生效;提交后未生效前可整批撤回</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="pendingList" size="small">
|
||||
<el-table-column label="参数" min-width="160">
|
||||
<template #default="{ row }">{{ row.item.param_name }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="维度" width="110">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值" min-width="110">
|
||||
<template #default="{ row }">{{ row.old_value || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新值" min-width="110">
|
||||
<template #default="{ row }"><b class="num">{{ row.new_value }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" size="small" @click="removePending(row.key)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="submit-row">
|
||||
<el-radio-group v-model="changeType">
|
||||
<el-radio-button :value="1">立即生效</el-radio-button>
|
||||
<el-radio-button :value="2">指定日期生效</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-date-picker
|
||||
v-if="changeType === 2"
|
||||
v-model="effectiveDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="生效日期"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<el-input
|
||||
v-model="remark"
|
||||
placeholder="变更说明(如:四季度系数调整)"
|
||||
style="width: 240px"
|
||||
maxlength="255"
|
||||
/>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAll">提交变更</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 修改参数 -->
|
||||
<el-dialog v-model="editVisible" title="修改参数" width="440px" destroy-on-close>
|
||||
<template v-if="editTarget">
|
||||
<div class="edit-name">{{ editTarget.item.param_name }}</div>
|
||||
<div class="edit-desc">{{ editTarget.item.description || editTarget.item.param_code }}</div>
|
||||
<el-form label-width="80px" style="margin-top: 16px" @submit.prevent>
|
||||
<el-form-item v-if="editDimensions.length > 1 || editTarget.item.dimension_type !== 'NONE'" label="维度">
|
||||
<el-select v-model="editTarget.dimension" style="width: 100%">
|
||||
<el-option
|
||||
v-for="dim in editDimensions"
|
||||
:key="dim"
|
||||
:label="dimensionLabel(dim)"
|
||||
:value="dim"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前值">
|
||||
<span class="num">{{ currentValue(editTarget.item, editTarget.dimension) ?? editTarget.item.default_value ?? '—' }}</span>
|
||||
<span v-if="editTarget.item.unit" style="color: var(--ys-ink-3)"> {{ editTarget.item.unit }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="新值">
|
||||
<el-select
|
||||
v-if="editTarget.item.value_type === 'ENUM'"
|
||||
v-model="editValue"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="opt in editTarget.item.options" :key="opt" :label="opt" :value="opt" />
|
||||
</el-select>
|
||||
<el-input v-else v-model="editValue" placeholder="输入新值(分数式如 4/360 原样保留)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="edit-tip">
|
||||
取值范围:{{ editTarget.item.value_min ?? '−' }} ~ {{ editTarget.item.value_max ?? '不限' }};
|
||||
精确分数(如 4/360)原样存储、不做小数截断。
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmEdit">加入待提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 版本管理抽屉 -->
|
||||
<el-drawer v-model="versionVisible" title="版本管理" size="760px">
|
||||
<div class="drawer-block">
|
||||
<div class="drawer-row">
|
||||
<el-date-picker
|
||||
v-model="compareFrom"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="对比起始日期"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="compareTo"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="对比截止日期"
|
||||
style="width: 150px"
|
||||
/>
|
||||
<el-button :loading="comparing" @click="doCompare">对比</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出记录</el-button>
|
||||
</div>
|
||||
<template v-if="diffSearched">
|
||||
<div class="drawer-title">快照对比({{ compareFrom }} → {{ compareTo }})</div>
|
||||
<el-table :data="diffResult" size="small" max-height="260">
|
||||
<el-table-column prop="param_name" label="参数" min-width="130" />
|
||||
<el-table-column label="维度" width="90">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值" min-width="90">
|
||||
<template #default="{ row }">{{ row.old_value ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新值" min-width="90">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ changed: row.changed }">{{ row.new_value ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变化" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.changed" size="small" type="warning" disable-transitions>变化</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="drawer-block">
|
||||
<div class="drawer-title">变更记录</div>
|
||||
<el-table v-loading="batchesLoading" :data="batches" size="small">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="batch-items">
|
||||
<template v-if="detailCache[row.id]">
|
||||
<div v-for="item in detailCache[row.id].items" :key="item.param_code + item.dimension_value" class="batch-item">
|
||||
<span class="param-name">{{ item.param_name }}</span>
|
||||
<span class="dim-label">{{ dimensionLabel(item.dimension_value) }}</span>
|
||||
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
|
||||
</div>
|
||||
</template>
|
||||
<el-button v-else link size="small" @click="detailOf(row)">加载明细…</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version_no" label="版本号" width="120" />
|
||||
<el-table-column prop="effective_date" label="生效日期" width="110" />
|
||||
<el-table-column label="方式" width="90">
|
||||
<template #default="{ row }">{{ changeTypeText(row.change_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="item_count" label="项数" width="60" align="center" />
|
||||
<el-table-column prop="operator_name" label="操作人" width="90" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.effective_date > new Date().toISOString().slice(0, 10)"
|
||||
link
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleWithdraw(row)"
|
||||
>
|
||||
撤回
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<AppPagination
|
||||
:total="batchesTotal"
|
||||
:page-num="batchesPage"
|
||||
:page-size="10"
|
||||
@page-change="(p: number) => { batchesPage = p; void loadBatches() }"
|
||||
@size-change="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 历史抽屉 -->
|
||||
<el-drawer v-model="historyVisible" :title="`参数历史 · ${historyItem?.param_name ?? ''}`" size="560px">
|
||||
<el-table v-loading="historyLoading" :data="historyRows" size="small">
|
||||
<el-table-column prop="effective_date" label="生效日期" width="105" />
|
||||
<el-table-column label="维度" width="90">
|
||||
<template #default="{ row }">{{ dimensionLabel(row.dimension_value) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="旧值 → 新值" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="num">{{ row.old_value_text ?? '—' }} → {{ row.value_text }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="version_no" label="版本" width="115" />
|
||||
<el-table-column prop="operator_name" label="操作人" width="80" />
|
||||
</el-table>
|
||||
<AppPagination
|
||||
:total="historyTotal"
|
||||
:page-num="historyPage"
|
||||
:page-size="10"
|
||||
@page-change="(p: number) => { historyPage = p; void loadHistory() }"
|
||||
@size-change="() => {}"
|
||||
/>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 计算预览抽屉 -->
|
||||
<el-drawer v-model="previewVisible" title="计算预览" size="480px">
|
||||
<el-form label-width="90px" @submit.prevent>
|
||||
<el-form-item label="客户类型">
|
||||
<el-select v-model="previewForm.customer_type" style="width: 100%">
|
||||
<el-option v-for="o in CUSTOMER_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位">
|
||||
<el-select v-model="previewForm.business_role" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="role in roleOptions"
|
||||
:key="role.role_code"
|
||||
:label="role.role_name"
|
||||
:value="role.role_code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类别">
|
||||
<el-select v-model="previewForm.product_category" style="width: 100%">
|
||||
<el-option v-for="o in PRODUCT_OPTIONS" :key="o.value" :label="o.label" :value="o.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="发货日">
|
||||
<el-date-picker
|
||||
v-model="previewForm.ship_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="回款到账日">
|
||||
<el-date-picker
|
||||
v-model="previewForm.receipt_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="销售额">
|
||||
<el-input v-model="previewForm.revenue" placeholder="不含运费" />
|
||||
</el-form-item>
|
||||
<el-form-item label="毛利润额">
|
||||
<el-input v-model="previewForm.profit" placeholder="可为负" />
|
||||
</el-form-item>
|
||||
<el-form-item label="本笔回款">
|
||||
<el-input v-model="previewForm.received_amount" />
|
||||
</el-form-item>
|
||||
<el-form-item label="以物抵顶">
|
||||
<el-switch v-model="previewForm.barter" />
|
||||
</el-form-item>
|
||||
<el-form-item label="试用期">
|
||||
<el-switch v-model="previewForm.probation" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="previewLoading" @click="runPreview">试 算</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template v-if="previewResult">
|
||||
<div class="preview-result">
|
||||
<div class="pr-row big">
|
||||
<span>单笔绩效</span><b class="num">{{ previewResult.performance_amount }} 元</b>
|
||||
</div>
|
||||
<div class="pr-row"><span>回款天数</span><b class="num">{{ previewResult.payment_days }} 天</b></div>
|
||||
<div class="pr-row"><span>计提比例</span><b class="num">{{ previewResult.rate_text }}</b></div>
|
||||
<div class="pr-row"><span>折算毛利润额</span><b class="num">{{ previewResult.converted_profit }} 元</b></div>
|
||||
<div class="pr-row"><span>毛利润率</span><b class="num">{{ previewResult.profit_rate }}</b></div>
|
||||
<div class="pr-row"><span>起付判定</span><b>{{ previewResult.start_payment_ok ? '已达标' : '未达标(累计中)' }}</b></div>
|
||||
<div class="pr-row"><span>支付权重</span><b class="num">R={{ previewResult.payment_weight_r }},权重 {{ previewResult.payment_weight }}</b></div>
|
||||
<div class="pr-row"><span>当月可支付</span><b class="num">{{ previewResult.payable_amount }} 元</b></div>
|
||||
<div v-if="previewResult.probation_note" class="pr-note">{{ previewResult.probation_note }}</div>
|
||||
</div>
|
||||
<div class="pr-used">
|
||||
<div class="drawer-title">所用参数(按回款到账日取值)</div>
|
||||
<div v-for="u in previewResult.params_used" :key="u.param_code" class="pr-used-row">
|
||||
{{ u.param_code }} = {{ u.value_text }}
|
||||
<span v-if="u.effective_date" class="eff">({{ u.effective_date }} 起)</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.param-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.param-table th {
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ys-ink-2);
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
}
|
||||
|
||||
.param-table td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.param-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.dim-label {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.eff {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.no-value {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pending-card {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.pending-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pending-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
}
|
||||
|
||||
.submit-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.edit-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-desc {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.edit-tip {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-3);
|
||||
background: var(--ys-mist);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.drawer-block {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.drawer-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ys-ink-2);
|
||||
margin: 10px 0 8px;
|
||||
}
|
||||
|
||||
.batch-items {
|
||||
padding: 6px 12px;
|
||||
background: var(--ys-mist);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.batch-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
font-size: 12.5px;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
.batch-item .param-name {
|
||||
min-width: 130px;
|
||||
color: var(--ys-ink);
|
||||
}
|
||||
|
||||
.changed {
|
||||
color: var(--ys-indigo);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-result {
|
||||
margin-top: 18px;
|
||||
border: 1px solid var(--ys-line);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(180deg, #ffffff, #fcfcfe);
|
||||
}
|
||||
|
||||
.pr-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.pr-row.big {
|
||||
border-bottom: 1px solid var(--ys-line);
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.pr-row.big b {
|
||||
font-size: 20px;
|
||||
color: var(--ys-indigo);
|
||||
}
|
||||
|
||||
.pr-row b {
|
||||
color: var(--ys-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pr-note {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-warn);
|
||||
background: #fbf4e4;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.pr-used {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pr-used-row {
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
line-height: 2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user