Files
kaidi/frontend/src/pages/reports/ReportsPage.vue
T
2026-08-17 14:08:40 +08:00

1254 lines
39 KiB
Vue

<template>
<div class="reports-page">
<h1 class="page-title-sr-only">查询与报表</h1>
<header class="page-header page-header--actions-only" aria-label="页面操作">
<t-space break-line>
<t-button variant="outline" :loading="loading" @click="refreshReport">
<template #icon><t-icon name="refresh" /></template>刷新
</t-button>
<t-button v-if="canExport" theme="primary" :loading="exporting" @click="prepareExportConfirm">
<template #icon><t-icon name="download" /></template>导出当前报表
</t-button>
</t-space>
</header>
<t-alert v-if="errorMessage" theme="error" :close-btn="false">
<div class="alert-content">
<span>{{ errorMessage }}</span>
<t-button variant="text" theme="primary" size="small" @click="refreshReport">重新加载</t-button>
</div>
</t-alert>
<t-tabs v-model="reportCode" @change="reportChanged">
<t-tab-panel v-for="report in reportOptions" :key="report.value" :value="report.value" :label="report.label" />
</t-tabs>
<section class="filter-panel" aria-label="报表查询条件">
<t-form :data="filters" label-align="top" @submit="search">
<div class="filter-grid">
<t-form-item label="关键词">
<t-input v-model="filters.keyword" clearable placeholder="业务编号、项目或公司" @enter="search" />
</t-form-item>
<t-form-item label="公司编号">
<t-input v-model="filters.companyId" clearable maxlength="26" />
</t-form-item>
<t-form-item label="项目编号">
<t-input v-model="filters.projectId" clearable maxlength="26" />
</t-form-item>
<t-form-item label="往来单位编号">
<t-input v-model="filters.counterpartyId" clearable maxlength="26" />
</t-form-item>
<t-form-item label="状态">
<t-select v-model="filters.status" :options="statusOptions" clearable placeholder="全部状态" />
</t-form-item>
<t-form-item :label="dateBasisLabel">
<t-date-range-picker v-model="dateRange" value-type="YYYY-MM-DD" />
</t-form-item>
<div class="filter-actions">
<t-button theme="primary" type="submit">
<template #icon><t-icon name="search" /></template>查询
</t-button>
<t-button variant="outline" type="button" @click="resetFilters">重置</t-button>
</div>
</div>
</t-form>
</section>
<section class="summary-band" aria-label="报表汇总">
<div>
<span>记录数</span><strong>{{ meta.summary.totalElements || meta.totalElements }}</strong>
</div>
<div>
<span>{{ meta.summary.metricLabel || '指标合计' }}</span>
<strong :class="{ 'summary-blocked': hasUnsafeCurrencyAggregate }">
{{ summaryMetricDisplay }}
</strong>
</div>
<div>
<span>口径版本</span><strong>{{ meta.definitionVersion || '-' }}</strong>
</div>
<div>
<span>筛选快照</span>
<strong class="hash">{{ meta.filterHash ? meta.filterHash.slice(0, 16) : '-' }}</strong>
</div>
</section>
<section class="table-panel" aria-label="报表数据">
<div class="table-toolbar">
<span>共 {{ meta.totalElements }} 条记录</span><span>{{ activeReport.description }}</span>
</div>
<t-table
v-if="loading || rows.length"
:columns="columns"
:data="rows"
row-key="rowId"
:loading="loading"
:hover="true"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
>
<template #businessNo="{ row }">
<t-button variant="text" theme="primary" size="small" @click="openDrilldown(row)">
{{ display(row.businessNo, 'businessNo') }}
</t-button>
</template>
<template #status="{ row }">
<t-tag :theme="statusTheme(String(row.status || ''))" variant="light">
{{ statusLabel(String(row.status || '')) }}
</t-tag>
</template>
<template #operations="{ row }">
<t-button size="small" variant="text" theme="primary" @click="openDrilldown(row)">
<template #icon><t-icon name="browse" /></template>查看明细
</t-button>
</template>
</t-table>
<t-empty v-else description="当前筛选条件下暂无报表数据" />
<div v-if="meta.totalElements" class="pagination-row">
<t-pagination
v-model="pagination.current"
v-model:page-size="pagination.pageSize"
:total="meta.totalElements"
:page-size-options="isMobile ? [] : [20, 50, 100]"
:show-jumper="!isMobile"
:theme="isMobile ? 'simple' : 'default'"
@current-change="pageChanged"
@page-size-change="pageSizeChanged"
/>
</div>
</section>
<t-drawer
v-model:visible="detailVisible"
:header="`${activeReport.label}明细`"
size="min(920px, 96vw)"
:footer="false"
destroy-on-close
>
<t-loading :loading="detailLoading" show-overlay>
<t-alert v-if="detailError" theme="error" :close-btn="false">{{ detailError }}</t-alert>
<template v-if="detail">
<div class="detail-contract">
<span>{{ detail.definitionVersion }}</span>
<span class="hash">{{ detail.filterHash.slice(0, 16) }}</span>
</div>
<t-descriptions bordered :column="isMobile ? 1 : 2" item-layout="vertical">
<t-descriptions-item
v-for="entry in sourceEntries"
:key="entry.key"
:label="columnLabel(entry.key)"
:span="!isMobile && wideKeys.includes(entry.key) ? 2 : 1"
>
{{ display(entry.value, entry.key) }}
</t-descriptions-item>
</t-descriptions>
<section class="detail-section">
<div class="section-heading">
<h2>关联指标</h2>
<span>{{ detail.groups.length }} 项</span>
</div>
<div class="metric-grid">
<div v-for="group in detail.groups" :key="group.code" class="metric-item">
<span>{{ group.label }}</span
><strong>{{ displayMetricGroup(group.value, group.code) }}</strong>
</div>
</div>
</section>
<section class="detail-section">
<div class="section-heading">
<h2>业务明细</h2>
<span>{{ detail.relatedRecords.length }} 条</span>
</div>
<t-table
v-if="detail.relatedRecords.length"
:columns="relatedColumns"
:data="detail.relatedRecords"
row-key="publicId"
size="small"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
/>
<t-empty v-else description="暂无可关联的业务明细" />
</section>
<section class="detail-section">
<div class="section-heading">
<h2>来源表单</h2>
<span>{{ detail.sourceForms.length }} 条</span>
</div>
<t-table
v-if="detail.sourceForms.length"
:columns="sourceFormColumns"
:data="detail.sourceForms"
row-key="publicId"
size="small"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
/>
<t-empty v-else description="当前数据尚未关联来源表单" />
</section>
<section class="detail-section">
<div class="section-heading">
<h2>审批记录</h2>
<span>{{ detail.approvals.length }} 条</span>
</div>
<t-table
v-if="detail.approvals.length"
:columns="approvalColumns"
:data="detail.approvals"
row-key="publicId"
size="small"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
/>
<t-empty v-else description="当前数据尚无审批记录" />
</section>
<section class="detail-section">
<div class="section-heading">
<h2>关联附件</h2>
<span>{{ detail.attachments.length }} 个</span>
</div>
<t-table
v-if="detail.attachments.length"
:columns="attachmentColumns"
:data="detail.attachments"
row-key="fileId"
size="small"
table-layout="fixed"
:horizontal-scroll-affixed-bottom="true"
/>
<t-empty v-else description="当前数据尚未关联附件" />
</section>
</template>
</t-loading>
</t-drawer>
<t-dialog
v-model:visible="exportConfirmVisible"
header="确认导出当前报表"
width="min(560px, 94vw)"
:footer="false"
:close-on-overlay-click="false"
>
<t-alert theme="info" :close-btn="false">{{ exportPreview?.maskingNotice }}</t-alert>
<t-descriptions bordered :column="1" class="export-summary">
<t-descriptions-item label="报表">{{ exportReportLabel }}</t-descriptions-item>
<t-descriptions-item label="预计行数">{{ exportPreview?.estimatedRows }} 条</t-descriptions-item>
<t-descriptions-item :label="exportDateBasisLabel">{{ exportDateRange }}</t-descriptions-item>
<t-descriptions-item label="冻结排序">{{ exportPreview?.sort }}</t-descriptions-item>
<t-descriptions-item label="筛选快照"
><span class="hash">{{ exportPreview?.filterHash }}</span></t-descriptions-item
>
</t-descriptions>
<div class="dialog-footer">
<t-button variant="outline" :disabled="exporting" @click="invalidateExport">取消</t-button>
<t-button theme="primary" :loading="exporting" @click="downloadReport">确认并导出</t-button>
</div>
</t-dialog>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import type { PrimaryTableCol } from 'tdesign-vue-next';
import { Button as TButton, MessagePlugin } from 'tdesign-vue-next';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type {
ReportCode,
ReportDrilldown,
ReportExportPreview,
ReportExportSnapshot,
ReportPageMeta,
ReportQuery,
ReportRow,
} from '@/api/reports';
import { confirmReportExport, getReport, getReportDrilldown, prepareReportExport } from '@/api/reports';
import { useUserStore } from '@/store';
defineOptions({ name: 'ReportsPage' });
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
type ReportFilters = Omit<ReportQuery, 'page' | 'size'>;
interface ReportOption {
label: string;
value: ReportCode;
description: string;
}
interface AppliedSnapshot {
reportCode: ReportCode;
filters: ReportFilters;
definitionVersion: string;
filterHash: string;
}
const reportOptions: ReportOption[] = [
{ label: '项目台账', value: 'project-ledger', description: '项目合同、收款、成本、实付、记账和归档状态总览' },
{ label: '合同执行', value: 'contract-execution', description: '合同、变更、结算、净开票、应付和已付口径' },
{ label: '收款与开票', value: 'receipt-invoice', description: '本次及累计收款、净开票和未收金额' },
{ label: '付款进度', value: 'payment-progress', description: '付款申请、审核、线下结果、回单和记账状态' },
{ label: '流程时长', value: 'workflow-duration', description: '审批节点实时耗时、退回次数和未处理任务' },
{ label: '资料完整性', value: 'archive-completeness', description: '项目最新档案包的缺失、待补、不涉及和已齐全数据' },
];
const reportStatusValues: Record<ReportCode, string[]> = {
'project-ledger': ['DRAFT', 'ACTIVE', 'SUSPENDED', 'CLOSED', 'ARCHIVED'],
'contract-execution': ['DRAFT', 'REVIEWING', 'RETURNED', 'ACTIVE', 'SETTLED', 'DISABLED', 'VOID'],
'receipt-invoice': ['CONFIRMED', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'ISSUED', 'RED_LETTERED'],
'payment-progress': [
'DRAFT',
'REVIEWING',
'RETURNED',
'REJECTED',
'APPROVED',
'OFFLINE_PENDING',
'RESULT_RECORDED',
'RESULT_VERIFIED',
'PART_PAID',
'PAID',
'FAILED',
'REFUNDED',
'VOID',
],
'workflow-duration': ['PENDING', 'APPROVED', 'RETURNED', 'REJECTED', 'CANCELLED'],
'archive-completeness': ['NOT_CREATED', 'PREPARING', 'CHECKED', 'REVIEWING', 'RETURNED', 'ARCHIVED', 'FROZEN'],
};
const dateBasisLabels: Record<ReportCode, string> = {
'project-ledger': '项目数据变更日期',
'contract-execution': '合同数据变更日期',
'receipt-invoice': '收款/开票业务日期',
'payment-progress': '付款申请日期',
'workflow-duration': '任务到达日期',
'archive-completeness': '档案包更新日期',
};
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const queryString = (key: string) => (typeof route.query[key] === 'string' ? String(route.query[key]) : '');
const isReportCode = (value: string): value is ReportCode => reportOptions.some((item) => item.value === value);
const monthRange = (): string[] => [
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().endOf('month').format('YYYY-MM-DD'),
];
const queryDateRange = () => {
const from = queryString('dateFrom');
const to = queryString('dateTo');
return /^\d{4}-\d{2}-\d{2}$/.test(from) && /^\d{4}-\d{2}-\d{2}$/.test(to) ? [from, to] : monthRange();
};
const requestedReport = queryString('reportCode');
const reportCode = ref<ReportCode>(isReportCode(requestedReport) ? requestedReport : 'project-ledger');
const activeReport = computed(() => reportOptions.find((item) => item.value === reportCode.value) || reportOptions[0]);
const dateBasisLabel = computed(() => dateBasisLabels[reportCode.value]);
const statusOptions = computed(() =>
reportStatusValues[reportCode.value].map((value) => ({ value, label: statusLabel(value) })),
);
const filters = reactive({
companyId: queryString('companyId'),
projectId: queryString('projectId'),
counterpartyId: queryString('counterpartyId'),
keyword: queryString('keyword'),
status: queryString('status'),
});
const dateRange = ref<string[]>(queryDateRange());
const pagination = reactive({
current: Math.max(1, Number(queryString('page')) || 1),
pageSize: [20, 50, 100].includes(Number(queryString('size'))) ? Number(queryString('size')) : 20,
});
const rows = ref<ReportRow[]>([]);
const loading = ref(false);
const exporting = ref(false);
const exportConfirmVisible = ref(false);
const exportPreview = ref<ReportExportPreview | null>(null);
const exportSnapshot = ref<ReportExportSnapshot | null>(null);
const errorMessage = ref('');
const appliedSnapshot = ref<AppliedSnapshot | null>(null);
let loadSequence = 0;
let syncingUrl = false;
const emptyMeta = (): ReportPageMeta => ({
page: 1,
size: 20,
totalElements: 0,
totalPages: 0,
reportCode: reportCode.value,
definitionVersion: '',
filterHash: '',
columns: [],
sort: '',
summary: {},
allowedActions: [],
});
const meta = reactive<ReportPageMeta>(emptyMeta());
const canExport = computed(() => meta.allowedActions.includes('EXPORT'));
const canView = computed(() => userStore.hasPermission('report:finance:view'));
const moneySummaryReports = new Set<ReportCode>([
'project-ledger',
'contract-execution',
'receipt-invoice',
'payment-progress',
]);
const hasUnsafeCurrencyAggregate = computed(() => moneySummaryReports.has(reportCode.value));
const summaryMetricDisplay = computed(() =>
hasUnsafeCurrencyAggregate.value ? '未按币种分组,已阻断合计' : formatMetric(meta.summary.metricTotal),
);
const exportDateRange = computed(() => formatDateRange(exportSnapshot.value?.filters));
const exportReportLabel = computed(() => {
const code = exportSnapshot.value?.reportCode;
return reportOptions.find((item) => item.value === code)?.label || '-';
});
const exportDateBasisLabel = computed(() => {
const code = exportSnapshot.value?.reportCode;
return code ? dateBasisLabels[code] : '日期范围';
});
const labels: Record<string, string> = {
rowId: '明细编号',
businessNo: '业务编号',
projectName: '项目',
companyName: '公司',
contractAmount: '合同额',
receiptAmount: '收款额',
costAmount: '成本额',
paymentAmount: '实际付款额',
completedVoucherCount: '完成凭证数',
pendingAccountingCount: '待记账数',
completeness: '完整率',
latestPackageStatus: '最新档案状态',
name: '合同名称',
originalAmount: '合同原额',
approvedChangeAmount: '已批变更',
settlementAmount: '结算额',
invoicedAmount: '净开票额',
pendingPayableAmount: '待确认应付',
payableAmount: '应付金额',
paidAmount: '已付金额',
availableAmount: '可付余额',
recordType: '记录类型',
businessDate: '业务日期',
amount: '本次金额',
cumulativeReceiptAmount: '累计收款',
cumulativeInvoiceAmount: '累计净开票',
outstandingAmount: '未收金额',
allocatedAmount: '已分配',
invoiceNo: '发票号码',
invoiceType: '发票类型',
requestedDate: '申请日期',
requestedAmount: '申请金额',
approvedAmount: '批准金额',
submittedAt: '提交时间',
checkedAt: '财务检查时间',
approvedAt: '批准时间',
offlineResult: '线下结果',
resultAmount: '结果金额',
paidDate: '支付日期',
transactionNo: '交易流水号',
receiptFileCount: '回单数',
accountingStatus: '记账状态',
formType: '表单类型',
nodeCode: '节点编码',
arrivedAt: '到达时间',
completedAt: '完成时间',
durationSeconds: '耗时(秒)',
returnCount: '退回次数',
pendingTaskCount: '未处理任务数',
packageVersion: '档案包版本',
missingCount: '缺失数',
pendingCount: '待补数',
notApplicableCount: '不涉及数',
presentCount: '已齐全数',
totalItemCount: '清单项总数',
frozen: '冻结',
status: '状态',
companyPublicId: '公司编号',
projectPublicId: '项目编号',
counterpartyPublicId: '往来单位编号',
sourceDocumentId: '来源表单编号',
sourceVersionId: '来源版本编号',
workflowInstanceId: '流程实例编号',
updatedAt: '更新时间',
metricAmount: '指标值',
fileCount: '文件数',
currency: '币种',
stage: '项目阶段',
financeStatus: '财务状态',
archiveStatus: '归档状态',
};
const wideKeys = ['rowId', 'companyPublicId', 'projectPublicId', 'counterpartyPublicId', 'sourceDocumentId'];
function isMetricColumn(key: string) {
return /amount/i.test(key) || ['completeness', 'durationSeconds'].includes(key);
}
const columns = computed<PrimaryTableCol[]>(() => {
const source = meta.columns.length ? meta.columns : ['businessNo', 'projectName', 'status'];
const result = source.map((key): PrimaryTableCol => ({
colKey: key,
title: columnLabel(key),
width: columnWidth(key),
ellipsis: true,
align: isMetricColumn(key) ? 'right' : undefined,
fixed: ['businessNo', 'projectName', 'name'].includes(key) ? 'left' : undefined,
cell:
key === 'businessNo' || key === 'status'
? undefined
: isMetricColumn(key)
? (h, { row }) => {
const props = { variant: 'text' as const, theme: 'primary' as const, size: 'small' as const };
return h(TButton, { ...props, onClick: () => openDrilldown(row) }, () => display(row[key], key));
}
: (_h, { row }) => display(row[key], key),
}));
result.push({ colKey: 'operations', title: '操作', width: 120, fixed: 'right' });
return result;
});
const relatedColumns: PrimaryTableCol[] = [
{ colKey: 'relationType', title: '关联类型', width: 130 },
{ colKey: 'businessNo', title: '业务编号', width: 190, ellipsis: true },
{ colKey: 'status', title: '状态', width: 120, cell: (_h, { row }) => statusLabel(String(row.status || '')) },
{
colKey: 'amount',
title: '金额/指标',
width: 140,
align: 'right',
cell: (_h, { row }) => display(row.amount, 'amount'),
},
{ colKey: 'businessDate', title: '业务日期', width: 130 },
{ colKey: 'sourceDocumentId', title: '来源表单编号', width: 220, ellipsis: true },
];
const sourceFormColumns: PrimaryTableCol[] = [
{ colKey: 'businessNo', title: '表单业务编号', width: 190, fixed: 'left', ellipsis: true },
{ colKey: 'formType', title: '表单类型', width: 110 },
{ colKey: 'sourceNo', title: '来源编号', width: 170, ellipsis: true },
{ colKey: 'versionNo', title: '版本', width: 80 },
{ colKey: 'relationType', title: '关联关系', width: 130 },
{ colKey: 'status', title: '状态', width: 120, cell: (_h, { row }) => statusLabel(String(row.status || '')) },
];
const approvalColumns: PrimaryTableCol[] = [
{ colKey: 'nodeCode', title: '节点', width: 130 },
{ colKey: 'action', title: '动作', width: 110 },
{ colKey: 'actorName', title: '处理人', width: 120 },
{ colKey: 'opinion', title: '意见', width: 220, ellipsis: true },
{ colKey: 'actedAt', title: '处理时间', width: 180, cell: (_h, { row }) => display(row.actedAt, 'actedAt') },
];
const attachmentColumns: PrimaryTableCol[] = [
{ colKey: 'displayName', title: '文件名', width: 220, ellipsis: true },
{ colKey: 'documentType', title: '资料类型', width: 150 },
{ colKey: 'ownerType', title: '来源对象', width: 140 },
{
colKey: 'scanStatus',
title: '文件检查',
width: 120,
cell: (_h, { row }) => scanStatusLabel(String(row.scanStatus || '')),
},
{ colKey: 'versionNo', title: '版本', width: 80 },
{ colKey: 'fileId', title: '文件编号', width: 220, ellipsis: true },
];
function columnLabel(key: string) {
return labels[key] || key;
}
function columnWidth(key: string) {
if (['projectName', 'name'].includes(key)) return 200;
if (['businessNo', 'arrivedAt', 'completedAt', 'submittedAt', 'checkedAt', 'approvedAt'].includes(key)) return 180;
if (/amount|total/i.test(key)) return 140;
return 120;
}
function formatMetric(value?: string) {
const number = Number(value || 0);
return Number.isFinite(number)
? new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
: value || '-';
}
function display(value: unknown, key: string) {
if (value === null || value === undefined || value === '') return '-';
if (key === 'status') return statusLabel(String(value));
if (key === 'scanStatus') return scanStatusLabel(String(value));
if (['financeStatus', 'archiveStatus', 'latestPackageStatus', 'offlineResult', 'accountingStatus'].includes(key)) {
return statusLabel(String(value));
}
if (key === 'frozen') return value ? '是' : '否';
if (key === 'completeness') return `${Number(value).toFixed(2).replace(/\.00$/, '')}%`;
if (/amount|total/i.test(key)) {
const number = Number(value);
return Number.isFinite(number)
? new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
: String(value);
}
if (/At$/.test(key)) {
const date = new Date(String(value));
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false });
}
return String(value);
}
function displayMetricGroup(value: string, code: string) {
if (
[
'CONTRACT',
'RECEIPT',
'COST',
'PAYMENT',
'INVOICE',
'PAYABLE',
'PAID',
'AVAILABLE',
'CURRENT',
'CUMULATIVE_RECEIPT',
'CUMULATIVE_INVOICE',
'OUTSTANDING',
'REQUESTED',
'APPROVED',
'PAYMENT_RESULT',
].includes(code)
) {
return formatMetric(value);
}
if (code === 'ARCHIVE') {
return `${Number(value || 0)
.toFixed(2)
.replace(/\.00$/, '')}%`;
}
return value || '-';
}
function statusLabel(value: string) {
return (
(
{
ACTIVE: '进行中',
DRAFT: '草稿',
REVIEWING: '审核中',
SETTLED: '已结算',
DISABLED: '已停用',
VOID: '已作废',
SUSPENDED: '已暂停',
CLOSED: '已关闭',
IN_PROGRESS: '进行中',
NOT_READY: '未就绪',
CHECKING: '检查中',
COMPLETED: '已完成',
APPROVED: '已批准',
ARCHIVED: '已归档',
FROZEN: '已冻结',
RETURNED: '已退回',
REJECTED: '已拒绝',
PAID: '已付款',
ISSUED: '已开票',
RED_LETTERED: '已红冲',
NOT_CREATED: '未生成',
PENDING: '待处理',
PREPARING: '准备中',
CHECKED: '已检查',
PART_PAID: '部分已付',
REFUNDED: '已退款',
FAILED: '失败',
CANCELLED: '已取消',
CONFIRMED: '已确认',
PARTIALLY_ALLOCATED: '部分核销',
ALLOCATED: '已核销',
RESULT_VERIFIED: '结果已复核',
RESULT_RECORDED: '结果待复核',
OFFLINE_PENDING: '待线下处理',
SUCCESS: '成功',
FAILURE: '失败',
REFUND: '退款',
} as Record<string, string>
)[value] ||
value ||
'-'
);
}
function statusTheme(value: string): TagTheme {
if (['ACTIVE', 'COMPLETED', 'APPROVED', 'ARCHIVED', 'PAID', 'ISSUED', 'RESULT_VERIFIED'].includes(value)) {
return 'success';
}
if (['RETURNED', 'REJECTED', 'FROZEN', 'FAILED'].includes(value)) return 'danger';
if (value === 'NOT_CREATED') return 'default';
return 'warning';
}
function scanStatusLabel(value: string) {
return (
(
{
SCANNING: '扫描中',
AVAILABLE: '可用',
REJECTED: '已拒绝',
QUARANTINED: '隔离',
SUPERSEDED: '已替代',
} as Record<string, string>
)[value] ||
value ||
'-'
);
}
function draftFilters(): ReportFilters {
return {
companyId: filters.companyId.trim() || undefined,
projectId: filters.projectId.trim() || undefined,
counterpartyId: filters.counterpartyId.trim() || undefined,
keyword: filters.keyword.trim() || undefined,
status: filters.status.trim() || undefined,
dateFrom: dateRange.value[0] || undefined,
dateTo: dateRange.value[1] || undefined,
};
}
function appliedReportSnapshot(): AppliedSnapshot | null {
const snapshot = appliedSnapshot.value;
if (!snapshot || snapshot.reportCode !== reportCode.value || !snapshot.definitionVersion || !snapshot.filterHash) {
MessagePlugin.warning('报表尚未完成加载,请刷新后重试');
return null;
}
return snapshot;
}
function formatDateRange(snapshot?: ReportFilters) {
if (!snapshot?.dateFrom && !snapshot?.dateTo) return '全部日期';
return `${snapshot.dateFrom || '最早'} 至 ${snapshot.dateTo || '最新'}`;
}
async function syncUrl(snapshot: AppliedSnapshot) {
const params = {
reportCode: snapshot.reportCode,
...snapshot.filters,
page: pagination.current,
size: pagination.pageSize,
};
const query = Object.fromEntries(
Object.entries(params)
.filter(([, value]) => value !== undefined && value !== '')
.map(([key, value]) => [key, String(value)]),
);
syncingUrl = true;
try {
await router.replace({ name: 'ReportsIndex', query });
} finally {
syncingUrl = false;
}
}
async function loadReport(applyDraft = false) {
if (!canView.value) {
rows.value = [];
Object.assign(meta, emptyMeta());
errorMessage.value = '当前身份没有查看财务报表的权限';
return false;
}
const sequence = ++loadSequence;
const code = reportCode.value;
if (applyDraft || !appliedSnapshot.value || appliedSnapshot.value.reportCode !== code) {
appliedSnapshot.value = { reportCode: code, filters: draftFilters(), definitionVersion: '', filterHash: '' };
}
const requestSnapshot = appliedSnapshot.value;
let loadedSnapshot: AppliedSnapshot | null = null;
loading.value = true;
errorMessage.value = '';
try {
const result = await getReport(requestSnapshot.reportCode, {
...requestSnapshot.filters,
page: pagination.current,
size: pagination.pageSize,
});
if (sequence !== loadSequence) return false;
rows.value = result.items;
Object.assign(meta, result.meta);
loadedSnapshot = {
reportCode: requestSnapshot.reportCode,
filters: { ...requestSnapshot.filters },
definitionVersion: result.meta.definitionVersion,
filterHash: result.meta.filterHash,
};
appliedSnapshot.value = loadedSnapshot;
} catch (error) {
if (sequence !== loadSequence) return false;
errorMessage.value = (error as Error).message || '报表加载失败';
MessagePlugin.error(errorMessage.value);
rows.value = [];
Object.assign(meta, emptyMeta());
return false;
} finally {
if (sequence === loadSequence) loading.value = false;
}
if (sequence !== loadSequence || !loadedSnapshot) return false;
try {
await syncUrl(loadedSnapshot);
} catch {
MessagePlugin.warning('报表已加载,但网址状态同步失败');
}
return true;
}
function refreshReport() {
void loadReport(false);
}
function search() {
invalidateDrilldown();
invalidateExport();
pagination.current = 1;
void loadReport(true);
}
function reportChanged() {
invalidateDrilldown();
invalidateExport();
filters.status = '';
pagination.current = 1;
rows.value = [];
Object.assign(meta, emptyMeta());
void loadReport(true);
}
function pageChanged() {
void loadReport(false);
}
function pageSizeChanged() {
pagination.current = 1;
void loadReport(false);
}
function resetFilters() {
filters.companyId = '';
filters.projectId = '';
filters.counterpartyId = '';
filters.keyword = '';
filters.status = '';
dateRange.value = monthRange();
search();
}
function restoreFromRoute() {
const requested = queryString('reportCode');
reportCode.value = isReportCode(requested) ? requested : 'project-ledger';
filters.companyId = queryString('companyId');
filters.projectId = queryString('projectId');
filters.counterpartyId = queryString('counterpartyId');
filters.keyword = queryString('keyword');
filters.status = queryString('status');
dateRange.value = queryDateRange();
pagination.current = Math.max(1, Number(queryString('page')) || 1);
pagination.pageSize = [20, 50, 100].includes(Number(queryString('size'))) ? Number(queryString('size')) : 20;
appliedSnapshot.value = null;
rows.value = [];
Object.assign(meta, emptyMeta());
invalidateDrilldown();
invalidateExport();
}
const detailVisible = ref(false);
const detailLoading = ref(false);
const detailError = ref('');
const detail = ref<ReportDrilldown | null>(null);
let detailSequence = 0;
const sourceEntries = computed(() =>
Object.entries(detail.value?.source || {}).map(([key, value]) => ({ key, value })),
);
function assertSameSnapshot(definitionVersion: string, filterHash: string) {
if (definitionVersion !== meta.definitionVersion || filterHash !== meta.filterHash) {
throw new Error('报表口径或筛选快照已变化,请刷新后重试');
}
}
async function openDrilldown(row: ReportRow) {
const rowId = String(row.rowId || row.publicId || '');
if (!rowId) {
MessagePlugin.warning('当前报表行缺少明细编号');
return;
}
const snapshot = appliedReportSnapshot();
if (!snapshot) return;
const sequence = ++detailSequence;
detailVisible.value = true;
detailLoading.value = true;
detailError.value = '';
detail.value = null;
try {
const result = await getReportDrilldown(snapshot.reportCode, rowId, snapshot.filters);
if (sequence !== detailSequence) return;
assertSameSnapshot(result.definitionVersion, result.filterHash);
detail.value = result;
} catch (error) {
if (sequence !== detailSequence) return;
detailError.value = (error as Error).message || '明细加载失败';
} finally {
if (sequence === detailSequence) detailLoading.value = false;
}
}
function invalidateDrilldown() {
detailSequence += 1;
detailVisible.value = false;
detailLoading.value = false;
detailError.value = '';
detail.value = null;
}
function invalidateExport() {
exportConfirmVisible.value = false;
exportPreview.value = null;
exportSnapshot.value = null;
}
async function sha256Hex(value: string) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function downloadReport() {
const prepared = exportPreview.value;
const snapshot = exportSnapshot.value;
if (!prepared || !snapshot || exporting.value) return;
exporting.value = true;
try {
const result = await confirmReportExport(snapshot.reportCode, prepared.exportId);
if (result.definitionVersion !== snapshot.definitionVersion || result.filterHash !== snapshot.filterHash) {
throw new Error('导出筛选快照已变化,请重新预检');
}
const contentSha256 = await sha256Hex(result.content);
if (!/^[a-f\d]{64}$/i.test(result.sha256) || contentSha256 !== result.sha256.toLowerCase()) {
throw new Error('导出文件完整性校验失败,请重新导出');
}
const blob = new Blob([result.content], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = result.fileName;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
invalidateExport();
const refreshed = await loadReport(false);
if (refreshed) MessagePlugin.success(`已导出 ${result.rowCount} 行,SHA-256 ${result.sha256.slice(0, 16)}...`);
else MessagePlugin.warning('导出文件已生成,但报表列表重新加载失败,请手动刷新核对最新数据');
} catch (error) {
MessagePlugin.error((error as Error).message || '报表导出失败');
} finally {
exporting.value = false;
}
}
async function prepareExportConfirm() {
const snapshot = appliedReportSnapshot();
if (!snapshot) return;
exporting.value = true;
try {
const prepared = await prepareReportExport(snapshot.reportCode, {
...snapshot.filters,
columns: meta.columns,
sort: meta.sort,
expectedFilterHash: snapshot.filterHash,
expectedDefinitionVersion: snapshot.definitionVersion,
});
if (prepared.definitionVersion !== snapshot.definitionVersion || prepared.filterHash !== snapshot.filterHash) {
throw new Error('导出预检筛选快照已变化,请刷新后重试');
}
exportPreview.value = prepared;
exportSnapshot.value = {
reportCode: snapshot.reportCode,
filters: { ...snapshot.filters },
definitionVersion: snapshot.definitionVersion,
filterHash: snapshot.filterHash,
};
exportConfirmVisible.value = true;
} catch (error) {
MessagePlugin.error((error as Error).message || '导出预检失败');
} finally {
exporting.value = false;
}
}
const isMobile = ref(false);
function updateViewport() {
isMobile.value = window.innerWidth <= 600;
}
onMounted(() => {
updateViewport();
window.addEventListener('resize', updateViewport);
void loadReport(true);
});
watch(
() => route.fullPath,
() => {
if (syncingUrl) return;
restoreFromRoute();
void loadReport(true);
},
{ flush: 'sync' },
);
watch(detailVisible, (visible) => {
if (!visible && detailLoading.value) {
detailSequence += 1;
detailLoading.value = false;
}
});
onBeforeUnmount(() => {
loadSequence += 1;
detailSequence += 1;
window.removeEventListener('resize', updateViewport);
});
</script>
<style scoped>
.reports-page {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.page-header,
.table-toolbar,
.alert-content,
.section-heading,
.detail-contract {
display: flex;
align-items: center;
}
.export-summary {
margin-top: 16px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.page-header {
justify-content: space-between;
gap: 16px;
}
.page-header h1 {
margin: 0 0 8px;
font-size: 24px;
line-height: 32px;
}
.page-header p {
margin: 0;
color: var(--td-text-color-secondary);
}
.alert-content {
justify-content: space-between;
gap: 12px;
width: 100%;
}
.filter-panel,
.table-panel {
min-width: 0;
padding: 16px;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, minmax(170px, 1fr));
gap: 0 16px;
align-items: end;
}
.filter-actions {
display: flex;
gap: 8px;
padding-bottom: 24px;
}
.summary-band {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
border: 1px solid var(--td-component-border);
background: var(--td-bg-color-container);
border-radius: 6px;
}
.summary-band > div {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 16px;
border-right: 1px solid var(--td-component-border);
}
.summary-band > div:last-child {
border-right: 0;
}
.summary-band span,
.metric-item span {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.summary-band strong {
overflow: hidden;
font-size: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-band strong.summary-blocked {
overflow: visible;
color: #704000;
font-size: 14px;
line-height: 20px;
text-overflow: clip;
white-space: normal;
}
.table-toolbar {
justify-content: space-between;
margin-bottom: 12px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.pagination-row {
display: flex;
justify-content: flex-end;
min-height: 32px;
margin-top: 16px;
}
.detail-contract {
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
color: var(--td-text-color-secondary);
font-size: 13px;
}
.detail-section {
margin-top: 24px;
}
.section-heading {
justify-content: space-between;
margin-bottom: 10px;
}
.section-heading h2 {
margin: 0;
font-size: 16px;
}
.section-heading span {
color: var(--td-text-color-secondary);
font-size: 13px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(140px, 1fr));
border: 1px solid var(--td-component-border);
border-radius: 6px;
}
.metric-item {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 16px;
border-right: 1px solid var(--td-component-border);
}
.metric-item:last-child {
border-right: 0;
}
.metric-item strong {
overflow-wrap: anywhere;
font-size: 18px;
}
.hash {
font-family: var(--td-font-family-mono);
letter-spacing: 0;
}
@media (width <= 900px) {
.filter-grid {
grid-template-columns: repeat(2, minmax(160px, 1fr));
}
.summary-band {
grid-template-columns: repeat(2, 1fr);
}
.summary-band > div:nth-child(2) {
border-right: 0;
}
.summary-band > div:nth-child(-n + 2) {
border-bottom: 1px solid var(--td-component-border);
}
.table-toolbar {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
}
@media (width <= 600px) {
.page-header {
align-items: flex-start;
flex-direction: column;
}
.filter-grid,
.summary-band,
.metric-grid {
grid-template-columns: 1fr;
}
.summary-band > div,
.metric-item {
border-right: 0;
border-bottom: 1px solid var(--td-component-border);
}
.summary-band > div:last-child,
.metric-item:last-child {
border-bottom: 0;
}
.pagination-row {
justify-content: center;
}
.pagination-row :deep(.t-pagination) {
justify-content: center;
max-width: 100%;
}
}
</style>