feat(system): 前端岗位角色与计提参数配置页面

- 岗位角色页:列表/新增/编辑/启停/删除,内置岗位保护、模板受控下拉(含声明参数引导)
- 计提参数页:分组目录(多维度生效值)→ 修改入「待提交」→ 批次提交(立即/指定日期 + 二次确认);
  版本管理抽屉(记录分页/明细旧值→新值/两日期对比/撤回未生效/xlsx 导出);计算预览抽屉(按回款到账日取参数)
- request.ts 对 responseType=blob 直通(导出文件流非统一信封)
- 路由注册岗位角色/计提参数(系统管理组 order 5/6);日志模块词表补两处

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-12 21:48:00 +08:00
parent fd801455f0
commit 4c442b8cf2
7 changed files with 1663 additions and 2 deletions

View 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
View 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 })
}

View File

@ -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 但业务码异常的情况

View File

@ -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 },
},
],
},

View File

@ -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 的展示一致) */

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

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