PayrollManagementSystem/frontend/src/views/system/params/index.vue
liuwenxin 7008de0c69 feat(api): 计提参数查看放开登录用户、修改仅管理员(#17/#6 答复)
- deps 新增 LoginUser 依赖;params 只读七端点改登录即可,写(提交/撤回)保持 AdminUser
- 前端 params 路由 requiresAdmin → allowedRoles(admin/finance/leader);页面写按钮按角色显隐
- 权限用例分层:普通用户目录 200、提交/撤回 403、未登录拦截

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 11:19:16 +08:00

1024 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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