- deps 新增 LoginUser 依赖;params 只读七端点改登录即可,写(提交/撤回)保持 AdminUser - 前端 params 路由 requiresAdmin → allowedRoles(admin/finance/leader);页面写按钮按角色显隐 - 权限用例分层:普通用户目录 200、提交/撤回 403、未登录拦截 Co-Authored-By: Claude Code <noreply@anthropic.com>
1024 lines
33 KiB
Vue
1024 lines
33 KiB
Vue
<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 { usePagedList } from '@/composables/usePagedList'
|
||
import { useUserStore } from '@/store/user'
|
||
import { ROLE_ADMIN } from '@/utils/constants'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 计提参数配置(需求 #28)。
|
||
*
|
||
* 与后端约定对齐:
|
||
* - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」,
|
||
* 以批次提交(一次一批,逐项留痕旧值/新值)
|
||
* - 支持立即生效或指定日期生效;未生效批次可整批撤回
|
||
* - 权限分层(澄清 #17 答复 2026-10-19):查看对财务/领导放开(只读),
|
||
* 修改仅管理员——写按钮按角色显隐,越权仍有后端 403 兜底
|
||
* - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流
|
||
*/
|
||
const { confirmAndRun } = useConfirmAction()
|
||
const userStore = useUserStore()
|
||
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
|
||
|
||
// ==================== 目录 ====================
|
||
|
||
const catalog = ref<ParamCatalogItem[]>([])
|
||
const catalogLoading = ref(false)
|
||
|
||
async function loadCatalog(): Promise<void> {
|
||
catalogLoading.value = true
|
||
try {
|
||
catalog.value = await paramsApi.getCatalog()
|
||
// 预览「所用参数」按编码取中文名;目录加载后填充映射
|
||
for (const item of catalog.value) {
|
||
PARAM_NAME_BY_CODE[item.param_code] = item.param_name
|
||
}
|
||
} 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: '轨道交通',
|
||
salesperson: '营销员',
|
||
sales_manager: '营销经理',
|
||
info_officer: '信息员',
|
||
}
|
||
|
||
function dimensionLabel(dimension: string): string {
|
||
return DIMENSION_LABELS[dimension] ?? dimension
|
||
}
|
||
|
||
/** 参数名 → 中文(预览「所用参数」列表用;编辑/历史/对比抽屉另有 param_name 直出) */
|
||
const PARAM_NAME_BY_CODE: Record<string, string> = {}
|
||
function paramNameByCode(code: string): string {
|
||
return PARAM_NAME_BY_CODE[code] ?? code
|
||
}
|
||
|
||
/** 枚举值 → 中文(FULL/BALANCE、INCLUDE/DEDUCT/EXCLUDE、0/1 开关) */
|
||
const ENUM_VALUE_LABELS: Record<string, string> = {
|
||
FULL: '当月全额',
|
||
BALANCE: '余额',
|
||
INCLUDE: '含基本工资',
|
||
DEDUCT: '扣除基本工资',
|
||
EXCLUDE: '不计基本工资',
|
||
'0': '关',
|
||
'1': '开',
|
||
}
|
||
|
||
function valueLabel(value: string | null | undefined): string {
|
||
if (value === null || value === undefined) return ''
|
||
return ENUM_VALUE_LABELS[value] ?? value
|
||
}
|
||
|
||
/** 带单位的值展示 */
|
||
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 detailCache = reactive<Record<number, ParamBatchDetail>>({})
|
||
|
||
// 分页五件套换 usePagedList:手写版没有请求序号防竞态(快速翻页时旧响应
|
||
// 后到会覆盖新页数据)与页码回退——正是该 composable 注释里写明已修过的坑
|
||
const {
|
||
records: batches,
|
||
total: batchesTotal,
|
||
pageNum: batchesPage,
|
||
pageSize: batchesPageSize,
|
||
loading: batchesLoading,
|
||
load: loadBatches,
|
||
onPageChange: onBatchesPageChange,
|
||
onSizeChange: onBatchesSizeChange,
|
||
} = usePagedList<ParamBatch, Record<string, never>>(paramsApi.listBatches, () => ({}))
|
||
|
||
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 ? '立即生效' : '指定日期'
|
||
}
|
||
|
||
/** 本地日期(YYYY-MM-DD):toISOString 是 UTC,北京时间 0–8 点会差一天 */
|
||
function localToday(): string {
|
||
const d = new Date()
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
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_${localToday()}.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)
|
||
|
||
// 历史抽屉分页同样收口 usePagedList;fetcher 闭包当前参数的编码
|
||
const {
|
||
records: historyRows,
|
||
total: historyTotal,
|
||
pageNum: historyPage,
|
||
pageSize: historyPageSize,
|
||
loading: historyLoading,
|
||
load: loadHistory,
|
||
onPageChange: onHistoryPageChange,
|
||
onSizeChange: onHistorySizeChange,
|
||
} = usePagedList<ParamHistory, Record<string, never>>(
|
||
(params) => paramsApi.getParamHistory(historyItem.value!.param_code, params),
|
||
() => ({}),
|
||
)
|
||
|
||
function openHistory(item: ParamCatalogItem): void {
|
||
historyItem.value = item
|
||
historyVisible.value = true
|
||
historyPage.value = 1
|
||
void loadHistory()
|
||
}
|
||
|
||
// ==================== 计算预览抽屉 ====================
|
||
|
||
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>
|
||
<el-button
|
||
v-if="isAdmin"
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
@click="openEdit(item, v.dimension_value)"
|
||
>
|
||
改
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
<span v-else class="no-value">
|
||
未设置(默认 {{ item.default_value ?? '—' }})
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<el-button
|
||
v-if="isAdmin && !item.values.length"
|
||
link
|
||
type="primary"
|
||
size="small"
|
||
@click="openEdit(item, '')"
|
||
>
|
||
设置
|
||
</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="isAdmin && 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).catch(() => {})">
|
||
加载明细…
|
||
</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="isAdmin && row.effective_date > localToday()"
|
||
link
|
||
type="danger"
|
||
size="small"
|
||
@click="handleWithdraw(row)"
|
||
>
|
||
撤回
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<AppPagination
|
||
:total="batchesTotal"
|
||
:page-num="batchesPage"
|
||
:page-size="batchesPageSize"
|
||
@page-change="onBatchesPageChange"
|
||
@size-change="onBatchesSizeChange"
|
||
/>
|
||
</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="historyPageSize"
|
||
@page-change="onHistoryPageChange"
|
||
@size-change="onHistorySizeChange"
|
||
/>
|
||
</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 }}</b></div>
|
||
<div class="pr-row"><span>计提说明</span><b>{{ 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">
|
||
{{ paramNameByCode(u.param_code) }}({{ u.param_code }})= {{ valueLabel(u.value_text) || 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>
|