- 引入 @element-plus/icons-vue 官方图标库,菜单项按模块配图标,消除 纯文字导航的辨识成本(新增依赖已评估:Element Plus 官方同源维护) - AppMenu/BasicLayout/router 同步:图标渲染、菜单结构与路由 meta 对齐 - index.css 视觉系统化:品牌色、语义三色(ok/warn/danger 及其 wash 层次)、 形体与材质(圆角、缓动、阴影、等宽字体)统一为 CSS 变量,并覆盖 Element Plus 主色 - 登录页、业务页、结算页、工作台、审批页等视图随新变量适配 验证:pnpm type-check 与 pnpm build 均通过;后端接口契约未变。 Co-Authored-By: Claude Code <noreply@anthropic.com>
405 lines
14 KiB
Vue
405 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
|
||
import * as calcApi from '@/api/calc'
|
||
import type { LedgerRow } from '@/api/calc'
|
||
import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
|
||
import { listPersonnel } from '@/api/personnel'
|
||
import * as reportApi from '@/api/reports'
|
||
import type { PersonalDetailRow, ReportFilterParams, ReportSummary } from '@/api/reports'
|
||
import { listBusinessRoleOptions } from '@/api/businessRoles'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 个人绩效工资明细表(FR-029):按人员/月份/客户类型/产品类别/岗位查明细。
|
||
*
|
||
* 口径与台账一致(后端同源查询);页脚合计是**全筛选结果**之和,不是
|
||
* 当前页之和(页面已注明)。行展开看计算快照(按台账 id 懒加载)。
|
||
*/
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const filters = reactive({
|
||
settlement_month: '',
|
||
personnel_id: undefined as number | undefined,
|
||
customer_type: '',
|
||
product_category: '',
|
||
role_code: '',
|
||
})
|
||
|
||
/**
|
||
* 部门汇总「查看明细」下钻:路由 query 必须真正落到筛选条件上。
|
||
*
|
||
* 无 query 时也要清空(不能只覆盖):从菜单再次进入同一页面时组件复用,
|
||
* 旧的 personnel_id/settlement_month 会残留,用户看到的仍是被下钻者的数据。
|
||
*/
|
||
function applyRouteQuery(): void {
|
||
const personnelId = Number(route.query.personnel_id)
|
||
filters.personnel_id =
|
||
Number.isInteger(personnelId) && personnelId > 0 ? personnelId : undefined
|
||
const month = route.query.settlement_month
|
||
filters.settlement_month =
|
||
typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : ''
|
||
}
|
||
applyRouteQuery()
|
||
|
||
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
|
||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||
),
|
||
)
|
||
const { options: roleOptions, load: loadRoles } = useDropdownOptions(() =>
|
||
listBusinessRoleOptions().then((data) =>
|
||
data.map((item) => ({ label: item.role_name, value: item.role_code })),
|
||
),
|
||
)
|
||
void loadPersonnel()
|
||
void loadRoles()
|
||
|
||
const summary = ref<ReportSummary | null>(null)
|
||
|
||
function getFilters(): ReportFilterParams {
|
||
return {
|
||
// axios 自动剔除 undefined;空串需显式转换,否则后端收到空字符串会精确匹配空值
|
||
settlement_month: filters.settlement_month || undefined,
|
||
personnel_id: filters.personnel_id,
|
||
customer_type: filters.customer_type || undefined,
|
||
product_category: filters.product_category || undefined,
|
||
role_code: filters.role_code || undefined,
|
||
}
|
||
}
|
||
|
||
function fetchPage(params: ReportFilterParams) {
|
||
return reportApi.listPersonalDetail(params)
|
||
}
|
||
|
||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||
usePagedList(fetchPage, getFilters, (data) => {
|
||
summary.value = data.summary
|
||
})
|
||
|
||
// 进页即加载(筛选条件可能已由部门汇总下钻带入)
|
||
onMounted(search)
|
||
|
||
// 同一路由再次下钻(例如从部门汇总换成员)时重新应用 query
|
||
watch(
|
||
() => route.query,
|
||
() => {
|
||
applyRouteQuery()
|
||
search()
|
||
},
|
||
)
|
||
|
||
function resetFilters(): void {
|
||
filters.settlement_month = ''
|
||
filters.personnel_id = undefined
|
||
filters.customer_type = ''
|
||
filters.product_category = ''
|
||
filters.role_code = ''
|
||
search()
|
||
}
|
||
|
||
/** 快照懒加载缓存:同一台账行只取一次 */
|
||
const snapshots = ref<Record<number, LedgerRow>>({})
|
||
|
||
async function loadSnapshot(row: PersonalDetailRow): Promise<void> {
|
||
if (snapshots.value[row.ledger_id]) return
|
||
try {
|
||
const ledger = await calcApi.getLedger(row.ledger_id)
|
||
snapshots.value = { ...snapshots.value, [row.ledger_id]: ledger }
|
||
} catch (error) {
|
||
handledError(error)
|
||
}
|
||
}
|
||
|
||
const exporting = ref(false)
|
||
|
||
async function doExport(): Promise<void> {
|
||
if (total.value === 0) {
|
||
ElMessage.warning('当前筛选无数据,无法导出')
|
||
return
|
||
}
|
||
exporting.value = true
|
||
try {
|
||
const blob = await reportApi.exportReport('personal-detail', getFilters())
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `个人绩效工资明细表_${today()}.xlsx`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
ElMessage.success('已导出')
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
function doPrint(): void {
|
||
const route = router.resolve({
|
||
name: 'report-print',
|
||
params: { code: 'personal-detail' },
|
||
query: cleanQuery(getFilters()),
|
||
})
|
||
window.open(route.href, '_blank')
|
||
}
|
||
|
||
function cleanQuery(params: ReportFilterParams): Record<string, string> {
|
||
const query: Record<string, string> = {}
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
|
||
}
|
||
return query
|
||
}
|
||
|
||
function today(): string {
|
||
const now = new Date()
|
||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||
const day = String(now.getDate()).padStart(2, '0')
|
||
return `${now.getFullYear()}${month}${day}`
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<!-- 页面头:一句话说清这张报表回答什么问题、口径是什么;水线每页只出现一次 -->
|
||
<div class="page-head">
|
||
<h1 class="page-title">个人绩效明细</h1>
|
||
<p class="page-desc">按人拆到每一笔回款;绩效 = 回款 × 计提比例 × 权重</p>
|
||
<span class="waterline"></span>
|
||
</div>
|
||
|
||
<!--
|
||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||
-->
|
||
<el-form class="filter-bar" @submit.prevent>
|
||
<el-form-item label="月份">
|
||
<el-date-picker
|
||
v-model="filters.settlement_month"
|
||
type="month"
|
||
value-format="YYYY-MM"
|
||
placeholder="全部月份"
|
||
clearable
|
||
style="width: 140px"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="人员">
|
||
<el-select
|
||
v-model="filters.personnel_id"
|
||
placeholder="全部人员"
|
||
clearable
|
||
filterable
|
||
style="width: 150px"
|
||
>
|
||
<el-option
|
||
v-for="option in personnelOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="客户类型">
|
||
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
|
||
<el-option
|
||
v-for="option in CUSTOMER_TYPE_OPTIONS"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="产品类别">
|
||
<el-select
|
||
v-model="filters.product_category"
|
||
placeholder="全部"
|
||
clearable
|
||
style="width: 130px"
|
||
>
|
||
<el-option label="市政水利" value="municipal_water" />
|
||
<el-option label="轨道交通" value="rail_transit" />
|
||
<el-option label="混合业务" value="mixed" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="岗位">
|
||
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 130px">
|
||
<el-option
|
||
v-for="option in roleOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="filter-bar__actions">
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||
<el-button @click="doPrint">打印</el-button>
|
||
</div>
|
||
</el-form>
|
||
|
||
<!-- 表格包进卡片:报表页的信息密度靠卡片边界收拢,分页条共用卡脚材质 -->
|
||
<div class="card">
|
||
<el-table
|
||
v-loading="loading"
|
||
class="dense"
|
||
:data="records"
|
||
stripe
|
||
empty-text="还没有符合条件的明细 —— 换个月份或放宽人员再查一次"
|
||
@expand-change="loadSnapshot"
|
||
>
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<pre class="snapshot">{{
|
||
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
|
||
}}</pre>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="business_no" label="合同编号" min-width="160">
|
||
<template #default="{ row }">
|
||
<span class="cell-code">{{ row.business_no }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="ship_date" label="发货日" width="105" />
|
||
<el-table-column prop="receipt_date" label="回款日" width="105" />
|
||
<el-table-column prop="payment_days" label="回款天数" width="90" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.payment_days }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="产品类别" width="100">
|
||
<template #default="{ row }">{{
|
||
reportApi.reportCategoryLabel(row.product_category)
|
||
}}</template>
|
||
</el-table-column>
|
||
<el-table-column label="客户类型" width="100">
|
||
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="role_name" label="岗位" width="100" />
|
||
<el-table-column prop="rate_text" label="计提比例" width="110">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.rate_text }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.weight_r }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
|
||
<template #default="{ row }">
|
||
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
|
||
<span
|
||
class="cell-num cell-strong"
|
||
:class="{ negative: Number(row.performance_amount) < 0 }"
|
||
>
|
||
{{ row.performance_amount ?? '—' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right">
|
||
<template #default="{ row }">
|
||
<span class="cell-num">{{ row.payable_amount }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
|
||
试用期
|
||
</el-tag>
|
||
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
|
||
缺成本
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
|
||
<div class="card-foot">
|
||
<div v-if="summary" class="summary-bar">
|
||
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计
|
||
{{ summary.performance_total ?? '0.00' }} | 应付合计
|
||
{{ summary.payable_total ?? '0.00' }}
|
||
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
||
</div>
|
||
|
||
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
|
||
不能用 v-model 或 @change——翻了页不会加载 -->
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/*
|
||
* 密集表(设计稿 §09-3):报表要的是信息密度,行高与字号各降半档。
|
||
* 只写在页面 scoped 里 —— 写进全局会波及其它列表页的行高。
|
||
*/
|
||
.dense :deep(.el-table__cell) {
|
||
padding: 7px 0;
|
||
}
|
||
|
||
.dense :deep(.el-table__cell .cell) {
|
||
font-size: 12.5px;
|
||
}
|
||
|
||
/*
|
||
* 金额/比例 span 变块级:行内元素的 text-align 无效,只有块级才真正右对齐;
|
||
* 表头保持左对齐(若改用列 align 会连表头一起右移,与设计稿不符)。
|
||
*/
|
||
.cell-num {
|
||
display: block;
|
||
}
|
||
|
||
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
|
||
.cell-strong {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.snapshot {
|
||
margin: 0;
|
||
padding: 12px 14px;
|
||
font-size: 12.5px;
|
||
line-height: 1.6;
|
||
background: var(--ys-mist);
|
||
border-radius: var(--ys-radius-sm);
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.negative {
|
||
color: var(--ys-danger);
|
||
}
|
||
|
||
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
|
||
.summary-bar {
|
||
margin-right: auto;
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
color: var(--ys-ink);
|
||
}
|
||
|
||
.summary-note {
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
</style>
|