diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md
index 4b39fcf1..01d195ba 100644
--- a/.agents/skills/hyb-oa-delivery/SKILL.md
+++ b/.agents/skills/hyb-oa-delivery/SKILL.md
@@ -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.
diff --git a/client/src/components/patterns/ListWorkspace.tsx b/client/src/components/patterns/ListWorkspace.tsx
index 844cbfbb..45b32937 100644
--- a/client/src/components/patterns/ListWorkspace.tsx
+++ b/client/src/components/patterns/ListWorkspace.tsx
@@ -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
{children}
;
}
-export function FilterBar({ className, children }: { className?: string; children: ReactNode }) {
+export function WorkspaceHeader({ eyebrow, title, subtitle, meta, actions, className }: WorkspaceHeaderProps) {
+ return (
+
+
+ {eyebrow ?
{eyebrow}
: null}
+
{title}
+ {subtitle ?
{subtitle}
: null}
+ {meta ?
{meta}
: null}
+
+ {actions ? {actions}
: null}
+
+ );
+}
+
+export function FilterBar({ className, children }: WorkspaceChildrenProps) {
return ;
}
-export function StatStrip({ className, children }: { className?: string; children: ReactNode }) {
- return ;
+export function FilterGrid({ className, children }: WorkspaceChildrenProps) {
+ return {children}
;
}
-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 ;
+}
+
+export function WorkspaceSection({
+ title,
+ description,
+ actions,
+ className,
+ children,
+}: WorkspaceChildrenProps & {
+ title?: string;
+ description?: ReactNode;
+ actions?: ReactNode;
+}) {
+ return (
+
+ {title || description || actions ? (
+
+
+ {title ?
{title} : null}
+ {description ?
{description}
: null}
+
+ {actions ?
{actions}
: null}
+
+ ) : null}
+ {children}
+
+ );
+}
+
+export function ActionToolbar({ className, children }: WorkspaceChildrenProps) {
return {children}
;
}
-export function PrimaryTable({ className, children }: { className?: string; children: ReactNode }) {
+export function PrimaryTable({ className, children }: WorkspaceChildrenProps) {
return ;
}
+
+export function WorkspaceEmptyState({
+ className,
+ children = "暂无数据",
+}: {
+ className?: string;
+ children?: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/client/src/pages/ApOperationsPage.tsx b/client/src/pages/ApOperationsPage.tsx
index 5b0af12b..5671e9d8 100644
--- a/client/src/pages/ApOperationsPage.tsx
+++ b/client/src/pages/ApOperationsPage.tsx
@@ -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 加载财务工作台...
;
+ return 加载财务工作台...
;
}
return (
-
-
+
setActiveTab(value as OperationsTab)}>
财务台账 {financeLedgerQuery.data?.total ?? 0}
@@ -1630,6 +1630,6 @@ export function ApOperationsPage() {
await executeMutation.mutateAsync(retryTarget.batchId);
}}
/>
-
+
);
}
diff --git a/client/src/pages/ApprovalRulesPage.tsx b/client/src/pages/ApprovalRulesPage.tsx
index b7bcb402..0d2b5377 100644
--- a/client/src/pages/ApprovalRulesPage.tsx
+++ b/client/src/pages/ApprovalRulesPage.tsx
@@ -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 (
-
-
+
) : null}
-
+
新建审批规则
@@ -286,7 +286,7 @@ export function ApprovalRulesPage() {
-
+
规则列表
{listQuery.isLoading ?
: null}
@@ -304,7 +304,7 @@ export function ApprovalRulesPage() {
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
-
+
@@ -371,7 +371,7 @@ export function ApprovalRulesPage() {
)}
-
+
) : null}
@@ -516,7 +516,6 @@ export function ApprovalRulesPage() {
-
+
);
}
-
diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx
index 6287fd25..9da76f46 100644
--- a/client/src/pages/AssetRegistryPage.tsx
+++ b/client/src/pages/AssetRegistryPage.tsx
@@ -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 (
-
-
+
-
+
资产分类
{canManage ? (
@@ -669,7 +669,7 @@ export function AssetRegistryPage() {
暂无资产分类
)}
-
+
@@ -691,13 +691,13 @@ export function AssetRegistryPage() {
))}
-
+
>
) : null}
-
+
setStatusFilter(event.target.value as AssetStatus | "") }>
{assetStatusOptions().map((item) => (
@@ -796,7 +796,7 @@ export function AssetRegistryPage() {
暂无资产卡片
)}
-
+
@@ -830,12 +830,12 @@ export function AssetRegistryPage() {
)}
-
+
>
) : null}
-
+
-
+
{!selectedAssetId && !isCreatingAsset ? (
请先从列表选择资产卡片,或点击“新建资产卡片”。
@@ -1128,6 +1128,6 @@ export function AssetRegistryPage() {
-
+
);
}
diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx
index 9cb5456d..b44f4811 100644
--- a/client/src/pages/InvoicePoolPage.tsx
+++ b/client/src/pages/InvoicePoolPage.tsx
@@ -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 (
-
+
);
}
diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx
index 093baab6..1634f8eb 100644
--- a/client/src/pages/LeaveRequestPage.tsx
+++ b/client/src/pages/LeaveRequestPage.tsx
@@ -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 (
-
-
+
0 ? "warning" : "neutral"} />
@@ -795,7 +794,7 @@ export function LeaveRequestPage() {
note="创建草稿前可先预览天数"
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
/>
-
+
diff --git a/client/src/pages/MasterDataPage.tsx b/client/src/pages/MasterDataPage.tsx
index a2be1c74..252ddc45 100644
--- a/client/src/pages/MasterDataPage.tsx
+++ b/client/src/pages/MasterDataPage.tsx
@@ -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 (
-
-
-
所需权限:masterdata.view 或 masterdata.manage
-
+
+
+ 所需权限:masterdata.view 或 masterdata.manage
+
);
}
return (
-
-
+
-
+
{canManage ? (
-
+
新建法体
@@ -377,10 +377,11 @@ export function MasterDataPage() {
) : null}
-
+
法体列表
{legalEntitiesQuery.isLoading ?
: null}
{!legalEntitiesQuery.isLoading ? (
+
@@ -431,12 +432,13 @@ export function MasterDataPage() {
)}
+
) : null}
-
+
{canManage ? (
-
+
新建成本中心
@@ -532,10 +534,11 @@ export function MasterDataPage() {
) : null}
-
+
成本中心列表
{costCentersQuery.isLoading ?
: null}
{!costCentersQuery.isLoading ? (
+
@@ -588,12 +591,13 @@ export function MasterDataPage() {
)}
+
) : null}
-
+
{canManage ? (
-
+
新建单据分类
@@ -693,7 +697,7 @@ export function MasterDataPage() {
) : null}
-
+
单据分类列表
{documentCategoriesQuery.isLoading ?
: null}
{!documentCategoriesQuery.isLoading ? (
@@ -790,6 +794,6 @@ export function MasterDataPage() {
}
}}
/>
-
+
);
}
diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx
index 23e83240..282deabc 100644
--- a/client/src/pages/MobileApprovalsPage.tsx
+++ b/client/src/pages/MobileApprovalsPage.tsx
@@ -999,11 +999,11 @@ export function MobileApprovalsPage() {
) : null}
{!isPushDetail && !canAccessRemoteApprovals ? (
-
-
-
-
我发起的进度
-
报销、请假、采购进度
+
+
+
{employeeProgressCards.length} 条
@@ -1028,7 +1028,7 @@ export function MobileApprovalsPage() {
暂无申请进度
) : null}
-
+
{employeeProgressCards.map((item) => (
-
+
-
{item.title}
- {item.subtitle ?
{item.subtitle}
: null}
+
{item.title}
+ {item.subtitle ?
{item.subtitle}
: null}
-
+
+
+
))}
@@ -1051,13 +1053,13 @@ export function MobileApprovalsPage() {
) : null}
{!isPushDetail && canAccessRemoteApprovals ? (
-
-
-
-
{listTitle}
-
{filteredTaskCards.length} 条
+
+
+
+
{listTitle}
+
{filteredTaskCards.length} 条
-
{isOnline ? "在线" : "离线"}
+
{isOnline ? "在线" : "离线"}
@@ -1195,7 +1197,7 @@ export function MobileApprovalsPage() {
) : null}
-
+
{filteredTaskCards.map((task) => {
const isSelected = selectedTaskId === task.id;
const isBatchSelected = selectedBatchTaskIds.includes(task.id);
@@ -1204,7 +1206,7 @@ export function MobileApprovalsPage() {
return (
@@ -1221,12 +1223,14 @@ export function MobileApprovalsPage() {
data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)}
>
-
-
{task.title}
-
+
-
{task.subtitle}
-
+
{task.subtitle}
+
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
{task.isCached ?
来源:离线缓存
: null}
@@ -1245,12 +1249,14 @@ export function MobileApprovalsPage() {
onClick={() => handleTaskSelection(task.id)}
type="button"
>
-
-
{task.title}
-
+
-
{task.subtitle}
-
+
{task.subtitle}
+
{joinBusinessMeta([task.applicantName ? `申请人:${task.applicantName}` : null, task.metric ? `金额/天数:${task.metric}` : null]) || "暂无摘要"}
{task.isCached ?
来源:离线缓存
: null}
@@ -1262,8 +1268,8 @@ export function MobileApprovalsPage() {
) : null}
{isPushDetail ? (
-
- 处理
+
+ 处理
{!selectedTaskId ? 请先选择任务。
: null}
@@ -1286,12 +1292,14 @@ export function MobileApprovalsPage() {
{selectedTask ? (
<>
-
+
-
{selectedDetailTitle}
-
{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}
+
{selectedDetailTitle}
+
{joinBusinessMeta([`节点:${selectedTask.task.stepName}`, `创建:${formatDateTime(selectedTask.task.createdAt)}`])}
-
+
+
+
@@ -1343,10 +1351,10 @@ export function MobileApprovalsPage() {
) : null}
-
-
-
处理历史
-
{selectedTask.history.length} 条
+
+
+
处理历史
+ {selectedTask.history.length} 条
{selectedTask.history.length ? (
selectedTask.history.map((item) => (
@@ -1381,7 +1389,7 @@ export function MobileApprovalsPage() {
离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。
) : null}
-
+
-
-
离线草稿
-
{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}
+
+
+
离线草稿
+ {currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}
{currentQueueItems.length ? (
@@ -1495,13 +1503,27 @@ export function MobileApprovalsPage() {
{currentQueueItems.map((item) => (
-
+
{publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"}
-
业务:{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}
-
状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}
-
意见:{item.comment ?? "-"}
-
最近错误:{item.lastError ?? "-"}
+
+
+
业务
+
{entityLabel(item.taskSnapshot.entityType)} · 步骤:{item.taskSnapshot.stepName ?? "-"}
+
+
+
状态
+
{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}
+
+
+
意见
+
{item.comment ?? "-"}
+
+
+
最近错误
+
{item.lastError ?? "-"}
+
+
-
-
未读 {listQuery.data?.unreadCount ?? 0}}
- actions={
- markAllReadMutation.mutate()}
- >
- 全部已读
-
- }
- />
+
+
未读 {listQuery.data?.unreadCount ?? 0}}
+ actions={
+ markAllReadMutation.mutate()}
+ >
+ 全部已读
+
+ }
+ />
-
-
+
+
-
+
类型
暂无通知
) : null}
- {notificationItems.map((item) => {
- const targetLink = resolveMobileNotificationTargetLink(item);
- return (
- void openNotificationTarget(item, targetLink)}
- >
-
-
-
{notificationTypeLabel(item.type)}
-
{item.title}
- {item.body ?
{item.body}
: null}
+
+ {notificationItems.map((item) => {
+ const targetLink = resolveMobileNotificationTargetLink(item);
+ return (
+
void openNotificationTarget(item, targetLink)}
+ >
+
+
+
{notificationTypeLabel(item.type)}
+
{item.title}
+ {item.body ?
{item.body}
: null}
+
+
+ {item.readAt ? "已读" : "未读"}
+
-
- {item.readAt ? "已读" : "未读"}
-
-
-
{formatTime(item.createdAt)}
-
{targetLink ? "直达详情" : "暂无直达详情"}
-
- );
- })}
+
+
+
时间
+
{formatTime(item.createdAt)}
+
+
+
去向
+
{targetLink ? "直达详情" : "暂无直达详情"}
+
+
+
+ );
+ })}
+
Boolean(item)).slice(0, 4);
return (
-
+
{canAccessApprovals && pendingApprovalCount > 0 ? (
-
-
-
-
待处理
-
待我审批
+
+
+
@@ -345,7 +345,7 @@ export function MobilePortalPage() {
{urgentPendingTask ? (
-
+
{(() => {
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"
>
-
-
- {reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
+
+
+ {reference ? `${businessEntityLabel(entityType, { noun: true })} ${reference}` : businessEntityLabel(entityType, { noun: true })}
+
-
-
- {joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
+
+ {joinBusinessMeta([task.businessSummary?.applicantName ? `申请人:${task.businessSummary.applicantName}` : null, task.businessSummary?.amount ?? task.businessSummary?.duration, `节点:${task.stepName}`])}
);
@@ -378,9 +378,9 @@ export function MobilePortalPage() {
) : null}
{canUseFinance ? (
-
-
-
财务
+
+
{portalHomeQuery.isLoading ? 读取中...
: null}
@@ -408,9 +408,9 @@ export function MobilePortalPage() {
) : null}
-
-
-
常用
+
+
{actions.map((action) => (
@@ -420,9 +420,9 @@ export function MobilePortalPage() {
{!attendanceFeatureDisabled ? (
-
-
-
打卡
+
+
+
打卡
{attendanceToday?.workDate ?? "今日"}
@@ -464,9 +464,9 @@ export function MobilePortalPage() {
) : null}
-
-
-
通知
+
+
+
通知
全部
@@ -482,23 +482,23 @@ export function MobilePortalPage() {
暂无通知。
) : null}
-
+
{homeNotifications.map((item) => (
void openHomeNotification(item)}
data-testid="mobile-home-notification-card"
>
-
+
-
{notificationTypeLabel(item.type)}
-
{item.title}
+
{notificationTypeLabel(item.type)}
+
{item.title}
- {!item.readAt ?
新 : null}
+ {!item.readAt ?
新 : null}
-
{formatDateTime(item.createdAt)}
+
{formatDateTime(item.createdAt)}
))}
diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx
index e18686dc..6518ff23 100644
--- a/client/src/pages/MobileReimbursementDetailPage.tsx
+++ b/client/src/pages/MobileReimbursementDetailPage.tsx
@@ -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"
>
-
+
{canOpenInvoiceDialog ? (
-
+
+
{error ? {error}
: null}
-
+
新建批次
{!canManageBatch ? (
@@ -281,7 +281,7 @@ export function PaymentBatchesPage() {
-
+
批次列表
{(listQuery.data?.items ?? []).map((item) => (
@@ -313,7 +313,7 @@ export function PaymentBatchesPage() {
{selectedBatch ? (
-
+
{selectedBatch.batchNo}
@@ -372,7 +372,7 @@ export function PaymentBatchesPage() {
-
+
批次项
{selectedBatch.items.map((item) => (
@@ -417,6 +417,6 @@ export function PaymentBatchesPage() {
) : null}
-
+
);
}
diff --git a/client/src/pages/ProjectsPage.tsx b/client/src/pages/ProjectsPage.tsx
index 7abf6bd9..182464e3 100644
--- a/client/src/pages/ProjectsPage.tsx
+++ b/client/src/pages/ProjectsPage.tsx
@@ -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 (
-
-
+
0 ? "warning" : "neutral"} />
0 ? "danger" : "neutral"} />
-
+
diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx
index 94431885..39b63b5f 100644
--- a/client/src/pages/PurchaseRequestsPage.tsx
+++ b/client/src/pages/PurchaseRequestsPage.tsx
@@ -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 (
-
-
+
) : null}
-
+
-
+
采购单列表
共 {listQuery.data?.total ?? 0} 条
@@ -952,7 +955,7 @@ export function PurchaseRequestsPage() {
暂无采购申请
)}
-
+
@@ -1002,7 +1005,7 @@ export function PurchaseRequestsPage() {
)}
-
+
>
) : null}
@@ -1014,7 +1017,7 @@ export function PurchaseRequestsPage() {
/>
-
+
{!selectedId ? (
请先在左侧选择采购单,或新建一张采购草稿。
@@ -1096,7 +1099,7 @@ export function PurchaseRequestsPage() {
附件
-
{detail.attachments.length} 个
+
{detailAttachments.length} 个
@@ -1116,12 +1119,12 @@ export function PurchaseRequestsPage() {
-
+
- {detail.approvalHistory.length ? (
+ {detailApprovalHistory.length ? (
- {detail.approvalHistory.map((item) => (
+ {detailApprovalHistory.map((item) => (
@@ -1515,8 +1518,8 @@ export function PurchaseRequestsPage() {
- {detail.attachments.length ? (
- detail.attachments.map((attachment) => (
+ {detailAttachments.length ? (
+ detailAttachments.map((attachment) => (
{attachment.originalName}
{attachment.mimeType} / {attachment.fileSize} bytes
@@ -1563,8 +1566,8 @@ export function PurchaseRequestsPage() {
- {detail.attachments.length ? (
- detail.attachments.map((attachment) => (
+ {detailAttachments.length ? (
+ detailAttachments.map((attachment) => (
{attachment.originalName}
@@ -1754,6 +1757,6 @@ export function PurchaseRequestsPage() {
) : null}
-
+
);
}
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx
index 1d80cc6a..b698d8c8 100644
--- a/client/src/pages/ReimbursementDetailPage.tsx
+++ b/client/src/pages/ReimbursementDetailPage.tsx
@@ -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;
-const REIMBURSEMENT_STATUS_LABEL: Record = {
- 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 = {
start: "提交",
@@ -1009,27 +997,24 @@ export function ReimbursementDetailPage() {
-
-
-
-
关联项目
-
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
-
-
-
明细数
-
{detail.items.length}
-
-
-
发票
-
{detail.invoices.length}
-
-
+
+
+ {detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
+
+ }
+ />
+
+
+ {isPaidReimbursement ? "已付款" : "未付款"}}
+ />
+
-
状态:{reimbursementStatusLabel(detail.status)}
报销编号:{displayCode}
报销日期:{detail.reimbursementDate ?? "-"}
金额:{detail.totalAmount ?? "-"}
diff --git a/client/src/pages/ReimbursementsPage.tsx b/client/src/pages/ReimbursementsPage.tsx
index ad7ffa42..d2ab8247 100644
--- a/client/src/pages/ReimbursementsPage.tsx
+++ b/client/src/pages/ReimbursementsPage.tsx
@@ -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 (
-
) : null}
-
+
0 ? "warning" : "neutral"} />
-
+
diff --git a/client/src/pages/SystemSettingsPage.tsx b/client/src/pages/SystemSettingsPage.tsx
index c924ea9f..85c7d418 100644
--- a/client/src/pages/SystemSettingsPage.tsx
+++ b/client/src/pages/SystemSettingsPage.tsx
@@ -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 (
-
-
+
+
{ITEMS.map((item) => (
-
+
{item.title}
{item.description}
))}
-
+
);
}
diff --git a/client/src/pages/WorkflowDefinitionsPage.tsx b/client/src/pages/WorkflowDefinitionsPage.tsx
index af5f76a9..c445d898 100644
--- a/client/src/pages/WorkflowDefinitionsPage.tsx
+++ b/client/src/pages/WorkflowDefinitionsPage.tsx
@@ -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 (
-
-
+
+
-
+
业务域
setEntityType(event.target.value as WorkflowDefinitionEditorModel["entityType"])}>
{ENTITY_TYPES.map((item) => {item} )}
@@ -90,7 +90,7 @@ export function WorkflowDefinitionsPage() {
{entityType !== "reimbursement" ? createMutation.mutate()}>创建草稿 : null}
-
+
版本列表
{scopedItems.map((item) => (
@@ -103,7 +103,7 @@ export function WorkflowDefinitionsPage() {
-
+
{selected ? (
<>
@@ -163,6 +163,6 @@ export function WorkflowDefinitionsPage() {
)}
-
+
);
}
diff --git a/client/src/pages/WorkflowTaskDetailPage.tsx b/client/src/pages/WorkflowTaskDetailPage.tsx
index b20042c8..6b8a7d27 100644
--- a/client/src/pages/WorkflowTaskDetailPage.tsx
+++ b/client/src/pages/WorkflowTaskDetailPage.tsx
@@ -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
= {
- 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 (
+
+
{label}
+ {value || "-"}
+
+ );
+}
+
function isAlreadyProcessedTaskError(error: unknown) {
return isApiError(error) && error.details?.task === "already_processed";
}
@@ -240,7 +236,6 @@ export function WorkflowTaskDetailPage() {
meta={(
<>
- 对象类型:{entityTitle(detail.workflowInstance?.entityType)}
业务状态:{businessStatusLabel}
>
)}
@@ -255,87 +250,78 @@ export function WorkflowTaskDetailPage() {
-
-
-
-
当前节点
-
{detail.task.stepName}
-
-
-
对象类型
-
{entityTitle(detail.workflowInstance?.entityType)}
-
-
-
可执行动作
-
{allowedActionLabels || "-"}
-
-
+
+ {detail.task.stepName}} />
+ {entityTitle(detail.workflowInstance?.entityType)}} />
+ {allowedActionLabels || "-"}} />
+ {formatDateTime(detail.task.createdAt)}} />
+
-
-
状态:{workflowStatusLabel(detail.task.status)}
-
业务状态:{businessStatusLabel}
-
对象类型:{entityTitle(detail.workflowInstance?.entityType)}
+
{detail.reimbursement ? (
<>
- 报销单号:{detail.reimbursement.formNumber ?? "-"}
- 报销金额:{detail.reimbursement.totalAmount ?? "-"}
- 提交人:{detail.reimbursement.submitter?.name ?? "-"}
- 报销人:{detail.reimbursement.owner?.name ?? "-"}
+
+
+
+
>
) : null}
{detail.leaveRequest ? (
<>
- 假期类型:{detail.leaveRequest.leaveType?.name ?? "-"}
- 申请人:{detail.leaveRequest.owner?.name ?? "-"}
-
- 时间段:
+
+
+
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
{" → "}
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
-
- 请假天数:{detail.leaveRequest.requestedDays}
- 说明:{detail.leaveRequest.reason ?? "-"}
- 附件:{detail.leaveRequest.attachment?.originalName ?? "-"}
+ >
+ }
+ />
+
+
+
>
) : null}
{detail.purchaseRequest ? (
<>
- 采购单号:{resolvePurchaseDisplayCode(detail.purchaseRequest)}
- 标题:{detail.purchaseRequest.title || "-"}
- 申请人:{detail.purchaseRequest.applicant?.name ?? "-"}
- 供应商:{detail.purchaseRequest.supplier?.name ?? "-"}
- 法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}
- 成本中心:{detail.purchaseRequest.costCenter?.name ?? "-"}
- 单据分类:{detail.purchaseRequest.documentCategory?.name ?? "-"}
- 金额:{detail.purchaseRequest.totalAmount ?? "-"} {detail.purchaseRequest.currencyCode}
- 预计到货:{detail.purchaseRequest.expectedDeliveryDate ?? "-"}
- 说明:{detail.purchaseRequest.reason ?? "-"}
+
+
+
+
+
+
+
+
+
+
>
) : null}
{detail.travelRequest ? (
<>
- 差旅单号:{detail.travelRequest.requestNo ?? "暂未生成"}
- 申请人:{detail.travelRequest.applicant?.name ?? "-"}
- 出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}
- 法体:{detail.travelRequest.legalEntity?.name ?? "-"}
- 成本中心:{detail.travelRequest.costCenter?.name ?? "-"}
- 起止日期:{detail.travelRequest.startDate ?? "-"} → {detail.travelRequest.endDate ?? "-"}
- 预估金额:{detail.travelRequest.estimatedAmount ?? "-"}
- 出差目的:{detail.travelRequest.purpose || "-"}
-
- 行程段:
- {detail.travelRequest.segments.length
- ? detail.travelRequest.segments
- .map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
- .join(";")
- : "-"}
-
+
+
+
+
+
+
+
+
+ `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
+ .join(";")
+ : "-"
+ }
+ />
>
) : null}
-
+
diff --git a/client/src/pages/WorkflowTasksPage.tsx b/client/src/pages/WorkflowTasksPage.tsx
index bbec9df4..46310142 100644
--- a/client/src/pages/WorkflowTasksPage.tsx
+++ b/client/src/pages/WorkflowTasksPage.tsx
@@ -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 (
-
-
+
+
-
-
-
待办总数
-
{summaryQuery.data?.pendingTotal ?? "-"}
-
-
-
超时待办
-
{summaryQuery.data?.overdueTotal ?? "-"}
-
-
-
最早待处理
-
{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}
-
-
-
业务分布
-
- {(summaryQuery.data?.byEntityType ?? [])
- .filter((item) => item.pendingTotal > 0)
- .map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
- .join(" / ") || "-"}
-
-
+
+
+
+
+
+
+ {(summaryQuery.data?.byEntityType ?? [])
+ .filter((item) => item.pendingTotal > 0)
+ .map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
+ .join(" / ") || "-"}
+
+ }
+ />
+
-
-
+
+
-
+
业务类型
updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
@@ -252,7 +260,8 @@ export function WorkflowTasksPage() {
))}
-
+
+
{query.isLoading ?
: null}
@@ -270,24 +279,36 @@ export function WorkflowTasksPage() {
{!query.isLoading && !query.isError ? (
<>
-
+
{taskItems.length ? (
taskItems.map((task) => (
-
-
+
+
-
{displayTitle(task)}
-
+
{displayTitle(task)}
+
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
-
+
-
-
申请人:{displayApplicant(task)}
-
金额/天数:{displayMetric(task)}
-
当前节点:{task.stepName}
-
业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}
+
+
+ 申请人
+ {displayApplicant(task)}
+
+
+ 金额/天数
+ {displayMetric(task)}
+
+
+ 当前节点
+ {task.stepName}
+
+
+ 业务状态
+ {businessStatusLabel(task.businessSummary?.businessStatus)}
+
{status === "pending" && (task.allowedActions ?? []).includes("approve") ? (
@@ -317,13 +338,11 @@ export function WorkflowTasksPage() {
))
) : (
-
- 暂无数据
-
+
)}
-
+
@@ -381,14 +400,14 @@ export function WorkflowTasksPage() {
))
) : (
-
+
暂无数据
)}
-
+
>
) : null}
@@ -398,7 +417,6 @@ export function WorkflowTasksPage() {
onPageChange={(p) => updateSearchParams({ page: p })}
testIdPrefix="workflow-tasks"
/>
-
-
+
);
}
diff --git a/client/src/styles/mobile-touch.css b/client/src/styles/mobile-touch.css
index 08fa8f67..4e1d58ed 100644
--- a/client/src/styles/mobile-touch.css
+++ b/client/src/styles/mobile-touch.css
@@ -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;
diff --git a/client/tests/e2e/project-wide-visual.spec.ts b/client/tests/e2e/project-wide-visual.spec.ts
index 5414742d..3a8abd8d 100644
--- a/client/tests/e2e/project-wide-visual.spec.ts
+++ b/client/tests/e2e/project-wide-visual.spec.ts
@@ -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(
diff --git a/client/tests/e2e/reimbursement-workflow-progress.spec.ts b/client/tests/e2e/reimbursement-workflow-progress.spec.ts
index d1f338e2..e089c5ea 100644
--- a/client/tests/e2e/reimbursement-workflow-progress.spec.ts
+++ b/client/tests/e2e/reimbursement-workflow-progress.spec.ts
@@ -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);
});
diff --git a/docs/Agent开发交付实践.md b/docs/Agent开发交付实践.md
index 4990491a..cac03ef9 100644
--- a/docs/Agent开发交付实践.md
+++ b/docs/Agent开发交付实践.md
@@ -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 指标;真实企业微信登录、手机应用消息到达、真实通知权限和财务制度决策必须保留人工确认记录。
移动页治理必须收敛到同一套事实源和组件体系:
diff --git a/docs/产品设计规则.md b/docs/产品设计规则.md
index a0d18d78..af6d4717 100644
--- a/docs/产品设计规则.md
+++ b/docs/产品设计规则.md
@@ -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
diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md
index aa4fa4ac..f69b60aa 100644
--- a/docs/文档事实源状态矩阵.md
+++ b/docs/文档事实源状态矩阵.md
@@ -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 协作开发实践和设计主参考层级 |
diff --git a/docs/文档索引.md b/docs/文档索引.md
index 29044d96..ec02e230 100644
--- a/docs/文档索引.md
+++ b/docs/文档索引.md
@@ -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、附件细则 |
diff --git a/issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv b/issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv
new file mode 100644
index 00000000..1f673778
--- /dev/null
+++ b/issues/2026-05-13_23-30-00-dg9-ui-layout-rollout.csv
@@ -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;未部署。
diff --git a/scripts/qa/check-ui-design-rules.mjs b/scripts/qa/check-ui-design-rules.mjs
index 1d10d30e..753d0bd5 100644
--- a/scripts/qa/check-ui-design-rules.mjs
+++ b/scripts/qa/check-ui-design-rules.mjs
@@ -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 内容 ;
+ }
+ `,
+ expectedRules: ["mobile.no_desktop_card_container"],
+ expectedSuppressedRules: [],
+ },
{
file: "client/src/pages/AuditLogsPage.tsx",
source: `