- 引入 @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>
382 lines
13 KiB
Vue
382 lines
13 KiB
Vue
<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>
|