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`.
- 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 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`.
@@ -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.
3. State the implementation approach before editing code; if already approved, proceed.
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.
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.
10. Update Issue CSV statuses only after the stated `Test_Method` passes.
11. Commit in Chinese batches with Issue IDs.
@@ -1,22 +1,104 @@
import type { ReactNode } from "react";
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>;
}
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>;
}
export function StatStrip({ className, children }: { className?: string; children: ReactNode }) {
return <section className={cn("grid gap-4 md:grid-cols-3", className)}>{children}</section>;
export function FilterGrid({ className, children }: WorkspaceChildrenProps) {
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>;
}
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>;
}
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 { hasPermission } from "../auth/permissions";
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 {
Dialog,
@@ -605,12 +605,12 @@ export function ApOperationsPage() {
};
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 (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
title="财务付款与对账"
subtitle="集中处理待付款批次、异常付款、风险标记和对账异常。"
actions={
@@ -671,7 +671,7 @@ export function ApOperationsPage() {
/>
</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)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger>
@@ -1630,6 +1630,6 @@ export function ApOperationsPage() {
await executeMutation.mutateAsync(retryTarget.batchId);
}}
/>
</div>
</ListWorkspace>
);
}
+8 -9
查看文件
@@ -10,7 +10,7 @@ import {
type ApprovalRoleCode,
type ApprovalRule,
} from "../api/approvalRules";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input";
@@ -144,8 +144,8 @@ export function ApprovalRulesPage() {
}, [editOpen, editRule]);
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
title="审批规则"
subtitle="维护金额段与审批层级(仅影响新提交的报销单,历史流程不回写)。"
actions={
@@ -161,7 +161,7 @@ export function ApprovalRulesPage() {
</p>
) : 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>
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
@@ -286,7 +286,7 @@ export function ApprovalRulesPage() {
</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>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
@@ -304,7 +304,7 @@ export function ApprovalRulesPage() {
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
<div className="rounded-lg border border-zinc-200">
<PrimaryTable>
<Table data-testid="approval-rules-table">
<TableHeader>
<tr>
@@ -371,7 +371,7 @@ export function ApprovalRulesPage() {
)}
</TableBody>
</Table>
</div>
</PrimaryTable>
) : null}
</div>
@@ -516,7 +516,6 @@ export function ApprovalRulesPage() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</ListWorkspace>
);
}
+12 -12
查看文件
@@ -27,10 +27,10 @@ import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from ".
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -578,8 +578,8 @@ export function AssetRegistryPage() {
};
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
title="资产台账"
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
actions={
@@ -597,7 +597,7 @@ export function AssetRegistryPage() {
/>
<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">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{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>
<div className="hidden rounded-lg border border-zinc-200 md:block">
<PrimaryTable className="hidden md:block">
<Table>
<TableHeader>
<tr>
@@ -691,13 +691,13 @@ export function AssetRegistryPage() {
))}
</TableBody>
</Table>
</div>
</PrimaryTable>
</>
) : null}
</div>
<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">
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }>
{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>
<div className="hidden rounded-lg border border-zinc-200 md:block">
<PrimaryTable className="hidden md:block">
<Table>
<TableHeader>
<tr>
@@ -830,12 +830,12 @@ export function AssetRegistryPage() {
)}
</TableBody>
</Table>
</div>
</PrimaryTable>
</>
) : 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 ? (
<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>
</ListWorkspace>
);
}
+4 -4
查看文件
@@ -28,7 +28,7 @@ import { hasPermission } from "../auth/permissions";
import { toast } from "sonner";
import { downloadBlob } from "../lib/download";
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
@@ -446,8 +446,8 @@ export function InvoicePoolPage() {
// --- Render ---
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
title="票据中心"
subtitle="上传、识别、绑定。"
actions={
@@ -569,6 +569,6 @@ export function InvoicePoolPage() {
confirmAction={confirmAction}
setConfirmAction={setConfirmAction}
/>
</div>
</ListWorkspace>
);
}
+4 -5
查看文件
@@ -20,11 +20,10 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
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 { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -780,12 +779,12 @@ export function LeaveRequestPage() {
return (
<ListWorkspace>
<PageHeader
<WorkspaceHeader
title="请假申请"
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.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
@@ -795,7 +794,7 @@ export function LeaveRequestPage() {
note="创建草稿前可先预览天数"
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
/>
</section>
</StatStrip>
<div className="page-shell-grid">
<SectionPanel title={formTitle} description={formDescription}>
+24 -20
查看文件
@@ -22,9 +22,9 @@ import { hasAnyPermission, hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab";
import { SupplierTab } from "../components/master-data/SupplierTab";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -292,16 +292,16 @@ export function MasterDataPage() {
if (!canView) {
return (
<div className="space-y-4">
<PageHeader title="主数据" subtitle="你当前没有主数据权限,请联系管理员开通。" />
<div className="card-std p-6 text-sm text-zinc-600">所需权限:masterdata.view 或 masterdata.manage</div>
</div>
<ListWorkspace>
<WorkspaceHeader title="主数据" subtitle="你当前没有主数据权限,请联系管理员开通。" />
<div className="card-std p-4 text-sm text-zinc-600 md:p-5">所需权限:masterdata.view 或 masterdata.manage</div>
</ListWorkspace>
);
}
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
title="主数据"
subtitle="集中维护法体、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一骨架。"
actions={
@@ -325,7 +325,7 @@ export function MasterDataPage() {
</TabsList>
<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">
<h3 className="text-base font-semibold text-zinc-900">法体筛选</h3>
<select
@@ -338,10 +338,10 @@ export function MasterDataPage() {
<option value="false">仅停用</option>
</select>
</div>
</div>
</FilterBar>
{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>
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1">
@@ -377,10 +377,11 @@ export function MasterDataPage() {
</div>
) : 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>
{legalEntitiesQuery.isLoading ? <TableSkeleton columns={6} rows={4} /> : null}
{!legalEntitiesQuery.isLoading ? (
<PrimaryTable>
<Table>
<TableHeader>
<TableRow>
@@ -431,12 +432,13 @@ export function MasterDataPage() {
)}
</TableBody>
</Table>
</PrimaryTable>
) : null}
</div>
</TabsContent>
<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">
<select
className="select-std"
@@ -462,10 +464,10 @@ export function MasterDataPage() {
))}
</select>
</div>
</div>
</FilterBar>
{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>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1 md:col-span-2">
@@ -532,10 +534,11 @@ export function MasterDataPage() {
</div>
) : 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>
{costCentersQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{!costCentersQuery.isLoading ? (
<PrimaryTable>
<Table>
<TableHeader>
<TableRow>
@@ -588,12 +591,13 @@ export function MasterDataPage() {
)}
</TableBody>
</Table>
</PrimaryTable>
) : null}
</div>
</TabsContent>
<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">
<select
className="select-std"
@@ -619,10 +623,10 @@ export function MasterDataPage() {
))}
</select>
</div>
</div>
</FilterBar>
{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>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1">
@@ -693,7 +697,7 @@ export function MasterDataPage() {
</div>
) : 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>
{documentCategoriesQuery.isLoading ? <TableSkeleton columns={7} rows={4} /> : null}
{!documentCategoriesQuery.isLoading ? (
@@ -790,6 +794,6 @@ export function MasterDataPage() {
}
}}
/>
</div>
</ListWorkspace>
);
}
+70 -48
查看文件
@@ -999,11 +999,11 @@ export function MobileApprovalsPage() {
) : null}
{!isPushDetail && !canAccessRemoteApprovals ? (
<section className="mobile-card space-y-3" data-testid="mobile-approvals-progress">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">我发起的进度</h3>
<p className="mt-1 text-sm text-zinc-500">报销、请假、采购进度</p>
<section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-progress">
<div className="mobile-section-header">
<div className="min-w-0">
<h3 className="mobile-section-title">我发起的进度</h3>
<p className="mobile-list-card__subtitle mt-1">报销、请假、采购进度</p>
</div>
<span className="badge-neutral">{employeeProgressCards.length} 条</span>
</div>
@@ -1028,7 +1028,7 @@ export function MobileApprovalsPage() {
<div className="mobile-empty-state">暂无申请进度</div>
) : null}
<div className="space-y-2">
<div className="mobile-page-stack">
{employeeProgressCards.map((item) => (
<Link
className="mobile-list-card block"
@@ -1037,12 +1037,14 @@ export function MobileApprovalsPage() {
to={item.to}
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="truncate text-sm font-semibold text-zinc-900">{item.title}</div>
{item.subtitle ? <div className="mt-1 truncate text-xs text-zinc-500">{item.subtitle}</div> : null}
<div className="mobile-list-card__title">{item.title}</div>
{item.subtitle ? <div className="mobile-list-card__subtitle mt-1">{item.subtitle}</div> : null}
</div>
<StatusBadge status={item.status} />
<span className="mobile-status-slot">
<StatusBadge status={item.status} />
</span>
</div>
</Link>
))}
@@ -1051,13 +1053,13 @@ export function MobileApprovalsPage() {
) : null}
{!isPushDetail && canAccessRemoteApprovals ? (
<section className="mobile-card space-y-4" data-testid="mobile-approvals-list">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3>
<p className="mt-1 text-sm text-zinc-500">{filteredTaskCards.length} 条</p>
<section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-list">
<div className="mobile-section-header">
<div className="min-w-0">
<h3 className="mobile-section-title">{listTitle}</h3>
<p className="mobile-list-card__subtitle mt-1">{filteredTaskCards.length} 条</p>
</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>
<Tabs value={taskTab} onValueChange={handleTaskTabChange}>
@@ -1195,7 +1197,7 @@ export function MobileApprovalsPage() {
</p>
) : null}
<div className="space-y-2">
<div className="mobile-page-stack">
{filteredTaskCards.map((task) => {
const isSelected = selectedTaskId === task.id;
const isBatchSelected = selectedBatchTaskIds.includes(task.id);
@@ -1204,7 +1206,7 @@ export function MobileApprovalsPage() {
return (
<div
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"
}`}
>
@@ -1221,12 +1223,14 @@ export function MobileApprovalsPage() {
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{task.title}</div>
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
<div className="mobile-list-card__header">
<div className="mobile-list-card__title">{task.title}</div>
<span className="mobile-status-slot">
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</span>
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
<div className="mt-1 text-xs text-zinc-500">
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
@@ -1245,12 +1249,14 @@ export function MobileApprovalsPage() {
onClick={() => handleTaskSelection(task.id)}
type="button"
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{task.title}</div>
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
<div className="mobile-list-card__header">
<div className="mobile-list-card__title">{task.title}</div>
<span className="mobile-status-slot">
<StatusBadge status={task.status} label={workflowStatusLabel(task.status)} />
</span>
</div>
<div className="mt-1 text-xs text-zinc-500">{task.subtitle}</div>
<div className="mt-1 text-xs text-zinc-500">
<div className="mobile-list-card__subtitle mt-1">{task.subtitle}</div>
<div className="mt-1 min-w-0 truncate text-xs text-zinc-500">
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
</div>
{task.isCached ? <div className="mt-1 text-xs text-zinc-500">来源:离线缓存</div> : null}
@@ -1262,8 +1268,8 @@ export function MobileApprovalsPage() {
) : null}
{isPushDetail ? (
<section className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
<h3 className="text-base font-semibold text-zinc-900">处理</h3>
<section className="mobile-card mobile-page-stack scroll-mt-4" data-testid="mobile-approvals-detail">
<h3 className="mobile-section-title">处理</h3>
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
@@ -1286,12 +1292,14 @@ export function MobileApprovalsPage() {
{selectedTask ? (
<>
<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="truncate font-semibold text-zinc-900">{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__title">{selectedDetailTitle}</div>
<div className="mobile-list-card__subtitle mt-1">{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}</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 className="mobile-field-list mt-3">
<div className="mobile-field-row">
@@ -1343,10 +1351,10 @@ export function MobileApprovalsPage() {
) : null}
</div>
<div className="space-y-2" data-testid="mobile-approvals-history">
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold text-zinc-900">处理历史</h4>
<span className="text-xs text-zinc-500">{selectedTask.history.length} 条</span>
<div className="mobile-page-stack" data-testid="mobile-approvals-history">
<div className="mobile-section-header">
<h4 className="mobile-section-title">处理历史</h4>
<span className="mobile-section-meta">{selectedTask.history.length} 条</span>
</div>
{selectedTask.history.length ? (
selectedTask.history.map((item) => (
@@ -1381,7 +1389,7 @@ export function MobileApprovalsPage() {
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null}
<div className="grid gap-2 min-[390px]:grid-cols-3">
<div className="mobile-sticky-action-grid">
<Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve"
@@ -1470,10 +1478,10 @@ export function MobileApprovalsPage() {
) : null}
{!isPushDetail && shouldShowDraftQueue ? (
<section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
<div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span>
<section className="mobile-card mobile-page-stack" data-testid="mobile-approvals-queue">
<div className="mobile-section-header">
<h3 className="mobile-section-title">离线草稿</h3>
<span className="mobile-section-meta">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span>
</div>
{currentQueueItems.length ? (
@@ -1495,13 +1503,27 @@ export function MobileApprovalsPage() {
{currentQueueItems.map((item) => (
<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" ? "通过" : "驳回"}
</div>
<div className="text-zinc-500">业务:{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}</div>
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
<div className="mobile-field-list mt-2">
<div className="mobile-field-row">
<div className="mobile-field-label">业务</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">
<Button
type="button"
+53 -43
查看文件
@@ -119,25 +119,25 @@ export function MobileNotificationsPage() {
return (
<PageScaffold className="pb-4">
<div className="space-y-3" data-testid="mobile-notifications-page">
<PageHeader
title="通知"
meta={<span className="badge-neutral">未读 {listQuery.data?.unreadCount ?? 0}</span>}
actions={
<Button
type="button"
variant="secondary"
data-testid="mobile-notifications-mark-all-read"
disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending}
onClick={() => markAllReadMutation.mutate()}
>
全部已读
</Button>
}
/>
<div className="mobile-page-stack" data-testid="mobile-notifications-page">
<PageHeader
title="通知"
meta={<span className="badge-neutral">未读 {listQuery.data?.unreadCount ?? 0}</span>}
actions={
<Button
type="button"
variant="secondary"
data-testid="mobile-notifications-mark-all-read"
disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending}
onClick={() => markAllReadMutation.mutate()}
>
全部已读
</Button>
}
/>
<section className="mobile-card space-y-4" data-testid="mobile-notifications-list">
<div className="space-y-3">
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications-list">
<div className="mobile-page-stack">
<Tabs
data-testid="mobile-notifications-filter-tabs"
value={unreadOnly ? "unread" : "all"}
@@ -158,7 +158,7 @@ export function MobileNotificationsPage() {
</TabsList>
</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>
<select
data-testid="mobile-notifications-type-filter"
@@ -194,32 +194,42 @@ export function MobileNotificationsPage() {
<div className="mobile-empty-state">暂无通知</div>
) : null}
{notificationItems.map((item) => {
const targetLink = resolveMobileNotificationTargetLink(item);
return (
<button
data-testid="mobile-notification-card"
key={item.id}
className="mobile-list-card w-full text-left"
type="button"
onClick={() => void openNotificationTarget(item, targetLink)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
{item.body ? <div className="mt-1 text-xs leading-5 text-zinc-500">{item.body}</div> : null}
<div className="mobile-page-stack">
{notificationItems.map((item) => {
const targetLink = resolveMobileNotificationTargetLink(item);
return (
<button
data-testid="mobile-notification-card"
key={item.id}
className="mobile-list-card w-full text-left"
type="button"
onClick={() => void openNotificationTarget(item, targetLink)}
>
<div className="mobile-list-card__header">
<div className="min-w-0">
<div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
<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>
<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="mt-3 text-xs text-primary">{targetLink ? "直达详情" : "暂无直达详情"}</div>
</button>
);
})}
<div className="mobile-field-list mt-3">
<div className="mobile-field-row">
<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
page={page}
+33 -33
查看文件
@@ -325,15 +325,15 @@ export function MobilePortalPage() {
].filter((item): item is MobileAction => Boolean(item)).slice(0, 4);
return (
<PageScaffold className="space-y-4">
<PageScaffold className="mobile-page-stack">
<PageHeader title="今天" />
{canAccessApprovals && pendingApprovalCount > 0 ? (
<section className="mobile-card" data-testid="mobile-pending-summary">
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-zinc-900">待处理</div>
<div className="mt-1 text-xs text-zinc-500">待我审批</div>
<section className="mobile-card mobile-page-stack" data-testid="mobile-pending-summary">
<div className="mobile-section-header">
<div className="min-w-0">
<div className="mobile-section-title">待处理</div>
<div className="mobile-list-card__subtitle mt-1">待我审批</div>
</div>
<div className="text-right">
<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>
{urgentPendingTask ? (
<div className="space-y-2">
<div className="mobile-page-stack">
{(() => {
const task = urgentPendingTask;
const entityType = task.workflowInstance?.entityType;
@@ -355,16 +355,16 @@ export function MobilePortalPage() {
key={task.id}
to={`/mobile/approvals/${encodeURIComponent(task.id)}`}
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"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate font-medium text-zinc-900">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
<div className="mobile-list-card__header">
<div className="mobile-list-card__title">
{reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
</div>
</div>
</div>
<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}`])}
<div className="mobile-list-card__subtitle mt-1">
{joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
</div>
</Link>
);
@@ -378,9 +378,9 @@ export function MobilePortalPage() {
) : null}
{canUseFinance ? (
<section className="mobile-card" data-testid="mobile-finance-summary">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">财务</div>
<section className="mobile-card mobile-page-stack" data-testid="mobile-finance-summary">
<div className="mobile-section-header">
<div className="mobile-section-title">财务</div>
<span className="badge-neutral">复核与付款</span>
</div>
{portalHomeQuery.isLoading ? <p className="text-sm text-zinc-500">读取中...</p> : null}
@@ -408,9 +408,9 @@ export function MobilePortalPage() {
</section>
) : null}
<section className="mobile-card" data-testid="mobile-today-focus">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">常用</div>
<section className="mobile-card mobile-page-stack" data-testid="mobile-today-focus">
<div className="mobile-section-header">
<div className="mobile-section-title">常用</div>
</div>
<div className="mobile-icon-grid" data-testid="mobile-action-grid">
{actions.map((action) => (
@@ -420,9 +420,9 @@ export function MobilePortalPage() {
</section>
{!attendanceFeatureDisabled ? (
<section className="mobile-card" data-testid="mobile-attendance-card">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">打卡</div>
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-card">
<div className="mobile-section-header">
<div className="mobile-section-title">打卡</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
@@ -464,9 +464,9 @@ export function MobilePortalPage() {
</section>
) : null}
<section className="mobile-card" data-testid="mobile-notifications">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-base font-semibold text-zinc-900">通知</div>
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
<div className="mobile-section-header">
<div className="mobile-section-title">通知</div>
<Button asChild size="sm" type="button" variant="secondary">
<Link to="/mobile/notifications">全部</Link>
</Button>
@@ -482,23 +482,23 @@ export function MobilePortalPage() {
<p className="text-sm text-zinc-500">暂无通知。</p>
) : null}
<div className="space-y-2">
<div className="mobile-page-stack">
{homeNotifications.map((item) => (
<button
key={item.id}
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)}
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="text-xs text-zinc-500">{notificationTypeLabel(item.type)}</div>
<div className="mt-0.5 truncate font-medium text-zinc-900">{item.title}</div>
<div className="text-xs font-medium text-primary">{notificationTypeLabel(item.type)}</div>
<div className="mobile-list-card__title mt-0.5">{item.title}</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 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>
))}
</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"
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 ? (
<Button
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 { useAuth } from "../auth/AuthContext";
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 { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -199,13 +199,13 @@ export function PaymentBatchesPage() {
});
return (
<div className="space-y-6">
<PageHeader title="支付批次" subtitle="财务批量付款、复核与执行;高风险批次达到 5 万元后需完成二次复核。" />
<ListWorkspace>
<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}
<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>
{!canManageBatch ? (
<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 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>
<div className="space-y-2">
{(listQuery.data?.items ?? []).map((item) => (
@@ -313,7 +313,7 @@ export function PaymentBatchesPage() {
{selectedBatch ? (
<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>
<div className="text-xs text-zinc-500">{selectedBatch.batchNo}</div>
@@ -372,7 +372,7 @@ export function PaymentBatchesPage() {
</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>
<div className="space-y-4">
{selectedBatch.items.map((item) => (
@@ -417,6 +417,6 @@ export function PaymentBatchesPage() {
) : null}
</div>
</div>
</div>
</ListWorkspace>
);
}
+4 -5
查看文件
@@ -12,9 +12,8 @@ import {
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
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 { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -161,7 +160,7 @@ export function ProjectsPage() {
return (
<ListWorkspace>
<PageHeader
<WorkspaceHeader
title="项目"
subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。"
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.active} note="优先跟进活跃项目" />
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="延期事项待处理" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
</section>
</StatStrip>
<div className="page-shell-grid">
<div className="space-y-6">
+21 -18
查看文件
@@ -36,7 +36,6 @@ import { fetchProjects } from "../api/projects";
import { remindWorkflowTask } from "../api/workflow";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Pagination } from "../components/common/Pagination";
@@ -46,6 +45,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
@@ -720,6 +720,9 @@ export function PurchaseRequestsPage() {
const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const detailAttachments = detail?.attachments ?? [];
const detailApprovalHistory = detail?.approvalHistory ?? [];
const detailRiskFlags = detail?.riskFlags ?? [];
const handleSubmitAction = async () => {
if (!selectedId) {
@@ -769,8 +772,8 @@ export function PurchaseRequestsPage() {
);
return (
<div className="space-y-6">
<PageHeader
<ListWorkspace>
<WorkspaceHeader
title="采购申请"
subtitle="草稿、附件、审批。"
actions={
@@ -793,7 +796,7 @@ export function PurchaseRequestsPage() {
</p>
) : null}
<div className="card-std space-y-4 p-6">
<FilterBar className="space-y-4">
<div className="grid gap-3 md:grid-cols-5">
<select
className="select-std"
@@ -887,10 +890,10 @@ export function PurchaseRequestsPage() {
</div>
) : null}
</div>
</div>
</FilterBar>
<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">
<h3 className="text-base font-semibold text-zinc-900">采购单列表</h3>
<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>
<div className="hidden rounded-lg border border-zinc-200 md:block">
<PrimaryTable className="hidden md:block">
<Table data-testid="purchase-requests-table">
<TableHeader>
<tr>
@@ -1002,7 +1005,7 @@ export function PurchaseRequestsPage() {
)}
</TableBody>
</Table>
</div>
</PrimaryTable>
</>
) : null}
@@ -1014,7 +1017,7 @@ export function PurchaseRequestsPage() {
/>
</div>
<div className="card-std space-y-5 p-6">
<div className="card-std space-y-5 p-4 md:p-5">
{!selectedId ? (
<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 className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
<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>
@@ -1116,12 +1119,12 @@ export function PurchaseRequestsPage() {
</div>
</div>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<RiskFlagsCard items={detailRiskFlags} title="待处理风险项" />
<SectionPanel title="审批记录" description="展示最近审批动作、退回或驳回说明,便于申请人按意见修改。">
{detail.approvalHistory.length ? (
{detailApprovalHistory.length ? (
<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 className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium text-zinc-900">
@@ -1515,8 +1518,8 @@ export function PurchaseRequestsPage() {
</div>
</div>
<div className="space-y-3 md:hidden">
{detail.attachments.length ? (
detail.attachments.map((attachment) => (
{detailAttachments.length ? (
detailAttachments.map((attachment) => (
<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="mt-1 text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
@@ -1563,8 +1566,8 @@ export function PurchaseRequestsPage() {
</tr>
</TableHeader>
<TableBody>
{detail.attachments.length ? (
detail.attachments.map((attachment) => (
{detailAttachments.length ? (
detailAttachments.map((attachment) => (
<TableRow key={attachment.id}>
<TableCell>
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
@@ -1754,6 +1757,6 @@ export function PurchaseRequestsPage() {
) : null}
</div>
</div>
</div>
</ListWorkspace>
);
}
+18 -33
查看文件
@@ -22,7 +22,6 @@ import {
withdrawReimbursement,
linkReimbursementProject,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import {
@@ -51,6 +50,8 @@ import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
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 { SectionPanel } from "../components/patterns/SectionPanel";
import {
@@ -73,15 +74,6 @@ import { buildPrecheckDialogMessage } from "../lib/precheck";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
type SelectedInvoiceMap = Record<string, string>;
const REIMBURSEMENT_STATUS_LABEL: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已通过",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
@@ -99,10 +91,6 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
return fallbackMessage;
}
function reimbursementStatusLabel(status: ReimbursementStatus) {
return REIMBURSEMENT_STATUS_LABEL[status] ?? "未知状态";
}
function workflowActionLabel(action: string) {
const labels: Record<string, string> = {
start: "提交",
@@ -1009,27 +997,24 @@ export function ReimbursementDetailPage() {
<div className="space-y-6">
<SectionPanel title="概览与关联">
<div className="space-y-6" data-testid="reimbursement-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">当前状态</div>
<div className="mt-2"><StatusBadge status={detail.status} /></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">{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</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">{detail.items.length}</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">{detail.invoices.length}</div>
</div>
</div>
<StatStrip columns={4}>
<MetricCard
title="关联项目"
value={
<span className="text-base">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</span>
}
/>
<MetricCard title="明细数" value={detail.items.length} />
<MetricCard title="发票" value={detail.invoices.length} />
<MetricCard
title="付款状态"
value={<span className="text-base">{isPaidReimbursement ? "已付款" : "未付款"}</span>}
/>
</StatStrip>
<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>报销日期:{detail.reimbursementDate ?? "-"}</div>
<div>金额:{detail.totalAmount ?? "-"}</div>
+4 -5
查看文件
@@ -25,11 +25,10 @@ import { hasPermission } from "../auth/permissions";
import { toast } from "sonner";
import { downloadBlob } from "../lib/download";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
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 { Button } from "../components/ui/button";
import {
@@ -362,7 +361,7 @@ export function ReimbursementsPage() {
return (
<ListWorkspace>
<PageHeader
<WorkspaceHeader
title="我的报销"
subtitle="草稿、审批、支付。"
actions={
@@ -513,12 +512,12 @@ export function ReimbursementsPage() {
</p>
) : 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={formatCurrency(stats.currentPageAmount)} note="当前页" />
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
</section>
</StatStrip>
<FilterBar className="space-y-4">
<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 { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
const ITEMS = [
{ to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" },
@@ -14,17 +14,17 @@ const ITEMS = [
export function SystemSettingsPage() {
return (
<div className="space-y-6">
<PageHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" />
<ListWorkspace>
<WorkspaceHeader title="系统设置" subtitle="小团队模式下的后台设置总入口。" />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{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="mt-2 text-sm text-zinc-600">{item.description}</div>
</Link>
))}
</div>
</div>
</ListWorkspace>
);
}
+7 -7
查看文件
@@ -8,7 +8,7 @@ import {
updateWorkflowDefinition,
type WorkflowDefinitionEditorModel,
} from "../api/workflowDefinitions";
import { PageHeader } from "../components/common/PageHeader";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
@@ -79,10 +79,10 @@ export function WorkflowDefinitionsPage() {
});
return (
<div className="space-y-6">
<PageHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" />
<ListWorkspace>
<WorkspaceHeader title="流程定义" subtitle="线性流程的版本化治理;报销继续由审批规则投影。" />
<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>
<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>)}
@@ -90,7 +90,7 @@ export function WorkflowDefinitionsPage() {
{entityType !== "reimbursement" ? <Button type="button" onClick={() => createMutation.mutate()}>创建草稿</Button> : null}
</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>
<div className="space-y-2">
{scopedItems.map((item) => (
@@ -103,7 +103,7 @@ export function WorkflowDefinitionsPage() {
</div>
</div>
<div className="card-std space-y-4 p-6">
<div className="card-std space-y-4 p-4 md:p-5">
{selected ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -163,6 +163,6 @@ export function WorkflowDefinitionsPage() {
)}
</div>
</div>
</div>
</ListWorkspace>
);
}
+64 -78
查看文件
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Link, Navigate, useNavigate, useParams } from "react-router-dom";
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 { DetailSideMeta } from "../components/patterns/DetailSideMeta";
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 { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
@@ -48,22 +51,6 @@ function formatDateTime(value: string | null | undefined) {
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) {
if (!status) {
return "-";
@@ -103,6 +90,15 @@ function workflowActionLabel(action: string | undefined | null) {
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) {
return isApiError(error) && error.details?.task === "already_processed";
}
@@ -240,7 +236,6 @@ export function WorkflowTaskDetailPage() {
meta={(
<>
<StatusBadge status={detail.task.status} />
<span className="info-chip">对象类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
<span className="info-chip">业务状态:{businessStatusLabel}</span>
</>
)}
@@ -255,87 +250,78 @@ export function WorkflowTaskDetailPage() {
<div className="space-y-6">
<SectionPanel title="审批对象摘要">
<div className="space-y-4" data-testid="workflow-task-overview">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
<div className="text-zinc-500">当前状态</div>
<div className="mt-2"><StatusBadge status={detail.task.status} /></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">{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>
<StatStrip columns={4}>
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels || "-"}</span>} />
<MetricCard title="创建时间" value={<span className="text-base">{formatDateTime(detail.task.createdAt)}</span>} />
</StatStrip>
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="workflow-task-summary">
<div>状态:{workflowStatusLabel(detail.task.status)}</div>
<div>业务状态:{businessStatusLabel}</div>
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
<dl className="grid gap-3 md:grid-cols-2" data-testid="workflow-task-summary">
{detail.reimbursement ? (
<>
<div>报销单号:{detail.reimbursement.formNumber ?? "-"}</div>
<div>报销金额:{detail.reimbursement.totalAmount ?? "-"}</div>
<div>提交人:{detail.reimbursement.submitter?.name ?? "-"}</div>
<div>报销人:{detail.reimbursement.owner?.name ?? "-"}</div>
<DetailField label="报销单号" value={detail.reimbursement.formNumber ?? "-"} />
<DetailField label="报销金额" value={detail.reimbursement.totalAmount ?? "-"} />
<DetailField label="提交人" value={detail.reimbursement.submitter?.name ?? "-"} />
<DetailField label="报销人" value={detail.reimbursement.owner?.name ?? "-"} />
</>
) : null}
{detail.leaveRequest ? (
<>
<div>假期类型:{detail.leaveRequest.leaveType?.name ?? "-"}</div>
<div>申请人:{detail.leaveRequest.owner?.name ?? "-"}</div>
<div>
时间段:
<DetailField label="假期类型" value={detail.leaveRequest.leaveType?.name ?? "-"} />
<DetailField label="申请人" value={detail.leaveRequest.owner?.name ?? "-"} />
<DetailField
label="时间段"
value={
<>
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "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}
{detail.purchaseRequest ? (
<>
<div>采购单号:{resolvePurchaseDisplayCode(detail.purchaseRequest)}</div>
<div>标题:{detail.purchaseRequest.title || "-"}</div>
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div>
<div>成本中心:{detail.purchaseRequest.costCenter?.name ?? "-"}</div>
<div>单据分类:{detail.purchaseRequest.documentCategory?.name ?? "-"}</div>
<div>金额:{detail.purchaseRequest.totalAmount ?? "-"} {detail.purchaseRequest.currencyCode}</div>
<div>预计到货:{detail.purchaseRequest.expectedDeliveryDate ?? "-"}</div>
<div>说明:{detail.purchaseRequest.reason ?? "-"}</div>
<DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} />
<DetailField label="标题" value={detail.purchaseRequest.title || "-"} />
<DetailField label="申请人" value={detail.purchaseRequest.applicant?.name ?? "-"} />
<DetailField label="供应商" value={detail.purchaseRequest.supplier?.name ?? "-"} />
<DetailField label="法体" value={detail.purchaseRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.purchaseRequest.costCenter?.name ?? "-"} />
<DetailField label="单据分类" value={detail.purchaseRequest.documentCategory?.name ?? "-"} />
<DetailField label="金额" value={`${detail.purchaseRequest.totalAmount ?? "-"} ${detail.purchaseRequest.currencyCode}`} />
<DetailField label="预计到货" value={detail.purchaseRequest.expectedDeliveryDate ?? "-"} />
<DetailField label="说明" value={detail.purchaseRequest.reason ?? "-"} />
</>
) : null}
{detail.travelRequest ? (
<>
<div>差旅单号:{detail.travelRequest.requestNo ?? "暂未生成"}</div>
<div>申请人:{detail.travelRequest.applicant?.name ?? "-"}</div>
<div>出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
<div>法体:{detail.travelRequest.legalEntity?.name ?? "-"}</div>
<div>成本中心:{detail.travelRequest.costCenter?.name ?? "-"}</div>
<div>起止日期:{detail.travelRequest.startDate ?? "-"} → {detail.travelRequest.endDate ?? "-"}</div>
<div>预估金额:{detail.travelRequest.estimatedAmount ?? "-"}</div>
<div>出差目的:{detail.travelRequest.purpose || "-"}</div>
<div>
行程段:
{detail.travelRequest.segments.length
? detail.travelRequest.segments
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
.join(";")
: "-"}
</div>
<DetailField label="差旅单号" value={detail.travelRequest.requestNo ?? "暂未生成"} />
<DetailField label="申请人" value={detail.travelRequest.applicant?.name ?? "-"} />
<DetailField label="出差类型" value={detail.travelRequest.tripType === "international" ? "国际" : "国内"} />
<DetailField label="法体" value={detail.travelRequest.legalEntity?.name ?? "-"} />
<DetailField label="成本中心" value={detail.travelRequest.costCenter?.name ?? "-"} />
<DetailField label="起止日期" value={`${detail.travelRequest.startDate ?? "-"} → ${detail.travelRequest.endDate ?? "-"}`} />
<DetailField label="预估金额" value={detail.travelRequest.estimatedAmount ?? "-"} />
<DetailField label="出差目的" value={detail.travelRequest.purpose || "-"} />
<DetailField
label="行程段"
value={
detail.travelRequest.segments.length
? detail.travelRequest.segments
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
.join(";")
: "-"
}
/>
</>
) : null}
</div>
</dl>
</div>
</SectionPanel>
+67 -49
查看文件
@@ -9,10 +9,19 @@ import {
fetchWorkflowTaskSummary,
type WorkflowTaskSummary,
} from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
@@ -187,35 +196,34 @@ export function WorkflowTasksPage() {
const taskItems = query.data?.items ?? [];
return (
<div className="space-y-4 md:space-y-6">
<PageHeader title="审批中心" subtitle="集中处理待办、查看已办和业务审批历史。" />
<ListWorkspace>
<WorkspaceHeader
eyebrow="审批"
title="审批中心"
subtitle="集中处理待办、查看已办和业务审批历史。"
/>
<div className="grid gap-3 md:grid-cols-4" data-testid="workflow-task-summary">
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
<div className="text-xs text-zinc-500">待办总数</div>
<div className="mt-2 text-2xl font-semibold text-zinc-900">{summaryQuery.data?.pendingTotal ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
<div className="text-xs text-zinc-500">超时待办</div>
<div className="mt-2 text-2xl font-semibold text-amber-700">{summaryQuery.data?.overdueTotal ?? "-"}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
<div className="text-xs text-zinc-500">最早待处理</div>
<div className="mt-2 text-sm font-medium text-zinc-900">{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
<div className="text-xs text-zinc-500">业务分布</div>
<div className="mt-2 truncate text-sm font-medium text-zinc-900">
{(summaryQuery.data?.byEntityType ?? [])
.filter((item) => item.pendingTotal > 0)
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
.join(" / ") || "-"}
</div>
</div>
<div data-testid="workflow-task-summary">
<StatStrip columns={4}>
<MetricCard title="待办总数" value={summaryQuery.data?.pendingTotal ?? "-"} />
<MetricCard title="超时待办" value={summaryQuery.data?.overdueTotal ?? "-"} tone="warning" />
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
<MetricCard
title="业务分布"
value={
<span className="block truncate text-base">
{(summaryQuery.data?.byEntityType ?? [])
.filter((item) => item.pendingTotal > 0)
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
.join(" / ") || "-"}
</span>
}
/>
</StatStrip>
</div>
<div className="card-std space-y-4 p-4 md:p-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<FilterBar>
<ActionToolbar className="items-start">
<Tabs
data-testid="workflow-tasks-status-tabs"
value={status}
@@ -234,13 +242,13 @@ export function WorkflowTasksPage() {
</TabsList>
</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>
<select
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"
value={entityType}
onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
@@ -252,7 +260,8 @@ export function WorkflowTasksPage() {
))}
</select>
</div>
</div>
</ActionToolbar>
</FilterBar>
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
@@ -270,24 +279,36 @@ export function WorkflowTasksPage() {
{!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.map((task) => (
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" key={task.id}>
<div className="flex items-start justify-between gap-3">
<article className="mobile-list-card" key={task.id}>
<div className="mobile-list-card__header">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{displayTitle(task)}</div>
<div className="mt-1 text-xs text-zinc-500">
<div className="mobile-list-card__title">{displayTitle(task)}</div>
<div className="mobile-list-card__subtitle">
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
</div>
</div>
<StatusBadge className="shrink-0" status={task.status} />
<StatusBadge className="mobile-status-slot" status={task.status} />
</div>
<div className="mt-3 grid gap-1 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
<div>申请人:{displayApplicant(task)}</div>
<div>金额/天数:{displayMetric(task)}</div>
<div>当前节点:{task.stepName}</div>
<div>业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}</div>
<div className="mobile-field-list mt-3 rounded-lg bg-zinc-50 px-3 py-2">
<div className="mobile-field-row">
<span className="mobile-field-label">申请人</span>
<span className="mobile-field-value">{displayApplicant(task)}</span>
</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 className="mt-3 grid grid-cols-2 gap-2">
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
@@ -317,13 +338,11 @@ export function WorkflowTasksPage() {
</article>
))
) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
暂无数据
</div>
<WorkspaceEmptyState />
)}
</div>
<div className="hidden rounded-lg border border-zinc-200 md:block">
<PrimaryTable className="hidden md:block">
<Table data-testid="workflow-tasks-table">
<TableHeader>
<tr>
@@ -381,14 +400,14 @@ export function WorkflowTasksPage() {
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={8}>
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</PrimaryTable>
</>
) : null}
@@ -398,7 +417,6 @@ export function WorkflowTasksPage() {
onPageChange={(p) => updateSearchParams({ page: p })}
testIdPrefix="workflow-tasks"
/>
</div>
</div>
</ListWorkspace>
);
}
+77
查看文件
@@ -222,6 +222,72 @@
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 {
min-width: 0;
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));
}
.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) {
:root {
--mobile-page-x: 8px;
+6 -2
查看文件
@@ -1,5 +1,5 @@
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 { expect, test, type Browser, type Page } from "@playwright/test";
@@ -30,7 +30,11 @@ type PreparedRoutes = {
};
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 BASE_URL = process.env.E2E_BASE_URL?.trim() || null;
const SCREENSHOT_DIR = resolveRootPath(
@@ -1,43 +1,77 @@
import type { APIRequestContext } from "@playwright/test";
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";
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 }) => {
const apiClient = await createAuthenticatedApiClient(EMPLOYEE);
await login(page, EMPLOYEE);
await (await openNavLink(page, "我的报销")).click();
await expect(page).toHaveURL(/\/reimbursements/);
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
try {
await (await openNavLink(page, "我的报销")).click();
await expect(page).toHaveURL(/\/reimbursements/);
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
});
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
});
await page.getByTestId("reimbursements-create").click();
await page.getByRole("menuitem", { name: "空白报销" }).click();
const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
const reimbursementId = createPayload.data?.id;
if (!reimbursementId) {
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;后续审批/通知/催办开发必须先核对统一审批中心规约,复用既有接口、审计和通知语义,不另造平行链路。
- 企业微信:入口、身份、个人应用消息和 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 指标;真实企业微信登录、手机应用消息到达、真实通知权限和财务制度决策必须保留人工确认记录。
移动页治理必须收敛到同一套事实源和组件体系:
+18
查看文件
@@ -41,6 +41,24 @@ DG8 之后,hyb-oa 的唯一主 UI/布局参考是飞书工作台/飞书审批
- 详情页是业务事实源,PC 按摘要、关键字段、附件/发票、审批流和日志分区;移动深层 Push 页隐藏底部 Tab,使用顶部返回、状态和吸底操作。
- 竞品资料只用于抽象原则;不得复制飞书、钉钉、企业微信、泛微、每刻、合思、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
+2 -1
查看文件
@@ -29,6 +29,7 @@
| --- | --- | --- | --- |
| `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/布局参考,不代表页面已整改 |
| `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 |
| `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` | 审批与通知增强;移动审批历史展示方案批准后继续执行 |
@@ -45,7 +46,7 @@
| `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 | 定义做什么、不做什么和验收口径 |
| `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 和交付规则 |
| `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 协作开发实践和设计主参考层级 |
+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 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
- QG6 已完成 QA 治理升级。
- MIA7 已完成移动信息架构、视觉和流程审查;MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
- DG8 已完成设计治理主参考系统收口:飞书工作台/飞书审批是唯一主 UI/布局参考;本结论只更新设计事实源,不代表已做页面整改。
- DG9 承接 DG8 后的页面整改:按 A/B/C 页面分层和 DG9 样板统一 PC、移动和企业微信入口布局;不替代 WF8、UX8 或 BIZ8 业务验收。
- 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` | 已收口 | 文档分类、台账、流程模板、审计脚本、需求冻结、变更控制 | 不改业务代码、不部署 |
| `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 能力 |
| `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 或审批设计器 |
@@ -62,7 +64,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| --- | --- |
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源 |
| [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 |
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考 |
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 |
| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则 |
| [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | 审批、催办、超时提醒、审批效率 |
| [报销发票附件规则](./报销发票附件规则.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) {
const normalized = toPosix(file);
if (!/Reimbursement|Invoice|Leave|Purchase|Travel/.test(path.basename(normalized))) {
@@ -542,6 +574,7 @@ function scanFileSource(file, source) {
scanMobileCapturePdfInputs(file, source);
scanMobileDesktopTables(file, source);
scanMobilePcShell(file, source);
scanMobileDesktopContainers(file, source);
scanUuidPrimaryDisplay(file, source);
scanRawBusinessIdentifiers(file, source);
scanReimbursementRawCodeDisplay(file, source);
@@ -575,6 +608,16 @@ function runSelfTest() {
],
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",
source: `