统一业务台账首屏设计(05-30-unified-page-design-review)

这个提交包含在:
2026-05-30 19:54:22 +08:00
父节点 8423ca63b5
当前提交 d5ef8189f1
共修改 15 个文件,包含 121 行新增和 446 行删除
+42 -93
查看文件
@@ -26,7 +26,6 @@ import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -250,9 +249,6 @@ export function ContractsRegistryPage() {
? "保留现有责任人"
: "默认当前用户";
const amountInvalid = Boolean(editor.amount.trim()) && !AMOUNT_PATTERN.test(editor.amount.trim());
const activeContractCount = visibleContracts.filter((item) => item.status === "active").length;
const draftContractCount = visibleContracts.filter((item) => item.status === "draft").length;
const archivedContractCount = visibleContracts.filter((item) => item.status === "archived").length;
const expiringContractCount = expiringQuery.data?.total ?? expiringItems.length;
const refreshAll = async () => {
@@ -323,21 +319,14 @@ export function ContractsRegistryPage() {
return (
<PageScaffold>
<PageHeader
eyebrow="合同"
title="合同台账"
subtitle="维护合同元数据、生命周期、到期预警与导出,不在 OA 存放涉密正文附件。"
meta={
<>
<span className="info-chip">可见 {visibleContracts.length} 份</span>
<span className="info-chip">30 天到期 {expiringContractCount} 份</span>
<span className="info-chip">私密正文不入库</span>
</>
}
actions={
<div className="flex flex-wrap gap-2">
{initialProjectId ? (
<Button asChild variant="secondary" type="button">
<Link to="/projects">返回项目</Link>
</Button>
) : null}
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
@@ -345,82 +334,21 @@ export function ContractsRegistryPage() {
}
/>
<section className="ops-status-strip grid gap-4 md:grid-cols-2 xl:grid-cols-4" data-testid="contracts-registry-summary">
<MetricCard title="可见合同" value={visibleContracts.length} note={filters.projectId ? "已按项目过滤" : "当前筛选结果"} />
<MetricCard title="生效中" value={activeContractCount} note="需要关注到期和责任人" />
<MetricCard title="草稿" value={draftContractCount} note="补齐摘要、金额和归档位置" tone={draftContractCount > 0 ? "warning" : "neutral"} />
<MetricCard title="已归档" value={archivedContractCount} note="只保留台账和线下档案索引" />
</section>
<div className="detail-command-panel space-y-4" data-testid="contracts-registry-command">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm text-zinc-500">生命周期焦点</div>
<div className="mt-1 text-3xl font-semibold text-zinc-900">{expiringQuery.data?.total ?? 0}</div>
<div className="text-sm text-zinc-600">
份合同将在 {EXPIRING_DAYS} 天内到期;续约会生成草稿合同,导出仅用于授权复核。
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={expiringQuery.isLoading}
onClick={() => setShowExpiring((value) => !value)}
>
{showExpiring ? "收起到期列表" : "展开到期列表"}
</Button>
</div>
</div>
{expiringQuery.isError ? (
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
) : null}
{showExpiring ? (
<div className="space-y-3 border-t border-zinc-200 pt-4">
{expiringItems.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 rounded-lg border border-amber-200 bg-amber-50/70 p-4 lg:flex-row lg:items-center lg:justify-between"
>
<div>
<div className="font-medium text-zinc-900">
{item.contractNo} · {item.counterpartyName}
</div>
<div className="mt-1 text-sm text-zinc-600">
{contractTypeLabel(item.contractType)} · 到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => openEditor(item)}
>
编辑
</Button>
</div>
</div>
))}
{!expiringQuery.isLoading && !expiringItems.length ? (
<div className="text-sm text-zinc-500">未来 {EXPIRING_DAYS} 天内暂无即将到期合同。</div>
) : null}
</div>
) : null}
</div>
{showLedgerTools ? (
<div className="detail-command-panel space-y-4" data-testid="contracts-ledger-tools">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">台账工具</h3>
<p className="mt-1 text-sm leading-6 text-zinc-500">
新建/编辑、导出和高级到期范围放在这里。导出文件可能包含合同金额、相对方、责任人和归档摘要。
</p>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
variant="secondary"
disabled={expiringQuery.isLoading}
onClick={() => setShowExpiring((value) => !value)}
>
到期列表 {expiringContractCount}
</Button>
<Button type="button" variant="secondary" disabled={exportMutation.isPending} onClick={() => setConfirmExportFormat("csv")}>
{exportMutation.isPending ? "导出中..." : "导出 CSV"}
</Button>
@@ -446,10 +374,31 @@ export function ContractsRegistryPage() {
/>
</div>
</div>
{expiringQuery.isError ? (
<div className="text-sm text-red-600">{resolveError(expiringQuery.error, "加载到期预警失败")}</div>
) : null}
{showExpiring ? (
<div className="space-y-2 border-t border-zinc-200 pt-3">
{expiringItems.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{item.contractNo} · {item.counterpartyName}</div>
<div className="text-xs text-zinc-500">到期日 {formatDisplayDate(item.expiryDate)} · 责任人 {item.owner?.name ?? "-"}</div>
</div>
<Button type="button" size="sm" variant="secondary" onClick={() => openEditor(item)}>
编辑
</Button>
</div>
))}
{!expiringQuery.isLoading && !expiringItems.length ? (
<div className="text-sm text-zinc-500">暂无到期合同</div>
) : null}
</div>
) : null}
</div>
) : null}
<div className={showLedgerTools || editorId ? "grid gap-6 xl:grid-cols-[400px,minmax(0,1fr)]" : "grid gap-6"}>
<div className="grid gap-6">
{showLedgerTools || editorId ? (
<div className="card-std space-y-4 p-6" data-testid="contracts-editor-panel">
<div className="flex items-start justify-between gap-3">
@@ -735,11 +684,11 @@ export function ContractsRegistryPage() {
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label>项目</Label>
<div className="grid gap-3 xl:grid-cols-[minmax(180px,1fr)_160px_160px_minmax(220px,1fr)]">
<div>
<select
className="select-std"
aria-label="项目"
value={filters.projectId}
onChange={(event) => setFilters((current) => ({ ...current, projectId: event.target.value }))}
>
@@ -752,10 +701,10 @@ export function ContractsRegistryPage() {
</select>
</div>
<div className="space-y-2">
<Label>状态</Label>
<div>
<select
className="select-std"
aria-label="状态"
value={filters.status}
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value as FiltersState["status"] }))}
>
@@ -768,10 +717,10 @@ export function ContractsRegistryPage() {
</select>
</div>
<div className="space-y-2">
<Label>合同类型</Label>
<div>
<select
className="select-std"
aria-label="合同类型"
value={filters.contractType}
onChange={(event) => setFilters((current) => ({ ...current, contractType: event.target.value as FiltersState["contractType"] }))}
>
@@ -784,12 +733,12 @@ export function ContractsRegistryPage() {
</select>
</div>
<div className="space-y-2">
<Label>相对方模糊筛选</Label>
<div>
<Input
aria-label="相对方"
value={filters.counterpartyName}
onChange={(event) => setFilters((current) => ({ ...current, counterpartyName: event.target.value }))}
placeholder="输入相对方名称关键字"
placeholder="搜索相对方"
/>
</div>