feat: 完成P6-P8前端能力闭环(V3,D2,D3,B3)

这个提交包含在:
2026-02-10 11:28:51 +08:00
父节点 6bc09f557b
当前提交 4e5a7793fe
共修改 14 个文件,包含 1151 行新增和 62 行删除
+334
查看文件
@@ -0,0 +1,334 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { createBudget, fetchBudgets, archiveBudget, type BudgetListQuery } from "../api/budgets";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
const PAGE_SIZE = 20;
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试。";
}
export function BudgetsPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canViewBudget = hasPermission(permissions, "budget.view") || hasPermission(permissions, "budget.manage");
const canManageBudget = hasPermission(permissions, "budget.manage");
const [status, setStatus] = useState<"active" | "archived" | "">("active");
const [departmentId, setDepartmentId] = useState("");
const [page, setPage] = useState(1);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [allocatedAmount, setAllocatedAmount] = useState("");
const [warningThresholdPercent, setWarningThresholdPercent] = useState("80");
const [formDepartmentId, setFormDepartmentId] = useState("");
const [error, setError] = useState("");
const queryInput = useMemo<BudgetListQuery>(
() => ({
status: status || undefined,
departmentId: departmentId || undefined,
page,
pageSize: PAGE_SIZE,
}),
[status, departmentId, page],
);
const departmentsQuery = useQuery({
enabled: canViewBudget,
queryKey: ["departments", "budget-filter"],
queryFn: () => fetchDepartments(),
});
const budgetsQuery = useQuery({
enabled: canViewBudget,
queryKey: ["budgets", queryInput],
queryFn: () => fetchBudgets(queryInput),
});
const createMutation = useMutation({
mutationFn: async () => {
return createBudget({
code: code.trim().toUpperCase(),
name: name.trim(),
departmentId: formDepartmentId || null,
periodStart,
periodEnd,
allocatedAmount: allocatedAmount.trim(),
warningThresholdPercent: Number(warningThresholdPercent || 80),
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["budgets"] });
setCode("");
setName("");
setPeriodStart("");
setPeriodEnd("");
setAllocatedAmount("");
setWarningThresholdPercent("80");
setFormDepartmentId("");
setError("");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
},
});
const archiveMutation = useMutation({
mutationFn: async (budgetId: string) => archiveBudget(budgetId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["budgets"] });
setError("");
},
onError: (mutationError) => {
setError(resolveError(mutationError));
},
});
if (!canViewBudget) {
return (
<div className="space-y-4">
<PageHeader title="预算管理" subtitle="你当前没有预算权限,请联系管理员开通。" />
<Card className="p-6 text-sm text-slate-600">所需权限:budget.view 或 budget.manage</Card>
</div>
);
}
const totalPages = Math.max(1, Math.ceil((budgetsQuery.data?.total ?? 0) / PAGE_SIZE));
return (
<div className="space-y-6">
<PageHeader
title="预算管理"
subtitle="支持预算池维护、状态筛选与归档。"
/>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{error}
</p>
) : null}
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">筛选条件</h3>
<div className="grid gap-3 md:grid-cols-3">
<select
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
value={status}
onChange={(event) => {
setStatus(event.target.value as "active" | "archived" | "");
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="active">active</option>
<option value="archived">archived</option>
</select>
<select
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
value={departmentId}
onChange={(event) => {
setDepartmentId(event.target.value);
setPage(1);
}}
>
<option value="">全部部门</option>
{(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
<Button
type="button"
variant="secondary"
onClick={() => queryClient.invalidateQueries({ queryKey: ["budgets"] })}
>
刷新列表
</Button>
</div>
</Card>
{canManageBudget ? (
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">新建预算池</h3>
<div className="grid gap-3 md:grid-cols-3">
<input
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
placeholder="编码(例如 BGT-2026-RD)"
value={code}
onChange={(event) => setCode(event.target.value)}
/>
<input
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
placeholder="名称"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<select
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
value={formDepartmentId}
onChange={(event) => setFormDepartmentId(event.target.value)}
>
<option value="">全局预算(不限定部门)</option>
{(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
<input
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
type="date"
value={periodStart}
onChange={(event) => setPeriodStart(event.target.value)}
/>
<input
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
type="date"
value={periodEnd}
onChange={(event) => setPeriodEnd(event.target.value)}
/>
<input
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
placeholder="预算总额(例如 100000.00)"
value={allocatedAmount}
onChange={(event) => setAllocatedAmount(event.target.value)}
/>
<input
className="h-11 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
type="number"
min={1}
max={100}
placeholder="预警阈值(%)"
value={warningThresholdPercent}
onChange={(event) => setWarningThresholdPercent(event.target.value)}
/>
</div>
<Button
type="button"
disabled={
createMutation.isPending ||
!code.trim() ||
!name.trim() ||
!periodStart ||
!periodEnd ||
!allocatedAmount.trim()
}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建预算池"}
</Button>
</Card>
) : null}
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">预算池列表</h3>
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>编码</TableHead>
<TableHead>名称</TableHead>
<TableHead>状态</TableHead>
<TableHead>期间</TableHead>
<TableHead>额度</TableHead>
<TableHead>预警阈值</TableHead>
<TableHead className="w-28">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(budgetsQuery.data?.items ?? []).length ? (
(budgetsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium text-slate-900">{item.code}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{(item.periodStart ?? "-")} ~ {(item.periodEnd ?? "-")}
</TableCell>
<TableCell>
{item.usedAmount} / {item.allocatedAmount}
<div className="text-xs text-slate-500">剩余 {item.remainingAmount}</div>
</TableCell>
<TableCell>{item.warningThresholdPercent}%</TableCell>
<TableCell>
{canManageBudget && item.status !== "archived" ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={archiveMutation.isPending}
onClick={() => {
if (!window.confirm(`确认归档预算池 ${item.code} ?`)) return;
archiveMutation.mutate(item.id);
}}
>
归档
</Button>
) : (
<span className="text-xs text-slate-400">-</span>
)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-slate-500" colSpan={7}>
暂无预算池
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<Button
variant="secondary"
type="button"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
上一页
</Button>
<Button
variant="secondary"
type="button"
disabled={page >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
>
下一页
</Button>
</div>
</div>
</Card>
</div>
);
}
+309 -58
查看文件
@@ -1,77 +1,328 @@
import { Link } from "react-router-dom";
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { PageHeader } from "../components/common/PageHeader";
import { useMemo, useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { mapApiErrorMessage } from "../api/errorMessages";
import { exportDashboardReimbursements } from "../api/exports";
import {
fetchDashboardApprovalEfficiency,
fetchDashboardOverview,
fetchDashboardTopCategories,
fetchDashboardTrends,
type DashboardQuery,
} from "../api/dashboard";
import { fetchDepartments } from "../api/departments";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Card } from "../components/ui/card";
import { downloadBlob } from "../lib/download";
function currentDateString() {
return new Date().toISOString().slice(0, 10);
}
function monthStartString() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
}
function toCurrency(value: string | number | null | undefined) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return "0.00";
}
return numeric.toFixed(2);
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "加载失败,请稍后重试。";
}
export function DashboardPage() {
const { me, permissions } = useAuth();
const { permissions, me } = useAuth();
const canViewDashboard = hasPermission(permissions, "report.dashboard.view");
const [dateFrom, setDateFrom] = useState(monthStartString());
const [dateTo, setDateTo] = useState(currentDateString());
const [departmentId, setDepartmentId] = useState("");
const queryParams = useMemo<DashboardQuery>(
() => ({
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
departmentId: departmentId || undefined,
}),
[dateFrom, dateTo, departmentId],
);
const departmentsQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["departments", "dashboard-filter"],
queryFn: () => fetchDepartments(),
});
const overviewQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "overview", queryParams],
queryFn: () => fetchDashboardOverview(queryParams),
});
const trendsQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "trends", queryParams],
queryFn: () => fetchDashboardTrends(queryParams),
});
const topCategoriesQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "top-categories", queryParams],
queryFn: () => fetchDashboardTopCategories({ ...queryParams, limit: 8 }),
});
const approvalEfficiencyQuery = useQuery({
enabled: canViewDashboard,
queryKey: ["dashboard", "approval-efficiency", queryParams],
queryFn: () => fetchDashboardApprovalEfficiency(queryParams),
});
const exportMutation = useMutation({
mutationFn: (format: "csv" | "xlsx") =>
exportDashboardReimbursements({
format,
dateFrom: queryParams.dateFrom,
dateTo: queryParams.dateTo,
departmentId: queryParams.departmentId,
}),
onSuccess: (response, format) => {
downloadBlob(response.blob, response.filename ?? `dashboard-reimbursements.${format}`);
},
});
if (!canViewDashboard) {
return (
<div className="space-y-4">
<PageHeader title="仪表盘" subtitle="你当前没有看板权限,请联系管理员开通。" />
<Card className="space-y-2 p-6 text-sm">
<p className="text-slate-700">当前用户:{me?.name ?? "-"}</p>
<p className="text-slate-500">所需权限:report.dashboard.view</p>
</Card>
</div>
);
}
const loading =
overviewQuery.isLoading ||
trendsQuery.isLoading ||
topCategoriesQuery.isLoading ||
approvalEfficiencyQuery.isLoading;
const firstError =
overviewQuery.error ??
trendsQuery.error ??
topCategoriesQuery.error ??
approvalEfficiencyQuery.error ??
departmentsQuery.error ??
exportMutation.error;
const trendPoints = trendsQuery.data?.points ?? [];
const maxTrendAmount = Math.max(
1,
...trendPoints.map((item) => Number(item.amount ?? 0)).filter((value) => Number.isFinite(value)),
);
const topCategories = topCategoriesQuery.data?.items ?? [];
const maxCategoryAmount = Math.max(
1,
...topCategories.map((item) => Number(item.amount ?? 0)).filter((value) => Number.isFinite(value)),
);
return (
<div>
<div className="space-y-6">
<PageHeader
title="仪表盘"
subtitle="前端壳已完成初始化,后续业务模块将在此基础上扩展。"
subtitle="费用概览、趋势、分类与审批效率。支持筛选与导出。"
/>
<div className="grid gap-4 lg:grid-cols-2">
<section className="card-std">
<h3 className="card-title">当前用户</h3>
<dl className="space-y-2 text-sm text-slate-700">
<div className="flex justify-between gap-3">
<dt className="text-secondary">姓名</dt>
<dd className="font-medium text-slate-900">{me?.name ?? "-"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-secondary">员工号</dt>
<dd className="font-medium text-slate-900">{me?.employeeNo ?? "-"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-secondary">邮箱</dt>
<dd className="font-medium text-slate-900">{me?.email ?? "-"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-secondary">角色</dt>
<dd className="font-medium text-slate-900">{me?.roleCodes?.join(" / ") ?? "-"}</dd>
</div>
</dl>
</section>
{firstError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(firstError)}
</p>
) : null}
<section className="card-std">
<h3 className="card-title">权限摘要</h3>
{permissions.length ? (
<div className="flex flex-wrap gap-2">
{permissions.map((permission) => (
<span
className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary"
key={permission}
>
{permission}
</span>
<Card className="space-y-4 p-6">
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="dashboard-date-from">
开始日期
</label>
<input
id="dashboard-date-from"
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
type="date"
value={dateFrom}
onChange={(event) => setDateFrom(event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="dashboard-date-to">
结束日期
</label>
<input
id="dashboard-date-to"
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
type="date"
value={dateTo}
onChange={(event) => setDateTo(event.target.value)}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="dashboard-department-id">
部门
</label>
<select
id="dashboard-department-id"
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
value={departmentId}
onChange={(event) => setDepartmentId(event.target.value)}
>
<option value="">全部部门</option>
{(departmentsQuery.data?.items ?? []).map((department) => (
<option key={department.id} value={department.id}>
{department.name}
</option>
))}
</select>
</div>
<div className="flex items-end gap-2">
<Button
variant="secondary"
type="button"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate("csv")}
>
导出 CSV
</Button>
<Button
variant="secondary"
type="button"
disabled={exportMutation.isPending}
onClick={() => exportMutation.mutate("xlsx")}
>
导出 Excel
</Button>
</div>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-slate-500">报销单总数</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">
{loading ? "..." : overviewQuery.data?.totalCount ?? 0}
</p>
</Card>
<Card className="p-4">
<p className="text-xs text-slate-500">报销总金额</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">
{loading ? "..." : toCurrency(overviewQuery.data?.totalAmount)}
</p>
</Card>
<Card className="p-4">
<p className="text-xs text-slate-500">已付款金额</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">
{loading ? "..." : toCurrency(overviewQuery.data?.paidAmount)}
</p>
</Card>
<Card className="p-4">
<p className="text-xs text-slate-500">审批中数量</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">
{loading ? "..." : overviewQuery.data?.reviewingCount ?? 0}
</p>
</Card>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">费用趋势</h3>
{trendPoints.length ? (
<div className="space-y-2">
{trendPoints.map((item) => {
const amount = Number(item.amount ?? 0);
const width = `${Math.max(5, Math.round((amount / maxTrendAmount) * 100))}%`;
return (
<div key={item.date} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>{item.date}</span>
<span>
{toCurrency(item.amount)} / {item.count} 笔
</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div className="h-2 rounded-full bg-primary" style={{ width }} />
</div>
</div>
);
})}
</div>
) : (
<p className="card-desc">暂无权限信息。</p>
<p className="text-sm text-slate-500">暂无趋势数据</p>
)}
</section>
</Card>
<Card className="space-y-3 p-6">
<h3 className="text-base font-semibold text-slate-900">费用分类 TopN</h3>
{topCategories.length ? (
<div className="space-y-2">
{topCategories.map((item) => {
const amount = Number(item.amount ?? 0);
const width = `${Math.max(5, Math.round((amount / maxCategoryAmount) * 100))}%`;
return (
<div key={item.code} className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-600">
<span>
{item.name} ({item.code})
</span>
<span>
{toCurrency(item.amount)} / {item.count} 笔
</span>
</div>
<div className="h-2 rounded-full bg-slate-100">
<div className="h-2 rounded-full bg-emerald-500" style={{ width }} />
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-slate-500">暂无分类统计</p>
)}
</Card>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-3">
<EmptyStateCard
title="我的报销"
description="下一阶段接入报销列表与详情。"
footer={<Link className="text-sm font-medium text-primary" to="/reimbursements">进入页面</Link>}
/>
<EmptyStateCard
title="待我处理"
description="下一阶段接入 workflow 待办列表与动作。"
footer={<Link className="text-sm font-medium text-primary" to="/workflow/tasks">进入页面</Link>}
/>
<EmptyStateCard
title="票据中心"
description="下一阶段接入上传、签名 URL 与绑定流程。"
footer={<Link className="text-sm font-medium text-primary" to="/invoice-pool">进入页面</Link>}
/>
</div>
<Card className="space-y-2 p-6 text-sm">
<h3 className="text-base font-semibold text-slate-900">审批效率</h3>
<p className="text-slate-700">
已统计通过单据:
<span className="ml-1 font-medium text-slate-900">
{approvalEfficiencyQuery.data?.approvedCount ?? 0}
</span>
</p>
<p className="text-slate-700">
平均审批耗时(小时):
<span className="ml-1 font-medium text-slate-900">
{approvalEfficiencyQuery.data?.averageHours?.toFixed(2) ?? "0.00"}
</span>
</p>
</Card>
</div>
);
}
+116
查看文件
@@ -7,12 +7,15 @@ import { exportInvoicePool } from "../api/exports";
import {
archiveInvoice,
bindInvoice,
fetchInvoiceVerifications,
fetchInvoicePoolDetail,
fetchInvoicePoolList,
fetchInvoiceSignedUrl,
overrideInvoiceDuplicate,
patchInvoice,
retryInvoiceVerification,
retryInvoiceOcr,
triggerInvoiceVerification,
unbindInvoice,
uploadInvoice,
type InvoicePoolStatus,
@@ -35,6 +38,7 @@ export function InvoicePoolPage() {
const canManageInvoice = hasPermission(permissions, "invoice.manage");
const canOverrideDuplicate = hasPermission(permissions, "expense.finance.override_duplicate");
const canUploadInvoice = hasPermission(permissions, "invoice.upload");
const canVerifyInvoice = hasPermission(permissions, "invoice.verify");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
@@ -90,6 +94,17 @@ export function InvoicePoolPage() {
},
});
const verificationQuery = useQuery({
enabled: Boolean(detailInvoiceId) && canVerifyInvoice,
queryKey: ["invoice-pool", "verification", detailInvoiceId],
queryFn: () => fetchInvoiceVerifications(detailInvoiceId, 20),
refetchInterval: (query) => {
const items = query.state.data?.items ?? [];
const hasPending = items.some((item) => item.status === "pending");
return hasPending ? 3_000 : false;
},
});
useEffect(() => {
if (!detailQuery.data || editOpen) {
return;
@@ -251,6 +266,42 @@ export function InvoicePoolPage() {
},
});
const triggerVerificationMutation = useMutation({
mutationFn: async (invoiceId: string) => triggerInvoiceVerification(invoiceId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]);
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("触发验真失败,请稍后重试");
}
},
});
const retryVerificationMutation = useMutation({
mutationFn: async (invoiceId: string) => retryInvoiceVerification(invoiceId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }),
queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }),
]);
setError("");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("重试验真失败,请稍后重试");
}
},
});
const overrideDuplicateMutation = useMutation({
mutationFn: async (params: { invoiceId: string; refFormNumber: string; reason: string }) =>
overrideInvoiceDuplicate(params.invoiceId, {
@@ -557,8 +608,48 @@ export function InvoicePoolPage() {
判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "}
{detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""}
</div>
<div>
验真状态:
{detail.verification
? `${detail.verification.status} (${detail.verification.provider})`
: "未触发"}
</div>
{detail.verification?.resultMessage ? (
<div>验真结果:{detail.verification.resultMessage}</div>
) : null}
{detail.verification?.errorMessage ? (
<div className="text-red-600">验真错误:{detail.verification.errorMessage}</div>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
{canVerifyInvoice ? (
<Button
data-testid="invoice-trigger-verification"
size="sm"
variant="secondary"
type="button"
disabled={triggerVerificationMutation.isPending || detail.status === "pending_ocr"}
onClick={() => triggerVerificationMutation.mutate(detail.id)}
>
{triggerVerificationMutation.isPending ? "触发中..." : "触发验真"}
</Button>
) : null}
{canVerifyInvoice &&
detail.verification &&
["failed", "error"].includes(detail.verification.status) ? (
<Button
data-testid="invoice-retry-verification"
size="sm"
variant="secondary"
type="button"
disabled={retryVerificationMutation.isPending}
onClick={() => retryVerificationMutation.mutate(detail.id)}
>
{retryVerificationMutation.isPending ? "重试中..." : "重试验真"}
</Button>
) : null}
{canEditDetail ? (
<Button
data-testid="invoice-edit-open"
@@ -647,6 +738,31 @@ export function InvoicePoolPage() {
) : null}
</div>
{canVerifyInvoice ? (
<div className="mt-3 rounded-lg border border-slate-200 p-3" data-testid="invoice-verification-records">
<p className="mb-2 text-sm font-medium text-slate-800">验真记录</p>
{(verificationQuery.data?.items ?? []).length ? (
<div className="space-y-2 text-xs">
{(verificationQuery.data?.items ?? []).map((record) => (
<div key={record.id} className="rounded-md border border-slate-200 px-2 py-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono">{record.id}</span>
<span>{record.status}</span>
</div>
<div className="text-slate-600">
provider={record.provider} attempts={record.attempts} requestedAt={record.requestedAt || "-"}
</div>
{record.resultMessage ? <div className="text-slate-700">{record.resultMessage}</div> : null}
{record.errorMessage ? <div className="text-red-600">{record.errorMessage}</div> : null}
</div>
))}
</div>
) : (
<p className="text-xs text-slate-500">暂无验真记录</p>
)}
</div>
) : null}
{editOpen ? (
<div className="mt-4 space-y-3 rounded-lg border border-slate-200 p-4">
<p className="text-sm font-medium text-slate-800">人工修正(仅必要字段)</p>
@@ -211,6 +211,7 @@ export function ReimbursementDetailPage() {
}
const detail = detailQuery.data;
const budgetHint = detail.budgetHint;
const canPay = hasPermission(permissions, "expense.finance.pay");
const canEditItems = ["draft", "rejected"].includes(detail.status);
@@ -262,6 +263,29 @@ export function ReimbursementDetailPage() {
<div>说明:{detail.description || "-"}</div>
</Card>
{budgetHint ? (
<Card className="space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
<h3 className="text-base font-semibold text-slate-900">预算提示</h3>
<div>预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
<div>
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
({budgetHint.nextUsagePercent.toFixed(2)}%)
</div>
<div>控制模式:{budgetHint.controlMode}</div>
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
<p className="rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
当前金额将超出预算上限,提交时会被阻断(block 模式)。
</p>
) : budgetHint.exceedsThreshold ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
</p>
) : (
<p className="text-slate-500">当前预算充足,可继续提交。</p>
)}
</Card>
) : null}
<Card className="space-y-4 p-6" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">