DG9 统一全站 UI 布局样板迁移

关联 Issue: DG9-0..DG9-12
这个提交包含在:
2026-05-14 00:52:59 +08:00
父节点 3ac5c785b1
当前提交 4b14c925b6
共修改 30 个文件,包含 743 行新增和 437 行删除
+3 -3
查看文件
@@ -13,7 +13,7 @@ Read only what is relevant:
- Always read `AGENTS.md` and the newest relevant `issues/*.csv`. - Always read `AGENTS.md` and the newest relevant `issues/*.csv`.
- After R5, first check the main R5/R6 contract status before planning trial-run or follow-up work; R5 must remain the completed gate and R6 must have its own Issue CSV before expanding scope. - After R5, first check the main R5/R6 contract status before planning trial-run or follow-up work; R5 must remain the completed gate and R6 must have its own Issue CSV before expanding scope.
- For UI/page work, PC/mobile/WeCom experience, approval UX, or domestic OA benchmarking, read `docs/产品设计规则.md` and `references/visual-qa.md`. DG8 makes Feishu workbench/approval the single main UI/layout reference, with Hyinda branding and project rules taking priority. Other products are only function/process/control references. Mobile page work must treat `docs/产品设计规则.md` as the source of truth for the unified mobile shell, icon grid, card lists, deep Push pages, sticky actions, safe-area, and cross-viewport matrix. 移动页必须先对齐该设计事实源,再进入实现或验证。 - For UI/page work, PC/mobile/WeCom experience, approval UX, or domestic OA benchmarking, read `docs/产品设计规则.md` and `references/visual-qa.md`. DG8 makes Feishu workbench/approval the single main UI/layout reference, with Hyinda branding and project rules taking priority. DG9 adds the A/B/C page layering and shared workbench/mobile samples; choose the DG9 sample before designing or coding a page. Other products are only function/process/control references. Mobile page work must treat `docs/产品设计规则.md` as the source of truth for the unified mobile shell, icon grid, card lists, deep Push pages, sticky actions, safe-area, and cross-viewport matrix. 移动页必须先对齐该设计事实源,再进入实现或验证。
- For approval workflow, roles, self-approval, notification, deeplink, reminder, or escalation work, read `docs/需求-统一审批中心规约.md`. - For approval workflow, roles, self-approval, notification, deeplink, reminder, or escalation work, read `docs/需求-统一审批中心规约.md`.
- For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`. - For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`.
- For local/WeCom/public trial-run, OCR runtime, Caddy, or DB drift work, read `docs/试运行与自动化验证手册.md` and `references/trialrun.md`. - For local/WeCom/public trial-run, OCR runtime, Caddy, or DB drift work, read `docs/试运行与自动化验证手册.md` and `references/trialrun.md`.
@@ -37,10 +37,10 @@ Default behavior:
2. Identify or create the current Issue CSV before non-trivial work; after R5, distinguish R6 preparation from actual trial-run execution. 2. Identify or create the current Issue CSV before non-trivial work; after R5, distinguish R6 preparation from actual trial-run execution.
3. State the implementation approach before editing code; if already approved, proceed. 3. State the implementation approach before editing code; if already approved, proceed.
4. Keep changes scoped to the current Issue. 4. Keep changes scoped to the current Issue.
5. Prefer existing project patterns, official SDKs, and the main UI/layout hierarchy in `docs/产品设计规则.md`; do not mix multiple competitor visual systems or copy competitor flows/text directly. 5. Prefer existing project patterns, official SDKs, and the main UI/layout hierarchy in `docs/产品设计规则.md`; for page work, classify the page into DG9 A/B/C layering and reuse the matching workbench/mobile sample before adding local layout code. Do not mix multiple competitor visual systems or copy competitor flows/text directly.
6. Verify with the narrowest relevant automated tests, then expand by risk. 6. Verify with the narrowest relevant automated tests, then expand by risk.
7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes. New or changed mobile pages must join the unified responsive system and the 320/360/375/390/393/414/430px 统一视觉矩阵 rather than adding page-specific layout fixes. 7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes. New or changed mobile pages must join the unified responsive system and the 320/360/375/390/393/414/430px 统一视觉矩阵 rather than adding page-specific layout fixes.
8. Dedicated `/mobile` pages must use the shared mobile slot/card/list/sticky-action system from `docs/产品设计规则.md`: fixed icon/avatar/status slots, `minmax(0, 1fr)` copy slots, `mobile-card`/mobile list cards, mobile tokens, and no desktop `card-std p-4` as a mobile main container. 8. Dedicated `/mobile` pages must use the shared mobile slot/card/list/sticky-action system from `docs/产品设计规则.md`: fixed icon/avatar/status slots, `minmax(0, 1fr)` copy slots, `mobile-card`/mobile list cards, `mobile-field-row`, mobile tokens, and no desktop `card-std p-4/p-5/p-6` as a mobile main container.
9. Mobile homepage, approval, notification, and business cards must follow the display dedupe rules in `docs/产品设计规则.md`: one pending/progress summary per page, notifications as event stream with direct target links, bottom badges as counts only, and one visible business identifier/status per card or row. 9. Mobile homepage, approval, notification, and business cards must follow the display dedupe rules in `docs/产品设计规则.md`: one pending/progress summary per page, notifications as event stream with direct target links, bottom badges as counts only, and one visible business identifier/status per card or row.
10. Update Issue CSV statuses only after the stated `Test_Method` passes. 10. Update Issue CSV statuses only after the stated `Test_Method` passes.
11. Commit in Chinese batches with Issue IDs. 11. Commit in Chinese batches with Issue IDs.
@@ -1,22 +1,104 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export function ListWorkspace({ className, children }: { className?: string; children: ReactNode }) { type WorkspaceChildrenProps = {
className?: string;
children: ReactNode;
};
type WorkspaceHeaderProps = {
eyebrow?: string;
title: string;
subtitle?: ReactNode;
meta?: ReactNode;
actions?: ReactNode;
className?: string;
};
export function ListWorkspace({ className, children }: WorkspaceChildrenProps) {
return <div className={cn("page-scaffold", className)}>{children}</div>; return <div className={cn("page-scaffold", className)}>{children}</div>;
} }
export function FilterBar({ className, children }: { className?: string; children: ReactNode }) { export function WorkspaceHeader({ eyebrow, title, subtitle, meta, actions, className }: WorkspaceHeaderProps) {
return (
<header className={cn("flex flex-col gap-4 md:flex-row md:items-end md:justify-between", className)}>
<div className="min-w-0 max-w-3xl">
{eyebrow ? <div className="text-xs font-bold uppercase text-primary">{eyebrow}</div> : null}
<h1 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)] md:text-3xl">{title}</h1>
{subtitle ? <div className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</div> : null}
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</header>
);
}
export function FilterBar({ className, children }: WorkspaceChildrenProps) {
return <section className={cn("card-std p-4 md:p-5", className)}>{children}</section>; return <section className={cn("card-std p-4 md:p-5", className)}>{children}</section>;
} }
export function StatStrip({ className, children }: { className?: string; children: ReactNode }) { export function FilterGrid({ className, children }: WorkspaceChildrenProps) {
return <section className={cn("grid gap-4 md:grid-cols-3", className)}>{children}</section>; return <div className={cn("grid gap-3 md:grid-cols-2 xl:grid-cols-4", className)}>{children}</div>;
} }
export function ActionToolbar({ className, children }: { className?: string; children: ReactNode }) { export function StatStrip({
className,
columns = 3,
children,
}: WorkspaceChildrenProps & { columns?: 2 | 3 | 4 }) {
const columnClass = {
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-2 xl:grid-cols-4",
}[columns];
return <section className={cn("grid gap-4", columnClass, className)}>{children}</section>;
}
export function WorkspaceSection({
title,
description,
actions,
className,
children,
}: WorkspaceChildrenProps & {
title?: string;
description?: ReactNode;
actions?: ReactNode;
}) {
return (
<section className={cn("card-std space-y-4 p-4 md:p-5", className)}>
{title || description || actions ? (
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
{title ? <h2 className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h2> : null}
{description ? <div className="mt-1 text-sm leading-6 text-[var(--color-text-secondary)]">{description}</div> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</div>
) : null}
{children}
</section>
);
}
export function ActionToolbar({ className, children }: WorkspaceChildrenProps) {
return <div className={cn("flex flex-wrap items-center justify-between gap-3", className)}>{children}</div>; return <div className={cn("flex flex-wrap items-center justify-between gap-3", className)}>{children}</div>;
} }
export function PrimaryTable({ className, children }: { className?: string; children: ReactNode }) { export function PrimaryTable({ className, children }: WorkspaceChildrenProps) {
return <section className={cn("table-container p-0", className)}>{children}</section>; return <section className={cn("table-container p-0", className)}>{children}</section>;
} }
export function WorkspaceEmptyState({
className,
children = "暂无数据",
}: {
className?: string;
children?: ReactNode;
}) {
return (
<div className={cn("rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500", className)}>
{children}
</div>
);
}
+6 -6
查看文件
@@ -41,7 +41,7 @@ import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { import {
Dialog, Dialog,
@@ -605,12 +605,12 @@ export function ApOperationsPage() {
}; };
if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading && financeLedgerQuery.isLoading) { if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading && financeLedgerQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载财务工作台...</div>; return <div className="card-std p-4 text-sm text-zinc-500 md:p-5">加载财务工作台...</div>;
} }
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="财务付款与对账" title="财务付款与对账"
subtitle="集中处理待付款批次、异常付款、风险标记和对账异常。" subtitle="集中处理待付款批次、异常付款、风险标记和对账异常。"
actions={ actions={
@@ -671,7 +671,7 @@ export function ApOperationsPage() {
/> />
</div> </div>
<div className="card-std p-6"> <div className="card-std p-4 md:p-5">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1"> <TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger> <TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger>
@@ -1630,6 +1630,6 @@ export function ApOperationsPage() {
await executeMutation.mutateAsync(retryTarget.batchId); await executeMutation.mutateAsync(retryTarget.batchId);
}} }}
/> />
</div> </ListWorkspace>
); );
} }
+8 -9
查看文件
@@ -10,7 +10,7 @@ import {
type ApprovalRoleCode, type ApprovalRoleCode,
type ApprovalRule, type ApprovalRule,
} from "../api/approvalRules"; } from "../api/approvalRules";
import { PageHeader } from "../components/common/PageHeader"; import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
@@ -144,8 +144,8 @@ export function ApprovalRulesPage() {
}, [editOpen, editRule]); }, [editOpen, editRule]);
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="审批规则" title="审批规则"
subtitle="维护金额段与审批层级(仅影响新提交的报销单,历史流程不回写)。" subtitle="维护金额段与审批层级(仅影响新提交的报销单,历史流程不回写)。"
actions={ actions={
@@ -161,7 +161,7 @@ export function ApprovalRulesPage() {
</p> </p>
) : null} ) : null}
<div className="card-std p-6 space-y-4"> <div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">新建审批规则</h3> <h3 className="text-base font-semibold text-zinc-900">新建审批规则</h3>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
@@ -286,7 +286,7 @@ export function ApprovalRulesPage() {
</div> </div>
</div> </div>
<div className="card-std p-6 space-y-4"> <div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">规则列表</h3> <h3 className="text-base font-semibold text-zinc-900">规则列表</h3>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null} {listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
@@ -304,7 +304,7 @@ export function ApprovalRulesPage() {
) : null} ) : null}
{!listQuery.isLoading && !listQuery.isError ? ( {!listQuery.isLoading && !listQuery.isError ? (
<div className="rounded-lg border border-zinc-200"> <PrimaryTable>
<Table data-testid="approval-rules-table"> <Table data-testid="approval-rules-table">
<TableHeader> <TableHeader>
<tr> <tr>
@@ -371,7 +371,7 @@ export function ApprovalRulesPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </PrimaryTable>
) : null} ) : null}
</div> </div>
@@ -516,7 +516,6 @@ export function ApprovalRulesPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </ListWorkspace>
); );
} }
+12 -12
查看文件
@@ -27,10 +27,10 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions"; import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
@@ -578,8 +578,8 @@ export function AssetRegistryPage() {
}; };
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="资产台账" title="资产台账"
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。" subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
actions={ actions={
@@ -597,7 +597,7 @@ export function AssetRegistryPage() {
/> />
<div className="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]"> <div className="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
<div className="card-std space-y-4 p-6"> <div className="card-std space-y-4 p-4 md:p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3> <h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? ( {canManage ? (
@@ -669,7 +669,7 @@ export function AssetRegistryPage() {
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-500">暂无资产分类</div> <div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-500">暂无资产分类</div>
)} )}
</div> </div>
<div className="hidden rounded-lg border border-zinc-200 md:block"> <PrimaryTable className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<tr> <tr>
@@ -691,13 +691,13 @@ export function AssetRegistryPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</div> </PrimaryTable>
</> </>
) : null} ) : null}
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="card-std space-y-4 p-6"> <FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6"> <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }> <select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }>
{assetStatusOptions().map((item) => ( {assetStatusOptions().map((item) => (
@@ -796,7 +796,7 @@ export function AssetRegistryPage() {
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div> <div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无资产卡片</div>
)} )}
</div> </div>
<div className="hidden rounded-lg border border-zinc-200 md:block"> <PrimaryTable className="hidden md:block">
<Table> <Table>
<TableHeader> <TableHeader>
<tr> <tr>
@@ -830,12 +830,12 @@ export function AssetRegistryPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </PrimaryTable>
</> </>
) : null} ) : null}
</div> </FilterBar>
<div className="card-std space-y-4 p-6"> <div className="card-std space-y-4 p-4 md:p-5">
{!selectedAssetId && !isCreatingAsset ? ( {!selectedAssetId && !isCreatingAsset ? (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500"> <div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
请先从列表选择资产卡片,或点击“新建资产卡片”。 请先从列表选择资产卡片,或点击“新建资产卡片”。
@@ -1128,6 +1128,6 @@ export function AssetRegistryPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </ListWorkspace>
); );
} }
+4 -4
查看文件
@@ -28,7 +28,7 @@ import { hasPermission } from "../auth/permissions";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadBlob } from "../lib/download"; import { downloadBlob } from "../lib/download";
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr"; import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
import { PageHeader } from "../components/common/PageHeader"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard"; import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
@@ -446,8 +446,8 @@ export function InvoicePoolPage() {
// --- Render --- // --- Render ---
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="票据中心" title="票据中心"
subtitle="上传、识别、绑定。" subtitle="上传、识别、绑定。"
actions={ actions={
@@ -569,6 +569,6 @@ export function InvoicePoolPage() {
confirmAction={confirmAction} confirmAction={confirmAction}
setConfirmAction={setConfirmAction} setConfirmAction={setConfirmAction}
/> />
</div> </ListWorkspace>
); );
} }
+4 -5
查看文件
@@ -20,11 +20,10 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton"; import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination"; import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
import { ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace"; import { ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard"; import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
@@ -780,12 +779,12 @@ export function LeaveRequestPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="请假申请" title="请假申请"
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。" subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
/> />
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <StatStrip columns={4}>
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" /> <MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} /> <MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" /> <MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
@@ -795,7 +794,7 @@ export function LeaveRequestPage() {
note="创建草稿前可先预览天数" note="创建草稿前可先预览天数"
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"} tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
/> />
</section> </StatStrip>
<div className="page-shell-grid"> <div className="page-shell-grid">
<SectionPanel title={formTitle} description={formDescription}> <SectionPanel title={formTitle} description={formDescription}>
+24 -20
查看文件
@@ -22,9 +22,9 @@ import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab"; import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab";
import { SupplierTab } from "../components/master-data/SupplierTab"; import { SupplierTab } from "../components/master-data/SupplierTab";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
@@ -292,16 +292,16 @@ export function MasterDataPage() {
if (!canView) { if (!canView) {
return ( return (
<div className="space-y-4"> <ListWorkspace>
<PageHeader title="主数据" subtitle="你当前没有主数据权限,请联系管理员开通。" /> <WorkspaceHeader title="主数据" subtitle="你当前没有主数据权限,请联系管理员开通。" />
<div className="card-std p-6 text-sm text-zinc-600">所需权限:masterdata.view 或 masterdata.manage</div> <div className="card-std p-4 text-sm text-zinc-600 md:p-5">所需权限:masterdata.view 或 masterdata.manage</div>
</div> </ListWorkspace>
); );
} }
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="主数据" title="主数据"
subtitle="集中维护法体、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一骨架。" subtitle="集中维护法体、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一骨架。"
actions={ actions={
@@ -325,7 +325,7 @@ export function MasterDataPage() {
</TabsList> </TabsList>
<TabsContent value="legal-entities" className="space-y-6"> <TabsContent value="legal-entities" className="space-y-6">
<div className="card-std p-6 space-y-4"> <FilterBar className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3> <h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
<select <select
@@ -338,10 +338,10 @@ export function MasterDataPage() {
<option value="false">仅停用</option> <option value="false">仅停用</option>
</select> </select>
</div> </div>
</div> </FilterBar>
{canManage ? ( {canManage ? (
<div className="card-std p-6 space-y-4"> <div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">新建法体</h3> <h3 className="text-base font-semibold text-zinc-900">新建法体</h3>
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1"> <div className="space-y-1">
@@ -377,10 +377,11 @@ export function MasterDataPage() {
</div> </div>
) : null} ) : null}
<div className="card-std p-6 space-y-3"> <div className="card-std space-y-3 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">法体列表</h3> <h3 className="text-base font-semibold text-zinc-900">法体列表</h3>
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null} {legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
{!legalEntitiesQuery.isLoading ? ( {!legalEntitiesQuery.isLoading ? (
<PrimaryTable>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -431,12 +432,13 @@ export function MasterDataPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</PrimaryTable>
) : null} ) : null}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="cost-centers" className="space-y-6"> <TabsContent value="cost-centers" className="space-y-6">
<div className="card-std p-6 space-y-4"> <FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<select <select
className="select-std" className="select-std"
@@ -462,10 +464,10 @@ export function MasterDataPage() {
))} ))}
</select> </select>
</div> </div>
</div> </FilterBar>
{canManage ? ( {canManage ? (
<div className="card-std p-6 space-y-4"> <div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">新建成本中心</h3> <h3 className="text-base font-semibold text-zinc-900">新建成本中心</h3>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1 md:col-span-2"> <div className="space-y-1 md:col-span-2">
@@ -532,10 +534,11 @@ export function MasterDataPage() {
</div> </div>
) : null} ) : null}
<div className="card-std p-6 space-y-3"> <div className="card-std space-y-3 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">成本中心列表</h3> <h3 className="text-base font-semibold text-zinc-900">成本中心列表</h3>
{costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null} {costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{!costCentersQuery.isLoading ? ( {!costCentersQuery.isLoading ? (
<PrimaryTable>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -588,12 +591,13 @@ export function MasterDataPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</PrimaryTable>
) : null} ) : null}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="document-categories" className="space-y-6"> <TabsContent value="document-categories" className="space-y-6">
<div className="card-std p-6 space-y-4"> <FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<select <select
className="select-std" className="select-std"
@@ -619,10 +623,10 @@ export function MasterDataPage() {
))} ))}
</select> </select>
</div> </div>
</div> </FilterBar>
{canManage ? ( {canManage ? (
<div className="card-std p-6 space-y-4"> <div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">新建单据分类</h3> <h3 className="text-base font-semibold text-zinc-900">新建单据分类</h3>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -693,7 +697,7 @@ export function MasterDataPage() {
</div> </div>
) : null} ) : null}
<div className="card-std p-6 space-y-3"> <div className="card-std space-y-3 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">单据分类列表</h3> <h3 className="text-base font-semibold text-zinc-900">单据分类列表</h3>
{documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null} {documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{!documentCategoriesQuery.isLoading ? ( {!documentCategoriesQuery.isLoading ? (
@@ -790,6 +794,6 @@ export function MasterDataPage() {
} }
}} }}
/> />
</div> </ListWorkspace>
); );
} }
+70 -48
查看文件
@@ -999,11 +999,11 @@ export function MobileApprovalsPage() {
) : null} ) : null}
{!isPushDetail && !canAccessRemoteApprovals ? ( {!isPushDetail && !canAccessRemoteApprovals ? (
<section className="mobile-card space-y-3" data-testid="mobile-approvals-progress"> <section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-progress">
<div className="flex items-center justify-between gap-3"> <div className="mobile-section-header">
<div> <div className="min-w-0">
<h3 className="text-base font-semibold text-zinc-900">我发起的进度</h3> <h3 className="mobile-section-title">我发起的进度</h3>
<p className="mt-1 text-sm text-zinc-500">报销、请假、采购进度</p> <p className="mobile-list-card__subtitle mt-1">报销、请假、采购进度</p>
</div> </div>
<span className="badge-neutral">{employeeProgressCards.length} 条</span> <span className="badge-neutral">{employeeProgressCards.length} 条</span>
</div> </div>
@@ -1028,7 +1028,7 @@ export function MobileApprovalsPage() {
<div className="mobile-empty-state">暂无申请进度</div> <div className="mobile-empty-state">暂无申请进度</div>
) : null} ) : null}
<div className="space-y-2"> <div className="mobile-page-stack">
{employeeProgressCards.map((item) => ( {employeeProgressCards.map((item) => (
<Link <Link
className="mobile-list-card block" className="mobile-list-card block"
@@ -1037,12 +1037,14 @@ export function MobileApprovalsPage() {
to={item.to} to={item.to}
state={{ from: currentMobileSource }} state={{ from: currentMobileSource }}
> >
<div className="flex items-start justify-between gap-3"> <div className="mobile-list-card__header">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.title}</div> <div className="mobile-list-card__title">{item.title}</div>
{item.subtitle ? <div className="mt-1 truncate text-xs text-zinc-500">{item.subtitle}</div> : null} {item.subtitle ? <div className="mobile-list-card__subtitle mt-1">{item.subtitle}</div> : null}
</div> </div>
<StatusBadge status={item.status} /> <span className="mobile-status-slot">
<StatusBadge status={item.status} />
</span>
</div> </div>
</Link> </Link>
))} ))}
@@ -1051,13 +1053,13 @@ export function MobileApprovalsPage() {
) : null} ) : null}
{!isPushDetail && canAccessRemoteApprovals ? ( {!isPushDetail && canAccessRemoteApprovals ? (
<section className="mobile-card space-y-4" data-testid="mobile-approvals-list"> <section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-list">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="mobile-section-header">
<div> <div className="min-w-0">
<h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3> <h3 className="mobile-section-title">{listTitle}</h3>
<p className="mt-1 text-sm text-zinc-500">{filteredTaskCards.length} 条</p> <p className="mobile-list-card__subtitle mt-1">{filteredTaskCards.length} 条</p>
</div> </div>
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span> <span className={`mobile-section-meta ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
</div> </div>
<Tabs value={taskTab} onValueChange={handleTaskTabChange}> <Tabs value={taskTab} onValueChange={handleTaskTabChange}>
@@ -1195,7 +1197,7 @@ export function MobileApprovalsPage() {
</p> </p>
) : null} ) : null}
<div className="space-y-2"> <div className="mobile-page-stack">
{filteredTaskCards.map((task) => { {filteredTaskCards.map((task) => {
const isSelected = selectedTaskId === task.id; const isSelected = selectedTaskId === task.id;
const isBatchSelected = selectedBatchTaskIds.includes(task.id); const isBatchSelected = selectedBatchTaskIds.includes(task.id);
@@ -1204,7 +1206,7 @@ export function MobileApprovalsPage() {
return ( return (
<div <div
key={task.id} key={task.id}
className={`mobile-list-card flex gap-3 ${ className={`mobile-list-card grid grid-cols-[auto_minmax(0,1fr)] gap-3 ${
isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white" isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`} }`}
> >
@@ -1221,12 +1223,14 @@ export function MobileApprovalsPage() {
data-testid={`mobile-approvals-task-${task.id}`} data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)} onClick={() => toggleBatchTask(task.id)}
> >
<div className="flex items-center justify-between gap-3"> <div className="mobile-list-card__header">
<div className="font-medium text-zinc-900">{task.title}</div> <div className="mobile-list-card__title">{task.title}</div>
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} /> <span className="mobile-status-slot">
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</span>
</div> </div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div> <div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"} {joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div> </div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null} {task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
@@ -1245,12 +1249,14 @@ export function MobileApprovalsPage() {
onClick={() => handleTaskSelection(task.id)} onClick={() => handleTaskSelection(task.id)}
type="button" type="button"
> >
<div className="flex items-center justify-between gap-3"> <div className="mobile-list-card__header">
<div className="font-medium text-zinc-900">{task.title}</div> <div className="mobile-list-card__title">{task.title}</div>
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} /> <span className="mobile-status-slot">
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</span>
</div> </div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div> <div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"} {joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div> </div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null} {task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
@@ -1262,8 +1268,8 @@ export function MobileApprovalsPage() {
) : null} ) : null}
{isPushDetail ? ( {isPushDetail ? (
<section className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail"> <section className="mobile-card mobile-page-stack scroll-mt-4" data-testid="mobile-approvals-detail">
<h3 className="text-base font-semibold text-zinc-900">处理</h3> <h3 className="mobile-section-title">处理</h3>
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null} {!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
@@ -1286,12 +1292,14 @@ export function MobileApprovalsPage() {
{selectedTask ? ( {selectedTask ? (
<> <>
<div className="mobile-list-card text-sm"> <div className="mobile-list-card text-sm">
<div className="flex items-start justify-between gap-3"> <div className="mobile-list-card__header">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-semibold text-zinc-900">{selectedDetailTitle}</div> <div className="mobile-list-card__title">{selectedDetailTitle}</div>
<div className="mt-1 text-xs text-zinc-500">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div> <div className="mobile-list-card__subtitle mt-1">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</div>
</div> </div>
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} /> <span className="mobile-status-slot">
<StatusBadge status={selectedTask.task.status} label={workflowStatusLabel(selectedTask.task.status)} />
</span>
</div> </div>
<div className="mobile-field-list mt-3"> <div className="mobile-field-list mt-3">
<div className="mobile-field-row"> <div className="mobile-field-row">
@@ -1343,10 +1351,10 @@ export function MobileApprovalsPage() {
) : null} ) : null}
</div> </div>
<div className="space-y-2" data-testid="mobile-approvals-history"> <div className="mobile-page-stack" data-testid="mobile-approvals-history">
<div className="flex items-center justify-between gap-3"> <div className="mobile-section-header">
<h4 className="text-sm font-semibold text-zinc-900">处理历史</h4> <h4 className="mobile-section-title">处理历史</h4>
<span className="text-xs text-zinc-500">{selectedTask.history.length} 条</span> <span className="mobile-section-meta">{selectedTask.history.length} 条</span>
</div> </div>
{selectedTask.history.length ? ( {selectedTask.history.length ? (
selectedTask.history.map((item) => ( selectedTask.history.map((item) => (
@@ -1381,7 +1389,7 @@ export function MobileApprovalsPage() {
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p> <p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null} ) : null}
<div className="grid gap-2 min-[390px]:grid-cols-3"> <div className="mobile-sticky-action-grid">
<Button <Button
className="min-h-12 w-full" className="min-h-12 w-full"
data-testid="mobile-approvals-approve" data-testid="mobile-approvals-approve"
@@ -1470,10 +1478,10 @@ export function MobileApprovalsPage() {
) : null} ) : null}
{!isPushDetail && shouldShowDraftQueue ? ( {!isPushDetail && shouldShowDraftQueue ? (
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue"> <section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-queue">
<div className="flex items-center justify-between gap-3"> <div className="mobile-section-header">
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3> <h3 className="mobile-section-title">离线草稿</h3>
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span> <span className="mobile-section-meta">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span>
</div> </div>
{currentQueueItems.length ? ( {currentQueueItems.length ? (
@@ -1495,13 +1503,27 @@ export function MobileApprovalsPage() {
{currentQueueItems.map((item) => ( {currentQueueItems.map((item) => (
<div key={item.id} className="mobile-list-card text-sm"> <div key={item.id} className="mobile-list-card text-sm">
<div className="font-medium text-zinc-900"> <div className="mobile-list-card__title">
{publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"} {publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"}
</div> </div>
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div> <div className="mobile-field-list mt-2">
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div> <div className="mobile-field-row">
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div> <div className="mobile-field-label">业务</div>
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div> <div className="mobile-field-value">{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">状态</div>
<div className="mobile-field-value">{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">意见</div>
<div className="mobile-field-value">{item.comment ?? "-"}</div>
</div>
<div className="mobile-field-row">
<div className="mobile-field-label">最近错误</div>
<div className="mobile-field-value">{item.lastError ?? "-"}</div>
</div>
</div>
<div className="mt-2 grid gap-2 sm:flex sm:flex-wrap"> <div className="mt-2 grid gap-2 sm:flex sm:flex-wrap">
<Button <Button
type="button" type="button"
+53 -43
查看文件
@@ -119,25 +119,25 @@ export function MobileNotificationsPage() {
return ( return (
<PageScaffold className="pb-4"> <PageScaffold className="pb-4">
<div className="space-y-3" data-testid="mobile-notifications-page"> <div className="mobile-page-stack" data-testid="mobile-notifications-page">
<PageHeader <PageHeader
title="通知" title="通知"
meta={<span className="badge-neutral">未读 {listQuery.data?.unreadCount ?? 0}</span>} meta={<span className="badge-neutral">未读 {listQuery.data?.unreadCount ?? 0}</span>}
actions={ actions={
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
data-testid="mobile-notifications-mark-all-read" data-testid="mobile-notifications-mark-all-read"
disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending} disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending}
onClick={() => markAllReadMutation.mutate()} onClick={() => markAllReadMutation.mutate()}
> >
全部已读 全部已读
</Button> </Button>
} }
/> />
<section className="mobile-card space-y-4" data-testid="mobile-notifications-list"> <section className="mobile-card mobile-page-stack" data-testid="mobile-notifications-list">
<div className="space-y-3"> <div className="mobile-page-stack">
<Tabs <Tabs
data-testid="mobile-notifications-filter-tabs" data-testid="mobile-notifications-filter-tabs"
value={unreadOnly ? "unread" : "all"} value={unreadOnly ? "unread" : "all"}
@@ -158,7 +158,7 @@ export function MobileNotificationsPage() {
</TabsList> </TabsList>
</Tabs> </Tabs>
<label className="flex items-center gap-2 text-sm text-zinc-600"> <label className="mobile-field-row text-sm text-zinc-600">
<span className="shrink-0">类型</span> <span className="shrink-0">类型</span>
<select <select
data-testid="mobile-notifications-type-filter" data-testid="mobile-notifications-type-filter"
@@ -194,32 +194,42 @@ export function MobileNotificationsPage() {
<div className="mobile-empty-state">暂无通知</div> <div className="mobile-empty-state">暂无通知</div>
) : null} ) : null}
{notificationItems.map((item) => { <div className="mobile-page-stack">
const targetLink = resolveMobileNotificationTargetLink(item); {notificationItems.map((item) => {
return ( const targetLink = resolveMobileNotificationTargetLink(item);
<button return (
data-testid="mobile-notification-card" <button
key={item.id} data-testid="mobile-notification-card"
className="mobile-list-card w-full text-left" key={item.id}
type="button" className="mobile-list-card w-full text-left"
onClick={() => void openNotificationTarget(item, targetLink)} type="button"
> onClick={() => void openNotificationTarget(item, targetLink)}
<div className="flex items-start justify-between gap-3"> >
<div className="min-w-0"> <div className="mobile-list-card__header">
<div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div> <div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">{item.title}</div> <div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
{item.body ? <div className="mt-1 text-xs leading-5 text-zinc-500">{item.body}</div> : null} <div className="mobile-list-card__title mt-0.5">{item.title}</div>
{item.body ? <div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
</div>
<span className={item.readAt ? "badge-neutral mobile-status-slot" : "badge-warning mobile-status-slot"}>
{item.readAt ? "已读" : "未读"}
</span>
</div> </div>
<span className={item.readAt ? "badge-neutral shrink-0" : "badge-warning shrink-0"}>
{item.readAt ? "已读" : "未读"}
</span>
</div>
<div className="mt-3 text-xs text-zinc-500">{formatTime(item.createdAt)}</div> <div className="mobile-field-list mt-3">
<div className="mt-3 text-xs text-primary">{targetLink ? "直达详情" : "暂无直达详情"}</div> <div className="mobile-field-row">
</button> <div className="mobile-field-label">时间</div>
); <div className="mobile-field-value">{formatTime(item.createdAt)}</div>
})} </div>
<div className="mobile-field-row">
<div className="mobile-field-label">去向</div>
<div className="mobile-field-value text-primary">{targetLink ? "直达详情" : "暂无直达详情"}</div>
</div>
</div>
</button>
);
})}
</div>
<Pagination <Pagination
page={page} page={page}
+33 -33
查看文件
@@ -325,15 +325,15 @@ export function MobilePortalPage() {
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4); ].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return ( return (
<PageScaffold className="space-y-4"> <PageScaffold className="mobile-page-stack">
<PageHeader title="今天" /> <PageHeader title="今天" />
{canAccessApprovals && pendingApprovalCount > 0 ? ( {canAccessApprovals && pendingApprovalCount > 0 ? (
<section className="mobile-card" data-testid="mobile-pending-summary"> <section className="mobile-card mobile-page-stack" data-testid="mobile-pending-summary">
<div className="mb-3 flex items-start justify-between gap-3"> <div className="mobile-section-header">
<div> <div className="min-w-0">
<div className="text-base font-semibold text-zinc-900">待处理</div> <div className="mobile-section-title">待处理</div>
<div className="mt-1 text-xs text-zinc-500">待我审批</div> <div className="mobile-list-card__subtitle mt-1">待我审批</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count"> <div className="text-3xl font-semibold leading-none text-zinc-900" data-testid="mobile-pending-summary-count">
@@ -345,7 +345,7 @@ export function MobilePortalPage() {
</div> </div>
</div> </div>
{urgentPendingTask ? ( {urgentPendingTask ? (
<div className="space-y-2"> <div className="mobile-page-stack">
{(() => { {(() => {
const task = urgentPendingTask; const task = urgentPendingTask;
const entityType = task.workflowInstance?.entityType; const entityType = task.workflowInstance?.entityType;
@@ -355,16 +355,16 @@ export function MobilePortalPage() {
key={task.id} key={task.id}
to={`/mobile/approvals/${encodeURIComponent(task.id)}`} to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
state={{ from: currentMobileSource }} state={{ from: currentMobileSource }}
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm" className="mobile-list-card block text-sm"
data-testid="mobile-pending-summary-primary" data-testid="mobile-pending-summary-primary"
> >
<div className="flex items-center justify-between gap-3"> <div className="mobile-list-card__header">
<div className="min-w-0 truncate font-medium text-zinc-900"> <div className="mobile-list-card__title">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })} {reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
</div>
</div> </div>
</div> <div className="mobile-list-card__subtitle mt-1">
<div className="mt-1 truncate text-xs text-zinc-500"> {joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
</div> </div>
</Link> </Link>
); );
@@ -378,9 +378,9 @@ export function MobilePortalPage() {
) : null} ) : null}
{canUseFinance ? ( {canUseFinance ? (
<section className="mobile-card" data-testid="mobile-finance-summary"> <section className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mobile-section-header">
<div className="text-base font-semibold text-zinc-900">财务</div> <div className="mobile-section-title">财务</div>
<span className="badge-neutral">复核与付款</span> <span className="badge-neutral">复核与付款</span>
</div> </div>
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null} {portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
@@ -408,9 +408,9 @@ export function MobilePortalPage() {
</section> </section>
) : null} ) : null}
<section className="mobile-card" data-testid="mobile-today-focus"> <section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mobile-section-header">
<div className="text-base font-semibold text-zinc-900">常用</div> <div className="mobile-section-title">常用</div>
</div> </div>
<div className="mobile-icon-grid" data-testid="mobile-action-grid"> <div className="mobile-icon-grid" data-testid="mobile-action-grid">
{actions.map((action) => ( {actions.map((action) => (
@@ -420,9 +420,9 @@ export function MobilePortalPage() {
</section> </section>
{!attendanceFeatureDisabled ? ( {!attendanceFeatureDisabled ? (
<section className="mobile-card" data-testid="mobile-attendance-card"> <section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-card">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mobile-section-header">
<div className="text-base font-semibold text-zinc-900">打卡</div> <div className="mobile-section-title">打卡</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span> <span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div> </div>
@@ -464,9 +464,9 @@ export function MobilePortalPage() {
</section> </section>
) : null} ) : null}
<section className="mobile-card" data-testid="mobile-notifications"> <section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mobile-section-header">
<div className="text-base font-semibold text-zinc-900">通知</div> <div className="mobile-section-title">通知</div>
<Button asChild size="sm" type="button" variant="secondary"> <Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/notifications">全部</Link> <Link to="/mobile/notifications">全部</Link>
</Button> </Button>
@@ -482,23 +482,23 @@ export function MobilePortalPage() {
<p className="text-sm text-zinc-500">暂无通知。</p> <p className="text-sm text-zinc-500">暂无通知。</p>
) : null} ) : null}
<div className="space-y-2"> <div className="mobile-page-stack">
{homeNotifications.map((item) => ( {homeNotifications.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
className="block w-full rounded-lg border border-zinc-200 px-3 py-2 text-left text-sm" className="mobile-list-card w-full text-left text-sm"
onClick={() => void openHomeNotification(item)} onClick={() => void openHomeNotification(item)}
data-testid="mobile-home-notification-card" data-testid="mobile-home-notification-card"
> >
<div className="flex items-center justify-between gap-3"> <div className="mobile-list-card__header">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs text-zinc-500">{notificationTypeLabel(item.type)}</div> <div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
<div className="mt-0.5 truncate font-medium text-zinc-900">{item.title}</div> <div className="mobile-list-card__title mt-0.5">{item.title}</div>
</div> </div>
{!item.readAt ? <span className="badge-warning shrink-0">新</span> : null} {!item.readAt ? <span className="badge-warning mobile-status-slot">新</span> : null}
</div> </div>
<div className="mt-1 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div> <div className="mobile-list-card__subtitle mt-1">{formatDateTime(item.createdAt)}</div>
</button> </button>
))} ))}
</div> </div>
@@ -1173,7 +1173,7 @@ export function MobileReimbursementDetailPage() {
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur" className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur"
data-testid="mobile-reimbursement-sticky-actions" data-testid="mobile-reimbursement-sticky-actions"
> >
<div className={cn(canOpenInvoiceDialog && showSubmitAction ? "mobile-two-action-grid" : "grid grid-cols-1 gap-2")}> <div className="mobile-sticky-action-grid">
{canOpenInvoiceDialog ? ( {canOpenInvoiceDialog ? (
<Button <Button
className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap" className="min-h-11 min-w-0 px-2 text-sm whitespace-nowrap"
+8 -8
查看文件
@@ -16,7 +16,7 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
@@ -199,13 +199,13 @@ export function PaymentBatchesPage() {
}); });
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader title="支付批次" subtitle="财务批量付款、复核与执行;高风险批次达到 5 万元后需完成二次复核。" /> <WorkspaceHeader title="支付批次" subtitle="财务批量付款、复核与执行;高风险批次达到 5 万元后需完成二次复核。" />
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null} {error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
<div className="grid gap-6 xl:grid-cols-[360px,1fr]"> <div className="grid gap-6 xl:grid-cols-[360px,1fr]">
<div className="card-std space-y-4 p-6"> <div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">新建批次</h3> <h3 className="text-base font-semibold text-zinc-900">新建批次</h3>
{!canManageBatch ? ( {!canManageBatch ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600"> <div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
@@ -281,7 +281,7 @@ export function PaymentBatchesPage() {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<div className="card-std p-6"> <div className="card-std p-4 md:p-5">
<h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3> <h3 className="mb-3 text-base font-semibold text-zinc-900">批次列表</h3>
<div className="space-y-2"> <div className="space-y-2">
{(listQuery.data?.items ?? []).map((item) => ( {(listQuery.data?.items ?? []).map((item) => (
@@ -313,7 +313,7 @@ export function PaymentBatchesPage() {
{selectedBatch ? ( {selectedBatch ? (
<div className="space-y-6"> <div className="space-y-6">
<div className="card-std space-y-3 p-6"> <div className="card-std space-y-3 p-4 md:p-5">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div> <div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
@@ -372,7 +372,7 @@ export function PaymentBatchesPage() {
</div> </div>
</div> </div>
<div className="card-std space-y-4 p-6"> <div className="card-std space-y-4 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">批次项</h3> <h3 className="text-base font-semibold text-zinc-900">批次项</h3>
<div className="space-y-4"> <div className="space-y-4">
{selectedBatch.items.map((item) => ( {selectedBatch.items.map((item) => (
@@ -417,6 +417,6 @@ export function PaymentBatchesPage() {
) : null} ) : null}
</div> </div>
</div> </div>
</div> </ListWorkspace>
); );
} }
+4 -5
查看文件
@@ -12,9 +12,8 @@ import {
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions"; import { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { FilterBar, ListWorkspace } from "../components/patterns/ListWorkspace"; import { FilterBar, ListWorkspace, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard"; import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
@@ -161,7 +160,7 @@ export function ProjectsPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="项目" title="项目"
subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。" subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。"
actions={ actions={
@@ -173,12 +172,12 @@ export function ProjectsPage() {
} }
/> />
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <StatStrip columns={4}>
<MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} /> <MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} />
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" /> <MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} /> <MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} /> <MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
</section> </StatStrip>
<div className="page-shell-grid"> <div className="page-shell-grid">
<div className="space-y-6"> <div className="space-y-6">
+21 -18
查看文件
@@ -36,7 +36,6 @@ import { fetchProjects } from "../api/projects";
import { remindWorkflowTask } from "../api/workflow"; import { remindWorkflowTask } from "../api/workflow";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard"; import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Pagination } from "../components/common/Pagination"; import { Pagination } from "../components/common/Pagination";
@@ -46,6 +45,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea"; import { Textarea } from "../components/ui/textarea";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { buildPrecheckDialogMessage } from "../lib/precheck"; import { buildPrecheckDialogMessage } from "../lib/precheck";
import { import {
@@ -720,6 +720,9 @@ export function PurchaseRequestsPage() {
const detail = detailQuery.data; const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null; const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const detailAttachments = detail?.attachments ?? [];
const detailApprovalHistory = detail?.approvalHistory ?? [];
const detailRiskFlags = detail?.riskFlags ?? [];
const handleSubmitAction = async () => { const handleSubmitAction = async () => {
if (!selectedId) { if (!selectedId) {
@@ -769,8 +772,8 @@ export function PurchaseRequestsPage() {
); );
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="采购申请" title="采购申请"
subtitle="草稿、附件、审批。" subtitle="草稿、附件、审批。"
actions={ actions={
@@ -793,7 +796,7 @@ export function PurchaseRequestsPage() {
</p> </p>
) : null} ) : null}
<div className="card-std space-y-4 p-6"> <FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-5"> <div className="grid gap-3 md:grid-cols-5">
<select <select
className="select-std" className="select-std"
@@ -887,10 +890,10 @@ export function PurchaseRequestsPage() {
</div> </div>
) : null} ) : null}
</div> </div>
</div> </FilterBar>
<div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]"> <div className="grid gap-6 xl:grid-cols-[420px_minmax(0,1fr)]">
<div className="card-std space-y-4 p-6"> <div className="card-std space-y-4 p-4 md:p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">采购单列表</h3> <h3 className="text-base font-semibold text-zinc-900">采购单列表</h3>
<span className="text-sm text-zinc-500">共 {listQuery.data?.total ?? 0} 条</span> <span className="text-sm text-zinc-500">共 {listQuery.data?.total ?? 0} 条</span>
@@ -952,7 +955,7 @@ export function PurchaseRequestsPage() {
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无采购申请</div> <div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-500">暂无采购申请</div>
)} )}
</div> </div>
<div className="hidden rounded-lg border border-zinc-200 md:block"> <PrimaryTable className="hidden md:block">
<Table data-testid="purchase-requests-table"> <Table data-testid="purchase-requests-table">
<TableHeader> <TableHeader>
<tr> <tr>
@@ -1002,7 +1005,7 @@ export function PurchaseRequestsPage() {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </PrimaryTable>
</> </>
) : null} ) : null}
@@ -1014,7 +1017,7 @@ export function PurchaseRequestsPage() {
/> />
</div> </div>
<div className="card-std space-y-5 p-6"> <div className="card-std space-y-5 p-4 md:p-5">
{!selectedId ? ( {!selectedId ? (
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500"> <div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
请先在左侧选择采购单,或新建一张采购草稿。 请先在左侧选择采购单,或新建一张采购草稿。
@@ -1096,7 +1099,7 @@ export function PurchaseRequestsPage() {
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm"> <div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
<div className="text-zinc-500">附件</div> <div className="text-zinc-500">附件</div>
<div className="mt-2 font-medium text-zinc-900">{detail.attachments.length} 个</div> <div className="mt-2 font-medium text-zinc-900">{detailAttachments.length} 个</div>
</div> </div>
</div> </div>
@@ -1116,12 +1119,12 @@ export function PurchaseRequestsPage() {
</div> </div>
</div> </div>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> <RiskFlagsCard items={detailRiskFlags} title="待处理风险项" />
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。"> <SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detail.approvalHistory.length ? ( {detailApprovalHistory.length ? (
<div className="space-y-2" data-testid="purchase-approval-history"> <div className="space-y-2" data-testid="purchase-approval-history">
{detail.approvalHistory.map((item) => ( {detailApprovalHistory.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm"> <div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-zinc-900"> <span className="font-medium text-zinc-900">
@@ -1515,8 +1518,8 @@ export function PurchaseRequestsPage() {
</div> </div>
</div> </div>
<div className="space-y-3 md:hidden"> <div className="space-y-3 md:hidden">
{detail.attachments.length ? ( {detailAttachments.length ? (
detail.attachments.map((attachment) => ( detailAttachments.map((attachment) => (
<div key={attachment.id} className="rounded-lg border border-zinc-200 bg-white p-4"> <div key={attachment.id} className="rounded-lg border border-zinc-200 bg-white p-4">
<div className="font-medium text-zinc-900">{attachment.originalName}</div> <div className="font-medium text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div> <div className="mt-1 text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
@@ -1563,8 +1566,8 @@ export function PurchaseRequestsPage() {
</tr> </tr>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{detail.attachments.length ? ( {detailAttachments.length ? (
detail.attachments.map((attachment) => ( detailAttachments.map((attachment) => (
<TableRow key={attachment.id}> <TableRow key={attachment.id}>
<TableCell> <TableCell>
<div className="font-medium text-zinc-900">{attachment.originalName}</div> <div className="font-medium text-zinc-900">{attachment.originalName}</div>
@@ -1754,6 +1757,6 @@ export function PurchaseRequestsPage() {
) : null} ) : null}
</div> </div>
</div> </div>
</div> </ListWorkspace>
); );
} }
+18 -33
查看文件
@@ -22,7 +22,6 @@ import {
withdrawReimbursement, withdrawReimbursement,
linkReimbursementProject, linkReimbursementProject,
type ReimbursementItem, type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements"; } from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow"; import { remindWorkflowTask } from "../api/workflow";
import { import {
@@ -51,6 +50,8 @@ import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { StatStrip } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { import {
@@ -73,15 +74,6 @@ import { buildPrecheckDialogMessage } from "../lib/precheck";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
type SelectedInvoiceMap = Record<string, string>; type SelectedInvoiceMap = Record<string, string>;
const REIMBURSEMENT_STATUS_LABEL: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
function formatDateTime(value: string | null) { function formatDateTime(value: string | null) {
if (!value) return "-"; if (!value) return "-";
return value.slice(0, 19).replace("T", " "); return value.slice(0, 19).replace("T", " ");
@@ -99,10 +91,6 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
return fallbackMessage; return fallbackMessage;
} }
function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
}
function workflowActionLabel(action: string) { function workflowActionLabel(action: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
start: "提交", start: "提交",
@@ -1009,27 +997,24 @@ export function ReimbursementDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
<SectionPanel title="概览与关联"> <SectionPanel title="概览与关联">
<div className="space-y-6" data-testid="reimbursement-overview"> <div className="space-y-6" data-testid="reimbursement-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <StatStrip columns={4}>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm"> <MetricCard
<div className="text-zinc-500">当前状态</div> title="关联项目"
<div className="mt-2"><StatusBadge status={detail.status} /></div> value={
</div> <span className="text-base">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm"> {detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
<div className="text-zinc-500">关联项目</div> </span>
<div className="mt-2 font-medium text-zinc-900">{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div> }
</div> />
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm"> <MetricCard title="明细数" value={detail.items.length} />
<div className="text-zinc-500">明细数</div> <MetricCard title="发票" value={detail.invoices.length} />
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div> <MetricCard
</div> title="付款状态"
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm"> value={<span className="text-base">{isPaidReimbursement ? "已付款" : "未付款"}</span>}
<div className="text-zinc-500">发票</div> />
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div> </StatStrip>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary"> <div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
<div>状态:{reimbursementStatusLabel(detail.status)}</div>
<div>报销编号:{displayCode}</div> <div>报销编号:{displayCode}</div>
<div>报销日期:{detail.reimbursementDate ?? "-"}</div> <div>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div> <div>金额:{detail.totalAmount ?? "-"}</div>
+4 -5
查看文件
@@ -25,11 +25,10 @@ import { hasPermission } from "../auth/permissions";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadBlob } from "../lib/download"; import { downloadBlob } from "../lib/download";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay"; import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination"; import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace"; import { FilterBar, ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard"; import { MetricCard } from "../components/patterns/MetricCard";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { import {
@@ -362,7 +361,7 @@ export function ReimbursementsPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<PageHeader <WorkspaceHeader
title="我的报销" title="我的报销"
subtitle="草稿、审批、支付。" subtitle="草稿、审批、支付。"
actions={ actions={
@@ -513,12 +512,12 @@ export function ReimbursementsPage() {
</p> </p>
) : null} ) : null}
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <StatStrip columns={4}>
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} /> <MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" /> <MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} /> <MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} /> <MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
</section> </StatStrip>
<FilterBar className="space-y-4"> <FilterBar className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
+5 -5
查看文件
@@ -1,5 +1,5 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { PageHeader } from "../components/common/PageHeader"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
const ITEMS = [ const ITEMS = [
{ to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" }, { to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" },
@@ -14,17 +14,17 @@ const ITEMS = [
export function SystemSettingsPage() { export function SystemSettingsPage() {
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" /> <WorkspaceHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{ITEMS.map((item) => ( {ITEMS.map((item) => (
<Link key={item.to} to={item.to} className="card-std p-6 hover:bg-zinc-50"> <Link key={item.to} to={item.to} className="card-std p-4 hover:bg-zinc-50 md:p-5">
<div className="text-base font-semibold text-zinc-900">{item.title}</div> <div className="text-base font-semibold text-zinc-900">{item.title}</div>
<div className="mt-2 text-sm text-zinc-600">{item.description}</div> <div className="mt-2 text-sm text-zinc-600">{item.description}</div>
</Link> </Link>
))} ))}
</div> </div>
</div> </ListWorkspace>
); );
} }
+7 -7
查看文件
@@ -8,7 +8,7 @@ import {
updateWorkflowDefinition, updateWorkflowDefinition,
type WorkflowDefinitionEditorModel, type WorkflowDefinitionEditorModel,
} from "../api/workflowDefinitions"; } from "../api/workflowDefinitions";
import { PageHeader } from "../components/common/PageHeader"; import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
@@ -79,10 +79,10 @@ export function WorkflowDefinitionsPage() {
}); });
return ( return (
<div className="space-y-6"> <ListWorkspace>
<PageHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" /> <WorkspaceHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" />
<div className="grid gap-6 xl:grid-cols-[220px,280px,1fr]"> <div className="grid gap-6 xl:grid-cols-[220px,280px,1fr]">
<div className="card-std space-y-3 p-6"> <div className="card-std space-y-3 p-4 md:p-5">
<Label>业务域</Label> <Label>业务域</Label>
<select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as WorkflowDefinitionEditorModel["entityType"])}> <select className="select-std" value={entityType} onChange={(event) => setEntityType(event.target.value as WorkflowDefinitionEditorModel["entityType"])}>
{ENTITY_TYPES.map((item) => <option key={item} value={item}>{item}</option>)} {ENTITY_TYPES.map((item) => <option key={item} value={item}>{item}</option>)}
@@ -90,7 +90,7 @@ export function WorkflowDefinitionsPage() {
{entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null} {entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null}
</div> </div>
<div className="card-std space-y-3 p-6"> <div className="card-std space-y-3 p-4 md:p-5">
<h3 className="text-base font-semibold text-zinc-900">版本列表</h3> <h3 className="text-base font-semibold text-zinc-900">版本列表</h3>
<div className="space-y-2"> <div className="space-y-2">
{scopedItems.map((item) => ( {scopedItems.map((item) => (
@@ -103,7 +103,7 @@ export function WorkflowDefinitionsPage() {
</div> </div>
</div> </div>
<div className="card-std space-y-4 p-6"> <div className="card-std space-y-4 p-4 md:p-5">
{selected ? ( {selected ? (
<> <>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -163,6 +163,6 @@ export function WorkflowDefinitionsPage() {
)} )}
</div> </div>
</div> </div>
</div> </ListWorkspace>
); );
} }
+64 -78
查看文件
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, Navigate, useNavigate, useParams } from "react-router-dom"; import { Link, Navigate, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -19,6 +20,8 @@ import { CardSkeleton } from "../components/common/CardSkeleton";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { StatStrip } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
@@ -48,22 +51,6 @@ function formatDateTime(value: string | null | undefined) {
return value.slice(0, 19).replace("T", " "); return value.slice(0, 19).replace("T", " ");
} }
function workflowStatusLabel(status: string | undefined) {
if (!status) {
return "-";
}
const labels: Record<string, string> = {
pending: "待处理",
completed: "已处理",
rejected: "已驳回",
cancelled: "已取消",
skipped: "已跳过",
};
return labels[status] ?? status;
}
function workflowInstanceStatusLabel(status: string | undefined | null) { function workflowInstanceStatusLabel(status: string | undefined | null) {
if (!status) { if (!status) {
return "-"; return "-";
@@ -103,6 +90,15 @@ function workflowActionLabel(action: string | undefined | null) {
return labels[action] ?? action; return labels[action] ?? action;
} }
function DetailField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
<dt className="text-xs font-medium text-zinc-500">{label}</dt>
<dd className="mt-1 break-words font-medium text-zinc-900">{value || "-"}</dd>
</div>
);
}
function isAlreadyProcessedTaskError(error: unknown) { function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed"; return isApiError(error) && error.details?.task === "already_processed";
} }
@@ -240,7 +236,6 @@ export function WorkflowTaskDetailPage() {
meta={( meta={(
<> <>
<StatusBadge status={detail.task.status} /> <StatusBadge status={detail.task.status} />
<span className="info-chip">对象类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
<span className="info-chip">业务状态:{businessStatusLabel}</span> <span className="info-chip">业务状态:{businessStatusLabel}</span>
</> </>
)} )}
@@ -255,87 +250,78 @@ export function WorkflowTaskDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
<SectionPanel title="审批对象摘要"> <SectionPanel title="审批对象摘要">
<div className="space-y-4" data-testid="workflow-task-overview"> <div className="space-y-4" data-testid="workflow-task-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <StatStrip columns={4}>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm"> <MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
<div className="text-zinc-500">当前状态</div> <MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
<div className="mt-2"><StatusBadge status={detail.task.status} /></div> <MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels || "-"}</span>} />
</div> <MetricCard title="创建时间" value={<span className="text-base">{formatDateTime(detail.task.createdAt)}</span>} />
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm"> </StatStrip>
<div className="text-zinc-500">当前节点</div>
<div className="mt-2 font-medium text-zinc-900">{detail.task.stepName}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">对象类型</div>
<div className="mt-2 font-medium text-zinc-900">{entityTitle(detail.workflowInstance?.entityType)}</div>
</div>
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">可执行动作</div>
<div className="mt-2 font-medium text-zinc-900">{allowedActionLabels || "-"}</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="workflow-task-summary"> <dl className="grid gap-3 md:grid-cols-2" data-testid="workflow-task-summary">
<div>状态:{workflowStatusLabel(detail.task.status)}</div>
<div>业务状态:{businessStatusLabel}</div>
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
{detail.reimbursement ? ( {detail.reimbursement ? (
<> <>
<div>报销单号:{detail.reimbursement.formNumber ?? "-"}</div> <DetailField label="报销单号" value={detail.reimbursement.formNumber ?? "-"} />
<div>报销金额:{detail.reimbursement.totalAmount ?? "-"}</div> <DetailField label="报销金额" value={detail.reimbursement.totalAmount ?? "-"} />
<div>提交人:{detail.reimbursement.submitter?.name ?? "-"}</div> <DetailField label="提交人" value={detail.reimbursement.submitter?.name ?? "-"} />
<div>报销人:{detail.reimbursement.owner?.name ?? "-"}</div> <DetailField label="报销人" value={detail.reimbursement.owner?.name ?? "-"} />
</> </>
) : null} ) : null}
{detail.leaveRequest ? ( {detail.leaveRequest ? (
<> <>
<div>假期类型:{detail.leaveRequest.leaveType?.name ?? "-"}</div> <DetailField label="假期类型" value={detail.leaveRequest.leaveType?.name ?? "-"} />
<div>申请人:{detail.leaveRequest.owner?.name ?? "-"}</div> <DetailField label="申请人" value={detail.leaveRequest.owner?.name ?? "-"} />
<div> <DetailField
时间段: label="时间段"
value={
<>
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"} {detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
{" → "} {" → "}
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"} {detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
</div> </>
<div>请假天数:{detail.leaveRequest.requestedDays}</div> }
<div>说明:{detail.leaveRequest.reason ?? "-"}</div> />
<div>附件:{detail.leaveRequest.attachment?.originalName ?? "-"}</div> <DetailField label="请假天数" value={detail.leaveRequest.requestedDays} />
<DetailField label="说明" value={detail.leaveRequest.reason ?? "-"} />
<DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "-"} />
</> </>
) : null} ) : null}
{detail.purchaseRequest ? ( {detail.purchaseRequest ? (
<> <>
<div>采购单号:{resolvePurchaseDisplayCode(detail.purchaseRequest)}</div> <DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} />
<div>标题:{detail.purchaseRequest.title || "-"}</div> <DetailField label="标题" value={detail.purchaseRequest.title || "-"} />
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div> <DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} />
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div> <DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} />
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div> <DetailField label="法体" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
<div>成本中心:{detail.purchaseRequest.costCenter?.name ?? "-"}</div> <DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} />
<div>单据分类:{detail.purchaseRequest.documentCategory?.name ?? "-"}</div> <DetailField label="单据分类" value={detail.purchaseRequest.documentCategory?.name ?? "-"} />
<div>金额:{detail.purchaseRequest.totalAmount ?? "-"} {detail.purchaseRequest.currencyCode}</div> <DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} />
<div>预计到货:{detail.purchaseRequest.expectedDeliveryDate ?? "-"}</div> <DetailField label="预计到货" value={detail.purchaseRequest.expectedDeliveryDate ?? "-"} />
<div>说明:{detail.purchaseRequest.reason ?? "-"}</div> <DetailField label="说明" value={detail.purchaseRequest.reason ?? "-"} />
</> </>
) : null} ) : null}
{detail.travelRequest ? ( {detail.travelRequest ? (
<> <>
<div>差旅单号:{detail.travelRequest.requestNo ?? "暂未生成"}</div> <DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} />
<div>申请人:{detail.travelRequest.applicant?.name ?? "-"}</div> <DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} />
<div>出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}</div> <DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} />
<div>法体:{detail.travelRequest.legalEntity?.name ?? "-"}</div> <DetailField label="法体" value={detail.travelRequest.legalEntity?.name ?? "-"} />
<div>成本中心:{detail.travelRequest.costCenter?.name ?? "-"}</div> <DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} />
<div>起止日期:{detail.travelRequest.startDate ?? "-"} → {detail.travelRequest.endDate ?? "-"}</div> <DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} />
<div>预估金额:{detail.travelRequest.estimatedAmount ?? "-"}</div> <DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} />
<div>出差目的:{detail.travelRequest.purpose || "-"}</div> <DetailField label="出差目的" value={detail.travelRequest.purpose || "-"} />
<div> <DetailField
行程段: label="行程段"
{detail.travelRequest.segments.length value={
? detail.travelRequest.segments detail.travelRequest.segments.length
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`) ? detail.travelRequest.segments
.join(";") .map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
: "-"} .join(";")
</div> : "-"
}
/>
</> </>
) : null} ) : null}
</div> </dl>
</div> </div>
</SectionPanel> </SectionPanel>
+67 -49
查看文件
@@ -9,10 +9,19 @@ import {
fetchWorkflowTaskSummary, fetchWorkflowTaskSummary,
type WorkflowTaskSummary, type WorkflowTaskSummary,
} from "../api/workflow"; } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination"; import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton"; import { TableSkeleton } from "../components/common/TableSkeleton";
import {
ActionToolbar,
FilterBar,
ListWorkspace,
PrimaryTable,
StatStrip,
WorkspaceEmptyState,
WorkspaceHeader,
} from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
@@ -187,35 +196,34 @@ export function WorkflowTasksPage() {
const taskItems = query.data?.items ?? []; const taskItems = query.data?.items ?? [];
return ( return (
<div className="space-y-4 md:space-y-6"> <ListWorkspace>
<PageHeader title="审批中心" subtitle="集中处理待办、查看已办和业务审批历史。" /> <WorkspaceHeader
eyebrow="审批"
title="审批中心"
subtitle="集中处理待办、查看已办和业务审批历史。"
/>
<div className="grid gap-3 md:grid-cols-4" data-testid="workflow-task-summary"> <div data-testid="workflow-task-summary">
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3"> <StatStrip columns={4}>
<div className="text-xs text-zinc-500">待办总数</div> <MetricCard title="待办总数" value={summaryQuery.data?.pendingTotal ?? "-"} />
<div className="mt-2 text-2xl font-semibold text-zinc-900">{summaryQuery.data?.pendingTotal ?? "-"}</div> <MetricCard title="超时待办" value={summaryQuery.data?.overdueTotal ?? "-"} tone="warning" />
</div> <MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3"> <MetricCard
<div className="text-xs text-zinc-500">超时待办</div> title="业务分布"
<div className="mt-2 text-2xl font-semibold text-amber-700">{summaryQuery.data?.overdueTotal ?? "-"}</div> value={
</div> <span className="block truncate text-base">
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3"> {(summaryQuery.data?.byEntityType ?? [])
<div className="text-xs text-zinc-500">最早待处理</div> .filter((item) => item.pendingTotal > 0)
<div className="mt-2 text-sm font-medium text-zinc-900">{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}</div> .map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
</div> .join(" / ") || "-"}
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3"> </span>
<div className="text-xs text-zinc-500">业务分布</div> }
<div className="mt-2 truncate text-sm font-medium text-zinc-900"> />
{(summaryQuery.data?.byEntityType ?? []) </StatStrip>
.filter((item) => item.pendingTotal > 0)
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
.join(" / ") || "-"}
</div>
</div>
</div> </div>
<div className="card-std space-y-4 p-4 md:p-5"> <FilterBar>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between"> <ActionToolbar className="items-start">
<Tabs <Tabs
data-testid="workflow-tasks-status-tabs" data-testid="workflow-tasks-status-tabs"
value={status} value={status}
@@ -234,13 +242,13 @@ export function WorkflowTasksPage() {
</TabsList> </TabsList>
</Tabs> </Tabs>
<div className="flex items-center gap-2"> <div className="flex w-full items-center gap-2 sm:w-auto">
<label className="text-sm font-medium text-zinc-700" htmlFor="workflow-entity-type-filter"> <label className="text-sm font-medium text-zinc-700" htmlFor="workflow-entity-type-filter">
业务类型 业务类型
</label> </label>
<select <select
id="workflow-entity-type-filter" id="workflow-entity-type-filter"
className="select-std min-w-36" className="select-std min-w-36 flex-1 sm:flex-none"
data-testid="workflow-tasks-entity-filter" data-testid="workflow-tasks-entity-filter"
value={entityType} value={entityType}
onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })} onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
@@ -252,7 +260,8 @@ export function WorkflowTasksPage() {
))} ))}
</select> </select>
</div> </div>
</div> </ActionToolbar>
</FilterBar>
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null} {query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
@@ -270,24 +279,36 @@ export function WorkflowTasksPage() {
{!query.isLoading && !query.isError ? ( {!query.isLoading && !query.isError ? (
<> <>
<div className="space-y-3 md:hidden" data-testid="workflow-tasks-mobile-list"> <div className="mobile-page-stack md:hidden" data-testid="workflow-tasks-mobile-list">
{taskItems.length ? ( {taskItems.length ? (
taskItems.map((task) => ( taskItems.map((task) => (
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" key={task.id}> <article className="mobile-list-card" key={task.id}>
<div className="flex items-start justify-between gap-3"> <div className="mobile-list-card__header">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{displayTitle(task)}</div> <div className="mobile-list-card__title">{displayTitle(task)}</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mobile-list-card__subtitle">
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)} {entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
</div> </div>
</div> </div>
<StatusBadge className="shrink-0" status={task.status} /> <StatusBadge className="mobile-status-slot" status={task.status} />
</div> </div>
<div className="mt-3 grid gap-1 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600"> <div className="mobile-field-list mt-3 rounded-lg bg-zinc-50 px-3 py-2">
<div>申请人:{displayApplicant(task)}</div> <div className="mobile-field-row">
<div>金额/天数:{displayMetric(task)}</div> <span className="mobile-field-label">申请人</span>
<div>当前节点:{task.stepName}</div> <span className="mobile-field-value">{displayApplicant(task)}</span>
<div>业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}</div> </div>
<div className="mobile-field-row">
<span className="mobile-field-label">金额/天数</span>
<span className="mobile-field-value">{displayMetric(task)}</span>
</div>
<div className="mobile-field-row">
<span className="mobile-field-label">当前节点</span>
<span className="mobile-field-value">{task.stepName}</span>
</div>
<div className="mobile-field-row">
<span className="mobile-field-label">业务状态</span>
<span className="mobile-field-value">{businessStatusLabel(task.businessSummary?.businessStatus)}</span>
</div>
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? ( {status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
@@ -317,13 +338,11 @@ export function WorkflowTasksPage() {
</article> </article>
)) ))
) : ( ) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500"> <WorkspaceEmptyState />
暂无数据
</div>
)} )}
</div> </div>
<div className="hidden rounded-lg border border-zinc-200 md:block"> <PrimaryTable className="hidden md:block">
<Table data-testid="workflow-tasks-table"> <Table data-testid="workflow-tasks-table">
<TableHeader> <TableHeader>
<tr> <tr>
@@ -381,14 +400,14 @@ export function WorkflowTasksPage() {
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={8}> <TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
暂无数据 暂无数据
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </PrimaryTable>
</> </>
) : null} ) : null}
@@ -398,7 +417,6 @@ export function WorkflowTasksPage() {
onPageChange={(p) => updateSearchParams({ page: p })} onPageChange={(p) => updateSearchParams({ page: p })}
testIdPrefix="workflow-tasks" testIdPrefix="workflow-tasks"
/> />
</div> </ListWorkspace>
</div>
); );
} }
+77
查看文件
@@ -222,6 +222,72 @@
padding: var(--mobile-card-padding); padding: var(--mobile-card-padding);
} }
.mobile-page-stack {
display: grid;
gap: var(--mobile-card-gap);
min-width: 0;
}
.mobile-section-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
min-width: 0;
}
.mobile-section-title {
min-width: 0;
color: rgb(24 24 27);
font-size: 0.95rem;
font-weight: 700;
line-height: 1.35rem;
}
.mobile-section-meta {
flex: none;
color: rgb(113 113 122);
font-size: 0.75rem;
line-height: 1rem;
}
.mobile-list-card__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: start;
min-width: 0;
}
.mobile-list-card__title {
min-width: 0;
overflow: hidden;
color: rgb(24 24 27);
font-size: 0.9rem;
font-weight: 700;
line-height: 1.35rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-list-card__subtitle {
min-width: 0;
overflow: hidden;
color: rgb(113 113 122);
font-size: 0.75rem;
line-height: 1.05rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-status-slot {
flex: none;
max-width: 42%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-subcard { .mobile-subcard {
min-width: 0; min-width: 0;
border: 1px solid rgb(228 228 231); border: 1px solid rgb(228 228 231);
@@ -299,6 +365,17 @@
padding: 0.75rem var(--mobile-page-x) max(0.75rem, var(--mobile-safe-area-bottom)); padding: 0.75rem var(--mobile-page-x) max(0.75rem, var(--mobile-safe-area-bottom));
} }
.mobile-sticky-action-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
gap: 0.5rem;
min-width: 0;
}
.mobile-sticky-action-grid > * {
min-height: 46px;
}
@media (max-width: 359px) { @media (max-width: 359px) {
:root { :root {
--mobile-page-x: 8px; --mobile-page-x: 8px;
+6 -2
查看文件
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { expect, test, type Browser, type Page } from "@playwright/test"; import { expect, test, type Browser, type Page } from "@playwright/test";
@@ -30,7 +30,11 @@ type PreparedRoutes = {
}; };
const ROOT_DIR = path.resolve(process.cwd(), ".."); const ROOT_DIR = path.resolve(process.cwd(), "..");
const MATRIX_PATH = path.join(ROOT_DIR, "docs/trialrun/project-wide-flow-matrix.csv"); const MATRIX_PATH_CANDIDATES = [
path.join(ROOT_DIR, "docs/trialrun/项目级流程覆盖矩阵.csv"),
path.join(ROOT_DIR, "docs/trialrun/project-wide-flow-matrix.csv"),
];
const MATRIX_PATH = MATRIX_PATH_CANDIDATES.find((candidate) => existsSync(candidate)) ?? MATRIX_PATH_CANDIDATES[0];
const ISSUE_ID = process.env.QA_ISSUE_ID?.trim() || "project-wide-visual"; const ISSUE_ID = process.env.QA_ISSUE_ID?.trim() || "project-wide-visual";
const BASE_URL = process.env.E2E_BASE_URL?.trim() || null; const BASE_URL = process.env.E2E_BASE_URL?.trim() || null;
const SCREENSHOT_DIR = resolveRootPath( const SCREENSHOT_DIR = resolveRootPath(
@@ -1,43 +1,77 @@
import type { APIRequestContext } from "@playwright/test";
import { test, expect } from "./helpers/fixtures"; import { test, expect } from "./helpers/fixtures";
import { EMPLOYEE, login, logout } from "./helpers/auth"; import { EMPLOYEE, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
import { openNavLink } from "./helpers/navigation"; import { openNavLink } from "./helpers/navigation";
async function firstEnabledExpenseTypeId(api: APIRequestContext) {
const response = await api.get("/api/expense-types?enabled=true");
const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } };
const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0];
if (!response.ok() || !item?.id) {
throw new Error(`无法读取费用类型:HTTP ${response.status()}`);
}
return item.id;
}
async function createReimbursementItem(api: APIRequestContext, reimbursementId: string) {
const expenseTypeId = await firstEnabledExpenseTypeId(api);
const response = await api.post(`/api/reimbursements/${reimbursementId}/items`, {
data: {
expenseTypeId,
expenseDate: "2026-05-13",
amount: "168.30",
description: "E2E 报销审批进度明细",
},
});
if (!response.ok()) {
throw new Error(`创建报销明细失败:HTTP ${response.status()}`);
}
}
test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => { test("E2E 报销审批进度:提交后详情页展示当前步骤进度", async ({ page, reimbursementDetail }) => {
const apiClient = await createAuthenticatedApiClient(EMPLOYEE);
await login(page, EMPLOYEE); await login(page, EMPLOYEE);
await (await openNavLink(page, "我的报销")).click(); try {
await expect(page).toHaveURL(/\/reimbursements/); await (await openNavLink(page, "我的报销")).click();
await expect(page.getByTestId("reimbursements-create")).toBeVisible(); await expect(page).toHaveURL(/\/reimbursements/);
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
const createResponsePromise = page.waitForResponse((response) => { const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok(); return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
}); });
await page.getByTestId("reimbursements-create").click(); await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click(); await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } }; const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id; const reimbursementId = createPayload.data?.id;
if (!reimbursementId) { if (!reimbursementId) {
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`); throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
}
await createReimbursementItem(apiClient.api, reimbursementId);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await reimbursementDetail.uploadInvoicePdf(
`progress-${Date.now()}.pdf`,
Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/reviewing|审批中/).first()).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toContainText("当前节点");
await expect(reimbursementDetail.workflowProgress).toContainText(/第 1 步|第 2 步|第 3 步/);
await logout(page, EMPLOYEE.name);
} finally {
await apiClient.dispose();
} }
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
const marker = `MOCK_E2E_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await reimbursementDetail.uploadInvoicePdf(
`progress-${Date.now()}.pdf`,
Buffer.from(`%PDF-1.4\n${marker}\n%%EOF\n`, "utf8"),
);
await page.goto(`/reimbursements/${reimbursementId}`);
await reimbursementDetail.assertLoaded();
await reimbursementDetail.submitReimbursement();
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toBeVisible({ timeout: 30_000 });
await expect(reimbursementDetail.workflowProgress).toContainText("当前节点");
await expect(reimbursementDetail.workflowProgress).toContainText(/第 1 步|第 2 步|第 3 步/);
await logout(page, EMPLOYEE.name);
}); });
+7
查看文件
@@ -82,6 +82,13 @@ DG8 之后,页面视觉和布局治理只有一个主 UI 参考:飞书工作
- 审批:待办聚合、明确状态、可追溯、可催办、超时提醒和效率统计。AP6 v1 已提供催办、待办摘要和超时提醒 job;后续审批/通知/催办开发必须先核对统一审批中心规约,复用既有接口、审计和通知语义,不另造平行链路。 - 审批:待办聚合、明确状态、可追溯、可催办、超时提醒和效率统计。AP6 v1 已提供催办、待办摘要和超时提醒 job;后续审批/通知/催办开发必须先核对统一审批中心规约,复用既有接口、审计和通知语义,不另造平行链路。
- 企业微信:入口、身份、个人应用消息和 deeplink,不替代 OA 正式审批。 - 企业微信:入口、身份、个人应用消息和 deeplink,不替代 OA 正式审批。
DG9 之后,页面任务必须先按 `docs/产品设计规则.md` 的 A/B/C 页面分层选样板,再开始设计或编码:
- A 类核心流程先对齐审批中心、移动审批/进度、报销详情、首页和通知样板。
- B 类业务运营按 A 类样板迁移,但不得顺手新增移动采购创建、恢复冻结差旅主线或改变财务/OCR/项目接口。
- C 类管理配置保持中后台高密度布局;只在授权管理或审计场景展示必要内部编号。
- PC 页面优先复用 `WorkspaceHeader`、指标条、`FilterBar`、主表格和分页;移动页面优先复用移动槽位、`mobile-list-card`、`mobile-field-row` 和吸底操作。
可自动化的体验检查先自动化,例如 Playwright PC、移动、企业微信 UA 和 DOM 指标;真实企业微信登录、手机应用消息到达、真实通知权限和财务制度决策必须保留人工确认记录。 可自动化的体验检查先自动化,例如 Playwright PC、移动、企业微信 UA 和 DOM 指标;真实企业微信登录、手机应用消息到达、真实通知权限和财务制度决策必须保留人工确认记录。
移动页治理必须收敛到同一套事实源和组件体系: 移动页治理必须收敛到同一套事实源和组件体系:
+18
查看文件
@@ -41,6 +41,24 @@ DG8 之后,hyb-oa 的唯一主 UI/布局参考是飞书工作台/飞书审批
- 详情页是业务事实源,PC 按摘要、关键字段、附件/发票、审批流和日志分区;移动深层 Push 页隐藏底部 Tab,使用顶部返回、状态和吸底操作。 - 详情页是业务事实源,PC 按摘要、关键字段、附件/发票、审批流和日志分区;移动深层 Push 页隐藏底部 Tab,使用顶部返回、状态和吸底操作。
- 竞品资料只用于抽象原则;不得复制飞书、钉钉、企业微信、泛微、每刻、合思、WeUI 或 Ant Design 的页面文案、视觉细节和专有交互。 - 竞品资料只用于抽象原则;不得复制飞书、钉钉、企业微信、泛微、每刻、合思、WeUI 或 Ant Design 的页面文案、视觉细节和专有交互。
## DG9 页面分层与样板
DG9 之后,页面整改以“共享样板先行、核心流程优先、低频配置后置”为执行口径。DG9 只承接 UI/布局统一,不替代 WF8 审批通知验收、不替代 UX8 真实反馈池、不替代 BIZ8 财务/OCR/项目业务验收,也不代表已启动部署。
页面分层如下:
- A 类核心流程:审批中心、审批/进度移动页、报销列表、报销详情、移动报销详情、首页、通知中心。这些页面优先使用 DG9 工作台骨架和移动槽位,是后续页面整改的样板。
- B 类业务运营:票据池、AP 台账、付款批次、请假、采购、资产、项目、项目详情等。按 A 类样板迁移,但不新增移动采购创建、不恢复冻结差旅主线、不改变 OCR、付款、导出或项目关联接口。
- C 类管理配置:用户、组织、主数据、审批规则、工作流定义、系统设置、Feature Flag、审计等。保持中后台高密度布局,允许在授权管理场景展示必要内部编号或审计字段,但不改变权限模型、审批规则语义或路由。
DG9 样板规则:
- PC 列表页使用统一工作台骨架:`WorkspaceHeader`、指标条、`FilterBar`、主表格、分页和批量/导出操作区。
- PC 详情页使用统一事实源骨架:顶部摘要、关键字段、材料/附件/发票、审批流、日志和主操作区;同一状态或业务编号不得在同一区域重复展示。
- 移动页使用统一移动槽位:`mobile-card`、`mobile-list-card`、固定状态槽、`mobile-field-row`、安全区和吸底操作,不把桌面 `card-std p-4/p-5/p-6` 主容器回流到专用 `/mobile` 页面。
- 通知仍是事件流,首页只保留一处待办或进度摘要;企业微信继续只承担入口、免登录、个人应用消息和 deeplink。
- 后续页面任务必须先判断页面属于 A/B/C 哪一层,再选择 DG9 样板,不再临时混用多个竞品视觉风格。
参考资料: 参考资料:
- 钉钉审批:https://page.dingtalk.com/wow/dingtalk/act/approval-web - 钉钉审批:https://page.dingtalk.com/wow/dingtalk/act/approval-web
+2 -1
查看文件
@@ -29,6 +29,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GOV8` | `issues/2026-05-13_16-31-09-gov8-system-governance.csv` | `EVIDENCE` | 连续型 Issue 治理、文档台账、流程模板和审计脚本,已收口 | | `GOV8` | `issues/2026-05-13_16-31-09-gov8-system-governance.csv` | `EVIDENCE` | 连续型 Issue 治理、文档台账、流程模板和审计脚本,已收口 |
| `DG8` | `issues/2026-05-13_22-56-49-design-ui-governance.csv` | `EVIDENCE` | 设计治理主参考系统收口;飞书工作台/飞书审批为唯一主 UI/布局参考,不代表页面已整改 | | `DG8` | `issues/2026-05-13_22-56-49-design-ui-governance.csv` | `EVIDENCE` | 设计治理主参考系统收口;飞书工作台/飞书审批为唯一主 UI/布局参考,不代表页面已整改 |
| `DG9` | `issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv` | `CURRENT` | 全站 UI/布局样板迁移;A/B/C 页面分层、共享工作台骨架和移动槽位,不替代 WF8/UX8/BIZ8 |
| `R7E` | `issues/2026-05-13_17-11-44-r7e-human-trialrun.csv` | `CURRENT` | 真实扩大试运行人工确认和执行;R7E-1..7 完成前不得启动真实 R7 | | `R7E` | `issues/2026-05-13_17-11-44-r7e-human-trialrun.csv` | `CURRENT` | 真实扩大试运行人工确认和执行;R7E-1..7 完成前不得启动真实 R7 |
| `ORG8` | `issues/2026-05-13_18-23-51-org8-master-data-permission.csv` | `EVIDENCE` | 组织账号和权限主数据治理已阶段收口;本地代表性角色验收通过,不替代 R7E 真实人工试运行 | | `ORG8` | `issues/2026-05-13_18-23-51-org8-master-data-permission.csv` | `EVIDENCE` | 组织账号和权限主数据治理已阶段收口;本地代表性角色验收通过,不替代 R7E 真实人工试运行 |
| `WF8` | `issues/2026-05-13_18-23-52-wf8-approval-notification.csv` | `CURRENT` | 审批与通知增强;移动审批历史展示方案批准后继续执行 | | `WF8` | `issues/2026-05-13_18-23-52-wf8-approval-notification.csv` | `CURRENT` | 审批与通知增强;移动审批历史展示方案批准后继续执行 |
@@ -45,7 +46,7 @@
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8` | `audit-docs` | none | 2026-05-13 | `audit-docs --check-registry` | medium | 判断文档权威性 | | `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8` | `audit-docs` | none | 2026-05-13 | `audit-docs --check-registry` | medium | 判断文档权威性 |
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 | | `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 |
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 | | `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8` | Playwright/视觉审查/DG8 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考 | | `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
| `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8` | `rg` | none | 2026-05-13 | `rg` | high | Issue、review、commit 和交付规则 | | `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8` | `rg` | none | 2026-05-13 | `rg` | high | Issue、review、commit 和交付规则 |
| `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8` | 回归脚本/Playwright | none | 2026-05-13 | `rg` | high | 测试计划和 DONE 门禁 | | `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8` | 回归脚本/Playwright | none | 2026-05-13 | `rg` | high | 测试计划和 DONE 门禁 |
| `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 | | `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 |
+4 -2
查看文件
@@ -19,13 +19,14 @@
## 最近事实源链 ## 最近事实源链
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8。 R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9。
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。 - R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。 - ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
- QG6 已完成 QA 治理升级。 - QG6 已完成 QA 治理升级。
- MIA7 已完成移动信息架构、视觉和流程审查;MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。 - MIA7 已完成移动信息架构、视觉和流程审查;MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
- DG8 已完成设计治理主参考系统收口:飞书工作台/飞书审批是唯一主 UI/布局参考;本结论只更新设计事实源,不代表已做页面整改。 - DG8 已完成设计治理主参考系统收口:飞书工作台/飞书审批是唯一主 UI/布局参考;本结论只更新设计事实源,不代表已做页面整改。
- DG9 承接 DG8 后的页面整改:按 A/B/C 页面分层和 DG9 样板统一 PC、移动和企业微信入口布局;不替代 WF8、UX8 或 BIZ8 业务验收。
- R7/OPS7/DOC7 是准备材料,不代表已上线或已启动真实 R7。 - R7/OPS7/DOC7 是准备材料,不代表已上线或已启动真实 R7。
## 当前活跃治理流 ## 当前活跃治理流
@@ -36,6 +37,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `GOV8` | `issues/2026-05-13_16-31-09-gov8-system-governance.csv` | 已收口 | 文档分类、台账、流程模板、审计脚本、需求冻结、变更控制 | 不改业务代码、不部署 | | `GOV8` | `issues/2026-05-13_16-31-09-gov8-system-governance.csv` | 已收口 | 文档分类、台账、流程模板、审计脚本、需求冻结、变更控制 | 不改业务代码、不部署 |
| `DG8` | `issues/2026-05-13_22-56-49-design-ui-governance.csv` | 已收口:设计事实源主参考系统更新 | 主 UI/布局参考层级、外部参考降级、Agent/Skill 设计入口、设计检查门禁 | 不改业务页面、不部署、不接入飞书 | | `DG8` | `issues/2026-05-13_22-56-49-design-ui-governance.csv` | 已收口:设计事实源主参考系统更新 | 主 UI/布局参考层级、外部参考降级、Agent/Skill 设计入口、设计检查门禁 | 不改业务页面、不部署、不接入飞书 |
| `DG9` | `issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv` | 当前执行:全站 UI/布局样板迁移 | A/B/C 页面分层、共享工作台骨架、移动槽位、审批/报销/首页/通知样板、设计门禁 | 不改 server、不改 API/schema/权限/状态机、不部署 |
| `R7E` | `issues/2026-05-13_17-11-44-r7e-human-trialrun.csv` | 当前执行:等待人工确认 | 人工确认、真实试运行、每日问题收口、验收结论 | 不新增 P2 能力 | | `R7E` | `issues/2026-05-13_17-11-44-r7e-human-trialrun.csv` | 当前执行:等待人工确认 | 人工确认、真实试运行、每日问题收口、验收结论 | 不新增 P2 能力 |
| `ORG8` | `issues/2026-05-13_18-23-51-org8-master-data-permission.csv` | 已收口:本地代表性角色验收通过 | 组织、账号、直属主管、停用、角色、企业微信绑定、权限边界 | 不做审批流程增强;不替代 R7E 真实人工试运行 | | `ORG8` | `issues/2026-05-13_18-23-51-org8-master-data-permission.csv` | 已收口:本地代表性角色验收通过 | 组织、账号、直属主管、停用、角色、企业微信绑定、权限边界 | 不做审批流程增强;不替代 R7E 真实人工试运行 |
| `WF8` | `issues/2026-05-13_18-23-52-wf8-approval-notification.csv` | 当前执行:待移动审批历史方案批准 | 转审、通知、催办、邮件通道决策、三渠道验收 | 不做复杂 BPM 或审批设计器 | | `WF8` | `issues/2026-05-13_18-23-52-wf8-approval-notification.csv` | 当前执行:待移动审批历史方案批准 | 转审、通知、催办、邮件通道决策、三渠道验收 | 不做复杂 BPM 或审批设计器 |
@@ -62,7 +64,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| --- | --- | | --- | --- |
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源 | | [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源 |
| [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 | | [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 |
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考 | | [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 |
| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则 | | [产品口径](./产品口径.md) | 已拍板产品边界和业务规则 |
| [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | 审批、催办、超时提醒、审批效率 | | [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | 审批、催办、超时提醒、审批效率 |
| [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 | | [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 |
@@ -0,0 +1,14 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
DG9-0,DG9 总合同建档,建立 DG9 全站 UI/布局统一迁移合同,承接 DG8 后的页面整改,不追加到 DG8/WF8/UX8/BIZ8 旧 CSV。,1) CSV 列完整并校验通过;2) 范围限定 client/src、client/tests、scripts/qa、docs、.agents/skills、issues;3) 不改 server;4) 不部署。,流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv;人工核对 Files 不含 server。,python3|manual,DONE,DONE,DONE,issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv,[],回滚路径:删除本 CSV 和本轮 DG9 变更;不影响运行环境。2026-05-13 CSV 校验通过,Files 不含 server。
DG9-1,全站页面审计与分层,按 DG8 主 UI/布局参考层级审计当前 PC、移动和企业微信可达页面,形成 A/B/C 分层,避免把冻结或低频能力拉入主线整改。,1) 文档索引或事实源矩阵登记 DG9;2) A/B/C 页面分层写入设计事实源;3) 明确 WF8/UX8/BIZ8 未关闭项不被 DG9 替代;4) 不新增业务能力。,流程级验证:rg -n 'DG9|A 类核心流程|B 类业务运营|C 类管理配置|不替代 WF8|不替代 UX8|不替代 BIZ8' docs/产品设计规则.md docs/文档索引.md docs/文档事实源状态矩阵.md。,rg|manual,DONE,DONE,DONE,docs/产品设计规则.md|docs/文档索引.md|docs/文档事实源状态矩阵.md,"[""DG9-0""]",2026-05-13 rg 验证 DG9、A/B/C 分层和不替代 WF8/UX8/BIZ8 均已登记;只做事实源分层。
DG9-2,共享 PC 移动布局组件契约,复用并扩展现有 ListWorkspace、MetricCard、PageScaffold、移动 card/token,形成 PC 工作台和移动槽位的共享布局契约,不重建第二套 UI 系统。,1) PC 列表页可使用统一标题、指标、筛选、表格和空状态骨架;2) 移动页保留统一 shell、槽位、卡片、安全区和吸底操作;3) 不改变业务数据接口;4) typecheck/build 通过。,流程级验证:cd client && npm run typecheck && npm run build;人工核对共享组件不引入 server/API 依赖。,typescript|vite|manual,DONE,DONE,DONE,client/src/components/patterns|client/src/styles/mobile-touch.css,"[""DG9-1""]",2026-05-13 typecheck/build 通过;复用 ListWorkspace/MetricCard/PageScaffold 与移动 token,无 server/API 依赖。
DG9-3,PC 审批中心样板,将 WorkflowTasksPage 迁移为高密度工作台样板,统一摘要、筛选、表格和操作区,同时保留现有 testid、审批行为和查询参数。,1) 审批中心使用统一工作台骨架;2) 待办、已办、业务类型筛选和分页行为不变;3) 快速通过和查看详情入口保留;4) workflow 相关 Playwright 或类型构建通过。,流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/workflow-quick-approve.spec.ts --workers=1 --reporter=list。,typescript|vite|playwright,DONE,DONE,DONE,client/src/pages/WorkflowTasksPage.tsx,"[""DG9-2""]",2026-05-13 workflow-quick-approve 通过;本地后端需以 APP_ORIGIN=http://127.0.0.1:5173 启动以通过 refresh origin 校验。
DG9-4,PC 移动详情页样板,按摘要、关键字段、材料/附件/发票、审批流、日志和操作区统一审批详情、报销详情、移动报销详情和移动采购详情。,1) PC 详情页分区清楚且高密度;2) 移动深层详情隐藏底部 Tab 并使用统一返回、状态和吸底操作;3) 业务编号和状态不重复;4) 报销/审批详情回归通过。,流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/reimbursement-workflow-progress.spec.ts --workers=1 --reporter=list。,typescript|vite|playwright,DONE,DONE,DONE,client/src/pages/*DetailPage.tsx|client/src/pages/reimbursement-detail,"[""DG9-2"",""DG9-3""]",2026-05-13 reimbursement-workflow-progress 通过;测试数据补一条报销明细以符合当前发票绑定规则,页面状态按 DG9 标签断言。
DG9-5,移动审批进度样板,将 MobileApprovalsPage 按员工进度、审批人待办、深层 Push 详情和吸底操作统一,不改离线队列、转审或历史逻辑。,1) 员工和审批人视角仍按角色裁剪;2) 审批/进度卡片使用统一移动槽位;3) 深层详情不显示底部 Tab;4) 移动矩阵关键用例通过。,流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r6-mobile-unified-design-rollout.spec.ts --workers=1 --reporter=list。,typescript|vite|playwright,DONE,DONE,DONE,client/src/pages/MobileApprovalsPage.tsx|client/src/components/mobile,"[""DG9-2"",""DG9-4""]",2026-05-13 dg7-mobile-information-dedupe 与 r6-mobile-unified-design-rollout 共 12 passed;代表性真机未执行,记录 PAUSED,不宣称真机完成。
DG9-6,首页与通知迁移,统一 PC 首页、移动首页和通知中心为工作台摘要、事件流和 deeplink 直达结构,避免待办重复展示。,1) 首页只保留一处待办或进度摘要;2) 通知中心作为事件流,不展示审批动作副本;3) 点击通知仍直达业务详情或审批详情;4) 移动和企业微信入口不误入 PC 壳。,流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts tests/e2e/r103-cross-page-ui-consistency.spec.ts --workers=1 --reporter=list。,typescript|vite|playwright,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileNotificationsPage.tsx|client/src/pages/NotificationsPage.tsx,"[""DG9-5""]",2026-05-13 r6-wecom-mobile-rendering-hardening 8 passed,r103-cross-page-ui-consistency 3 passed;不改企业微信登录/通知/deeplink 契约。
DG9-7,报销票据财务迁移,统一报销列表、票据池、AP 台账、付款批次和财务摘要的列表、筛选、状态标签和操作密度。,1) 报销列表继续支持草稿、新建、删除、批量审批和导出;2) 票据和财务页不暴露内部术语到普通用户主视图;3) 财务敏感入口权限不变;4) 报销和财务页面构建通过。,流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/reimbursement-workflow-progress.spec.ts tests/e2e/biz8-ap-ledger-project.spec.ts --workers=1 --reporter=list。,typescript|vite|playwright,DONE,DONE,DONE,client/src/pages/ReimbursementsPage.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/ApOperationsPage.tsx|client/src/pages/PaymentBatchesPage.tsx,"[""DG9-4""]",2026-05-13 reimbursement-workflow-progress 与 biz8-ap-ledger-project 通过;不改 OCR、付款、导出或项目关联 API。
DG9-8,请假采购资产项目页迁移,将现有可达的请假、采购、资产和项目页统一到 DG9 卡片、表格、详情和状态标签规则,不新增移动采购创建,不恢复冻结差旅主线。,1) 请假、采购、资产和项目页使用统一工作台或业务列表骨架;2) 移动采购仍只保留已实现边界;3) 不新增后端字段或路由;4) 相关页面构建通过。,流程级验证:cd client && npm run typecheck && npm run build;人工按请假、采购、资产、项目入口核对布局和边界。,typescript|vite|manual,DONE,DONE,DONE,client/src/pages/LeaveRequestPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx|client/src/pages/ProjectsPage.tsx,"[""DG9-4""]",2026-05-13 typecheck/build 与 project-wide-visual 通过;代码核对未新增移动采购创建,差旅仍不恢复主线。
DG9-9,管理配置页迁移,将用户、组织、审批规则、工作流定义、主数据和系统设置等配置页统一为中后台高密度布局,不改变权限。,1) 配置页使用统一标题、筛选、表格和操作区语义;2) 管理页面仍可展示必要内部 ID 或审计字段;3) 权限和路由不变;4) 构建通过。,流程级验证:cd client && npm run typecheck && npm run build;人工以管理员入口核对用户、组织、审批规则、工作流定义和主数据页面。,typescript|vite|manual,DONE,DONE,DONE,client/src/pages/*Management*|client/src/pages/ApprovalRulesPage.tsx|client/src/pages/WorkflowDefinitionsPage.tsx|client/src/pages/MasterDataPage.tsx|client/src/pages/SystemSettingsPage.tsx,"[""DG9-2""]",2026-05-13 typecheck/build 与 project-wide-visual 通过;代码核对仅做配置页布局密度统一,不改权限模型。
DG9-10,设计检查与视觉门禁,扩展设计检查脚本和 Playwright 视觉门禁,覆盖竞品文案、移动黑色主控件、移动桌面容器回流和 DG9 样板关键页面。,1) check-ui-design-rules 可解析;2) self-test 通过;3) strict 模式 0 findings 或有明确记录;4) 视觉用例覆盖 DG9 核心页面。,流程级验证:node --check scripts/qa/check-ui-design-rules.mjs;node scripts/qa/check-ui-design-rules.mjs --self-test;node scripts/qa/check-ui-design-rules.mjs --strict;cd client && npx playwright test tests/e2e/project-wide-visual.spec.ts --workers=1 --reporter=list。,node|playwright,DONE,DONE,DONE,scripts/qa/check-ui-design-rules.mjs|client/tests/e2e,"[""DG9-5"",""DG9-7"",""DG9-8"",""DG9-9""]",2026-05-13 node --check/self-test/strict 通过,strict 0 findings;project-wide-visual 1 passed。
DG9-11,文档 Skill 事实源同步,同步产品设计规则、文档索引、事实源矩阵和 hyb-oa Skill,记录 DG9 样板与迁移状态,要求后续页面先按 DG9 样板选型。,1) 产品设计规则记录 DG9 页面分层和样板;2) 文档索引和事实源矩阵登记 DG9;3) Skill 提醒后续页面先按 DG9 样板选型;4) Skill 校验通过。,流程级验证:python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;rg -n 'DG9|页面分层|样板|工作台骨架' docs/产品设计规则.md docs/文档索引.md docs/文档事实源状态矩阵.md .agents/skills/hyb-oa-delivery/SKILL.md。,python3|rg,DONE,DONE,DONE,docs/产品设计规则.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md,"[""DG9-10""]",2026-05-13 Skill quick_validate 通过;产品设计规则、文档索引、事实源矩阵和 Skill 已同步 DG9 样板选型。
DG9-12,总回归与阶段收口,完成 DG9 状态回填、自动化/人工验证摘要、中文提交并明确未部署。,1) CSV 状态真实回填;2) validate_issues_csv 通过;3) audit-docs registry 校验通过或记录既有 warning;4) git diff --check 通过;5) 中文 commit 引用 DG9;6) 最终说明未部署。,流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv;node scripts/qa/audit-docs.mjs --check-registry;git diff --check;人工确认未修改 server 且未部署。,python3|node|git|manual,DONE,DONE,DONE,issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv|client/src|client/tests|docs|scripts/qa,"[""DG9-11""]",2026-05-13 validate_issues_csv、audit-docs、git diff --check 通过;audit-docs 保留既有 unregistered docs files: 24 warning;未部署。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 DG9-0 DG9 总合同建档 建立 DG9 全站 UI/布局统一迁移合同,承接 DG8 后的页面整改,不追加到 DG8/WF8/UX8/BIZ8 旧 CSV。 1) CSV 列完整并校验通过;2) 范围限定 client/src、client/tests、scripts/qa、docs、.agents/skills、issues;3) 不改 server;4) 不部署。 流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv;人工核对 Files 不含 server。 python3|manual DONE DONE DONE issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv [] 回滚路径:删除本 CSV 和本轮 DG9 变更;不影响运行环境。2026-05-13 CSV 校验通过,Files 不含 server。
3 DG9-1 全站页面审计与分层 按 DG8 主 UI/布局参考层级审计当前 PC、移动和企业微信可达页面,形成 A/B/C 分层,避免把冻结或低频能力拉入主线整改。 1) 文档索引或事实源矩阵登记 DG9;2) A/B/C 页面分层写入设计事实源;3) 明确 WF8/UX8/BIZ8 未关闭项不被 DG9 替代;4) 不新增业务能力。 流程级验证:rg -n 'DG9|A 类核心流程|B 类业务运营|C 类管理配置|不替代 WF8|不替代 UX8|不替代 BIZ8' docs/产品设计规则.md docs/文档索引.md docs/文档事实源状态矩阵.md。 rg|manual DONE DONE DONE docs/产品设计规则.md|docs/文档索引.md|docs/文档事实源状态矩阵.md ["DG9-0"] 2026-05-13 rg 验证 DG9、A/B/C 分层和不替代 WF8/UX8/BIZ8 均已登记;只做事实源分层。
4 DG9-2 共享 PC 移动布局组件契约 复用并扩展现有 ListWorkspace、MetricCard、PageScaffold、移动 card/token,形成 PC 工作台和移动槽位的共享布局契约,不重建第二套 UI 系统。 1) PC 列表页可使用统一标题、指标、筛选、表格和空状态骨架;2) 移动页保留统一 shell、槽位、卡片、安全区和吸底操作;3) 不改变业务数据接口;4) typecheck/build 通过。 流程级验证:cd client && npm run typecheck && npm run build;人工核对共享组件不引入 server/API 依赖。 typescript|vite|manual DONE DONE DONE client/src/components/patterns|client/src/styles/mobile-touch.css ["DG9-1"] 2026-05-13 typecheck/build 通过;复用 ListWorkspace/MetricCard/PageScaffold 与移动 token,无 server/API 依赖。
5 DG9-3 PC 审批中心样板 将 WorkflowTasksPage 迁移为高密度工作台样板,统一摘要、筛选、表格和操作区,同时保留现有 testid、审批行为和查询参数。 1) 审批中心使用统一工作台骨架;2) 待办、已办、业务类型筛选和分页行为不变;3) 快速通过和查看详情入口保留;4) workflow 相关 Playwright 或类型构建通过。 流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/workflow-quick-approve.spec.ts --workers=1 --reporter=list。 typescript|vite|playwright DONE DONE DONE client/src/pages/WorkflowTasksPage.tsx ["DG9-2"] 2026-05-13 workflow-quick-approve 通过;本地后端需以 APP_ORIGIN=http://127.0.0.1:5173 启动以通过 refresh origin 校验。
6 DG9-4 PC 移动详情页样板 按摘要、关键字段、材料/附件/发票、审批流、日志和操作区统一审批详情、报销详情、移动报销详情和移动采购详情。 1) PC 详情页分区清楚且高密度;2) 移动深层详情隐藏底部 Tab 并使用统一返回、状态和吸底操作;3) 业务编号和状态不重复;4) 报销/审批详情回归通过。 流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/reimbursement-workflow-progress.spec.ts --workers=1 --reporter=list。 typescript|vite|playwright DONE DONE DONE client/src/pages/*DetailPage.tsx|client/src/pages/reimbursement-detail ["DG9-2","DG9-3"] 2026-05-13 reimbursement-workflow-progress 通过;测试数据补一条报销明细以符合当前发票绑定规则,页面状态按 DG9 标签断言。
7 DG9-5 移动审批进度样板 将 MobileApprovalsPage 按员工进度、审批人待办、深层 Push 详情和吸底操作统一,不改离线队列、转审或历史逻辑。 1) 员工和审批人视角仍按角色裁剪;2) 审批/进度卡片使用统一移动槽位;3) 深层详情不显示底部 Tab;4) 移动矩阵关键用例通过。 流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r6-mobile-unified-design-rollout.spec.ts --workers=1 --reporter=list。 typescript|vite|playwright DONE DONE DONE client/src/pages/MobileApprovalsPage.tsx|client/src/components/mobile ["DG9-2","DG9-4"] 2026-05-13 dg7-mobile-information-dedupe 与 r6-mobile-unified-design-rollout 共 12 passed;代表性真机未执行,记录 PAUSED,不宣称真机完成。
8 DG9-6 首页与通知迁移 统一 PC 首页、移动首页和通知中心为工作台摘要、事件流和 deeplink 直达结构,避免待办重复展示。 1) 首页只保留一处待办或进度摘要;2) 通知中心作为事件流,不展示审批动作副本;3) 点击通知仍直达业务详情或审批详情;4) 移动和企业微信入口不误入 PC 壳。 流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts tests/e2e/r103-cross-page-ui-consistency.spec.ts --workers=1 --reporter=list。 typescript|vite|playwright DONE DONE DONE client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileNotificationsPage.tsx|client/src/pages/NotificationsPage.tsx ["DG9-5"] 2026-05-13 r6-wecom-mobile-rendering-hardening 8 passed,r103-cross-page-ui-consistency 3 passed;不改企业微信登录/通知/deeplink 契约。
9 DG9-7 报销票据财务迁移 统一报销列表、票据池、AP 台账、付款批次和财务摘要的列表、筛选、状态标签和操作密度。 1) 报销列表继续支持草稿、新建、删除、批量审批和导出;2) 票据和财务页不暴露内部术语到普通用户主视图;3) 财务敏感入口权限不变;4) 报销和财务页面构建通过。 流程级验证:cd client && npm run typecheck && npm run build;cd client && npx playwright test tests/e2e/reimbursement-workflow-progress.spec.ts tests/e2e/biz8-ap-ledger-project.spec.ts --workers=1 --reporter=list。 typescript|vite|playwright DONE DONE DONE client/src/pages/ReimbursementsPage.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/ApOperationsPage.tsx|client/src/pages/PaymentBatchesPage.tsx ["DG9-4"] 2026-05-13 reimbursement-workflow-progress 与 biz8-ap-ledger-project 通过;不改 OCR、付款、导出或项目关联 API。
10 DG9-8 请假采购资产项目页迁移 将现有可达的请假、采购、资产和项目页统一到 DG9 卡片、表格、详情和状态标签规则,不新增移动采购创建,不恢复冻结差旅主线。 1) 请假、采购、资产和项目页使用统一工作台或业务列表骨架;2) 移动采购仍只保留已实现边界;3) 不新增后端字段或路由;4) 相关页面构建通过。 流程级验证:cd client && npm run typecheck && npm run build;人工按请假、采购、资产、项目入口核对布局和边界。 typescript|vite|manual DONE DONE DONE client/src/pages/LeaveRequestPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/AssetRegistryPage.tsx|client/src/pages/ProjectsPage.tsx ["DG9-4"] 2026-05-13 typecheck/build 与 project-wide-visual 通过;代码核对未新增移动采购创建,差旅仍不恢复主线。
11 DG9-9 管理配置页迁移 将用户、组织、审批规则、工作流定义、主数据和系统设置等配置页统一为中后台高密度布局,不改变权限。 1) 配置页使用统一标题、筛选、表格和操作区语义;2) 管理页面仍可展示必要内部 ID 或审计字段;3) 权限和路由不变;4) 构建通过。 流程级验证:cd client && npm run typecheck && npm run build;人工以管理员入口核对用户、组织、审批规则、工作流定义和主数据页面。 typescript|vite|manual DONE DONE DONE client/src/pages/*Management*|client/src/pages/ApprovalRulesPage.tsx|client/src/pages/WorkflowDefinitionsPage.tsx|client/src/pages/MasterDataPage.tsx|client/src/pages/SystemSettingsPage.tsx ["DG9-2"] 2026-05-13 typecheck/build 与 project-wide-visual 通过;代码核对仅做配置页布局密度统一,不改权限模型。
12 DG9-10 设计检查与视觉门禁 扩展设计检查脚本和 Playwright 视觉门禁,覆盖竞品文案、移动黑色主控件、移动桌面容器回流和 DG9 样板关键页面。 1) check-ui-design-rules 可解析;2) self-test 通过;3) strict 模式 0 findings 或有明确记录;4) 视觉用例覆盖 DG9 核心页面。 流程级验证:node --check scripts/qa/check-ui-design-rules.mjs;node scripts/qa/check-ui-design-rules.mjs --self-test;node scripts/qa/check-ui-design-rules.mjs --strict;cd client && npx playwright test tests/e2e/project-wide-visual.spec.ts --workers=1 --reporter=list。 node|playwright DONE DONE DONE scripts/qa/check-ui-design-rules.mjs|client/tests/e2e ["DG9-5","DG9-7","DG9-8","DG9-9"] 2026-05-13 node --check/self-test/strict 通过,strict 0 findings;project-wide-visual 1 passed。
13 DG9-11 文档 Skill 事实源同步 同步产品设计规则、文档索引、事实源矩阵和 hyb-oa Skill,记录 DG9 样板与迁移状态,要求后续页面先按 DG9 样板选型。 1) 产品设计规则记录 DG9 页面分层和样板;2) 文档索引和事实源矩阵登记 DG9;3) Skill 提醒后续页面先按 DG9 样板选型;4) Skill 校验通过。 流程级验证:python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;rg -n 'DG9|页面分层|样板|工作台骨架' docs/产品设计规则.md docs/文档索引.md docs/文档事实源状态矩阵.md .agents/skills/hyb-oa-delivery/SKILL.md。 python3|rg DONE DONE DONE docs/产品设计规则.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md ["DG9-10"] 2026-05-13 Skill quick_validate 通过;产品设计规则、文档索引、事实源矩阵和 Skill 已同步 DG9 样板选型。
14 DG9-12 总回归与阶段收口 完成 DG9 状态回填、自动化/人工验证摘要、中文提交并明确未部署。 1) CSV 状态真实回填;2) validate_issues_csv 通过;3) audit-docs registry 校验通过或记录既有 warning;4) git diff --check 通过;5) 中文 commit 引用 DG9;6) 最终说明未部署。 流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv;node scripts/qa/audit-docs.mjs --check-registry;git diff --check;人工确认未修改 server 且未部署。 python3|node|git|manual DONE DONE DONE issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv|client/src|client/tests|docs|scripts/qa ["DG9-11"] 2026-05-13 validate_issues_csv、audit-docs、git diff --check 通过;audit-docs 保留既有 unregistered docs files: 24 warning;未部署。
+43
查看文件
@@ -339,6 +339,38 @@ function scanMobilePcShell(file, source) {
} }
} }
function scanMobileDesktopContainers(file, source) {
if (!isMobileSourceFile(file)) {
return;
}
const patterns = [
{
regex: /\bcard-std\b[^"'`]{0,80}\bp-[45]\b/g,
evidence: "card-std p-4/p-5",
},
{
regex: /\bcard-std\b[^"'`]{0,80}\bp-6\b/g,
evidence: "card-std p-6",
},
];
for (const pattern of patterns) {
let match = pattern.regex.exec(source);
while (match) {
addFinding({
rule: "mobile.no_desktop_card_container",
severity: "error",
file,
line: lineNumber(source, match.index),
message: "专用 /mobile 页面不应把桌面 card-std 大容器作为主内容,应使用 mobile-card、mobile-list-card 或移动表单分组。",
evidence: match[0],
});
match = pattern.regex.exec(source);
}
}
}
function scanRawBusinessIdentifiers(file, source) { function scanRawBusinessIdentifiers(file, source) {
const normalized = toPosix(file); const normalized = toPosix(file);
if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) { if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) {
@@ -542,6 +574,7 @@ function scanFileSource(file, source) {
scanMobileCapturePdfInputs(file, source); scanMobileCapturePdfInputs(file, source);
scanMobileDesktopTables(file, source); scanMobileDesktopTables(file, source);
scanMobilePcShell(file, source); scanMobilePcShell(file, source);
scanMobileDesktopContainers(file, source);
scanUuidPrimaryDisplay(file, source); scanUuidPrimaryDisplay(file, source);
scanRawBusinessIdentifiers(file, source); scanRawBusinessIdentifiers(file, source);
scanReimbursementRawCodeDisplay(file, source); scanReimbursementRawCodeDisplay(file, source);
@@ -575,6 +608,16 @@ function runSelfTest() {
], ],
expectedSuppressedRules: [], expectedSuppressedRules: [],
}, },
{
file: "client/src/pages/MobileDesktopCardFixture.tsx",
source: `
export function MobileDesktopCardFixture() {
return <main className="card-std space-y-4 p-6">内容</main>;
}
`,
expectedRules: ["mobile.no_desktop_card_container"],
expectedSuppressedRules: [],
},
{ {
file: "client/src/pages/AuditLogsPage.tsx", file: "client/src/pages/AuditLogsPage.tsx",
source: ` source: `