MUD6 推广移动统一设计

这个提交包含在:
2026-05-12 00:07:24 +08:00
父节点 6359612cd0
当前提交 53a7779a70
共修改 22 个文件,包含 677 行新增和 89 行删除
+5 -3
查看文件
@@ -40,9 +40,10 @@ Default behavior:
5. Prefer existing project patterns, official SDKs, and principles from documented domestic OA/approval/mobile references; do not copy competitor flows or text directly. 5. Prefer existing project patterns, official SDKs, and principles from documented domestic OA/approval/mobile references; do not copy competitor flows or text directly.
6. Verify with the narrowest relevant automated tests, then expand by risk. 6. Verify with the narrowest relevant automated tests, then expand by risk.
7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes. New or changed mobile pages must join the unified responsive system and the 320/360/375/390/393/414/430px 统一视觉矩阵 rather than adding page-specific layout fixes. 7. Use Playwright screenshots or visual metrics for PC/mobile/WeCom UI changes. New or changed mobile pages must join the unified responsive system and the 320/360/375/390/393/414/430px 统一视觉矩阵 rather than adding page-specific layout fixes.
8. Update Issue CSV statuses only after the stated `Test_Method` passes. 8. Dedicated `/mobile` pages must use the shared mobile slot/card/list/sticky-action system from `docs/product-design-rules.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.
9. Commit in Chinese batches with Issue IDs. 9. Update Issue CSV statuses only after the stated `Test_Method` passes.
10. 面向用户可测试的改动,最终必须明确“是否已部署”。只有部署与公网检查通过后,才能告知用户可以去企业微信或公网试运行环境测试。 10. Commit in Chinese batches with Issue IDs.
11. 面向用户可测试的改动,最终必须明确“是否已部署”。只有部署与公网检查通过后,才能告知用户可以去企业微信或公网试运行环境测试。
## Default Verification Matrix ## Default Verification Matrix
@@ -104,3 +105,4 @@ Final responses should include:
- Approval-chain correctness can be proven by PC or automation, while Enterprise WeChat validates entry, binding,免登录, notification, deeplink, and mobile layout. - Approval-chain correctness can be proven by PC or automation, while Enterprise WeChat validates entry, binding,免登录, notification, deeplink, and mobile layout.
- If real-device mobile validation is paused after automation passes, record the gate as `PAUSED`; do not close RS6/UI6/R6 mobile blockers until representative real-device confirmation is recorded. - If real-device mobile validation is paused after automation passes, record the gate as `PAUSED`; do not close RS6/UI6/R6 mobile blockers until representative real-device confirmation is recorded.
- Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views. - Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views.
- After MUD6, `R6-ISSUE-001` is treated as resolved based on representative real-device confirmation; future mobile regressions should be recorded as new Issues rather than reopening the old WCM6/MME6 gate unless the same production behavior returns.
@@ -183,7 +183,7 @@ export function BarcodeScannerPanel({ disabled = false, onDetected }: BarcodeSca
}, [startWithBarcodeDetector, startWithZxing, stopScanner]); }, [startWithBarcodeDetector, startWithZxing, stopScanner]);
return ( return (
<div className="space-y-3 rounded-xl border border-zinc-200 p-4" data-testid="barcode-scanner-panel"> <div className="mobile-card space-y-3" data-testid="barcode-scanner-panel">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">相机扫码</h3> <h3 className="text-base font-semibold text-zinc-900">相机扫码</h3>
+11 -11
查看文件
@@ -811,7 +811,7 @@ export function MobileApprovalsPage() {
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p> <p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null} ) : null}
<section className="card-std space-y-4 p-4" data-testid="mobile-approvals-list"> <section className="mobile-card space-y-4" data-testid="mobile-approvals-list">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3> <h3 className="text-base font-semibold text-zinc-900">{listTitle}</h3>
@@ -835,7 +835,7 @@ export function MobileApprovalsPage() {
placeholder="搜索待办" placeholder="搜索待办"
/> />
<details className="rounded-xl border border-zinc-200 bg-zinc-50/70 px-3 py-2 text-sm"> <details className="mobile-subcard bg-zinc-50/70 text-sm">
<summary className="cursor-pointer select-none font-medium text-zinc-700"> <summary className="cursor-pointer select-none font-medium text-zinc-700">
筛选与批量 筛选与批量
</summary> </summary>
@@ -891,7 +891,7 @@ export function MobileApprovalsPage() {
</div> </div>
{batchMode ? ( {batchMode ? (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm"> <div className="mobile-subcard bg-zinc-50 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-zinc-700">已选 {selectedBatchTaskIds.length} 条</div> <div className="text-zinc-700">已选 {selectedBatchTaskIds.length} 条</div>
<div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap"> <div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
@@ -964,7 +964,7 @@ export function MobileApprovalsPage() {
return ( return (
<div <div
key={task.id} key={task.id}
className={`flex gap-3 rounded-xl border px-3 py-3 ${ className={`mobile-list-card flex gap-3 ${
isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white" isBatchSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`} }`}
> >
@@ -999,7 +999,7 @@ export function MobileApprovalsPage() {
return ( return (
<button <button
key={task.id} key={task.id}
className={`min-h-20 w-full rounded-xl border px-3 py-3 text-left transition-colors ${ className={`mobile-list-card min-h-20 w-full text-left transition-colors ${
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white" isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`} }`}
data-testid={`mobile-approvals-task-${task.id}`} data-testid={`mobile-approvals-task-${task.id}`}
@@ -1022,7 +1022,7 @@ export function MobileApprovalsPage() {
</div> </div>
</section> </section>
<section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail"> <section ref={detailSectionRef} className="mobile-card scroll-mt-4 space-y-3" data-testid="mobile-approvals-detail">
<h3 className="text-base font-semibold text-zinc-900">处理</h3> <h3 className="text-base font-semibold text-zinc-900">处理</h3>
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null} {!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
@@ -1045,7 +1045,7 @@ export function MobileApprovalsPage() {
{selectedTask ? ( {selectedTask ? (
<> <>
<div className="rounded-lg border border-zinc-200 p-3 text-sm"> <div className="mobile-list-card text-sm">
<div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div> <div>任务状态:{workflowStatusLabel(selectedTask.task.status)}</div>
<div>步骤:{selectedTask.task.stepName}</div> <div>步骤:{selectedTask.task.stepName}</div>
<div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div> <div>对象类型:{entityLabel(selectedTask.workflowInstance?.entityType)}</div>
@@ -1105,7 +1105,7 @@ export function MobileApprovalsPage() {
<p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p> <p className="text-xs text-zinc-500">离线仅保存通过 / 驳回草稿,联网同步成功后才会正式提交。</p>
) : null} ) : null}
<div className="grid gap-2 sm:grid-cols-3"> <div className="grid gap-2 min-[390px]:grid-cols-3">
<Button <Button
className="min-h-12 w-full" className="min-h-12 w-full"
data-testid="mobile-approvals-approve" data-testid="mobile-approvals-approve"
@@ -1150,7 +1150,7 @@ export function MobileApprovalsPage() {
</div> </div>
{canTransfer ? ( {canTransfer ? (
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3 text-sm"> <div className="mobile-list-card space-y-3 text-sm">
<p className="font-medium text-zinc-800">转审</p> <p className="font-medium text-zinc-800">转审</p>
<select <select
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm" className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
@@ -1192,7 +1192,7 @@ export function MobileApprovalsPage() {
) : null} ) : null}
</section> </section>
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-queue"> <section className="mobile-card space-y-3" data-testid="mobile-approvals-queue">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线草稿</h3> <h3 className="text-base font-semibold text-zinc-900">离线草稿</h3>
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span> <span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length}` : "无草稿"}</span>
@@ -1216,7 +1216,7 @@ export function MobileApprovalsPage() {
{!currentQueueItems.length ? <p className="text-sm text-zinc-500">暂无待同步草稿。</p> : null} {!currentQueueItems.length ? <p className="text-sm text-zinc-500">暂无待同步草稿。</p> : null}
{currentQueueItems.map((item) => ( {currentQueueItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3 text-sm"> <div key={item.id} className="mobile-list-card text-sm">
<div className="font-medium text-zinc-900"> <div className="font-medium text-zinc-900">
{publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"} {publicReference(item.taskSnapshot.reference) ?? entityLabel(item.taskSnapshot.entityType)} · {item.action === "approve" ? "通过" : "驳回"}
</div> </div>
+21 -20
查看文件
@@ -16,6 +16,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel"; import { BarcodeScannerPanel } from "../components/mobile/BarcodeScannerPanel";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { import {
@@ -394,7 +395,7 @@ export function MobileAssetScanPage() {
); );
return ( return (
<div className="space-y-4"> <PageScaffold className="space-y-4">
<PageHeader <PageHeader
title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"} title={isInventoryMode ? "移动扫码盘点" : "移动资产扫码"}
subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"} subtitle={isInventoryMode ? "在盘点任务上下文中扫码定位资产并快捷录入结果。" : "扫码或手工输入资产编号,快速查看资产卡片。"}
@@ -410,7 +411,7 @@ export function MobileAssetScanPage() {
) : null} ) : null}
{isInventoryMode ? ( {isInventoryMode ? (
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-inventory-context"> <div className="mobile-card space-y-3" data-testid="mobile-asset-scan-inventory-context">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">盘点任务上下文</h3> <h3 className="text-base font-semibold text-zinc-900">盘点任务上下文</h3>
@@ -436,7 +437,7 @@ export function MobileAssetScanPage() {
) : null} ) : null}
{selectedInventoryCheck ? ( {selectedInventoryCheck ? (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 px-3 py-3 text-sm"> <div className="mobile-list-card flex flex-wrap items-center justify-between gap-3 text-sm">
<div> <div>
<div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div> <div className="font-medium text-zinc-900">{selectedInventoryCheck.checkNo}</div>
<div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{selectedInventoryCheck.scopeType} / {selectedInventoryCheck.scopeRefId ?? "全量"}</div> <div className="text-zinc-500">状态:{inventoryStatusLabel(selectedInventoryCheck.status)} · 范围:{selectedInventoryCheck.scopeType} / {selectedInventoryCheck.scopeRefId ?? "全量"}</div>
@@ -459,13 +460,13 @@ export function MobileAssetScanPage() {
}} }}
/> />
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-search"> <div className="mobile-card space-y-3" data-testid="mobile-asset-scan-search">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">手工输入 / 搜索</h3> <h3 className="text-base font-semibold text-zinc-900">手工输入 / 搜索</h3>
<span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span> <span className={`text-sm ${isOnline ? "text-emerald-600" : "text-orange-600"}`}>{isOnline ? "在线" : "离线"}</span>
</div> </div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="grid gap-2 min-[390px]:grid-cols-[minmax(0,1fr)_auto]">
<Input <Input
data-testid="mobile-asset-scan-input" data-testid="mobile-asset-scan-input"
value={searchInput} value={searchInput}
@@ -483,14 +484,14 @@ export function MobileAssetScanPage() {
</div> </div>
{filteredSearchResults.length > 1 ? ( {filteredSearchResults.length > 1 ? (
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-candidates"> <div className="mobile-card space-y-3" data-testid="mobile-asset-scan-candidates">
<h3 className="text-base font-semibold text-zinc-900">候选资产</h3> <h3 className="text-base font-semibold text-zinc-900">候选资产</h3>
<div className="space-y-2"> <div className="space-y-2">
{filteredSearchResults.map((item) => ( {filteredSearchResults.map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
className={`w-full rounded-lg border px-3 py-3 text-left text-sm ${selectedAssetId === item.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"}`} className={`mobile-list-card w-full text-left text-sm ${selectedAssetId === item.id ? "border-primary bg-primary-50 text-primary" : "border-zinc-200 bg-white text-zinc-800"}`}
onClick={() => setSelectedAssetId(item.id)} onClick={() => setSelectedAssetId(item.id)}
data-testid={`mobile-asset-scan-candidate-${item.id}`} data-testid={`mobile-asset-scan-candidate-${item.id}`}
> >
@@ -503,7 +504,7 @@ export function MobileAssetScanPage() {
) : null} ) : null}
{selectedAsset ? ( {selectedAsset ? (
<div className="card-std space-y-4 p-4" data-testid="mobile-asset-scan-detail"> <div className="mobile-card space-y-4" data-testid="mobile-asset-scan-detail">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3> <h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3>
@@ -514,22 +515,22 @@ export function MobileAssetScanPage() {
</Button> </Button>
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> <div className="mobile-info-grid">
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">状态:{statusLabel(selectedAsset.status)}</div> <div className="mobile-subcard text-sm">状态:{statusLabel(selectedAsset.status)}</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div> <div className="mobile-subcard text-sm">法体:{selectedAsset.legalEntity?.name ?? "-"}</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div> <div className="mobile-subcard text-sm">成本中心:{selectedAsset.costCenter?.name ?? "-"}</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">位置:{selectedAsset.location ?? "-"}</div> <div className="mobile-subcard text-sm">位置:{selectedAsset.location ?? "-"}</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div> <div className="mobile-subcard text-sm">归属人:{selectedAsset.assignee?.name ?? "未分配"}</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div> <div className="mobile-subcard text-sm">序列号:{selectedAsset.serialNo ?? "-"}</div>
</div> </div>
{isInventoryMode && canRecordInventory && selectedInventoryCheck ? ( {isInventoryMode && canRecordInventory && selectedInventoryCheck ? (
<div className="space-y-3 rounded-xl border border-zinc-200 bg-zinc-50 p-4"> <div className="mobile-subcard space-y-3 bg-zinc-50">
<div> <div>
<h4 className="text-sm font-semibold text-zinc-900">盘点录入</h4> <h4 className="text-sm font-semibold text-zinc-900">盘点录入</h4>
<p className="text-xs text-zinc-500">仅在盘点中的任务可提交或离线缓存结果。</p> <p className="text-xs text-zinc-500">仅在盘点中的任务可提交或离线缓存结果。</p>
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> <div className="mobile-info-grid">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">盘点结果</label> <label className="text-sm font-medium text-zinc-700">盘点结果</label>
<select className="select-std" value={recordResult} onChange={(event) => setRecordResult(event.target.value as InventoryCheckResult)}> <select className="select-std" value={recordResult} onChange={(event) => setRecordResult(event.target.value as InventoryCheckResult)}>
@@ -564,7 +565,7 @@ export function MobileAssetScanPage() {
) : null} ) : null}
{isInventoryMode && canRecordInventory ? ( {isInventoryMode && canRecordInventory ? (
<div className="card-std space-y-3 p-4" data-testid="mobile-asset-scan-queue"> <div className="mobile-card space-y-3" data-testid="mobile-asset-scan-queue">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线盘点队列</h3> <h3 className="text-base font-semibold text-zinc-900">离线盘点队列</h3>
<span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length} 项` : "无待同步项"}</span> <span className="text-sm text-zinc-500">{currentQueueItems.length ? `待同步 ${currentQueueItems.length} 项` : "无待同步项"}</span>
@@ -575,7 +576,7 @@ export function MobileAssetScanPage() {
) : null} ) : null}
{currentQueueItems.map((item) => ( {currentQueueItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3 text-sm"> <div key={item.id} className="mobile-list-card text-sm">
<div className="font-medium text-zinc-900">{item.assetNo}</div> <div className="font-medium text-zinc-900">{item.assetNo}</div>
<div className="text-zinc-500">结果:{inventoryResultLabel(item.result)} · 创建时间:{formatDateTime(item.createdAt)}</div> <div className="text-zinc-500">结果:{inventoryResultLabel(item.result)} · 创建时间:{formatDateTime(item.createdAt)}</div>
<div className="text-zinc-500">备注:{item.note ?? "-"}</div> <div className="text-zinc-500">备注:{item.note ?? "-"}</div>
@@ -597,6 +598,6 @@ export function MobileAssetScanPage() {
))} ))}
</div> </div>
) : null} ) : null}
</div> </PageScaffold>
); );
} }
+7 -7
查看文件
@@ -186,7 +186,7 @@ export function MobileCapturePage() {
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p> <p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null} ) : null}
<div className="card-std space-y-4 p-4"> <div className="mobile-card space-y-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">选择票据</h3> <h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
@@ -200,10 +200,10 @@ export function MobileCapturePage() {
<div className="grid gap-2" data-testid="mobile-capture-file"> <div className="grid gap-2" data-testid="mobile-capture-file">
<label <label
htmlFor="mobile-capture-pdf-file" htmlFor="mobile-capture-pdf-file"
className="flex min-h-16 cursor-pointer items-center gap-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-left shadow-sm transition-colors hover:bg-zinc-50" className="mobile-action-link min-h-16 cursor-pointer"
> >
<MobileIcon name="picture_as_pdf" className="size-10 rounded-lg bg-emerald-50 p-2 text-emerald-700" /> <MobileIcon name="picture_as_pdf" className="mobile-action-icon bg-emerald-50 text-emerald-700" />
<span className="min-w-0"> <span className="mobile-action-copy">
<span className="block text-sm font-semibold text-zinc-900">选择 PDF 发票</span> <span className="block text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
<span className="mt-0.5 block text-xs leading-5 text-zinc-500">数电票或扫描件 PDF</span> <span className="mt-0.5 block text-xs leading-5 text-zinc-500">数电票或扫描件 PDF</span>
</span> </span>
@@ -221,7 +221,7 @@ export function MobileCapturePage() {
/> />
</div> </div>
<div className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-600"> <div className="mobile-subcard text-sm text-zinc-600">
<span className="font-medium text-zinc-800">当前文件:</span> <span className="font-medium text-zinc-800">当前文件:</span>
{selectedFiles.length ? `已选择 ${selectedFiles.length} 个` : "未选择"} {selectedFiles.length ? `已选择 ${selectedFiles.length} 个` : "未选择"}
{selectedFiles.length ? ( {selectedFiles.length ? (
@@ -282,7 +282,7 @@ export function MobileCapturePage() {
</div> </div>
</div> </div>
<div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue"> <div className="mobile-card space-y-3" data-testid="mobile-capture-queue">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3> <h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3>
<span className="shrink-0 text-sm text-zinc-500">{queuedCountLabel}</span> <span className="shrink-0 text-sm text-zinc-500">{queuedCountLabel}</span>
@@ -295,7 +295,7 @@ export function MobileCapturePage() {
) : null} ) : null}
{queuedItems.map((item) => ( {queuedItems.map((item) => (
<div key={item.id} className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm"> <div key={item.id} className="mobile-list-card text-sm">
<div className="font-medium text-zinc-800">{item.fileName}</div> <div className="font-medium text-zinc-800">{item.fileName}</div>
<div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div> <div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div>
<div className="mt-2"> <div className="mt-2">
+3 -3
查看文件
@@ -376,7 +376,7 @@ export function MobileLeavePage() {
{balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null} {balancesQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null} {!balancesQuery.isLoading && !balances.length ? <div className="text-sm text-zinc-500">暂无额度</div> : null}
{balances.slice(0, 3).map((item) => ( {balances.slice(0, 3).map((item) => (
<div key={item.leaveType.id} className="rounded-lg border border-zinc-200 p-3"> <div key={item.leaveType.id} className="mobile-list-card">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="font-medium text-zinc-900">{item.leaveType.name}</div> <div className="font-medium text-zinc-900">{item.leaveType.name}</div>
<span className="badge-success">启用</span> <span className="badge-success">启用</span>
@@ -413,14 +413,14 @@ export function MobileLeavePage() {
</Tabs> </Tabs>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{requestsQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null} {requestsQuery.isLoading ? <div className="mobile-empty-state">加载中...</div> : null}
{requestsQuery.isError ? ( {requestsQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600"> <div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{resolveErrorMessage(requestsQuery.error)} {resolveErrorMessage(requestsQuery.error)}
</div> </div>
) : null} ) : null}
{!requestsQuery.isLoading && !requestsQuery.isError && !records.length ? ( {!requestsQuery.isLoading && !requestsQuery.isError && !records.length ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无请假记录</div> <div className="mobile-empty-state">暂无请假记录</div>
) : null} ) : null}
{records.map((item) => ( {records.map((item) => (
<LeaveRecordCard <LeaveRecordCard
+8 -8
查看文件
@@ -38,11 +38,11 @@ function MobileActionLink({
return ( return (
<Link <Link
to={to} to={to}
className="mobile-me-action-link" className="mobile-action-link mobile-me-action-link"
data-testid="mobile-me-action-link" data-testid="mobile-me-action-link"
> >
<MobileIcon name={icon} className="mobile-me-action-icon" /> <MobileIcon name={icon} className="mobile-action-icon mobile-me-action-icon" />
<span className="mobile-me-action-copy"> <span className="mobile-action-copy mobile-me-action-copy">
<span className="block text-sm font-semibold text-zinc-900">{label}</span> <span className="block text-sm font-semibold text-zinc-900">{label}</span>
{description ? <span className="mt-0.5 block text-xs leading-5 text-zinc-500">{description}</span> : null} {description ? <span className="mt-0.5 block text-xs leading-5 text-zinc-500">{description}</span> : null}
</span> </span>
@@ -82,11 +82,11 @@ export function MobileMePage() {
<PageHeader title="我的" /> <PageHeader title="我的" />
<SectionPanel title="个人信息"> <SectionPanel title="个人信息">
<div className="mobile-me-profile-row" data-testid="mobile-me-profile-row"> <div className="mobile-slot-row mobile-me-profile-row" data-testid="mobile-me-profile-row">
<div className="mobile-me-avatar-slot" data-testid="mobile-me-avatar-slot"> <div className="mobile-slot-avatar mobile-me-avatar-slot" data-testid="mobile-me-avatar-slot">
<EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="md" /> <EmployeeAvatar name={me?.name ?? "员工"} src={profile?.avatarUrl} size="md" />
</div> </div>
<div className="mobile-me-profile-copy" data-testid="mobile-me-profile-copy"> <div className="mobile-slot-copy mobile-me-profile-copy" data-testid="mobile-me-profile-copy">
<div className="text-lg font-semibold leading-7 text-zinc-900" data-testid="mobile-me-profile-name"> <div className="text-lg font-semibold leading-7 text-zinc-900" data-testid="mobile-me-profile-name">
{me?.name ?? "-"} {me?.name ?? "-"}
</div> </div>
@@ -113,7 +113,7 @@ export function MobileMePage() {
</SectionPanel> </SectionPanel>
<SectionPanel title="更多入口"> <SectionPanel title="更多入口">
<div className="mobile-me-entry-grid" data-testid="mobile-more-links"> <div className="mobile-entry-grid mobile-me-entry-grid" data-testid="mobile-more-links">
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" /> <MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" /> <MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" /> <MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
@@ -125,7 +125,7 @@ export function MobileMePage() {
{canAccessAdmin ? ( {canAccessAdmin ? (
<SectionPanel title="管理入口"> <SectionPanel title="管理入口">
<div className="mobile-me-entry-grid"> <div className="mobile-entry-grid mobile-me-entry-grid">
<MobileActionLink to="/mobile/me" label="人员管理" icon="group" /> <MobileActionLink to="/mobile/me" label="人员管理" icon="group" />
<MobileActionLink to="/mobile/me" label="组织架构" icon="account_tree" /> <MobileActionLink to="/mobile/me" label="组织架构" icon="account_tree" />
<MobileActionLink to="/mobile/me" label="系统设置" icon="settings" /> <MobileActionLink to="/mobile/me" label="系统设置" icon="settings" />
+4 -4
查看文件
@@ -115,7 +115,7 @@ export function MobileNotificationsPage() {
} }
/> />
<section className="card-std space-y-4 p-4" data-testid="mobile-notifications-list"> <section className="mobile-card space-y-4" data-testid="mobile-notifications-list">
<div className="space-y-3"> <div className="space-y-3">
<Tabs <Tabs
data-testid="mobile-notifications-filter-tabs" data-testid="mobile-notifications-filter-tabs"
@@ -160,7 +160,7 @@ export function MobileNotificationsPage() {
</div> </div>
{listQuery.isLoading ? ( {listQuery.isLoading ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> <div className="mobile-empty-state">加载中...</div>
) : null} ) : null}
{listQuery.isError ? ( {listQuery.isError ? (
@@ -170,13 +170,13 @@ export function MobileNotificationsPage() {
) : null} ) : null}
{!listQuery.isLoading && !listQuery.isError && !notificationItems.length ? ( {!listQuery.isLoading && !listQuery.isError && !notificationItems.length ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无通知</div> <div className="mobile-empty-state">暂无通知</div>
) : null} ) : null}
{notificationItems.map((item) => { {notificationItems.map((item) => {
const targetLink = resolveMobileNotificationTarget(item); const targetLink = resolveMobileNotificationTarget(item);
return ( return (
<article data-testid="mobile-notification-card" key={item.id} className="rounded-lg border border-zinc-200 bg-white p-4"> <article data-testid="mobile-notification-card" key={item.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">{item.title}</div> <div className="text-sm font-semibold text-zinc-900">{item.title}</div>
+9 -9
查看文件
@@ -124,20 +124,20 @@ export function MobileProfilePage() {
) : null} ) : null}
<SectionPanel title="基本信息"> <SectionPanel title="基本信息">
<dl className="grid gap-3 text-sm"> <dl className="mobile-info-grid text-sm">
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3"> <div className="mobile-subcard">
<dt className="text-xs text-zinc-500">姓名</dt> <dt className="text-xs text-zinc-500">姓名</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd> <dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3"> <div className="mobile-subcard">
<dt className="text-xs text-zinc-500">员工号</dt> <dt className="text-xs text-zinc-500">员工号</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd> <dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3"> <div className="mobile-subcard">
<dt className="text-xs text-zinc-500">邮箱</dt> <dt className="text-xs text-zinc-500">邮箱</dt>
<dd className="mt-1 break-words font-medium text-zinc-900">{me?.email ?? "-"}</dd> <dd className="mt-1 break-words font-medium text-zinc-900">{me?.email ?? "-"}</dd>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3"> <div className="mobile-subcard">
<dt className="text-xs text-zinc-500">手机号</dt> <dt className="text-xs text-zinc-500">手机号</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd> <dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
</div> </div>
@@ -145,7 +145,7 @@ export function MobileProfilePage() {
</SectionPanel> </SectionPanel>
<SectionPanel title="外部通知推送"> <SectionPanel title="外部通知推送">
<label className="flex min-h-12 items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-800"> <label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
<span>接收外部渠道推送</span> <span>接收外部渠道推送</span>
<input <input
type="checkbox" type="checkbox"
@@ -159,13 +159,13 @@ export function MobileProfilePage() {
<SectionPanel title="企业微信绑定"> <SectionPanel title="企业微信绑定">
<div className="space-y-3" data-testid="mobile-profile-wecom-card"> <div className="space-y-3" data-testid="mobile-profile-wecom-card">
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3"> <div className="mobile-subcard">
<div className="text-xs text-zinc-500">接入状态</div> <div className="text-xs text-zinc-500">接入状态</div>
<div className="mt-1 font-semibold text-zinc-900">{wecomStatus.label}</div> <div className="mt-1 font-semibold text-zinc-900">{wecomStatus.label}</div>
<div className="mt-1 text-sm leading-5 text-zinc-500">{wecomStatus.description}</div> <div className="mt-1 text-sm leading-5 text-zinc-500">{wecomStatus.description}</div>
</div> </div>
<div className="rounded-lg border border-zinc-200 bg-white px-3 py-3"> <div className="mobile-subcard">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<div className="text-xs text-zinc-500">当前绑定</div> <div className="text-xs text-zinc-500">当前绑定</div>
@@ -181,7 +181,7 @@ export function MobileProfilePage() {
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="mobile-two-action-grid">
<Button <Button
className="w-full" className="w-full"
type="button" type="button"
@@ -880,7 +880,7 @@ export function MobileReimbursementDetailPage() {
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{detail.items.length ? ( {detail.items.length ? (
detail.items.map((item) => ( detail.items.map((item) => (
<article key={item.id} className="rounded-lg border border-zinc-200 px-3 py-3"> <article key={item.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div> <div className="truncate text-sm font-semibold text-zinc-900">{item.expenseType?.name ?? "费用"}</div>
@@ -934,7 +934,7 @@ export function MobileReimbursementDetailPage() {
{detail.invoices.length ? ( {detail.invoices.length ? (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{detail.invoices.map((invoice) => ( {detail.invoices.map((invoice) => (
<article key={invoice.id} className="rounded-lg border border-zinc-200 px-3 py-3"> <article key={invoice.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div> <div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceTitle(invoice)}</div>
@@ -1025,7 +1025,7 @@ export function MobileReimbursementDetailPage() {
{(detail.attachments ?? []).length ? ( {(detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{(detail.attachments ?? []).map((attachment) => ( {(detail.attachments ?? []).map((attachment) => (
<article key={attachment.id} className="rounded-lg border border-zinc-200 px-3 py-3"> <article key={attachment.id} className="mobile-list-card">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div> <div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500"> <div className="mt-1 text-xs text-zinc-500">
@@ -199,14 +199,14 @@ export function MobileReimbursementsPage() {
</Tabs> </Tabs>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{listQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null} {listQuery.isLoading ? <div className="mobile-empty-state">加载中...</div> : null}
{listQuery.isError ? ( {listQuery.isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600"> <div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{resolveErrorMessage(listQuery.error)} {resolveErrorMessage(listQuery.error)}
</div> </div>
) : null} ) : null}
{!listQuery.isLoading && !listQuery.isError && !items.length ? ( {!listQuery.isLoading && !listQuery.isError && !items.length ? (
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无报销记录</div> <div className="mobile-empty-state">暂无报销记录</div>
) : null} ) : null}
{items.map((item) => ( {items.map((item) => (
<ReimbursementCard <ReimbursementCard
+50 -9
查看文件
@@ -16,8 +16,10 @@
--mobile-bottom-nav-height: 68px; --mobile-bottom-nav-height: 68px;
--mobile-sticky-action-height: 78px; --mobile-sticky-action-height: 78px;
--mobile-icon-action-min-height: 72px; --mobile-icon-action-min-height: 72px;
--mobile-me-slot-gap: clamp(12px, 3.7vw, 16px); --mobile-slot-gap: clamp(12px, 3.7vw, 16px);
--mobile-me-action-icon-slot: 36px; --mobile-action-icon-slot: 36px;
--mobile-me-slot-gap: var(--mobile-slot-gap);
--mobile-me-action-icon-slot: var(--mobile-action-icon-slot);
} }
.page-scaffold.mobile-fixed-action-page { .page-scaffold.mobile-fixed-action-page {
@@ -119,6 +121,7 @@
min-width: 0; min-width: 0;
} }
.mobile-entry-grid,
.mobile-me-entry-grid { .mobile-me-entry-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -126,14 +129,16 @@
min-width: 0; min-width: 0;
} }
.mobile-slot-row,
.mobile-me-profile-row { .mobile-me-profile-row {
display: grid; display: grid;
grid-template-columns: 44px minmax(0, 1fr); grid-template-columns: 44px minmax(0, 1fr);
column-gap: var(--mobile-me-slot-gap); column-gap: var(--mobile-slot-gap);
align-items: center; align-items: center;
min-width: 0; min-width: 0;
} }
.mobile-slot-avatar,
.mobile-me-avatar-slot { .mobile-me-avatar-slot {
width: 44px; width: 44px;
min-width: 44px; min-width: 44px;
@@ -142,10 +147,12 @@
justify-content: center; justify-content: center;
} }
.mobile-slot-copy,
.mobile-me-profile-copy { .mobile-me-profile-copy {
min-width: 0; min-width: 0;
} }
.mobile-slot-copy > *,
.mobile-me-profile-copy > * { .mobile-me-profile-copy > * {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -153,10 +160,11 @@
white-space: nowrap; white-space: nowrap;
} }
.mobile-action-link,
.mobile-me-action-link { .mobile-me-action-link {
display: grid; display: grid;
grid-template-columns: var(--mobile-me-action-icon-slot) minmax(0, 1fr); grid-template-columns: var(--mobile-action-icon-slot) minmax(0, 1fr);
column-gap: var(--mobile-me-slot-gap); column-gap: var(--mobile-slot-gap);
align-items: center; align-items: center;
min-width: 0; min-width: 0;
min-height: 48px; min-height: 48px;
@@ -169,30 +177,35 @@
transition: background-color 150ms ease, border-color 150ms ease; transition: background-color 150ms ease, border-color 150ms ease;
} }
.mobile-action-link:hover,
.mobile-me-action-link:hover { .mobile-me-action-link:hover {
background: rgb(250 250 250); background: rgb(250 250 250);
} }
.mobile-action-icon,
.mobile-me-action-icon { .mobile-me-action-icon {
width: var(--mobile-me-action-icon-slot); width: var(--mobile-action-icon-slot);
height: var(--mobile-me-action-icon-slot); height: var(--mobile-action-icon-slot);
border-radius: 10px; border-radius: 10px;
background: rgb(244 244 245); background: rgb(244 244 245);
color: rgb(63 63 70); color: rgb(63 63 70);
padding: 7px; padding: 7px;
} }
.mobile-action-icon svg,
.mobile-me-action-icon svg { .mobile-me-action-icon svg {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.mobile-action-copy,
.mobile-me-action-copy { .mobile-me-action-copy {
display: block; display: block;
min-width: 0; min-width: 0;
} }
.mobile-action-copy > *,
.mobile-me-action-copy > * { .mobile-me-action-copy > * {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@@ -200,6 +213,34 @@
white-space: nowrap; white-space: nowrap;
} }
.mobile-list-card {
min-width: 0;
border: 1px solid rgb(228 228 231);
border-radius: 12px;
background: rgb(255 255 255);
box-shadow: 0 1px 2px rgb(15 23 42 / 0.05);
padding: var(--mobile-card-padding);
}
.mobile-subcard {
min-width: 0;
border: 1px solid rgb(228 228 231);
border-radius: 10px;
background: rgb(255 255 255);
padding: 10px 12px;
}
.mobile-empty-state {
min-width: 0;
border: 1px dashed rgb(228 228 231);
border-radius: 12px;
padding: 20px 12px;
text-align: center;
font-size: 0.875rem;
line-height: 1.25rem;
color: rgb(113 113 122);
}
.mobile-info-grid { .mobile-info-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 7.5rem), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 7.5rem), 1fr));
@@ -238,8 +279,8 @@
--mobile-card-gap: 6px; --mobile-card-gap: 6px;
--mobile-card-padding: 10px; --mobile-card-padding: 10px;
--mobile-icon-action-min-height: 68px; --mobile-icon-action-min-height: 68px;
--mobile-me-slot-gap: 12px; --mobile-slot-gap: 12px;
--mobile-me-action-icon-slot: 34px; --mobile-action-icon-slot: 34px;
} }
.mobile-icon-action__icon { .mobile-icon-action__icon {
@@ -0,0 +1,529 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Page, type Route } from "@playwright/test";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/r6-mobile-unified-design-rollout");
const IOS_WECOM_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const ANDROID_WECOM_UA =
"Mozilla/5.0 (Linux; Android 14; Redmi K60 Build/UKQ1.230804.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile Safari/537.36 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
const MOBILE_BROWSER_UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1";
const VIEWPORT_CASES = [
{ key: "w320-ios-wecom", label: "320 iOS 企业微信", viewport: { width: 320, height: 568 }, userAgent: IOS_WECOM_UA },
{ key: "w360-android-wecom", label: "360 Android 企业微信", viewport: { width: 360, height: 740 }, userAgent: ANDROID_WECOM_UA },
{ key: "w375-browser", label: "375 普通移动浏览器", viewport: { width: 375, height: 667 }, userAgent: MOBILE_BROWSER_UA },
{ key: "w390-ios-wecom", label: "390 iOS 企业微信", viewport: { width: 390, height: 844 }, userAgent: IOS_WECOM_UA },
{ key: "w393-android-wecom", label: "393 Android 企业微信", viewport: { width: 393, height: 873 }, userAgent: ANDROID_WECOM_UA },
{ key: "w414-ios-wecom", label: "414 iOS 企业微信", viewport: { width: 414, height: 896 }, userAgent: IOS_WECOM_UA },
{ key: "w430-browser", label: "430 普通移动浏览器", viewport: { width: 430, height: 932 }, userAgent: MOBILE_BROWSER_UA },
] as const;
const MOBILE_PAGES = [
{ key: "home", path: "/mobile", marker: "mobile-action-grid", deep: false },
{ key: "approvals", path: "/mobile/approvals", marker: "mobile-approvals-list", deep: false },
{ key: "reimbursements", path: "/mobile/reimbursements", marker: "mobile-reimbursements-list", deep: false },
{ key: "reimbursement-detail", path: "/mobile/reimbursements/mud6-reimbursement", marker: "mobile-reimbursement-detail", deep: true },
{ key: "me", path: "/mobile/me", marker: "mobile-more-links", deep: false },
{ key: "profile", path: "/mobile/profile", marker: "mobile-profile-page", deep: false },
{ key: "notifications", path: "/mobile/notifications", marker: "mobile-notifications-page", deep: false },
{ key: "capture", path: "/mobile/capture", marker: "mobile-capture-file", deep: false },
{ key: "leave", path: "/mobile/leave", marker: "mobile-leave-form", deep: false },
{ key: "asset-scan", path: "/mobile/assets/scan", marker: "barcode-scanner-panel", deep: false },
] as const;
const employee = {
id: "mud6-employee",
employeeNo: "SMOKE002",
name: "OA试运行测试01",
phone: "13800000000",
email: "[email protected]",
departmentId: "mud6-dept",
directManagerId: "mud6-manager",
status: "active",
notificationPushEnabled: true,
roleCodes: ["manager", "finance_mgr", "employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 80,
},
wecomBinding: {
externalUserId: "oa_smoke_01",
createdAt: "2026-05-11T08:00:00.000Z",
},
};
function envelope(data: unknown) {
return { requestId: "mock-r6-mobile-unified", data };
}
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function screenshotPath(fileName: string) {
mkdirSync(SCREENSHOT_DIR, { recursive: true });
return path.join(SCREENSHOT_DIR, `${fileName}.png`);
}
function reimbursementListItem() {
return {
id: "mud6-reimbursement",
formNumber: "BX20260511007",
status: "draft",
reimbursementDate: "2026-05-11",
totalAmount: "168.30",
description: "MUD6 移动统一设计验证",
payment: {},
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
project: null,
pendingWorkflow: null,
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
};
}
async function mockApis(page: Page) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathName = url.pathname;
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({ accessToken: "mock-mud6-token", expiresIn: 900 })),
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "ww-mud6",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fmobile",
bindEnabled: true,
defaultReturnTo: "/mobile",
status: "ready",
statusReasons: [],
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, employee);
return;
}
if (pathName === `/api/employees/${employee.id}/profile`) {
await fulfillJson(route, {
employee: {
...employee,
title: "测试员工",
avatarUrl: null,
department: { id: "mud6-dept", name: "试运行部" },
directManager: { id: "mud6-manager", name: "OA试运行测试02" },
},
lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } },
assignedAssets: [],
organization: { reportingChain: [], directReports: [] },
});
return;
}
if (pathName === "/api/attendance/me/today") {
await fulfillJson(route, { workDate: "2026-05-11", needClockIn: false, exemptionReason: null, item: null });
return;
}
if (pathName === "/api/notifications" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
unreadOnly: false,
unreadCount: 1,
items: [
{
id: "mud6-notification",
type: "workflow.task_assigned",
title: "报销待审批",
body: "MUD6 移动统一设计待办",
targetType: "workflow_task",
targetId: "mud6-task",
readAt: null,
createdAt: "2026-05-11T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/workflow/tasks" && request.method() === "GET") {
await fulfillJson(route, {
page: 1,
pageSize: 100,
total: 1,
items: [
{
id: "mud6-task",
instanceId: "mud6-instance",
stepId: "mud6-step",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comments: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
workflowInstance: {
id: "mud6-instance",
entityType: "reimbursement",
entityId: "mud6-reimbursement",
currentStatus: "reviewing",
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
businessSummary: {
reference: "BX20260511007",
title: "报销待审批",
applicantName: employee.name,
amount: "168.30",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
},
],
});
return;
}
if (pathName === "/api/workflow/tasks/mud6-task" && request.method() === "GET") {
await fulfillJson(route, {
task: {
id: "mud6-task",
instanceId: "mud6-instance",
stepId: "mud6-step",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comment: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
workflowInstance: {
id: "mud6-instance",
entityType: "reimbursement",
currentStatus: "reviewing",
currentStepId: "mud6-step",
contextData: { standardViolations: [] },
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
reimbursement: reimbursementListItem(),
leaveRequest: null,
purchaseRequest: null,
travelRequest: null,
allowedActions: ["approve", "reject", "return"],
history: [],
});
return;
}
if (pathName === "/api/workflow/approvers" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/reimbursements" && request.method() === "GET") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, scope: "own", items: [reimbursementListItem()] });
return;
}
if (pathName === "/api/reimbursements/mud6-reimbursement") {
await fulfillJson(route, {
...reimbursementListItem(),
submitterId: employee.id,
ownerId: employee.id,
departmentId: "mud6-dept",
department: { id: "mud6-dept", name: "试运行部" },
submittedAt: null,
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
project: null,
owner: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employee_no: employee.employeeNo, name: employee.name },
invoices: [],
attachments: [],
budgetHint: null,
pendingWorkflow: null,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
items: [
{
id: "mud6-item",
expenseTypeId: "mud6-expense-type",
expenseDate: "2026-05-11",
amount: "168.30",
description: "统一设计验证",
expenseType: { id: "mud6-expense-type", code: "office", name: "办公费", category: "office", enabled: true },
},
],
});
return;
}
if (pathName === "/api/expense-types") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
items: [{ id: "mud6-expense-type", code: "office", name: "办公费", category: "office", enabled: true }],
});
return;
}
if (pathName === "/api/leave-types") {
await fulfillJson(route, {
items: [
{
id: "mud6-leave-type",
code: "annual",
name: "年假",
description: null,
enabled: true,
requiresAttachment: false,
attachmentMimeTypes: [],
sortOrder: 1,
policy: { id: "mud6-leave-policy", annualQuotaDays: 5, enabled: true, description: null },
createdAt: null,
updatedAt: null,
},
],
});
return;
}
if (pathName === "/api/leave-balances") {
await fulfillJson(route, {
employeeId: employee.id,
items: [
{
leaveType: { id: "mud6-leave-type", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [] },
policy: { id: "mud6-leave-policy", annualQuotaDays: 5, enabled: true, description: null },
granted: 5,
adjusted: 0,
reserved: 0,
used: 1,
available: 4,
periodStart: "2026-01-01",
periodEnd: "2026-12-31",
},
],
});
return;
}
if (pathName === "/api/leave-requests") {
await fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
scope: "own",
items: [
{
id: "mud6-leave",
ownerId: employee.id,
departmentId: "mud6-dept",
status: "draft",
startDate: "2026-05-12",
endDate: "2026-05-12",
startSession: "am",
endSession: "pm",
requestedDays: 1,
reason: "统一设计验证",
attachment: null,
leaveType: { id: "mud6-leave-type", code: "annual", name: "年假", requiresAttachment: false, attachmentMimeTypes: [] },
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
createdAt: "2026-05-11T08:00:00.000Z",
updatedAt: "2026-05-11T08:00:00.000Z",
},
],
});
return;
}
if (pathName === "/api/inventory-checks") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/assets") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
await fulfillJson(route, {});
});
}
async function login(page: Page) {
await page.goto("/login");
const accountLogin = page.getByRole("button", { name: "使用账号密码登录" });
if (await accountLogin.isVisible().catch(() => false)) {
await accountLogin.click();
}
await page.getByTestId("login-identifier").fill(employee.email);
await page.getByTestId("login-password").fill("Password1234");
await page.getByTestId("login-submit").click();
await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 });
}
async function collectMetrics(page: Page) {
return page.evaluate(() => {
const viewportWidth = window.innerWidth;
const visibleElements = (selector: string) =>
Array.from(document.querySelectorAll(selector)).filter((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
});
const bottomNav = document.querySelector("[data-testid='mobile-bottom-nav']");
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
const rect = element?.getBoundingClientRect();
const centerX = rect ? rect.left + rect.width / 2 : 0;
const centerY = rect ? rect.top + rect.height / 2 : 0;
const hit = rect ? document.elementFromPoint(centerX, centerY) : null;
return {
key,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
width: rect?.width ?? 0,
height: rect?.height ?? 0,
hitWithin: Boolean(element && hit instanceof Element && hit.closest(`[data-testid='mobile-nav-${key}']`) === element),
};
});
const actionSlotGaps = visibleElements(".mobile-action-link").map((element) => {
const icon = element.querySelector(".mobile-action-icon");
const copy = element.querySelector(".mobile-action-copy");
const iconRect = icon?.getBoundingClientRect();
const copyRect = copy?.getBoundingClientRect();
return iconRect && copyRect ? copyRect.left - iconRect.right : null;
}).filter((item): item is number => typeof item === "number");
const mobileSectionHasDecorativeTopLine = visibleElements(".hyb-mobile-shell .section-panel").some((element) => {
const before = window.getComputedStyle(element, "::before");
const opacity = Number.parseFloat(before.opacity || "1");
const height = Number.parseFloat(before.height || "0");
return before.display !== "none" && before.visibility !== "hidden" && opacity > 0.01 && height > 0;
});
const stickyButtons = [
document.querySelector("[data-testid='mobile-reimbursement-sticky-invoice-add']"),
document.querySelector("[data-testid='reimbursement-submit']"),
].filter(Boolean).map((element) => element!.getBoundingClientRect());
return {
viewportWidth,
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
shellScoped: Boolean(document.querySelector(".hyb-mobile-shell")),
desktopSidebarCount: visibleElements("aside, [data-testid='sidebar']").length,
tableCount: visibleElements("table").length,
desktopCardMainCount: visibleElements(".hyb-mobile-shell .card-std:not(.section-panel)").length,
mobileCardCount: visibleElements(".mobile-card, .section-panel").length,
mobileListCardCount: visibleElements(".mobile-list-card").length,
hasDecorativeTopLine: mobileSectionHasDecorativeTopLine,
bottomNavVisible: Boolean(bottomNav?.getBoundingClientRect().height),
navItems,
actionGapMin: actionSlotGaps.length ? Math.min(...actionSlotGaps) : null,
actionGapMax: actionSlotGaps.length ? Math.max(...actionSlotGaps) : null,
stickyButtonsSameRow:
stickyButtons.length === 2 ? Math.abs(stickyButtons[0].top - stickyButtons[1].top) <= 2 : stickyButtons.length <= 1,
stickyButtonMinHeight: stickyButtons.length ? Math.min(...stickyButtons.map((rect) => rect.height)) : 0,
};
});
}
async function expectUnifiedMobilePage(page: Page, label: string, screenshotName: string, options: { deep: boolean }) {
await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true });
const metrics = await collectMetrics(page);
expect(metrics.shellScoped, `${label} mobile shell scope`).toBe(true);
expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2);
expect(metrics.desktopSidebarCount, `${label} no desktop sidebar`).toBe(0);
expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0);
expect(metrics.desktopCardMainCount, `${label} no desktop card main container`).toBe(0);
expect(metrics.mobileCardCount, `${label} has mobile card system`).toBeGreaterThan(0);
expect(metrics.hasDecorativeTopLine, `${label} mobile card top line hidden`).toBe(false);
if (metrics.actionGapMin !== null) {
const minSlotGap = metrics.viewportWidth >= 390 ? 14 : 12;
expect(metrics.actionGapMin, `${label} action slot gap min`).toBeGreaterThanOrEqual(minSlotGap);
expect(metrics.actionGapMax ?? 0, `${label} action slot gap max`).toBeLessThanOrEqual(18);
}
if (options.deep) {
expect(metrics.bottomNavVisible, `${label} deep page hides bottom nav`).toBe(false);
expect(metrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true);
expect(metrics.stickyButtonMinHeight, `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
} else {
expect(metrics.bottomNavVisible, `${label} bottom nav visible`).toBe(true);
for (const item of metrics.navItems) {
expect(item.visible, `${label} ${item.key} nav visible`).toBe(true);
expect(item.width, `${label} ${item.key} nav width`).toBeGreaterThanOrEqual(44);
expect(item.height, `${label} ${item.key} nav height`).toBeGreaterThanOrEqual(44);
expect(item.hitWithin, `${label} ${item.key} nav hit`).toBe(true);
}
}
}
test.describe("MUD6 专用移动页面统一设计", () => {
for (const device of VIEWPORT_CASES) {
test.describe(device.label, () => {
test.use({
viewport: device.viewport,
isMobile: true,
hasTouch: true,
userAgent: device.userAgent,
locale: "zh-CN",
serviceWorkers: "block",
});
test("所有专用 /mobile 页面复用统一槽位、卡片和底部导航体系", async ({ page }) => {
await mockApis(page);
await login(page);
for (const mobilePage of MOBILE_PAGES) {
await page.goto(mobilePage.path);
await expect(page.getByTestId(mobilePage.marker)).toBeVisible();
await expectUnifiedMobilePage(page, `${device.label} ${mobilePage.key}`, `${device.key}-${mobilePage.key}`, {
deep: mobilePage.deep,
});
}
});
});
}
});
+1
查看文件
@@ -86,6 +86,7 @@ AGENTS.md 只放硬约束和入口,不复制长规则。
- 改移动页面前先读 `docs/product-design-rules.md`,确认是否属于底部一级 Tab、首页图标宫格、卡片列表、深层 Push 页、顶部返回、吸底主操作或安全区问题。 - 改移动页面前先读 `docs/product-design-rules.md`,确认是否属于底部一级 Tab、首页图标宫格、卡片列表、深层 Push 页、顶部返回、吸底主操作或安全区问题。
- 新移动页默认纳入统一响应式 token 和跨视口视觉矩阵,覆盖 320、360、375、390、393、414、430px;不得只按某一台手机打补丁。 - 新移动页默认纳入统一响应式 token 和跨视口视觉矩阵,覆盖 320、360、375、390、393、414、430px;不得只按某一台手机打补丁。
- 专用 `/mobile` 路由默认使用统一移动槽位、`mobile-card`、移动列表卡片和吸底操作;不得把桌面 `card-std p-4` 作为手机主容器,也不得为单个页面自造另一套间距、卡片或图标布局。
- 角色差异采用“同框架、按角色裁剪内容和动作”:员工、审批人、财务、管理员、负责人看到不同入口和动作,但不创建互不一致的角色专属移动 UI。 - 角色差异采用“同框架、按角色裁剪内容和动作”:员工、审批人、财务、管理员、负责人看到不同入口和动作,但不创建互不一致的角色专属移动 UI。
- 真机验证暂停时记录为 `PAUSED`;自动化通过不能替代代表性真机确认,也不能关闭仍待人工确认的移动阻断。 - 真机验证暂停时记录为 `PAUSED`;自动化通过不能替代代表性真机确认,也不能关闭仍待人工确认的移动阻断。
+3
查看文件
@@ -100,6 +100,7 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共
## 移动统一页面范式 ## 移动统一页面范式
- 所有 `/mobile` 页面默认复用同一套移动 Shell、响应式 token、底部导航、安全区和视觉矩阵;不允许单页自造布局体系解决局部拥挤。 - 所有 `/mobile` 页面默认复用同一套移动 Shell、响应式 token、底部导航、安全区和视觉矩阵;不允许单页自造布局体系解决局部拥挤。
- 专用 `/mobile` 页面包括移动首页、审批、报销列表、报销详情、我的、个人设置、通知、票据上传、请假和资产扫码;这些页面必须先使用统一移动组件和样式,再按业务差异裁剪内容。
- 手机端默认隐藏 PC 侧边栏和顶栏,使用移动首页、底部导航和轻量卡片。 - 手机端默认隐藏 PC 侧边栏和顶栏,使用移动首页、底部导航和轻量卡片。
- 菜单不应占用大量空间;常用入口放底部导航,更多入口放“我的”。 - 菜单不应占用大量空间;常用入口放底部导航,更多入口放“我的”。
- 移动底部导航固定为“首页、审批、报销、我的”;票据上传作为首页快捷入口和报销页内操作,不作为底部一级 Tab。 - 移动底部导航固定为“首页、审批、报销、我的”;票据上传作为首页快捷入口和报销页内操作,不作为底部一级 Tab。
@@ -117,6 +118,8 @@ R6 之后的 PC、移动和审批体验参考国内主流协同/OA 产品的共
- 企业微信入口应尽量免登录;绑定失败或权限不足时显示明确下一步,而不是让用户反复登录。 - 企业微信入口应尽量免登录;绑定失败或权限不足时显示明确下一步,而不是让用户反复登录。
- 头像 fallback 遵循国内中文习惯:中文/CJK 姓名默认取首个可见字符,英文或拼音姓名保留两段首字母。 - 头像 fallback 遵循国内中文习惯:中文/CJK 姓名默认取首个可见字符,英文或拼音姓名保留两段首字母。
- “我的”页和更多入口使用组件级自适应槽位:头像/图标槽固定,文字槽使用 `minmax(0, 1fr)` 可收缩,间距使用移动 token 或 `clamp()`;不按具体 iPhone/Android 机型写补丁,不通过放大字号、放大卡片或改成单列来规避图文拥挤。 - “我的”页和更多入口使用组件级自适应槽位:头像/图标槽固定,文字槽使用 `minmax(0, 1fr)` 可收缩,间距使用移动 token 或 `clamp()`;不按具体 iPhone/Android 机型写补丁,不通过放大字号、放大卡片或改成单列来规避图文拥挤。
- 所有专用 `/mobile` 页面统一使用移动槽位和移动卡片:图标/头像/状态槽固定,文字槽可收缩,列表项使用移动列表卡片,提示、空状态和表单分组使用移动 token;不得把桌面 `card-std p-4` 当作移动主容器重新引入。
- 移动卡片保持白底、浅边框、轻阴影,不显示 PC 端 `SectionPanel` 的蓝青色顶边装饰线。
- 移动角色差异采用“同框架、按角色裁剪内容和动作”:员工、审批人、财务、管理员、负责人看到不同入口、摘要、动作和敏感信息,但不做多套角色专属移动 UI。 - 移动角色差异采用“同框架、按角色裁剪内容和动作”:员工、审批人、财务、管理员、负责人看到不同入口、摘要、动作和敏感信息,但不做多套角色专属移动 UI。
- 移动验收必须包含跨视口自动化和代表性真机确认;若真机验证暂缓,只能记为 `PAUSED`,不能把相关移动阻断标为完成。 - 移动验收必须包含跨视口自动化和代表性真机确认;若真机验证暂缓,只能记为 `PAUSED`,不能把相关移动阻断标为完成。
+4 -2
查看文件
@@ -60,10 +60,12 @@
| ID | 级别 | 角色 | 入口/设备 | 流程 | 复现步骤 | 当前处理 | 负责人 | 状态 | 关联 Issue | | ID | 级别 | 角色 | 入口/设备 | 流程 | 复现步骤 | 当前处理 | 负责人 | 状态 | 关联 Issue |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| R6-ISSUE-001 | 阻断 | 审批人 / 员工 | iPhone 12 / iOS 14.1 / 企业微信 WebView | 移动底部导航、审批入口、首页快捷卡 | 旧记录误写为 iPhone 11;用户更正为 iPhone 12 / iOS 14.1。真机截图显示首页快捷卡和底部 Tab 的字体图标漂移、重影、挤压,底部“审批”体验不稳定;自动化 Chromium UA 截图与真实 iOS 企业微信 WebView 存在差异 | `PAUSED`:WCM6 已部署公网并完成自动复检,移动核心图标改 SVG,补企业微信 WebView viewport/字号/safe-area,底部角标独立定位;公网 WCM6 视觉专项 8 passed,live smoke readiness PASS,截图 `output/playwright/r6-wecom-mobile-rendering-hardening/`;待 iPhone 12 / iOS 14.1 / 企业微信和 Redmi K60 真机确认后关闭或降级 | Codex / 待指派 | open | `issues/2026-05-11_20-38-33-r6-wecom-mobile-rendering-hardening.csv` | | R6-ISSUE-001 | 阻断 | 审批人 / 员工 | iPhone 12 / iOS 14.1 / 企业微信 WebView | 移动底部导航、审批入口、首页快捷卡 | 旧记录误写为 iPhone 11;用户更正为 iPhone 12 / iOS 14.1。真机截图显示首页快捷卡和底部 Tab 的字体图标漂移、重影、挤压,底部“审批”体验不稳定;自动化 Chromium UA 截图与真实 iOS 企业微信 WebView 存在差异 | 已关闭:WCM6/MME6 已部署公网并完成自动复检,移动核心图标改 SVG,补企业微信 WebView viewport/字号/safe-area,底部角标独立定位,`/mobile/me` 图文槽位和卡片顶边完成自适应收口;用户确认“看过了,可以没问题”。后续若出现移动回归,另建新 Issue 处理。 | Codex | closed | `issues/2026-05-11_20-38-33-r6-wecom-mobile-rendering-hardening.csv`; `issues/2026-05-11_22-42-11-r6-mobile-me-adaptive-visual-polish.csv`; `issues/2026-05-11_23-26-00-r6-mobile-unified-design-rollout.csv` |
| R6-ISSUE-002 | 一般 | 自动化 / 员工与财务测试账号 | 本地隔离 E2E / PC | 报销审批进度、报销支付批次 | `BUSINESS_FLOW_GROUPS=reimbursement bash scripts/qa/run-business-flow-gates.sh local` 中 2 条用例曾失败,失败点为 Playwright 查找旧导航入口 `票据中心`;当前正式员工路径应从报销详情添加 PDF 发票 | 已关闭:测试已改为报销详情页“添加发票”路径,不恢复普通员工“票据中心”入口;支付批次用例同步按业务编号定位待办,并按金额选择财务或财务主管二级审批;报销业务流 gate 10 passed、1 skipped | Codex | closed | `issues/2026-05-11_15-50-00-r6-reimbursement-e2e-nav-alignment.csv` | | R6-ISSUE-002 | 一般 | 自动化 / 员工与财务测试账号 | 本地隔离 E2E / PC | 报销审批进度、报销支付批次 | `BUSINESS_FLOW_GROUPS=reimbursement bash scripts/qa/run-business-flow-gates.sh local` 中 2 条用例曾失败,失败点为 Playwright 查找旧导航入口 `票据中心`;当前正式员工路径应从报销详情添加 PDF 发票 | 已关闭:测试已改为报销详情页“添加发票”路径,不恢复普通员工“票据中心”入口;支付批次用例同步按业务编号定位待办,并按金额选择财务或财务主管二级审批;报销业务流 gate 10 passed、1 skipped | Codex | closed | `issues/2026-05-11_15-50-00-r6-reimbursement-e2e-nav-alignment.csv` |
MME6 补充(2026-05-11):针对用户补充的 iPhone 12 / iOS 14.1 图文贴近与 iPhone 14 / iOS 16.2 卡片顶边观感,已把 `/mobile/me` 改为固定头像/图标槽 + `minmax(0, 1fr)` 文字槽,移动卡片顶边装饰线仅在 `/mobile` 作用域禁用,并将该规则写入产品设计规约。本地 `client typecheck`、`client build`、WCM6 视觉专项 8 passed、R5/R6/R124 移动相关回归 10 passed;部署后 `/ready` 正常、public bundle `findings=0`、live smoke readiness PASS、真实 OCR 样票返回重复发票可解释终态且测试通过,公网 WCM6 视觉专项 8 passed,报告 `artifacts/qa-flow/live-smoke-readiness-20260511-230918`。三台代表性真机确认前,`R6-ISSUE-001` 继续 `open/PAUSED`。 MME6 补充(2026-05-11):针对用户补充的 iPhone 12 / iOS 14.1 图文贴近与 iPhone 14 / iOS 16.2 卡片顶边观感,已把 `/mobile/me` 改为固定头像/图标槽 + `minmax(0, 1fr)` 文字槽,移动卡片顶边装饰线仅在 `/mobile` 作用域禁用,并将该规则写入产品设计规约。本地 `client typecheck`、`client build`、WCM6 视觉专项 8 passed、R5/R6/R124 移动相关回归 10 passed;部署后 `/ready` 正常、public bundle `findings=0`、live smoke readiness PASS、真实 OCR 样票返回重复发票可解释终态且测试通过,公网 WCM6 视觉专项 8 passed,报告 `artifacts/qa-flow/live-smoke-readiness-20260511-230918`。用户已确认当前设计可接受,`R6-ISSUE-001` 关闭;MUD6 继续把该设计推广到所有专用 `/mobile` 页面。
MUD6 收口(2026-05-11):已将 MME6 槽位自适应方案推广到专用 `/mobile` 路由:移动首页、审批、报销列表、报销详情、我的、个人设置、通知、票据上传、请假和资产扫码。通用移动样式固定图标/头像槽、`minmax(0, 1fr)` 文本槽、`clamp()` 间距、`mobile-card`、移动列表卡片、空状态和深层页吸底操作;业务角色差异继续按权限裁剪入口和动作,不拆多套 UI。本地 `client typecheck`、`client build`、MUD6 全移动页视觉专项 7 passed、WCM6/RS6/R124 回归 16 passed;已部署到 `https://oa.hyinda.com`,`/ready` 正常、public bundle `findings=0`、live smoke readiness PASS、真实 OCR 样票为重复发票可解释终态且通过,公网 MUD6 视觉专项 7 passed,报告 `artifacts/qa-flow/live-smoke-readiness-20260511-235918`。R6-3 可恢复执行试运行前检查,建议仍抽查 iPhone 12 / iOS 14、iPhone 14 / iOS 16.2、Redmi K60 的企业微信首页、审批、报销详情、我的和通知。
级别定义: 级别定义:
@@ -98,7 +98,7 @@
| 工作台入口与免登录 | `oa_smoke_01` | `SMOKE002` | 从企业微信工作台进入 OA,已绑定后免登录 | 通过 | 用户确认;截图/录屏不提交仓库 | | 工作台入口与免登录 | `oa_smoke_01` | `SMOKE002` | 从企业微信工作台进入 OA,已绑定后免登录 | 通过 | 用户确认;截图/录屏不提交仓库 |
| 提交人移动报销体验 | `oa_smoke_01` | `SMOKE002` | 移动首页、报销页、详情页、PDF 发票入口可用 | 公网自动视觉通过;真机核心链路通过 | `output/playwright/r5-live-mobile-wecom-visual/` | | 提交人移动报销体验 | `oa_smoke_01` | `SMOKE002` | 移动首页、报销页、详情页、PDF 发票入口可用 | 公网自动视觉通过;真机核心链路通过 | `output/playwright/r5-live-mobile-wecom-visual/` |
| 审批人移动待办体验 | `oa_smoke_02` | `SMOKE101` | 移动审批待办、审批详情、结果反馈可用 | 公网自动视觉通过;真机审批与消息回流通过 | `output/playwright/r5-live-mobile-wecom-visual/` | | 审批人移动待办体验 | `oa_smoke_02` | `SMOKE101` | 移动审批待办、审批详情、结果反馈可用 | 公网自动视觉通过;真机审批与消息回流通过 | `output/playwright/r5-live-mobile-wecom-visual/` |
| 移动底部导航与深层页 | `oa_smoke_01` / `oa_smoke_02` | `SMOKE002` / `SMOKE101` | 首页、审批、报销、我的固定可见;报销详情隐藏底部 Tab,吸底双按钮不遮挡 | `PAUSED`:WCM6 已部署公网且自动视觉通过;代表性真机确认待补 | `output/playwright/r6-wecom-mobile-rendering-hardening/` | | 移动底部导航与深层页 | `oa_smoke_01` / `oa_smoke_02` | `SMOKE002` / `SMOKE101` | 首页、审批、报销、我的固定可见;报销详情隐藏底部 Tab,吸底双按钮不遮挡 | 通过:WCM6/MME6 已部署公网且自动视觉通过;用户确认当前设计可接受,MUD6 继续推广统一设计 | `output/playwright/r6-wecom-mobile-rendering-hardening/` |
| 通知与 deeplink | `oa_smoke_01` / `oa_smoke_02` | `SMOKE002` / `SMOKE101` | 通知能回到移动待办或详情页 | 真机通过:收到个人应用消息,点击后首次需点进入工作台,后续可直达移动审批 | `output/playwright/r5-wecom-app-message-live/` | | 通知与 deeplink | `oa_smoke_01` / `oa_smoke_02` | `SMOKE002` / `SMOKE101` | 通知能回到移动待办或详情页 | 真机通过:收到个人应用消息,点击后首次需点进入工作台,后续可直达移动审批 | `output/playwright/r5-wecom-app-message-live/` |
自动化补充证据(用于审批链业务验收): 自动化补充证据(用于审批链业务验收):
@@ -113,8 +113,9 @@
- `2026-05-11` 公网自动视觉补充:部署 `795e976` 后,Playwright 企业微信 UA 覆盖 iPhone 近似视口与 Redmi K60 视口;`SMOKE002` 移动首页、报销列表、报销详情、PDF-only 上传入口通过,`SMOKE101` 移动审批列表通过;截图目录 `output/playwright/r5-live-mobile-wecom-visual/`。用户已确认真实企业微信工作台入口和免登录通过。 - `2026-05-11` 公网自动视觉补充:部署 `795e976` 后,Playwright 企业微信 UA 覆盖 iPhone 近似视口与 Redmi K60 视口;`SMOKE002` 移动首页、报销列表、报销详情、PDF-only 上传入口通过,`SMOKE101` 移动审批列表通过;截图目录 `output/playwright/r5-live-mobile-wecom-visual/`。用户已确认真实企业微信工作台入口和免登录通过。
- `2026-05-11` 通知与 deeplink 自动视觉补充:`SMOKE101` 现有两条未读报销待办站内通知,iPhone 近似视口与 Redmi K60 近似视口、企业微信 UA 下通知页无横向溢出,通知卡片和“查看”按钮可见;点击后进入 `/mobile/approvals?taskId=177b3a9e-67fe-447d-92f4-96a652a06841` 移动审批详情,无 PC 壳混入,截图目录 `output/playwright/r5-notification-deeplink-live/`。该历史待办已处理,因此本证据只证明站内通知和移动 deeplink,不证明新待办审批按钮;通知投递审计显示渠道 `R50-MOCK-007334` 为 `mock_sent`,真实企业微信外发应用消息仍待通道确认或真机记录。 - `2026-05-11` 通知与 deeplink 自动视觉补充:`SMOKE101` 现有两条未读报销待办站内通知,iPhone 近似视口与 Redmi K60 近似视口、企业微信 UA 下通知页无横向溢出,通知卡片和“查看”按钮可见;点击后进入 `/mobile/approvals?taskId=177b3a9e-67fe-447d-92f4-96a652a06841` 移动审批详情,无 PC 壳混入,截图目录 `output/playwright/r5-notification-deeplink-live/`。该历史待办已处理,因此本证据只证明站内通知和移动 deeplink,不证明新待办审批按钮;通知投递审计显示渠道 `R50-MOCK-007334` 为 `mock_sent`,真实企业微信外发应用消息仍待通道确认或真机记录。
- `2026-05-11` 个人企微应用消息补充:部署 `b1ae41f` 后启用 `R5-WECOM-APP-PERSONAL / wecom_app_message`,停用旧 mock 渠道;`SMOKE002` 创建请假测试单据并生成 `SMOKE101` 待办 `936362d8-c4e0-454f-a589-9c9653bcbfda`,通知 `9ee0602e-66a5-4a6e-81e5-04d41908c782` 投递审计为 `wecom_app_sent`、`errcode=0`、无 `invaliduser`。iPhone 近似视口与 Redmi K60 企业微信 UA 自动视觉验证通知列表和审批详情均无横向溢出,截图目录 `output/playwright/r5-wecom-app-message-live/`。用户确认手机企业微信 `oa_smoke_02` 收到“海音达工作台”应用消息,标题正确;点击后首次进入会提示进入工作台,进入后可看到移动审批,后续再次进入可直接看到移动审批;存在消息通知但未观察到系统弹窗。 - `2026-05-11` 个人企微应用消息补充:部署 `b1ae41f` 后启用 `R5-WECOM-APP-PERSONAL / wecom_app_message`,停用旧 mock 渠道;`SMOKE002` 创建请假测试单据并生成 `SMOKE101` 待办 `936362d8-c4e0-454f-a589-9c9653bcbfda`,通知 `9ee0602e-66a5-4a6e-81e5-04d41908c782` 投递审计为 `wecom_app_sent`、`errcode=0`、无 `invaliduser`。iPhone 近似视口与 Redmi K60 企业微信 UA 自动视觉验证通知列表和审批详情均无横向溢出,截图目录 `output/playwright/r5-wecom-app-message-live/`。用户确认手机企业微信 `oa_smoke_02` 收到“海音达工作台”应用消息,标题正确;点击后首次进入会提示进入工作台,进入后可看到移动审批,后续再次进入可直接看到移动审批;存在消息通知但未观察到系统弹窗。
- `2026-05-11` R6 移动体验补充:RS6 响应式体系修复已完成公网自动化,320/360/375/390/393/414/430px 视觉矩阵通过;用户补充真实设备为 iPhone 12 / iOS 14.1 / 企业微信,截图显示字体图标漂移、重影和首页/底部导航视觉拥挤。当前记录为 `PAUSED`,不得将 `RS6-7`、`UI6-6` 或 `R6-ISSUE-001` 置为完成;WCM6 已部署公网并完成自动复检,移动核心 SVG 图标、企业微信 WebView 基线、多手机视口和字体失败场景均已覆盖,公网 WCM6 视觉专项 8 passed,截图目录 `output/playwright/r6-wecom-mobile-rendering-hardening/`;R6-3 继续等待代表性真机确认。 - `2026-05-11` R6 移动体验补充:RS6 响应式体系修复已完成公网自动化,320/360/375/390/393/414/430px 视觉矩阵通过;用户补充真实设备为 iPhone 12 / iOS 14.1 / 企业微信,截图显示字体图标漂移、重影和首页/底部导航视觉拥挤。该阶段曾记录为 `PAUSED`,不得仅凭自动化关闭;随后 WCM6/MME6 已部署公网并完成自动复检,移动核心 SVG 图标、企业微信 WebView 基线、多手机视口、字体失败场景和 `/mobile/me` 自适应均已覆盖,公网 WCM6 视觉专项 8 passed,截图目录 `output/playwright/r6-wecom-mobile-rendering-hardening/`。用户已确认当前设计可接受,`R6-ISSUE-001` 关闭;后续移动回归另建新 Issue。
- `2026-05-11` MME6 我的页自适应补充:针对 iPhone 12 / iOS 14.1 企业微信中图文贴近和 iPhone 14 / iOS 16.2 企业微信中卡片顶边观感,`/mobile/me` 已改为固定头像/图标槽 + 可收缩文字槽,移动卡片顶边装饰线仅在 `/mobile` 作用域禁用。本地 WCM6 视觉专项 8 passed,R5/R6/R124 移动相关回归 10 passed;部署后 `/ready` 正常、public bundle `findings=0`、live smoke readiness PASS、真实 OCR 样票返回重复发票可解释终态且测试通过,公网 WCM6 视觉专项 8 passed,报告 `artifacts/qa-flow/live-smoke-readiness-20260511-230918`。iPhone 12 / iPhone 14 / Redmi K60 真机确认前,`R6-ISSUE-001` 继续 `PAUSED`。 - `2026-05-11` MME6 我的页自适应补充:针对 iPhone 12 / iOS 14.1 企业微信中图文贴近和 iPhone 14 / iOS 16.2 企业微信中卡片顶边观感,`/mobile/me` 已改为固定头像/图标槽 + 可收缩文字槽,移动卡片顶边装饰线仅在 `/mobile` 作用域禁用。本地 WCM6 视觉专项 8 passed,R5/R6/R124 移动相关回归 10 passed;部署后 `/ready` 正常、public bundle `findings=0`、live smoke readiness PASS、真实 OCR 样票返回重复发票可解释终态且测试通过,公网 WCM6 视觉专项 8 passed,报告 `artifacts/qa-flow/live-smoke-readiness-20260511-230918`。用户已确认当前设计可接受,`R6-ISSUE-001` 关闭;MUD6 继续把该设计推广到所有专用 `/mobile` 页面,后续若有移动回归另建新 Issue。
- `2026-05-11` MUD6 全移动页统一补充:专用 `/mobile` 路由已统一使用固定图标/头像槽、可收缩文字槽、`mobile-card`、移动列表卡片、空状态、安全区和吸底操作。覆盖 `/mobile`、审批、报销列表、报销详情、我的、个人设置、通知、票据上传、请假和资产扫码;不改后端、审批链、权限模型、PDF-only 规则或企业微信个人应用消息通道。本地 MUD6 视觉专项 7 passed,WCM6/RS6/R124 回归 16 passed;部署到 `https://oa.hyinda.com` 后 `/ready` 正常、public bundle `findings=0`、live smoke readiness PASS、真实 OCR 样票为重复发票可解释终态且通过,公网 MUD6 视觉专项 7 passed,报告 `artifacts/qa-flow/live-smoke-readiness-20260511-235918`。R6-3 可恢复试运行前检查,仍建议抽查 iPhone 12 / iOS 14、iPhone 14 / iOS 16.2、Redmi K60 企业微信。
- 经理账号可在移动端看到审批待办。 - 经理账号可在移动端看到审批待办。
- 审批详情可查看申请摘要、附件、发票、审批记录。 - 审批详情可查看申请摘要、附件、发票、审批记录。
@@ -4,4 +4,4 @@ UI6-2,补 iPhone 11 企业微信命中测试,补强 Playwright 视觉和命中
UI6-3,修复底部 Tab 固定与点击命中,修复移动 Shell 在 iPhone 11 企业微信 WebView 下底部导航不在真实可点区域或被内容层遮挡的问题。,"1) 底部 Tab 固定在可见视口底部并预留 safe-area;2) 四个 Tab 等宽且触控高度不低于 44px;3) 第二项审批 Tab 可点击进入审批列表;4) 不影响 Redmi K60 和普通手机浏览器。","流程级验证:在 iPhone 11 企业微信 UA 下从 /mobile 点击底部审批,进入 /mobile/approvals 并显示 mobile-approvals-list;重复点击首页、报销、我的均可命中;无横向滚动。",playwright|manual,DONE,DONE,DONE,client/src/*,"[""UI6-2""]",移动审批一级 Tab 已取消前端权限禁用和路由 403;底部导航提升层级、触控区域和 safe-area 预留;iPhone 11 / Redmi K60 E2E 通过。 UI6-3,修复底部 Tab 固定与点击命中,修复移动 Shell 在 iPhone 11 企业微信 WebView 下底部导航不在真实可点区域或被内容层遮挡的问题。,"1) 底部 Tab 固定在可见视口底部并预留 safe-area;2) 四个 Tab 等宽且触控高度不低于 44px;3) 第二项审批 Tab 可点击进入审批列表;4) 不影响 Redmi K60 和普通手机浏览器。","流程级验证:在 iPhone 11 企业微信 UA 下从 /mobile 点击底部审批,进入 /mobile/approvals 并显示 mobile-approvals-list;重复点击首页、报销、我的均可命中;无横向滚动。",playwright|manual,DONE,DONE,DONE,client/src/*,"[""UI6-2""]",移动审批一级 Tab 已取消前端权限禁用和路由 403;底部导航提升层级、触控区域和 safe-area 预留;iPhone 11 / Redmi K60 E2E 通过。
UI6-4,修复 iPhone 11 页面拥挤和控件挤压,调整移动首页、审批、报销和我的页面的小屏密度,避免文字和控件在 iPhone 11 企业微信 WebView 内相互挤压。,"1) iPhone 11 下核心卡片和按钮不重叠;2) 文字不挤出容器且不遮挡相邻控件;3) 保留报销详情吸底双按钮同行,不降级为单列;4) 移动端不出现 PC 表格或 PC 壳。","流程级验证:Playwright 截图检查 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/me、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;人工视觉确认控件间距可读可点。",playwright|manual,DONE,DONE,DONE,client/src/*,"[""UI6-2""]",移动 Shell 改为可视视口高度、紧凑小屏 padding 和 overscroll 约束;E2E 断言无横向滚动、无 PC 表格、报销详情吸底双按钮仍同行。 UI6-4,修复 iPhone 11 页面拥挤和控件挤压,调整移动首页、审批、报销和我的页面的小屏密度,避免文字和控件在 iPhone 11 企业微信 WebView 内相互挤压。,"1) iPhone 11 下核心卡片和按钮不重叠;2) 文字不挤出容器且不遮挡相邻控件;3) 保留报销详情吸底双按钮同行,不降级为单列;4) 移动端不出现 PC 表格或 PC 壳。","流程级验证:Playwright 截图检查 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/me、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;人工视觉确认控件间距可读可点。",playwright|manual,DONE,DONE,DONE,client/src/*,"[""UI6-2""]",移动 Shell 改为可视视口高度、紧凑小屏 padding 和 overscroll 约束;E2E 断言无横向滚动、无 PC 表格、报销详情吸底双按钮仍同行。
UI6-5,本地构建与移动视觉回归,完成前端类型检查、构建和移动专项 E2E,覆盖 iPhone 11、Redmi K60、企业微信 UA、底部导航、审批入口、报销详情和 PDF 上传入口。,"1) cd client && npm run typecheck 通过;2) cd client && npm run build 通过;3) 移动视觉专项通过;4) 截图证据保存在 output/playwright/r6-iphone11-wecom-mobile-layout-hotfix。","流程级验证:cd client && npm run typecheck;cd client && npm run build;cd client && npm run test:e2e -- r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",npm|playwright,DONE,DONE,DONE,client/tests/e2e/*|output/playwright/r6-iphone11-wecom-mobile-layout-hotfix,"[""UI6-3"",""UI6-4""]",已通过 npm run typecheck、npm run build、r5-real-device-feedback-mobile-ux.spec.ts、r124-mobile-entry-matrix.spec.ts;UI6-6 部署与真机复测仍待执行。 UI6-5,本地构建与移动视觉回归,完成前端类型检查、构建和移动专项 E2E,覆盖 iPhone 11、Redmi K60、企业微信 UA、底部导航、审批入口、报销详情和 PDF 上传入口。,"1) cd client && npm run typecheck 通过;2) cd client && npm run build 通过;3) 移动视觉专项通过;4) 截图证据保存在 output/playwright/r6-iphone11-wecom-mobile-layout-hotfix。","流程级验证:cd client && npm run typecheck;cd client && npm run build;cd client && npm run test:e2e -- r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",npm|playwright,DONE,DONE,DONE,client/tests/e2e/*|output/playwright/r6-iphone11-wecom-mobile-layout-hotfix,"[""UI6-3"",""UI6-4""]",已通过 npm run typecheck、npm run build、r5-real-device-feedback-mobile-ux.spec.ts、r124-mobile-entry-matrix.spec.ts;UI6-6 部署与真机复测仍待执行。
UI6-6,部署复测并恢复 R6 Gate,部署修复后做 R4 复检和企业微信真机复测,只有 iPhone 11 底部导航与拥挤问题关闭后才允许继续 R6-3。,"1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) iPhone 11 企业微信底部第二项可点;4) Redmi K60 不回归;5) R6-ISSUE-001 状态关闭或降级为非阻断。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测 iPhone 11 企业微信打开 OA 并点击底部审批;回填 docs/trialrun/manual-trialrun-report.md。",bash|playwright|manual,DONE,DONE,TODO,deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md,"[""UI6-5""]",PAUSED:已升级到 RS6 响应式体系修复并部署到 https://oa.hyinda.com;/ready 正常;公网 bundle findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-124651;公网移动视觉矩阵 320/360/375/390/393/414/430 全部通过。用户暂时暂停真机验证;仍待 iPhone 11 企业微信底部审批可点、首页宫格不拥挤和 Redmi K60 不回归,确认后再置 Regression DONE。 UI6-6,部署复测并恢复 R6 Gate,部署修复后做 R4 复检和企业微信真机复测,只有 iPhone 11 底部导航与拥挤问题关闭后才允许继续 R6-3。,"1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) iPhone 11 企业微信底部第二项可点;4) Redmi K60 不回归;5) R6-ISSUE-001 状态关闭或降级为非阻断。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测 iPhone 11 企业微信打开 OA 并点击底部审批;回填 docs/trialrun/manual-trialrun-report.md。",bash|playwright|manual,DONE,DONE,DONE,deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md,"[""UI6-5""]",已由 RS6/WCM6/MME6/MUD6 升级收口:用户更正代表设备为 iPhone 12 / iOS 14.1 和 iPhone 14 / iOS 16.2,并确认当前设计可以;R6-ISSUE-001 已关闭,后续移动回归另建新 Issue。
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
4 UI6-3 修复底部 Tab 固定与点击命中 修复移动 Shell 在 iPhone 11 企业微信 WebView 下底部导航不在真实可点区域或被内容层遮挡的问题。 1) 底部 Tab 固定在可见视口底部并预留 safe-area;2) 四个 Tab 等宽且触控高度不低于 44px;3) 第二项审批 Tab 可点击进入审批列表;4) 不影响 Redmi K60 和普通手机浏览器。 流程级验证:在 iPhone 11 企业微信 UA 下从 /mobile 点击底部审批,进入 /mobile/approvals 并显示 mobile-approvals-list;重复点击首页、报销、我的均可命中;无横向滚动。 playwright|manual DONE DONE DONE client/src/* ["UI6-2"] 移动审批一级 Tab 已取消前端权限禁用和路由 403;底部导航提升层级、触控区域和 safe-area 预留;iPhone 11 / Redmi K60 E2E 通过。
5 UI6-4 修复 iPhone 11 页面拥挤和控件挤压 调整移动首页、审批、报销和我的页面的小屏密度,避免文字和控件在 iPhone 11 企业微信 WebView 内相互挤压。 1) iPhone 11 下核心卡片和按钮不重叠;2) 文字不挤出容器且不遮挡相邻控件;3) 保留报销详情吸底双按钮同行,不降级为单列;4) 移动端不出现 PC 表格或 PC 壳。 流程级验证:Playwright 截图检查 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/me、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;人工视觉确认控件间距可读可点。 playwright|manual DONE DONE DONE client/src/* ["UI6-2"] 移动 Shell 改为可视视口高度、紧凑小屏 padding 和 overscroll 约束;E2E 断言无横向滚动、无 PC 表格、报销详情吸底双按钮仍同行。
6 UI6-5 本地构建与移动视觉回归 完成前端类型检查、构建和移动专项 E2E,覆盖 iPhone 11、Redmi K60、企业微信 UA、底部导航、审批入口、报销详情和 PDF 上传入口。 1) cd client && npm run typecheck 通过;2) cd client && npm run build 通过;3) 移动视觉专项通过;4) 截图证据保存在 output/playwright/r6-iphone11-wecom-mobile-layout-hotfix。 流程级验证:cd client && npm run typecheck;cd client && npm run build;cd client && npm run test:e2e -- r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。 npm|playwright DONE DONE DONE client/tests/e2e/*|output/playwright/r6-iphone11-wecom-mobile-layout-hotfix ["UI6-3","UI6-4"] 已通过 npm run typecheck、npm run build、r5-real-device-feedback-mobile-ux.spec.ts、r124-mobile-entry-matrix.spec.ts;UI6-6 部署与真机复测仍待执行。
7 UI6-6 部署复测并恢复 R6 Gate 部署修复后做 R4 复检和企业微信真机复测,只有 iPhone 11 底部导航与拥挤问题关闭后才允许继续 R6-3。 1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) iPhone 11 企业微信底部第二项可点;4) Redmi K60 不回归;5) R6-ISSUE-001 状态关闭或降级为非阻断。 流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;人工真机复测 iPhone 11 企业微信打开 OA 并点击底部审批;回填 docs/trialrun/manual-trialrun-report.md。 bash|playwright|manual DONE DONE TODO DONE deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md ["UI6-5"] PAUSED:已升级到 RS6 响应式体系修复并部署到 https://oa.hyinda.com;/ready 正常;公网 bundle findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-124651;公网移动视觉矩阵 320/360/375/390/393/414/430 全部通过。用户暂时暂停真机验证;仍待 iPhone 11 企业微信底部审批可点、首页宫格不拥挤和 Redmi K60 不回归,确认后再置 Regression DONE。 已由 RS6/WCM6/MME6/MUD6 升级收口:用户更正代表设备为 iPhone 12 / iOS 14.1 和 iPhone 14 / iOS 16.2,并确认当前设计可以;R6-ISSUE-001 已关闭,后续移动回归另建新 Issue。
@@ -5,4 +5,4 @@ RS6-3,重构移动首页快捷入口为图标宫格,将移动首页审批、传
RS6-4,统一底部导航滚动容器和吸底操作,统一移动 Shell、底部 Tab、内容滚动区和报销详情吸底操作的 safe-area 与高度预留策略。,"1) 底部导航固定为首页/审批/报销/我的;2) 四个 Tab 等宽且中心点 elementFromPoint 命中自身;3) 深层报销详情隐藏底部 Tab;4) 报销详情吸底双按钮保持同行且内容不被遮挡。","流程级验证:Playwright 访问 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;断言底部 Tab 中心点可点;断言吸底双按钮同行且高度 >=44px。",playwright,DONE,DONE,DONE,client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/pages/MobileReimbursementDetailPage.tsx,["RS6-2"],已统一移动 Shell、底部导航和报销详情吸底操作 safe-area;报销详情主按钮仍保持双按钮同行。 RS6-4,统一底部导航滚动容器和吸底操作,统一移动 Shell、底部 Tab、内容滚动区和报销详情吸底操作的 safe-area 与高度预留策略。,"1) 底部导航固定为首页/审批/报销/我的;2) 四个 Tab 等宽且中心点 elementFromPoint 命中自身;3) 深层报销详情隐藏底部 Tab;4) 报销详情吸底双按钮保持同行且内容不被遮挡。","流程级验证:Playwright 访问 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2;断言底部 Tab 中心点可点;断言吸底双按钮同行且高度 >=44px。",playwright,DONE,DONE,DONE,client/src/layouts/AppShellLayout.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/pages/MobileReimbursementDetailPage.tsx,["RS6-2"],已统一移动 Shell、底部导航和报销详情吸底操作 safe-area;报销详情主按钮仍保持双按钮同行。
RS6-5,巡检 R6 相关移动页刚性布局,巡检移动审批、报销、通知、票据上传、请假、我的等页面,清理小屏刚性两列和文字挤压风险。,"1) R6 相关移动页在窄屏无 PC 壳、无桌面表格、无横向溢出;2) 信息字段可根据最小宽度从两列变一列;3) 核心操作按钮保持可点且无遮挡;4) 不扩大业务功能范围。","流程级验证:Playwright 覆盖 /mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id、/mobile/me、/mobile/notifications、/mobile/capture、/mobile/leave;人工检查截图中无挤压和遮挡。",playwright|manual,DONE,DONE,DONE,client/src/pages/Mobile*.tsx,"[""RS6-3"",""RS6-4""]",已巡检并清理 R6 关键移动页刚性布局;只修布局体系,不改审批链、组织关系、通知通道或上传业务规则。 RS6-5,巡检 R6 相关移动页刚性布局,巡检移动审批、报销、通知、票据上传、请假、我的等页面,清理小屏刚性两列和文字挤压风险。,"1) R6 相关移动页在窄屏无 PC 壳、无桌面表格、无横向溢出;2) 信息字段可根据最小宽度从两列变一列;3) 核心操作按钮保持可点且无遮挡;4) 不扩大业务功能范围。","流程级验证:Playwright 覆盖 /mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id、/mobile/me、/mobile/notifications、/mobile/capture、/mobile/leave;人工检查截图中无挤压和遮挡。",playwright|manual,DONE,DONE,DONE,client/src/pages/Mobile*.tsx,"[""RS6-3"",""RS6-4""]",已巡检并清理 R6 关键移动页刚性布局;只修布局体系,不改审批链、组织关系、通知通道或上传业务规则。
RS6-6,补跨视口视觉自动化矩阵,新增或补强移动视觉 E2E,覆盖 320x568、360x740、375x667、390x844、393x873、414x896、430x932 和企业微信/普通移动 UA。,"1) 新视觉矩阵截图输出到 output/playwright/r6-mobile-responsive-system-hotfix;2) 断言无横向滚动;3) 断言底部 Tab 命中;4) 断言首页图标宫格不重叠;5) 断言报销详情吸底双按钮同行。","流程级验证:cd client && npm run test:e2e -- r6-mobile-responsive-system-hotfix.spec.ts r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-mobile-responsive-system-hotfix.spec.ts|client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts,"[""RS6-3"",""RS6-4"",""RS6-5""]",本地 typecheck/build 通过;移动回归组合 10 passed,截图输出到 output/playwright/r6-mobile-responsive-system-hotfix。 RS6-6,补跨视口视觉自动化矩阵,新增或补强移动视觉 E2E,覆盖 320x568、360x740、375x667、390x844、393x873、414x896、430x932 和企业微信/普通移动 UA。,"1) 新视觉矩阵截图输出到 output/playwright/r6-mobile-responsive-system-hotfix;2) 断言无横向滚动;3) 断言底部 Tab 命中;4) 断言首页图标宫格不重叠;5) 断言报销详情吸底双按钮同行。","流程级验证:cd client && npm run test:e2e -- r6-mobile-responsive-system-hotfix.spec.ts r5-real-device-feedback-mobile-ux.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-mobile-responsive-system-hotfix.spec.ts|client/tests/e2e/r5-real-device-feedback-mobile-ux.spec.ts,"[""RS6-3"",""RS6-4"",""RS6-5""]",本地 typecheck/build 通过;移动回归组合 10 passed,截图输出到 output/playwright/r6-mobile-responsive-system-hotfix。
RS6-7,部署公网复检并回填 R6 移动阻断,本地验证通过后部署公网,复检 /ready、bundle、live smoke、PDF OCR 和企微个人应用消息,等待真机确认后关闭或降级 R6-ISSUE-001。,"1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) live smoke readiness PASS;4) 公网移动视觉专项通过;5) 用户真机确认 iPhone 11 企业微信不拥挤、审批可点、Redmi K60 不回归后回填 UI6-6 和 R6 问题状态。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 或 BASE_URL=https://oa.hyinda.com 运行移动视觉专项;人工真机复测并回填 docs/trialrun/manual-trialrun-report.md。",bash|playwright|manual,DONE,DONE,TODO,deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md,["RS6-6"],PAUSED:已部署到 https://oa.hyinda.com;/ready 正常;公网 bundle findings=0;live smoke readiness PASS,报告 artifacts/qa-flow/live-smoke-readiness-20260511-124651;公网移动视觉专项 7 passed。用户暂时暂停真机验证;仍待 iPhone 11 企业微信不拥挤、审批可点、Redmi K60 不回归后再置 Regression DONE。 RS6-7,部署公网复检并回填 R6 移动阻断,本地验证通过后部署公网,复检 /ready、bundle、live smoke、PDF OCR 和企微个人应用消息,等待真机确认后关闭或降级 R6-ISSUE-001。,"1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) live smoke readiness PASS;4) 公网移动视觉专项通过;5) 用户真机确认 iPhone 11 企业微信不拥挤、审批可点、Redmi K60 不回归后回填 UI6-6 和 R6 问题状态。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 或 BASE_URL=https://oa.hyinda.com 运行移动视觉专项;人工真机复测并回填 docs/trialrun/manual-trialrun-report.md。",bash|playwright|manual,DONE,DONE,DONE,deploy/trialrun/*|docs/trialrun/manual-trialrun-report.md,["RS6-6"],已由 WCM6/MME6/MUD6 升级收口:多手机企业微信视觉自动化、公网复检和用户代表性真机确认完成;R6-ISSUE-001 已关闭,R6-3 不再因此阻塞。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 756 列。
@@ -5,4 +5,4 @@ WCM6-3,企业微信 WebView 视口字号安全区基线,"补充 企业微信 iPh
WCM6-4,底部 Tab 与首页宫格视觉密度重整,"底部导航保持四项等宽但减轻 active 大块深蓝态,badge 独立定位;首页宫格保持四项同行并固定图标槽和标题槽,避免 iPhone 企业微信真机上图标和文字互相挤压。","1) 底部首页/审批/报销/我的四项等宽、中心点命中自身;2) active 态不挤占相邻图标;3) badge 不参与图标文字排版;4) 首页宫格 4 项同一行,标题单行省略,触控高度不低于 44px。","流程级验证:Playwright 320/360/375/390/393/414/430 宽度截图;断言 nav/action 图标、文字、badge 不重叠;点击审批进入 /mobile/approvals;截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。",playwright,DONE,DONE,DONE,client/src/components/mobile/MobileBottomNav.tsx|client/src/pages/MobilePortalPage.tsx|client/src/styles/mobile-touch.css,"[""WCM6-2"",""WCM6-3""]",WCM6-4 completed:底部 active 态改为轻量选中态,待办 badge 固定为按钮级角标,不参与图标/文字排版;320/360/375/390/393/414/430px 企业微信多手机视觉矩阵通过。回滚路径:恢复 RS6 前一版移动导航样式。 WCM6-4,底部 Tab 与首页宫格视觉密度重整,"底部导航保持四项等宽但减轻 active 大块深蓝态,badge 独立定位;首页宫格保持四项同行并固定图标槽和标题槽,避免 iPhone 企业微信真机上图标和文字互相挤压。","1) 底部首页/审批/报销/我的四项等宽、中心点命中自身;2) active 态不挤占相邻图标;3) badge 不参与图标文字排版;4) 首页宫格 4 项同一行,标题单行省略,触控高度不低于 44px。","流程级验证:Playwright 320/360/375/390/393/414/430 宽度截图;断言 nav/action 图标、文字、badge 不重叠;点击审批进入 /mobile/approvals;截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。",playwright,DONE,DONE,DONE,client/src/components/mobile/MobileBottomNav.tsx|client/src/pages/MobilePortalPage.tsx|client/src/styles/mobile-touch.css,"[""WCM6-2"",""WCM6-3""]",WCM6-4 completed:底部 active 态改为轻量选中态,待办 badge 固定为按钮级角标,不参与图标/文字排版;320/360/375/390/393/414/430px 企业微信多手机视觉矩阵通过。回滚路径:恢复 RS6 前一版移动导航样式。
WCM6-5,R6 核心移动页巡检收口,"巡检审批、报销、通知、我的、票据上传和报销详情等 R6 核心移动页,必要时把桌面通用密度替换为移动 token,但不扩展新功能。","1) R6 核心移动页无 PC 壳、无桌面表格、无横向溢出;2) 图标渲染不依赖字体连字;3) 核心按钮不遮挡内容;4) 报销详情深层页继续隐藏底部 Tab。","流程级验证:访问 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id、/mobile/me、/mobile/notifications、/mobile/capture;断言无横向溢出、无 visible table、底部或吸底操作命中正常。",playwright|manual,DONE,DONE,DONE,client/src/pages/Mobile*.tsx,["WCM6-4"],WCM6-5 completed:/mobile、审批、报销、通知、我的、票据上传、报销详情核心路径通过 WCM6 专项与 RS6 响应式回归;无横向溢出、无 PC 表格、深层详情继续隐藏底部 Tab。回滚路径:按文件恢复本轮样式与组件改动。 WCM6-5,R6 核心移动页巡检收口,"巡检审批、报销、通知、我的、票据上传和报销详情等 R6 核心移动页,必要时把桌面通用密度替换为移动 token,但不扩展新功能。","1) R6 核心移动页无 PC 壳、无桌面表格、无横向溢出;2) 图标渲染不依赖字体连字;3) 核心按钮不遮挡内容;4) 报销详情深层页继续隐藏底部 Tab。","流程级验证:访问 /mobile、/mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id、/mobile/me、/mobile/notifications、/mobile/capture;断言无横向溢出、无 visible table、底部或吸底操作命中正常。",playwright|manual,DONE,DONE,DONE,client/src/pages/Mobile*.tsx,["WCM6-4"],WCM6-5 completed:/mobile、审批、报销、通知、我的、票据上传、报销详情核心路径通过 WCM6 专项与 RS6 响应式回归;无横向溢出、无 PC 表格、深层详情继续隐藏底部 Tab。回滚路径:按文件恢复本轮样式与组件改动。
WCM6-6,补强企业微信多手机与字体失败视觉自动化,"新增或补强移动视觉 E2E,覆盖 iPhone/Android 企业微信 UA、多手机视口和 Material Symbols 字体失败场景,弥补此前只用单一模拟视口导致的真实企业微信 WebView 差异。","1) 新视觉用例覆盖 iPhone 12 企业微信近似视口 390x844;2) 拦截 Material Symbols 字体后移动核心图标仍正常;3) 自动截图写入 output/playwright/r6-wecom-mobile-rendering-hardening;4) 相关旧移动回归不下降。","流程级验证:cd client && npm run test:e2e -- r6-wecom-mobile-rendering-hardening.spec.ts r5-real-device-feedback-mobile-ux.spec.ts r6-mobile-responsive-system-hotfix.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts|client/tests/e2e/*mobile*.spec.ts,["WCM6-5"],WCM6-6 completed:WCM6 专项 8 passed;R6 响应式矩阵拆分补跑 320/360/375/390/393/414/430px 全部通过;R124 入口矩阵 1 passed;R5 真机反馈模拟 2 passed;截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。自动化不能替代真机;回滚路径:移除新增视觉 spec 和截图产物。 WCM6-6,补强企业微信多手机与字体失败视觉自动化,"新增或补强移动视觉 E2E,覆盖 iPhone/Android 企业微信 UA、多手机视口和 Material Symbols 字体失败场景,弥补此前只用单一模拟视口导致的真实企业微信 WebView 差异。","1) 新视觉用例覆盖 iPhone 12 企业微信近似视口 390x844;2) 拦截 Material Symbols 字体后移动核心图标仍正常;3) 自动截图写入 output/playwright/r6-wecom-mobile-rendering-hardening;4) 相关旧移动回归不下降。","流程级验证:cd client && npm run test:e2e -- r6-wecom-mobile-rendering-hardening.spec.ts r5-real-device-feedback-mobile-ux.spec.ts r6-mobile-responsive-system-hotfix.spec.ts r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts|client/tests/e2e/*mobile*.spec.ts,["WCM6-5"],WCM6-6 completed:WCM6 专项 8 passed;R6 响应式矩阵拆分补跑 320/360/375/390/393/414/430px 全部通过;R124 入口矩阵 1 passed;R5 真机反馈模拟 2 passed;截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。自动化不能替代真机;回滚路径:移除新增视觉 spec 和截图产物。
WCM6-7,部署公网复检并等待真机门禁,"本地验证通过后受控部署,复检 /ready、bundle、live smoke、PDF OCR 和企微个人应用消息;等待 iPhone 12 / iOS 14.1 / 企业微信与 Redmi K60 真机确认后再关闭或降级 R6-ISSUE-001。","1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) live smoke readiness PASS;4) 公网移动视觉专项通过;5) 真机确认前 R6-ISSUE-001 仍 open/PAUSED。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 运行移动视觉专项;人工真机确认 iPhone 12 / iOS 14.1 / 企业微信和 Redmi K60 企业微信。",bash|playwright|manual,DONE,DONE,TODO,deploy/trialrun/*|docs/trialrun/*.md,["WCM6-6"],WCM6-7 automated completed:已部署公网,/ready 正常,public bundle findings=0,live smoke readiness PASS,真实 OCR 样票返回“重复发票已用于报销”的可解释终态且测试通过,公网 WCM6 移动视觉专项 8 passed;真机确认前 R6-ISSUE-001 保持 open/PAUSED,R6-3 不启动。默认不覆盖远端 .env。 WCM6-7,部署公网复检并等待真机门禁,"本地验证通过后受控部署,复检 /ready、bundle、live smoke、PDF OCR 和企微个人应用消息;等待 iPhone 12 / iOS 14.1 / 企业微信与 Redmi K60 真机确认后再关闭或降级 R6-ISSUE-001。","1) 部署后 /ready 正常;2) 公网 bundle findings=0;3) live smoke readiness PASS;4) 公网移动视觉专项通过;5) 真机确认前 R6-ISSUE-001 仍 open/PAUSED。","流程级验证:bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 运行移动视觉专项;人工真机确认 iPhone 12 / iOS 14.1 / 企业微信和 Redmi K60 企业微信。",bash|playwright|manual,DONE,DONE,DONE,deploy/trialrun/*|docs/trialrun/*.md,["WCM6-6"],WCM6/MME6 真机门禁已关闭:用户确认当前企业微信移动设计可以;MUD6 继续推广到全部专用 /mobile 页面。此前公网 /ready、bundle、live smoke、真实 OCR 和 WCM6 公网视觉专项均已通过;默认不覆盖远端 .env。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 836 列。
@@ -5,4 +5,4 @@ MME6-3,我的页头像与入口槽位自适应,"将 /mobile/me 的个人信息
MME6-4,移动卡片顶边视觉收口,"仅在 /mobile 页面禁用 SectionPanel 顶部蓝青色渐变线,保留白卡、浅边框和轻阴影,解决 iOS16.2 企业微信卡片顶部漏边观感。","1) /mobile/me 的 SectionPanel 顶部无蓝青色渐变线;2) PC SectionPanel 装饰线不受影响;3) 移动卡片边框、圆角、阴影仍清晰。","流程级验证:WCM6 Playwright 390x844 iPhone 企业微信近似视口打开 /mobile/me;断言移动 SectionPanel ::before 不可见或透明;截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。",css|playwright,DONE,DONE,DONE,client/src/styles/mobile-touch.css|client/src/layouts/AppShellLayout.tsx,["MME6-2"],MME6-4 completed:仅在 hyb-mobile-shell 下禁用 SectionPanel 顶部蓝青色渐变线;PC SectionPanel 不在本轮改动范围;WCM6 断言移动卡片顶边不可见。回滚路径:恢复移动 SectionPanel 伪元素覆盖。 MME6-4,移动卡片顶边视觉收口,"仅在 /mobile 页面禁用 SectionPanel 顶部蓝青色渐变线,保留白卡、浅边框和轻阴影,解决 iOS16.2 企业微信卡片顶部漏边观感。","1) /mobile/me 的 SectionPanel 顶部无蓝青色渐变线;2) PC SectionPanel 装饰线不受影响;3) 移动卡片边框、圆角、阴影仍清晰。","流程级验证:WCM6 Playwright 390x844 iPhone 企业微信近似视口打开 /mobile/me;断言移动 SectionPanel ::before 不可见或透明;截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。",css|playwright,DONE,DONE,DONE,client/src/styles/mobile-touch.css|client/src/layouts/AppShellLayout.tsx,["MME6-2"],MME6-4 completed:仅在 hyb-mobile-shell 下禁用 SectionPanel 顶部蓝青色渐变线;PC SectionPanel 不在本轮改动范围;WCM6 断言移动卡片顶边不可见。回滚路径:恢复移动 SectionPanel 伪元素覆盖。
MME6-5,补强我的页自适应视觉断言,"在 WCM6 企业微信多手机视觉专项中补 /mobile/me 槽位间距、两列入口、卡片顶边和底部导航稳定断言。","1) 覆盖 320/360/375/390/393/414/430px;2) 覆盖 Material Symbols 字体失败场景;3) 截图输出到既有 WCM6 目录;4) 不把真实企业微信真机验收替换为自动化。","流程级验证:cd client && npm run test:e2e -- r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts,"[""MME6-3"",""MME6-4""]",MME6-5 completed:WCM6 专项已补 /mobile/me 槽位间距、两列入口、卡片顶边和底部导航断言;8 passed,截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。回滚路径:移除新增断言;自动化不能替代真机。 MME6-5,补强我的页自适应视觉断言,"在 WCM6 企业微信多手机视觉专项中补 /mobile/me 槽位间距、两列入口、卡片顶边和底部导航稳定断言。","1) 覆盖 320/360/375/390/393/414/430px;2) 覆盖 Material Symbols 字体失败场景;3) 截图输出到既有 WCM6 目录;4) 不把真实企业微信真机验收替换为自动化。","流程级验证:cd client && npm run test:e2e -- r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts,"[""MME6-3"",""MME6-4""]",MME6-5 completed:WCM6 专项已补 /mobile/me 槽位间距、两列入口、卡片顶边和底部导航断言;8 passed,截图输出 output/playwright/r6-wecom-mobile-rendering-hardening/。回滚路径:移除新增断言;自动化不能替代真机。
MME6-6,本地验证与状态回填,"执行前端 typecheck/build、WCM6 专项和必要移动回归;通过后回填 MME6-1 到 MME6-6。","1) typecheck 通过;2) build 通过;3) WCM6 专项通过;4) git diff --check 通过;5) R6-ISSUE-001 仍 open/PAUSED。","流程级验证:cd client && npm run typecheck;cd client && npm run build;cd client && npm run test:e2e -- r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list;git diff --check。",npm|playwright|git,DONE,DONE,DONE,client|issues|docs,["MME6-5"],MME6-6 completed:client typecheck 通过;client build 通过;WCM6 专项 8 passed;R5/R6/R124 移动相关回归 10 passed;R6-ISSUE-001 仍 open/PAUSED。回滚路径:撤销本轮前端、测试与文档变更。 MME6-6,本地验证与状态回填,"执行前端 typecheck/build、WCM6 专项和必要移动回归;通过后回填 MME6-1 到 MME6-6。","1) typecheck 通过;2) build 通过;3) WCM6 专项通过;4) git diff --check 通过;5) R6-ISSUE-001 仍 open/PAUSED。","流程级验证:cd client && npm run typecheck;cd client && npm run build;cd client && npm run test:e2e -- r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list;git diff --check。",npm|playwright|git,DONE,DONE,DONE,client|issues|docs,["MME6-5"],MME6-6 completed:client typecheck 通过;client build 通过;WCM6 专项 8 passed;R5/R6/R124 移动相关回归 10 passed;R6-ISSUE-001 仍 open/PAUSED。回滚路径:撤销本轮前端、测试与文档变更。
MME6-7,部署公网复检并等待真机门禁,"本地验证通过后受控部署,复检 /ready、bundle、live smoke 和公网 WCM6 视觉专项;等待 iPhone12/iOS14、iPhone14/iOS16.2、Redmi K60 真机确认。","1) 部署后 /ready 正常;2) bundle findings=0;3) live smoke PASS;4) 公网 WCM6 视觉专项通过;5) 真机确认前 R6-ISSUE-001 不关闭。","流程级验证:bash deploy/trialrun/deploy.sh;node scripts/qa/check-public-bundle-copy.mjs https://oa.hyinda.com;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 运行 WCM6 专项;人工真机确认。",bash|playwright|manual,DONE,DONE,TODO,deploy/trialrun/*|docs/trialrun/*.md,["MME6-6"],MME6-7 automated completed:已部署公网,/ready 正常,public bundle findings=0,live smoke readiness PASS,真实 OCR 样票返回重复发票可解释终态且测试通过,公网 WCM6 视觉专项 8 passed,报告 artifacts/qa-flow/live-smoke-readiness-20260511-230918;真机通过前 R6-ISSUE-001 保持 open/PAUSED,R6-3 不启动。默认不覆盖远端 .env。 MME6-7,部署公网复检并等待真机门禁,"本地验证通过后受控部署,复检 /ready、bundle、live smoke 和公网 WCM6 视觉专项;等待 iPhone12/iOS14、iPhone14/iOS16.2、Redmi K60 真机确认。","1) 部署后 /ready 正常;2) bundle findings=0;3) live smoke PASS;4) 公网 WCM6 视觉专项通过;5) 真机确认前 R6-ISSUE-001 不关闭。","流程级验证:bash deploy/trialrun/deploy.sh;node scripts/qa/check-public-bundle-copy.mjs https://oa.hyinda.com;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 运行 WCM6 专项;人工真机确认。",bash|playwright|manual,DONE,DONE,DONE,deploy/trialrun/*|docs/trialrun/*.md,["MME6-6"],MME6-7 completed:已部署公网,/ready 正常,public bundle findings=0,live smoke readiness PASS,真实 OCR 样票返回重复发票可解释终态且测试通过,公网 WCM6 视觉专项 8 passed;用户确认当前设计可以,R6-ISSUE-001 已关闭,后续移动回归另建新 Issue。默认不覆盖远端 .env。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 575 列。
@@ -0,0 +1,8 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
MUD6-1,建档并关闭 R6 移动阻断,"基于用户确认 MME6 真机效果可以,将 R6-ISSUE-001 从 open/PAUSED 关闭或降级,并记录本轮范围仅限专用 /mobile 路由。","1) 本 CSV 存在且校验通过;2) R6-ISSUE-001 记录为已处理,不再阻塞 R6-3;3) 本轮不改后端、权限、审批链、PDF-only 或企微通知通道。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-11_23-26-00-r6-mobile-unified-design-rollout.csv;人工核对 docs/trialrun/manual-trialrun-report.md 与 docs/trialrun/real-device-public-checklist.md 中 R6-ISSUE-001 状态。",python3|manual,DONE,DONE,DONE,issues/2026-05-11_23-26-00-r6-mobile-unified-design-rollout.csv|docs/trialrun/*.md,[],MUD6-1 completed:CSV 校验通过;R6-ISSUE-001 已按用户真机确认关闭,UI6/RS6/WCM6/MME6 收口项同步 DONE;本轮不改后端、权限、审批链、PDF-only 或企微通知通道。
MUD6-2,规约与交付 Skill 稳定,"把 MME6 槽位自适应、移动卡片无蓝青色顶边、专用 /mobile 路由统一矩阵写入产品规约、交付实践和 hyb-oa delivery Skill。","1) docs/product-design-rules.md 明确所有专用 /mobile 页面共享槽位、卡片、列表、底部导航和吸底操作;2) docs/agent-development-practices.md 与 Skill 明确新移动页必须复用该体系;3) 不复制竞品文案或交互细节。","流程级验证:python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;rg -n \"移动槽位|专用 /mobile|mobile-card|R6-ISSUE-001|MUD6\" docs .agents/skills/hyb-oa-delivery issues。",rg|python3,DONE,DONE,DONE,docs/product-design-rules.md|docs/agent-development-practices.md|.agents/skills/hyb-oa-delivery/SKILL.md,["MUD6-1"],MUD6-2 completed:产品规约、交付实践和 Skill 已写入专用 /mobile 统一槽位、卡片、列表、吸底操作和跨视口矩阵;Skill quick_validate 通过。
MUD6-3,共享移动样式抽象,"将 MME6 的 mobile-me 专用槽位样式抽象为通用移动样式,保留兼容别名,供所有 /mobile 页面复用。","1) 新增通用移动槽位、列表卡、表单网格、空状态和提示样式;2) mobile-me 旧类继续兼容或已替换,不破坏既有测试;3) 移动卡片顶边禁用仍只作用于 /mobile。","流程级验证:cd client && npm run typecheck;WCM6 专项打开 /mobile/me 仍通过槽位间距、两列入口和卡片顶边断言。",typescript|css|playwright,DONE,DONE,DONE,client/src/styles/mobile-touch.css|client/src/pages/MobileMePage.tsx,["MUD6-2"],MUD6-3 completed:已抽象 mobile-slot/mobile-action/mobile-list-card/mobile-empty-state 等通用样式并保留 mobile-me 兼容别名;client typecheck/build 通过。
MUD6-4,统一高频移动页,"统一 /mobile、/mobile/me、/mobile/profile、/mobile/notifications 的卡片、入口项、列表卡和操作区,不改变角色入口裁剪。","1) 高频页全部使用 mobile-card 或共享移动槽位;2) 无 card-std p-4 主容器;3) 无横向滚动、图文不重叠、底部 Tab 可点;4) 角色差异按权限裁剪入口不变。","流程级验证:移动统一视觉专项覆盖 /mobile、/mobile/me、/mobile/profile、/mobile/notifications;断言 scrollWidth <= viewportWidth + 2、无 PC 壳、底部 Tab 中心点命中。",typescript|playwright,DONE,DONE,DONE,client/src/pages/MobilePortalPage.tsx|client/src/pages/MobileMePage.tsx|client/src/pages/MobileProfilePage.tsx|client/src/pages/MobileNotificationsPage.tsx,["MUD6-3"],MUD6-4 completed:高频移动页已纳入 mobile-card、共享槽位和移动列表卡;本地与公网 MUD6 视觉专项均 7 passed。
MUD6-5,统一业务与工具移动页,"统一 /mobile/approvals、/mobile/reimbursements、/mobile/reimbursements/:id、/mobile/capture、/mobile/leave、/mobile/assets/scan 和扫码组件的移动卡片与槽位布局。","1) 业务页不再使用桌面 card-std 作为主容器;2) 报销详情继续隐藏底部 Tab,吸底双按钮同行;3) PDF-only 发票上传入口不回归;4) 资产扫码业务逻辑不变。","流程级验证:移动统一视觉专项覆盖全部专用 /mobile 路由;业务回归跑 r6-wecom-mobile-rendering-hardening、r6-mobile-responsive-system-hotfix、r124-mobile-entry-matrix;必要时补审批、报销、请假相关 E2E。",typescript|playwright,DONE,DONE,DONE,client/src/pages/MobileApprovalsPage.tsx|client/src/pages/MobileReimbursementsPage.tsx|client/src/pages/MobileReimbursementDetailPage.tsx|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileLeavePage.tsx|client/src/pages/MobileAssetScanPage.tsx|client/src/components/mobile/BarcodeScannerPanel.tsx,["MUD6-3"],MUD6-5 completed:审批、报销、报销详情、票据上传、请假、资产扫码和扫码组件已统一移动卡片/槽位;WCM6/RS6/R124 回归 16 passed。
MUD6-6,补强全移动页视觉矩阵,"新增或扩展 Playwright 专项,遍历所有专用 /mobile 路由,覆盖企业微信 iOS UA、企业微信 Android UA、普通移动浏览器 UA 与 320/360/375/390/393/414/430px 宽度。","1) 全 /mobile 路由无横向滚动;2) 无 PC 侧边栏、桌面表格和桌面卡片主容器;3) 底部 Tab 可点;4) 图文槽位不重叠;5) 报销详情深层页规则不回归。","流程级验证:cd client && npm run test:e2e -- r6-mobile-unified-design-rollout.spec.ts --workers=1 --reporter=list;截图输出 output/playwright/r6-mobile-unified-design-rollout/。",playwright,DONE,DONE,DONE,client/tests/e2e/r6-mobile-unified-design-rollout.spec.ts,"[""MUD6-4"",""MUD6-5""]",MUD6-6 completed:新增全 /mobile 视觉专项覆盖 7 个宽度、3 类 UA 和全部专用 /mobile 路由;本地 7 passed,截图输出 output/playwright/r6-mobile-unified-design-rollout/。
MUD6-7,部署公网复检并恢复 R6-3 条件,"本地验证通过后受控部署,复检 /ready、bundle、live smoke、真实 PDF OCR、企业微信个人应用消息和公网移动统一视觉专项;确认 R6-3 可恢复。","1) client typecheck/build 通过;2) 本地与公网移动统一视觉通过;3) /ready 正常、bundle findings=0、live smoke readiness PASS;4) R6-3 恢复条件写入试运行文档;5) 用户仅需抽查三台代表性真机。","流程级验证:cd client && npm run typecheck;cd client && npm run build;bash deploy/trialrun/deploy.sh;node scripts/qa/check-public-bundle-copy.mjs https://oa.hyinda.com;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;E2E_MODE=live 运行移动统一视觉专项。",npm|bash|playwright|manual,DONE,DONE,DONE,deploy/trialrun/*|docs/trialrun/*.md,["MUD6-6"],MUD6-7 completed:已部署到 https://oa.hyinda.com;/ready ok,public bundle findings=0,live smoke readiness PASS,真实 OCR 样票为重复发票可解释终态且通过;公网 MUD6 视觉专项 7 passed;报告 artifacts/qa-flow/live-smoke-readiness-20260511-235918。默认不覆盖远端 .env;建议用户抽查 iPhone12/iOS14、iPhone14/iOS16.2、Redmi K60。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 600 列。