WCM6 收口企业微信多手机移动渲染稳定化
这个提交包含在:
+2
-2
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
+10
@@ -17,6 +17,7 @@
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -5591,6 +5592,15 @@
|
||||
"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": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
MOBILE_NAV_ITEMS,
|
||||
} from "../../features/mobile/navigation";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { MobileIcon } from "./MobileIcon";
|
||||
|
||||
function resolveBadgeLabel(count: number) {
|
||||
if (count > 99) {
|
||||
@@ -53,8 +54,13 @@ export function MobileBottomNav() {
|
||||
aria-disabled="true"
|
||||
title={`${item.label}不可用`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
<span className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{item.label}</span>
|
||||
<MobileIcon name={item.icon} className="size-5" />
|
||||
<span
|
||||
className="max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
data-testid={`mobile-nav-${item.key}-label`}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -67,29 +73,35 @@ export function MobileBottomNav() {
|
||||
data-testid={`mobile-nav-${item.key}`}
|
||||
className={({ isActive }) =>
|
||||
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
|
||||
? "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",
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="relative">
|
||||
<span className="material-symbols-outlined text-[19px] leading-none">{item.icon}</span>
|
||||
{badge ? (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-3 -top-2 min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none",
|
||||
isActive ? "bg-white text-primary" : "bg-red-500 text-white",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
<MobileIcon name={item.icon} className="size-5" />
|
||||
</span>
|
||||
<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>
|
||||
{badge ? (
|
||||
<span
|
||||
data-testid={`mobile-nav-${item.key}-badge`}
|
||||
className={cn(
|
||||
"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-red-500 text-white" : "bg-red-500 text-white",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,16 @@
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
min-height: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
color-scheme: light;
|
||||
background: var(--color-bg-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
min-height: -webkit-fill-available;
|
||||
background:
|
||||
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),
|
||||
@@ -29,6 +33,7 @@
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: -webkit-fill-available;
|
||||
@apply min-h-screen flex flex-col;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { uploadInvoice } from "../api/invoicePool";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Spinner } from "../components/ui/spinner";
|
||||
@@ -201,7 +202,7 @@ export function MobileCapturePage() {
|
||||
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"
|
||||
>
|
||||
<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="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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { EmployeeAvatar } from "../components/common/EmployeeAvatar";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -39,9 +40,7 @@ function MobileActionLink({
|
||||
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"
|
||||
>
|
||||
<span className="material-symbols-outlined flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-[18px] text-zinc-700">
|
||||
{icon}
|
||||
</span>
|
||||
<MobileIcon name={icon} className="size-8 rounded-lg bg-zinc-100 p-1.5 text-zinc-700" />
|
||||
<span className="min-w-0">
|
||||
<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}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
MOBILE_APPROVAL_PERMISSIONS,
|
||||
@@ -139,13 +140,11 @@ function ActionTile({ action }: { action: MobileAction }) {
|
||||
title={`${action.label}:${action.meta}`}
|
||||
aria-label={`${action.label},${action.meta}`}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined mobile-icon-action__icon"
|
||||
<MobileIcon
|
||||
className="mobile-icon-action__icon"
|
||||
data-testid={`mobile-action-${action.key}-icon`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{action.icon}
|
||||
</span>
|
||||
name={action.icon}
|
||||
/>
|
||||
<span className="mobile-icon-action__label" data-testid={`mobile-action-${action.key}-label`}>
|
||||
{action.label}
|
||||
</span>
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { PrecheckResponse } from "../api/feeControl";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -1175,7 +1176,7 @@ export function MobileReimbursementDetailPage() {
|
||||
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"
|
||||
>
|
||||
<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-1 text-xs text-zinc-500">仅支持 PDF,减少拍照模糊和错传</span>
|
||||
</label>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
:root {
|
||||
--mobile-viewport-height: 100vh;
|
||||
--mobile-safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--mobile-page-x: clamp(10px, 3vw, 20px);
|
||||
--mobile-page-y: clamp(10px, 2.8vw, 20px);
|
||||
--mobile-card-gap: clamp(8px, 2.5vw, 14px);
|
||||
@@ -81,6 +82,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 0.86);
|
||||
font-size: 22px;
|
||||
@@ -88,6 +90,10 @@
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.07);
|
||||
}
|
||||
|
||||
.mobile-icon-action__icon svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-icon-action__label {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
@@ -129,12 +135,14 @@
|
||||
}
|
||||
|
||||
.mobile-bottom-nav-surface {
|
||||
min-height: calc(var(--mobile-bottom-nav-height) + env(safe-area-inset-bottom, 0px));
|
||||
padding: 0.375rem var(--mobile-page-x) calc(0.35rem + 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 + var(--mobile-safe-area-bottom));
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户