WCM6 收口企业微信多手机移动渲染稳定化

这个提交包含在:
2026-05-11 21:45:49 +08:00
父节点 bded569d92
当前提交 e6c739ddd6
共修改 15 个文件,包含 664 行新增和 44 行删除
+1 -1
查看文件
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>海音达工作台</title> <title>海音达工作台</title>
</head> </head>
+10
查看文件
@@ -17,6 +17,7 @@
"@zxing/browser": "^0.1.5", "@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -5591,6 +5592,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
+1
查看文件
@@ -31,6 +31,7 @@
"@zxing/browser": "^0.1.5", "@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -8,6 +8,7 @@ import {
MOBILE_NAV_ITEMS, MOBILE_NAV_ITEMS,
} from "../../features/mobile/navigation"; } from "../../features/mobile/navigation";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { MobileIcon } from "./MobileIcon";
function resolveBadgeLabel(count: number) { function resolveBadgeLabel(count: number) {
if (count > 99) { if (count > 99) {
@@ -53,8 +54,13 @@ export function MobileBottomNav() {
aria-disabled="true" aria-disabled="true"
title={`${item.label}不可用`} title={`${item.label}不可用`}
> >
<span className="material-symbols-outlined text-[20px]">{item.icon}</span> <MobileIcon name={item.icon} className="size-5" />
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{item.label}</span> <span
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
data-testid={`mobile-nav-${item.key}-label`}
>
{item.label}
</span>
</button> </button>
); );
} }
@@ -67,29 +73,35 @@ export function MobileBottomNav() {
data-testid={`mobile-nav-${item.key}`} data-testid={`mobile-nav-${item.key}`}
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
"touch-target pointer-events-auto flex h-12 min-h-0 min-w-0 select-none flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none transition-colors", "touch-target pointer-events-auto relative flex h-12 min-h-0 min-w-0 select-none flex-col items-center justify-center gap-0.5 rounded-xl px-0.5 py-1 text-[11px] font-medium leading-none transition-colors",
isActive isActive
? "bg-primary text-white shadow-sm" ? "bg-primary/10 text-primary ring-1 ring-primary/15"
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900", : "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-900",
) )
} }
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
<span className="relative"> <span>
<span className="material-symbols-outlined text-[19px] leading-none">{item.icon}</span> <MobileIcon name={item.icon} className="size-5" />
</span>
<span
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
data-testid={`mobile-nav-${item.key}-label`}
>
{item.label}
</span>
{badge ? ( {badge ? (
<span <span
data-testid={`mobile-nav-${item.key}-badge`}
className={cn( className={cn(
"absolute -right-3 -top-2 min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none", "pointer-events-none absolute right-1 top-1 min-w-4 rounded-full px-1 text-[10px] font-semibold leading-4 shadow-sm",
isActive ? "bg-white text-primary" : "bg-red-500 text-white", isActive ? "bg-red-500 text-white" : "bg-red-500 text-white",
)} )}
> >
{badge} {badge}
</span> </span>
) : null} ) : null}
</span>
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{item.label}</span>
</> </>
)} )}
</NavLink> </NavLink>
@@ -0,0 +1,60 @@
import type { HTMLAttributes } from "react";
import {
Bell,
BriefcaseBusiness,
CalendarCheck,
Circle,
ClipboardCheck,
CloudUpload,
FileCheck2,
FileText,
Home,
IdCard,
Network,
PackageSearch,
ReceiptText,
Settings,
UserCog,
UserRound,
Users,
type LucideIcon,
} from "lucide-react";
import { cn } from "../../lib/utils";
const MOBILE_ICON_MAP: Record<string, LucideIcon> = {
account_tree: Network,
badge: IdCard,
cloud_upload: CloudUpload,
event_available: CalendarCheck,
fact_check: ClipboardCheck,
group: Users,
home: Home,
inventory_2: PackageSearch,
manage_accounts: UserCog,
notifications: Bell,
person: UserRound,
picture_as_pdf: FileText,
receipt_long: ReceiptText,
request_quote: FileCheck2,
settings: Settings,
workspaces: BriefcaseBusiness,
};
type MobileIconProps = HTMLAttributes<HTMLSpanElement> & {
iconClassName?: string;
name: string;
};
export function MobileIcon({ className, iconClassName, name, ...props }: MobileIconProps) {
const Icon = MOBILE_ICON_MAP[name] ?? Circle;
return (
<span
aria-hidden="true"
className={cn("inline-flex shrink-0 items-center justify-center leading-none", className)}
{...props}
>
<Icon className={cn("size-5", iconClassName)} strokeWidth={2.1} />
</span>
);
}
+5
查看文件
@@ -13,12 +13,16 @@
@layer base { @layer base {
html { html {
min-height: 100%;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
color-scheme: light; color-scheme: light;
background: var(--color-bg-canvas); background: var(--color-bg-canvas);
} }
body { body {
min-height: 100vh; min-height: 100vh;
min-height: -webkit-fill-available;
background: background:
radial-gradient(circle at 10% -8%, rgba(0, 57, 138, 0.10), transparent 30rem), radial-gradient(circle at 10% -8%, rgba(0, 57, 138, 0.10), transparent 30rem),
radial-gradient(circle at 92% 0%, rgba(19, 168, 184, 0.12), transparent 32rem), radial-gradient(circle at 92% 0%, rgba(19, 168, 184, 0.12), transparent 32rem),
@@ -29,6 +33,7 @@
} }
#root { #root {
min-height: -webkit-fill-available;
@apply min-h-screen flex flex-col; @apply min-h-screen flex flex-col;
} }
+2 -1
查看文件
@@ -5,6 +5,7 @@ import { uploadInvoice } from "../api/invoicePool";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Spinner } from "../components/ui/spinner"; import { Spinner } from "../components/ui/spinner";
@@ -201,7 +202,7 @@ export function MobileCapturePage() {
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="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"
> >
<span className="material-symbols-outlined flex size-10 shrink-0 items-center justify-center rounded-lg bg-emerald-50 text-[22px] text-emerald-700">picture_as_pdf</span> <MobileIcon name="picture_as_pdf" className="size-10 rounded-lg bg-emerald-50 p-2 text-emerald-700" />
<span className="min-w-0"> <span className="min-w-0">
<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>
+2 -3
查看文件
@@ -9,6 +9,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions"; import { hasAnyPermission } from "../auth/permissions";
import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
@@ -39,9 +40,7 @@ function MobileActionLink({
to={to} to={to}
className="flex min-h-12 items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-left shadow-sm transition-colors hover:bg-zinc-50" className="flex min-h-12 items-center gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-left shadow-sm transition-colors hover:bg-zinc-50"
> >
<span className="material-symbols-outlined flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-[18px] text-zinc-700"> <MobileIcon name={icon} className="size-8 rounded-lg bg-zinc-100 p-1.5 text-zinc-700" />
{icon}
</span>
<span className="min-w-0"> <span className="min-w-0">
<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}
+5 -6
查看文件
@@ -15,6 +15,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions"; import { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { import {
MOBILE_APPROVAL_PERMISSIONS, MOBILE_APPROVAL_PERMISSIONS,
@@ -139,13 +140,11 @@ function ActionTile({ action }: { action: MobileAction }) {
title={`${action.label}:${action.meta}`} title={`${action.label}:${action.meta}`}
aria-label={`${action.label},${action.meta}`} aria-label={`${action.label},${action.meta}`}
> >
<span <MobileIcon
className="material-symbols-outlined mobile-icon-action__icon" className="mobile-icon-action__icon"
data-testid={`mobile-action-${action.key}-icon`} data-testid={`mobile-action-${action.key}-icon`}
aria-hidden="true" name={action.icon}
> />
{action.icon}
</span>
<span className="mobile-icon-action__label" data-testid={`mobile-action-${action.key}-label`}> <span className="mobile-icon-action__label" data-testid={`mobile-action-${action.key}-label`}>
{action.label} {action.label}
</span> </span>
@@ -31,6 +31,7 @@ import type { PrecheckResponse } from "../api/feeControl";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { import {
@@ -1175,7 +1176,7 @@ export function MobileReimbursementDetailPage() {
htmlFor="mobile-reimbursement-invoice-upload-pdf" htmlFor="mobile-reimbursement-invoice-upload-pdf"
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center" className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
> >
<span className="material-symbols-outlined text-[28px] text-zinc-700">picture_as_pdf</span> <MobileIcon name="picture_as_pdf" className="size-7 text-zinc-700" />
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF 发票</span> <span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF 发票</span>
<span className="mt-1 text-xs text-zinc-500">仅支持 PDF,减少拍照模糊和错传</span> <span className="mt-1 text-xs text-zinc-500">仅支持 PDF,减少拍照模糊和错传</span>
</label> </label>
+11 -3
查看文件
@@ -8,6 +8,7 @@
:root { :root {
--mobile-viewport-height: 100vh; --mobile-viewport-height: 100vh;
--mobile-safe-area-bottom: env(safe-area-inset-bottom, 0px);
--mobile-page-x: clamp(10px, 3vw, 20px); --mobile-page-x: clamp(10px, 3vw, 20px);
--mobile-page-y: clamp(10px, 2.8vw, 20px); --mobile-page-y: clamp(10px, 2.8vw, 20px);
--mobile-card-gap: clamp(8px, 2.5vw, 14px); --mobile-card-gap: clamp(8px, 2.5vw, 14px);
@@ -81,6 +82,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden;
border-radius: 12px; border-radius: 12px;
background: rgb(255 255 255 / 0.86); background: rgb(255 255 255 / 0.86);
font-size: 22px; font-size: 22px;
@@ -88,6 +90,10 @@
box-shadow: 0 1px 2px rgb(15 23 42 / 0.07); box-shadow: 0 1px 2px rgb(15 23 42 / 0.07);
} }
.mobile-icon-action__icon svg {
display: block;
}
.mobile-icon-action__label { .mobile-icon-action__label {
display: block; display: block;
max-width: 100%; max-width: 100%;
@@ -129,12 +135,14 @@
} }
.mobile-bottom-nav-surface { .mobile-bottom-nav-surface {
min-height: calc(var(--mobile-bottom-nav-height) + env(safe-area-inset-bottom, 0px)); min-height: calc(var(--mobile-bottom-nav-height) + var(--mobile-safe-area-bottom));
padding: 0.375rem var(--mobile-page-x) calc(0.35rem + env(safe-area-inset-bottom, 0px)); padding: 0.375rem var(--mobile-page-x) calc(0.35rem + var(--mobile-safe-area-bottom));
-webkit-transform: translateZ(0);
transform: translateZ(0);
} }
.mobile-sticky-action-surface { .mobile-sticky-action-surface {
padding: 0.75rem var(--mobile-page-x) max(0.75rem, env(safe-area-inset-bottom, 0px)); padding: 0.75rem var(--mobile-page-x) max(0.75rem, var(--mobile-safe-area-bottom));
} }
@media (max-width: 359px) { @media (max-width: 359px) {
@@ -0,0 +1,516 @@
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-wecom-mobile-rendering-hardening");
const IPHONE_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 VIEWPORT_CASES = [
{ key: "w320-iphone-wecom", label: "320 iPhone 企业微信", viewport: { width: 320, height: 568 }, userAgent: IPHONE_WECOM_UA },
{ key: "w360-android-wecom", label: "360 Android 企业微信", viewport: { width: 360, height: 740 }, userAgent: ANDROID_WECOM_UA },
{ key: "w375-iphone-wecom", label: "375 iPhone 企业微信", viewport: { width: 375, height: 667 }, userAgent: IPHONE_WECOM_UA },
{ key: "w390-iphone12-wecom", label: "390 iPhone12 企业微信", viewport: { width: 390, height: 844 }, userAgent: IPHONE_WECOM_UA },
{ key: "w393-android-wecom", label: "393 Android 企业微信", viewport: { width: 393, height: 873 }, userAgent: ANDROID_WECOM_UA },
{ key: "w414-iphone-wecom", label: "414 iPhone 企业微信", viewport: { width: 414, height: 896 }, userAgent: IPHONE_WECOM_UA },
{ key: "w430-iphone-wecom", label: "430 iPhone 企业微信", viewport: { width: 430, height: 932 }, userAgent: IPHONE_WECOM_UA },
] as const;
const IPHONE_ENGINE_CASE = {
key: "iphone12-wecom-w390-font-failure",
label: "390 iPhone12 企业微信 字体失败",
viewport: { width: 390, height: 844 },
userAgent: IPHONE_WECOM_UA,
} as const;
const employee = {
id: "wcm6-employee",
employeeNo: "SMOKE002",
name: "OA试运行测试01",
phone: "13800000000",
email: "[email protected]",
departmentId: "wcm6-dept",
directManagerId: "wcm6-manager",
status: "active",
notificationPushEnabled: true,
roleCodes: ["manager", "employee"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 60,
},
wecomBinding: {
externalUserId: "oa_smoke_01",
createdAt: "2026-05-11T08:00:00.000Z",
},
};
function envelope(data: unknown) {
return { requestId: "mock-r6-wecom-rendering", 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: "wcm6-reimbursement",
formNumber: "BX20260511006",
status: "draft",
reimbursementDate: "2026-05-11",
totalAmount: "168.30",
description: "WCM6 移动渲染稳定化验证",
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 blockMaterialSymbolsFont(page: Page) {
await page.route((url) => /material-symbols.*\.(woff2|css)$/i.test(url.pathname), async (route) => {
await route.abort();
});
}
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-wcm6-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-wcm6",
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: "wcm6-dept", name: "试运行部" },
directManager: { id: "wcm6-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: true,
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: "wcm6-notification",
type: "workflow.task_assigned",
title: "报销待审批",
body: "WCM6 移动渲染稳定化待办",
targetType: "workflow_task",
targetId: "wcm6-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: "wcm6-task",
instanceId: "wcm6-instance",
stepId: "wcm6-step",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comments: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
workflowInstance: {
id: "wcm6-instance",
entityType: "reimbursement",
entityId: "wcm6-reimbursement",
currentStatus: "reviewing",
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
businessSummary: {
reference: "BX20260511006",
title: "报销待审批",
applicantName: employee.name,
amount: "168.30",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return"],
},
],
});
return;
}
if (pathName === "/api/workflow/tasks/wcm6-task" && request.method() === "GET") {
await fulfillJson(route, {
task: {
id: "wcm6-task",
instanceId: "wcm6-instance",
stepId: "wcm6-step",
stepName: "主管审批",
assigneeId: employee.id,
assigneeRole: null,
status: "pending",
outcome: null,
comment: null,
createdAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
workflowInstance: {
id: "wcm6-instance",
entityType: "reimbursement",
currentStatus: "reviewing",
currentStepId: "wcm6-step",
contextData: { standardViolations: [] },
startedAt: "2026-05-11T08:00:00.000Z",
completedAt: null,
},
reimbursement: {
id: "wcm6-reimbursement",
formNumber: "BX20260511006",
status: "reviewing",
reimbursementDate: "2026-05-11",
totalAmount: "168.30",
description: "WCM6 移动渲染稳定化验证",
owner: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
submitter: { id: employee.id, employeeNo: employee.employeeNo, name: employee.name },
},
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/wcm6-reimbursement") {
await fulfillJson(route, {
...reimbursementListItem(),
submitterId: employee.id,
ownerId: employee.id,
departmentId: "wcm6-dept",
department: { id: "wcm6-dept", name: "试运行部" },
submittedAt: null,
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: 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,
workflowProgress: null,
approvalHistory: [],
riskFlags: [],
items: [
{
id: "wcm6-item",
expenseTypeId: "wcm6-expense-type",
expenseDate: "2026-05-11",
amount: "168.30",
description: "移动渲染验证",
expenseType: { id: "wcm6-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: "wcm6-expense-type", code: "office", name: "办公费", category: "office", enabled: true }],
});
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 collectRenderingMetrics(page: Page) {
return page.evaluate(() => {
const viewportWidth = window.innerWidth;
const navItems = ["home", "approvals", "reimbursements", "me"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-nav-${key}']`);
const icon = element?.querySelector("svg");
const label = element?.querySelector(`[data-testid='mobile-nav-${key}-label']`);
const badge = document.querySelector(`[data-testid='mobile-nav-${key}-badge']`);
const rect = element?.getBoundingClientRect();
const iconRect = icon?.getBoundingClientRect();
const labelRect = label?.getBoundingClientRect();
const badgeRect = badge?.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),
iconVisible: Boolean(iconRect && iconRect.width >= 16 && iconRect.height >= 16),
labelVisible: Boolean(labelRect && labelRect.width >= 20 && labelRect.height > 0),
badgeOverlapsLabel:
Boolean(badgeRect && labelRect) &&
!(badgeRect!.right <= labelRect!.left || badgeRect!.left >= labelRect!.right || badgeRect!.bottom <= labelRect!.top || badgeRect!.top >= labelRect!.bottom),
badgeOverlapsIcon:
Boolean(badgeRect && iconRect) &&
!(badgeRect!.right <= iconRect!.left || badgeRect!.left >= iconRect!.right || badgeRect!.bottom <= iconRect!.top || badgeRect!.top >= iconRect!.bottom),
};
});
const actionItems = ["approvals", "capture", "reimbursements", "me"].map((key) => {
const element = document.querySelector(`[data-testid='mobile-action-${key}']`);
const icon = document.querySelector(`[data-testid='mobile-action-${key}-icon'] svg`);
const label = document.querySelector(`[data-testid='mobile-action-${key}-label']`);
const rect = element?.getBoundingClientRect();
const iconRect = icon?.getBoundingClientRect();
const labelRect = label?.getBoundingClientRect();
const labelStyle = label ? window.getComputedStyle(label) : null;
return {
key,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
width: rect?.width ?? 0,
height: rect?.height ?? 0,
iconVisible: Boolean(iconRect && iconRect.width >= 16 && iconRect.height >= 16),
labelHeight: labelRect?.height ?? 0,
labelLineHeight: labelStyle ? Number.parseFloat(labelStyle.lineHeight) || 18 : 18,
labelWhiteSpace: labelStyle?.whiteSpace ?? "",
overlaps:
Boolean(labelRect && iconRect) &&
!(labelRect!.right <= iconRect!.left || labelRect!.left >= iconRect!.right || labelRect!.bottom <= iconRect!.top || labelRect!.top >= iconRect!.bottom),
};
});
const stickyActions = document.querySelector("[data-testid='mobile-reimbursement-sticky-actions']");
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),
coreMaterialIconCount: document.querySelectorAll("[data-testid='mobile-bottom-nav'] .material-symbols-outlined, [data-testid='mobile-action-grid'] .material-symbols-outlined").length,
coreSvgCount: document.querySelectorAll("[data-testid='mobile-bottom-nav'] svg, [data-testid='mobile-action-grid'] svg").length,
bottomNavVisible: Boolean(document.querySelector("[data-testid='mobile-bottom-nav']")?.getBoundingClientRect().height),
tableCount: Array.from(document.querySelectorAll("table")).filter((item) => item.getBoundingClientRect().height > 0).length,
navItems,
actionItems,
stickyActionsVisible: Boolean(stickyActions?.getBoundingClientRect().height),
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 expectRenderingStable(page: Page, label: string, screenshotName: string) {
await page.screenshot({ path: screenshotPath(screenshotName), fullPage: true });
const metrics = await collectRenderingMetrics(page);
expect(metrics.scrollWidth, `${label} no horizontal overflow`).toBeLessThanOrEqual(metrics.viewportWidth + 2);
expect(metrics.tableCount, `${label} no visible desktop table`).toBe(0);
expect(metrics.coreMaterialIconCount, `${label} no core material font icons`).toBe(0);
if (metrics.bottomNavVisible) {
expect(metrics.coreSvgCount, `${label} core svg icons`).toBeGreaterThanOrEqual(4);
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);
expect(item.iconVisible, `${label} ${item.key} nav svg visible`).toBe(true);
expect(item.labelVisible, `${label} ${item.key} nav label visible`).toBe(true);
expect(item.badgeOverlapsLabel, `${label} ${item.key} badge not over label`).toBe(false);
expect(item.badgeOverlapsIcon, `${label} ${item.key} badge not over icon`).toBe(false);
}
}
return metrics;
}
async function exerciseMobileRendering(page: Page, key: string, label: string) {
await mockApis(page);
await login(page);
await page.goto("/mobile");
await expect(page.getByTestId("mobile-action-grid")).toBeVisible();
const homeMetrics = await expectRenderingStable(page, label, `${key}-mobile-home`);
expect(homeMetrics.actionItems.length, `${label} action count`).toBe(4);
for (const action of homeMetrics.actionItems) {
expect(action.visible, `${label} ${action.key} action visible`).toBe(true);
expect(action.width, `${label} ${action.key} action width`).toBeGreaterThanOrEqual(44);
expect(action.height, `${label} ${action.key} action height`).toBeGreaterThanOrEqual(44);
expect(action.iconVisible, `${label} ${action.key} action svg visible`).toBe(true);
expect(action.labelWhiteSpace, `${label} ${action.key} label nowrap`).toBe("nowrap");
expect(action.labelHeight, `${label} ${action.key} label single line`).toBeLessThanOrEqual(action.labelLineHeight + 2);
expect(action.overlaps, `${label} ${action.key} icon/label overlap`).toBe(false);
}
await page.getByTestId("mobile-nav-approvals").click();
await expect(page).toHaveURL(/\/mobile\/approvals$/);
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
await expectRenderingStable(page, label, `${key}-mobile-approvals`);
await page.goto("/mobile/capture");
await expect(page.getByTestId("mobile-capture-file")).toBeVisible();
await expectRenderingStable(page, label, `${key}-mobile-capture`);
await page.goto("/mobile/me");
await expect(page.getByTestId("mobile-more-links")).toBeVisible();
await expectRenderingStable(page, label, `${key}-mobile-me`);
await page.goto("/mobile/reimbursements/wcm6-reimbursement");
await expect(page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(page.getByTestId("mobile-bottom-nav")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-sticky-actions")).toBeVisible();
const detailMetrics = await expectRenderingStable(page, label, `${key}-mobile-reimbursement-detail`);
expect(detailMetrics.stickyActionsVisible, `${label} sticky actions visible`).toBe(true);
expect(detailMetrics.stickyButtonsSameRow, `${label} sticky buttons same row`).toBe(true);
expect(detailMetrics.stickyButtonMinHeight, `${label} sticky buttons touch height`).toBeGreaterThanOrEqual(44);
}
test.describe("WCM6 企业微信多手机移动渲染稳定化", () => {
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("核心移动入口使用 SVG 图标且跨视口不挤压", async ({ page }) => {
await exerciseMobileRendering(page, device.key, device.label);
});
});
}
test.describe(IPHONE_ENGINE_CASE.label, () => {
test.use({
viewport: IPHONE_ENGINE_CASE.viewport,
isMobile: true,
hasTouch: true,
userAgent: IPHONE_ENGINE_CASE.userAgent,
locale: "zh-CN",
serviceWorkers: "block",
});
test("Material Symbols 字体失败时核心移动入口仍稳定", async ({ page }) => {
await blockMaterialSymbolsFont(page);
await exerciseMobileRendering(page, IPHONE_ENGINE_CASE.key, IPHONE_ENGINE_CASE.label);
});
});
});
+4 -4
查看文件
@@ -35,14 +35,14 @@
| 业务自动化 | 通过 | `approval/leave/procurement` 本地隔离业务流通过,报告 `artifacts/qa-flow/business-flow-20260511-140942`;公网 live smoke 通过;`reimbursement` 组已在 `R6-ISSUE-002` 对齐测试口径后通过,报告 `artifacts/qa-flow/business-flow-20260511-161344` | | 业务自动化 | 通过 | `approval/leave/procurement` 本地隔离业务流通过,报告 `artifacts/qa-flow/business-flow-20260511-140942`;公网 live smoke 通过;`reimbursement` 组已在 `R6-ISSUE-002` 对齐测试口径后通过,报告 `artifacts/qa-flow/business-flow-20260511-161344` |
| 财务链路 | 通过 / 候选待确认 | `server/scripts/regression-payment-batches.sh` 通过,`paymentBatches` 单测 3 passed;只读查询确认 `SMOKE301/SMOKE302` 为 active 测试财务账号;未命中 `wangzhiling` OA 候选账号,R6-3 前需补确认 | | 财务链路 | 通过 / 候选待确认 | `server/scripts/regression-payment-batches.sh` 通过,`paymentBatches` 单测 3 passed;只读查询确认 `SMOKE301/SMOKE302` 为 active 测试财务账号;未命中 `wangzhiling` OA 候选账号,R6-3 前需补确认 |
| PC 与移动模拟视觉 | 通过 | R105 公网视觉 4 passed;移动响应式矩阵 9 passed;截图汇总到 `output/playwright/r6-non-device-business-verification/` | | PC 与移动模拟视觉 | 通过 | R105 公网视觉 4 passed;移动响应式矩阵 9 passed;截图汇总到 `output/playwright/r6-non-device-business-verification/` |
| 真机门禁 | 暂停 | iPhone 11 / Redmi K60 企业微信真实触控、入口和布局体验仍需人工确认;自动视觉不替代真机验收 | | 真机门禁 | 暂停 | iPhone 12 / iOS 14.1 / 企业微信 与 Redmi K60 企业微信真实触控、入口和布局体验仍需人工确认;自动视觉不替代真机验收 |
### R6 人员与角色 ### R6 人员与角色
| 用户 | 角色 | 入口 | 设备 | 负责流程 | 结果 | 备注 | | 用户 | 角色 | 入口 | 设备 | 负责流程 | 结果 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| `SMOKE002` | 员工 / 提交人 | PC / 企业微信 / 手机浏览器 | iPhone 11 / Redmi K60 | 报销、请假、采购提交 | 待执行 | 用户口径为 `smkoe02/smoke02`,按既有账号统一记录为 `SMOKE002` | | `SMOKE002` | 员工 / 提交人 | PC / 企业微信 / 手机浏览器 | iPhone 12 / iOS 14.1 / Redmi K60 | 报销、请假、采购提交 | 待执行 | 用户口径为 `smkoe02/smoke02`,按既有账号统一记录为 `SMOKE002` |
| `SMOKE101` | 经理 / 审批人 | PC / 企业微信 / 手机浏览器 | iPhone 11 / Redmi K60 | 审批待办、退回或驳回 | 待执行 | 审批人可以是经理;不使用 admin 解释审批权 | | `SMOKE101` | 经理 / 审批人 | PC / 企业微信 / 手机浏览器 | iPhone 12 / iOS 14.1 / Redmi K60 | 审批待办、退回或驳回 | 待执行 | 审批人可以是经理;不使用 admin 解释审批权 |
| `wangshunli` | 管理员 / 回滚或试运行拍板候选 | PC | 待填写 | 管理后台、配置核对、试运行决策 | 待执行 | 不作为本轮普通业务审批人;财务由 `wangzhiling` 候选承担 | | `wangshunli` | 管理员 / 回滚或试运行拍板候选 | PC | 待填写 | 管理后台、配置核对、试运行决策 | 待执行 | 不作为本轮普通业务审批人;财务由 `wangzhiling` 候选承担 |
| `wangzhiling` | 财务 | PC / 手机浏览器 | Redmi K60 | 财务复核、付款标记、导出 | 待执行 | R6-3 前需只读确认账号 active、财务角色和权限 | | `wangzhiling` | 财务 | PC / 手机浏览器 | Redmi K60 | 财务复核、付款标记、导出 | 待执行 | R6-3 前需只读确认账号 active、财务角色和权限 |
@@ -60,7 +60,7 @@
| ID | 级别 | 角色 | 入口/设备 | 流程 | 复现步骤 | 当前处理 | 负责人 | 状态 | 关联 Issue | | ID | 级别 | 角色 | 入口/设备 | 流程 | 复现步骤 | 当前处理 | 负责人 | 状态 | 关联 Issue |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| R6-ISSUE-001 | 阻断 | 审批人 / 员工 | iPhone 11 / 企业微信 WebView | 移动底部导航、审批入口、首页快捷卡 | iPhone 11 使用企业微信打开 OA,底部菜单第二项“审批”点不了,首页快捷卡图标和文字挤在一起;用户反馈单点适配不足,需要像主流移动网页/App 一样自动适配不同分辨率 | `PAUSED`:已从单机型热修升级为移动响应式体系修复并部署;首页快捷入口改为图标宫格,公网 320/360/375/390/393/414/430 视觉矩阵通过;用户暂时暂停真机验证,待 iPhone 11 / Redmi K60 真机确认后关闭或降级 | Codex / 待指派 | open | `issues/2026-05-11_12-17-07-r6-mobile-responsive-system-hotfix.csv` | | 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,底部角标独立定位;320/360/375/390/393/414/430px 与字体失败场景通过,截图 `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-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` |
级别定义: 级别定义:
@@ -36,7 +36,7 @@
## 移动统一体验验收 ## 移动统一体验验收
- 移动验收不再只按 iPhone 11 和 Redmi K60 两个固定机型判断;先通过 320、360、375、390、393、414、430px 跨视口自动化证明响应式基线,再用代表性真机确认企业微信 WebView 行为。 - 移动验收不再只按单个固定机型判断;先通过 320、360、375、390、393、414、430px 跨视口自动化证明响应式基线,再用代表性真机确认企业微信 WebView 行为。当前 iOS 代表设备修正为 iPhone 12 / iOS 14.1 / 企业微信,Android 代表设备为 Redmi K60 / 企业微信。
- 所有移动页面使用同一套响应式体系:底部一级 Tab、首页图标宫格、卡片列表、深层 Push 页、顶部返回、吸底主操作和安全区预留。 - 所有移动页面使用同一套响应式体系:底部一级 Tab、首页图标宫格、卡片列表、深层 Push 页、顶部返回、吸底主操作和安全区预留。
- 深层页如报销详情、审批详情隐藏底部 Tab;一级页才显示“首页、审批、报销、我的”底部导航。 - 深层页如报销详情、审批详情隐藏底部 Tab;一级页才显示“首页、审批、报销、我的”底部导航。
- 底部四项 Tab 必须等宽、可见、可点;不能被滚动层、卡片、安全区或遮罩覆盖。 - 底部四项 Tab 必须等宽、可见、可点;不能被滚动层、卡片、安全区或遮罩覆盖。
@@ -96,7 +96,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`:公网自动视觉通过;用户反馈仍需系统性规约治理,真机验收暂缓 | `output/playwright/r6-mobile-responsive-system-hotfix/` | | 移动底部导航与深层页 | `oa_smoke_01` / `oa_smoke_02` | `SMOKE002` / `SMOKE101` | 首页、审批、报销、我的固定可见;报销详情隐藏底部 Tab,吸底双按钮不遮挡 | `PAUSED`:WCM6 本地自动视觉通过;公网部署和代表性真机确认待补 | `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/` |
自动化补充证据(用于审批链业务验收): 自动化补充证据(用于审批链业务验收):
@@ -106,12 +106,12 @@
- `SMOKE001` 仅凭 `admin` 审批业务待办被拒绝,HTTP `403/PERM_002`。 - `SMOKE001` 仅凭 `admin` 审批业务待办被拒绝,HTTP `403/PERM_002`。
- 小额报销经 `SMOKE101` 后进入 `finance` role;大额报销进入 `SMOKE101 -> ceo -> finance_mgr`,由 `SMOKE301/SMOKE302` 测试角色验证。 - 小额报销经 `SMOKE101` 后进入 `finance` role;大额报销进入 `SMOKE101 -> ceo -> finance_mgr`,由 `SMOKE301/SMOKE302` 测试角色验证。
- 企业微信工作台、OAuth 绑定和提交人/审批人核心移动链路已有 `oa_smoke_01/oa_smoke_02` 真机通过;移动布局、PDF 上传、通知 deeplink 已完成公网自动视觉和真机确认,R5/S5 可按合同回填 `DONE`。 - 企业微信工作台、OAuth 绑定和提交人/审批人核心移动链路已有 `oa_smoke_01/oa_smoke_02` 真机通过;移动布局、PDF 上传、通知 deeplink 已完成公网自动视觉和真机确认,R5/S5 可按合同回填 `DONE`。
- `2026-05-10` 真机补充:iPhone 11(iOS 14)与 Redmi K60(MIUI)企业微信最新版下,`oa_smoke_01 -> SMOKE002`、`oa_smoke_02 -> SMOKE101` 绑定、工作台进入、免登录、提交和审批详情/结果均通过。 - `2026-05-10` 真机补充:原记录为 iPhone 11(iOS 14),用户于 `2026-05-11` 更正实际设备为 iPhone 12 / iOS 14.1;与 Redmi K60(MIUI)企业微信最新版下,`oa_smoke_01 -> SMOKE002`、`oa_smoke_02 -> SMOKE101` 绑定、工作台进入、免登录、提交和审批详情/结果均通过。
- `2026-05-10` 阻断:真机发现移动底部导航和报销详情深层页在不同分辨率下存在底部区域不可见或控件挤压;发票上传正式口径改为 PDF-only。本地修复已完成,部署与真机复测完成前,S5/O3/R5 不标 `DONE`。 - `2026-05-10` 阻断:真机发现移动底部导航和报销详情深层页在不同分辨率下存在底部区域不可见或控件挤压;发票上传正式口径改为 PDF-only。本地修复已完成,部署与真机复测完成前,S5/O3/R5 不标 `DONE`。
- `2026-05-11` 公网自动视觉补充:部署 `795e976` 后,Playwright 企业微信 UA 覆盖 iPhone 11/iOS14 与 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 11 与 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 11 与 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 视觉矩阵通过;用户现场暂时看不到真机效果,验证暂停。当前记录为 `PAUSED`,不得将 `RS6-7`、`UI6-6` 或 `R6-ISSUE-001` 置为完成;R6-3 继续等待代表性真机确认。 - `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 基线、多手机视口和字体失败场景均已覆盖,截图目录 `output/playwright/r6-wecom-mobile-rendering-hardening/`;R6-3 继续等待公网部署复检和代表性真机确认。
- 经理账号可在移动端看到审批待办。 - 经理账号可在移动端看到审批待办。
- 审批详情可查看申请摘要、附件、发票、审批记录。 - 审批详情可查看申请摘要、附件、发票、审批记录。
@@ -0,0 +1,8 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
WCM6-1,建档 企业微信多手机移动渲染稳定化,"记录 iPhone 12 / iOS 14.1 / 企业微信真机截图中移动首页和底部导航图标漂移、重影、拥挤问题;本轮以 企业微信 WebView 稳定渲染为目标,不按单机型继续调间距。","1) 本 CSV 存在且校验通过;2) 设备口径修正为 iPhone 12 / iOS 14.1 / 企业微信;3) R6-ISSUE-001 保持 open/PAUSED;4) 不启动 R6,不改审批链、角色、通知通道或远端数据。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-11_20-38-33-r6-wecom-mobile-rendering-hardening.csv;人工核对 docs/trialrun/manual-trialrun-report.md 和 docs/trialrun/real-device-public-checklist.md 中 R6-ISSUE-001 仍为 PAUSED。",python3|manual,DONE,DONE,DONE,issues/2026-05-11_20-38-33-r6-wecom-mobile-rendering-hardening.csv|docs/trialrun/*.md,[],WCM6-1 completed:CSV 校验通过;设备口径已修正为 iPhone 12 / iOS 14.1 / 企业微信和 Redmi K60 / 企业微信;R6-ISSUE-001 保持 open/PAUSED。
WCM6-2,移动核心图标 SVG 化,"引入 lucide-react,将 /mobile 首页宫格、底部导航、我的页入口、票据上传等核心移动入口从 Material Symbols 字体连字替换为稳定 SVG 图标。","1) 移动核心入口不再依赖 fact_check/request_quote/receipt_long 等字体连字;2) 图标槽固定尺寸且 SVG aria-hidden;3) 文字可访问名称由按钮或链接文本提供;4) Material Symbols 可保留在非核心后续清理范围。","流程级验证:cd client && npm run typecheck;cd client && npm run build;Playwright 字体失败场景拦截 Material Symbols 字体后,/mobile 首页宫格和底部导航仍显示 SVG 图标且可点击。",npm|typescript|playwright,DONE,DONE,DONE,client/package.json|client/package-lock.json|client/src/components/mobile|client/src/pages/Mobile*.tsx,["WCM6-1"],WCM6-2 completed:lucide-react 已用于移动核心 SVG 图标;/mobile 首页宫格、底部导航、我的页入口和票据上传核心路径不再依赖 Material Symbols 字体连字;typecheck/build/WCM6 字体失败场景通过。回滚路径:恢复原 Material Symbols 渲染并移除依赖。
WCM6-3,企业微信 WebView 视口字号安全区基线,"补充 企业微信 iPhone WebView 需要的 viewport-fit、text-size-adjust、safe-area 和高度兜底,降低企业微信顶部壳、底部安全区和 iPhone 字号自动调整造成的拥挤风险。","1) client/index.html viewport 包含 viewport-fit=cover;2) 全局样式设置 -webkit-text-size-adjust:100% 和 text-size-adjust:100%;3) 移动 Shell 和底部导航继续使用 visualViewport 与 safe-area 兜底;4) 内容不被底部栏或吸底操作遮挡。","流程级验证:企业微信 UA 覆盖 390x844 iPhone 近似视口与 Android 近似视口 访问 /mobile、/mobile/approvals、/mobile/reimbursements/:id;断言 scrollWidth <= viewportWidth + 2、底部导航贴底、报销详情吸底双按钮同行。",typescript|playwright,DONE,DONE,DONE,client/index.html|client/src/styles/mobile-touch.css|client/src/layouts/AppShellLayout.tsx,["WCM6-1"],WCM6-3 completed:viewport-fit、text-size-adjust、safe-area token 和移动高度兜底已补齐;WCM6 390x844 iPhone12 企业微信近似视口与 Android 企业微信视口验证通过。回滚路径:恢复原 meta 和移动 token。
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-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,DOING,TODO,TODO,deploy/trialrun/*|docs/trialrun/*.md,["WCM6-6"],WCM6-7 in progress:本地 typecheck/build/WCM6 专项和移动回归已通过;待提交、部署公网、复检 /ready/bundle/live smoke/PDF OCR/企微个人应用消息,并等待 iPhone 12 / iOS 14.1 / 企业微信与 Redmi K60 真机确认。默认不覆盖远端 .env;真机通过前不启动 R6-3。
无法渲染此文件,因为它包含了意外字符,其位于第 3 行和第 836 列。