Files
PayrollManagementSystem/frontend/src/views/reports/personal-detail/index.vue
T
liuwenxinandClaude 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 lines
14 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 { 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>