Files
PayrollManagementSystem/frontend/src/views/reports/business-stats/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

382 lines
13 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 } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { BUSINESS_STATUS_OPTIONS, businessStatusLabel } from '@/api/businesses'
import * as calcApi from '@/api/calc'
import type { LedgerRow } from '@/api/calc'
import { listCustomers } from '@/api/customers'
import { listPersonnel } from '@/api/personnel'
import * as receiptApi from '@/api/receipts'
import * as reportApi from '@/api/reports'
import type { BusinessStatsRow, ReportFilterParams, ReportSummary } from '@/api/reports'
import AppPagination from '@/components/table/AppPagination.vue'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { usePagedList } from '@/composables/usePagedList'
import { handledError } from '@/utils/error'
/**
* 业务统计表(FR-031 之一):按业务查发货、回款、绩效情况。
*
* 回款额/绩效是**业务累计值**,不随业务日期筛选变化(页面已注明);
* 行展开下钻回款明细与台账明细(复用既有查询)。
*/
const router = useRouter()
const filters = reactive({
keyword: '',
customer_id: undefined as number | undefined,
owner_personnel_id: undefined as number | undefined,
status: undefined as number | undefined,
})
const dateRange = ref<[string, string] | null>(null)
const { options: customerOptions, load: loadCustomers } = useDropdownOptions(() =>
listCustomers({ pageNum: 1, pageSize: 200 }).then((data) =>
data.list.map((item) => ({ label: item.company_name, value: item.id })),
),
)
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions(() =>
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
),
)
void loadCustomers()
void loadPersonnelOptions()
const summary = ref<ReportSummary | null>(null)
function getFilters(): ReportFilterParams {
return {
keyword: filters.keyword.trim() || undefined,
customer_id: filters.customer_id,
owner_personnel_id: filters.owner_personnel_id,
status: filters.status,
// el-date-picker 必须指定 value-format,否则绑定值是 Date 对象
date_from: dateRange.value?.[0],
date_to: dateRange.value?.[1],
}
}
function fetchPage(params: ReportFilterParams) {
return reportApi.listBusinessStats(params)
}
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
usePagedList(fetchPage, getFilters, (data) => {
summary.value = data.summary
})
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
onMounted(search)
function resetFilters(): void {
filters.keyword = ''
filters.customer_id = undefined
filters.owner_personnel_id = undefined
filters.status = undefined
dateRange.value = null
search()
}
/** 展开行下钻:回款明细与台账明细各自懒加载并缓存 */
const receiptsMap = ref<Record<number, receiptApi.ReceiptItem[]>>({})
const ledgersMap = ref<Record<number, LedgerRow[]>>({})
async function loadDrill(row: BusinessStatsRow): Promise<void> {
const id = row.business_id
try {
if (!receiptsMap.value[id]) {
const data = await receiptApi.listReceipts({ business_id: id, pageNum: 1, pageSize: 200 })
receiptsMap.value = { ...receiptsMap.value, [id]: data.list }
}
if (!ledgersMap.value[id]) {
const data = await calcApi.listLedger({
business_id: id,
is_active: 1,
calc_type: 1,
pageNum: 1,
pageSize: 200,
})
ledgersMap.value = { ...ledgersMap.value, [id]: data.list }
}
} 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('business-stats', 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 query: Record<string, string> = {}
for (const [key, value] of Object.entries(getFilters())) {
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
}
const route = router.resolve({
name: 'report-print',
params: { code: 'business-stats' },
query,
})
window.open(route.href, '_blank')
}
function today(): string {
const now = new Date()
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate(),
).padStart(2, '0')}`
}
</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-input
v-model="filters.keyword"
placeholder="业务编号/客户名称"
clearable
style="width: 180px"
@keyup.enter="search"
/>
</el-form-item>
<el-form-item label="客户">
<el-select
v-model="filters.customer_id"
placeholder="全部客户"
clearable
filterable
style="width: 160px"
>
<el-option
v-for="option in customerOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="归属人">
<el-select
v-model="filters.owner_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.status" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="option in BUSINESS_STATUS_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务日期">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
style="width: 240px"
/>
</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="loadDrill"
>
<el-table-column type="expand">
<template #default="{ row }">
<div class="drill-wrap">
<p class="section-title">回款明细</p>
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
<el-table-column prop="settlement_status" label="入结状态" width="100" />
<el-table-column prop="remark" label="备注" min-width="140" />
</el-table>
<p class="section-title">台账明细</p>
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
<el-table-column prop="personnel_name" label="归属人员" width="120" />
<el-table-column prop="receipt_date" label="回款日" width="110" />
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
<el-table-column prop="rate_text" label="计提比例" width="110" />
<el-table-column
prop="performance_amount"
label="绩效金额"
width="120"
align="right"
/>
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
</el-table>
</div>
</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="business_date" label="业务日期" width="110" />
<el-table-column prop="customer_name" label="客户" min-width="170" />
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_sale_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_ship_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.total_received_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right">
<template #default="{ row }">
<!-- 绩效列加粗:一屏几十行时,加粗是读者定位「钱」的唯一线索 -->
<span class="cell-num cell-strong">{{ row.performance_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right">
<template #default="{ row }">
<span class="cell-num">{{ row.payable_amount }}</span>
</template>
</el-table-column>
</el-table>
<!-- 合计与分页共用卡脚:合计靠 margin-right:auto 占到左侧,分页恒靠右 -->
<div class="card-foot">
<div v-if="summary" class="summary-bar">
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计
{{ summary.ship_total ?? '0.00' }} | 回款合计 {{ summary.received_total ?? '0.00' }} |
绩效合计 {{ summary.performance_total ?? '0.00' }}
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
</div>
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
<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;
}
/* 绩效列加粗:设计稿要求「密集不等于看不清」 */
.cell-strong {
font-weight: 600;
}
/* 展开区:两块下钻表跟着密集表收内距,字号由 size="small" 与 .dense 决定 */
.drill-wrap {
padding: 10px 14px 6px;
}
.drill-wrap .section-title {
margin-top: 8px;
}
/* 合计条:比正文加粗、字号放大半档,读者一眼能把「合计」与数据行分开 */
.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>