文件
PayrollManagementSystem/frontend/src/views/reports/personal-detail/index.vue
T
liuwenxin和Claude Code e61800dcdc feat(frontend): 菜单图标与视觉设计系统落地
- 引入 @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>
2026-10-22 19:45:54 +08:00

405 行
14 KiB
Vue
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<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>