diff --git a/client/package.json b/client/package.json index e25c9b0f..f39a0c70 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "test:e2e:r106-visual-audit": "playwright test tests/e2e/r106-visual-audit.spec.ts --workers=1 --reporter=list", "test:e2e:r108-mobile-visual": "playwright test tests/e2e/r108-mobile-wecom-visual.spec.ts --workers=1 --reporter=list", "test:e2e:r109-mobile-feedback": "playwright test tests/e2e/r109-mobile-reimbursement-feedback.spec.ts --workers=1 --reporter=list", + "test:e2e:r110-mobile-routing": "playwright test tests/e2e/r110-mobile-pc-routing.spec.ts --workers=1 --reporter=list", "test:e2e:r75-gap-fill": "playwright test tests/e2e/r75-automation-gap-fill.spec.ts --workers=1 --reporter=list", "test:e2e:r70-assist": "R70_TRIALRUN_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-assist.spec.ts --workers=1 --reporter=list", "test:e2e:r70-deep-assist": "PLAYWRIGHT_MANUAL=1 R70_TRIALRUN_DEEP_ASSIST=1 playwright test tests/e2e/manual/r70-trialrun-deep-assist.spec.ts --workers=1 --reporter=list", diff --git a/client/src/layouts/AppShellLayout.tsx b/client/src/layouts/AppShellLayout.tsx index c80d45eb..b85ef41f 100644 --- a/client/src/layouts/AppShellLayout.tsx +++ b/client/src/layouts/AppShellLayout.tsx @@ -6,11 +6,14 @@ import { Sidebar } from "../components/navigation/Sidebar"; import { Topbar } from "../components/navigation/Topbar"; import { resolvePageMeta } from "../features/navigation/schema"; import { writeRecentVisit } from "../lib/recentVisits"; -import { shouldPreferMobileWorkplace } from "../lib/runtimeContext"; +import { resolveMobileEquivalentPath, shouldPreferMobileWorkplace } from "../lib/runtimeContext"; export function AppShellLayout() { const location = useLocation(); const isMobileRoute = location.pathname.startsWith("/mobile"); + const mobileEquivalentPath = !isMobileRoute && shouldPreferMobileWorkplace() + ? resolveMobileEquivalentPath(location.pathname, location.search) + : null; useEffect(() => { const pathname = location.pathname; @@ -20,8 +23,8 @@ export function AppShellLayout() { writeRecentVisit(pathname, resolvePageMeta(pathname).title); }, [location.pathname]); - if (location.pathname === "/portal" && shouldPreferMobileWorkplace()) { - return ; + if (mobileEquivalentPath && mobileEquivalentPath !== `${location.pathname}${location.search}`) { + return ; } return ( diff --git a/client/src/lib/runtimeContext.ts b/client/src/lib/runtimeContext.ts index 57b0298a..e4f3942c 100644 --- a/client/src/lib/runtimeContext.ts +++ b/client/src/lib/runtimeContext.ts @@ -36,3 +36,88 @@ export function shouldPreferMobileWorkplace() { export function resolveDefaultWorkspacePath() { return shouldPreferMobileWorkplace() ? "/mobile" : "/portal"; } + +function encodeSegment(value: string) { + return encodeURIComponent(value); +} + +export function resolveMobileEquivalentPath(pathname: string, search = "") { + const normalizedPath = pathname.replace(/\/+$/, "") || "/"; + + if (normalizedPath === "/" || normalizedPath === "/portal" || normalizedPath === "/dashboard") { + return "/mobile"; + } + + if (normalizedPath === "/notifications") { + return "/mobile/notifications"; + } + + if (normalizedPath === "/settings/profile") { + return "/mobile/profile"; + } + + if (normalizedPath === "/reimbursements" || normalizedPath === "/reimbursements/dashboard") { + return "/mobile/reimbursements"; + } + + const reimbursementPrintMatch = normalizedPath.match(/^\/reimbursements\/([^/]+)\/print$/); + if (reimbursementPrintMatch?.[1]) { + return `/mobile/reimbursements/${encodeSegment(reimbursementPrintMatch[1])}`; + } + + const reimbursementDetailMatch = normalizedPath.match(/^\/reimbursements\/([^/]+)$/); + if (reimbursementDetailMatch?.[1]) { + return `/mobile/reimbursements/${encodeSegment(reimbursementDetailMatch[1])}`; + } + + if (normalizedPath === "/leave-requests") { + return "/mobile/leave"; + } + + if (normalizedPath === "/invoice-pool") { + return "/mobile/capture"; + } + + const workflowTaskMatch = normalizedPath.match(/^\/workflow\/tasks\/([^/]+)$/); + if (workflowTaskMatch?.[1]) { + return `/mobile/approvals?${new URLSearchParams({ taskId: workflowTaskMatch[1] }).toString()}`; + } + + if (normalizedPath === "/workflow/tasks") { + return "/mobile/approvals"; + } + + if (normalizedPath === "/assets" || normalizedPath === "/inventory-checks") { + return "/mobile/assets/scan"; + } + + if ( + normalizedPath.startsWith("/system") || + normalizedPath.startsWith("/people") || + normalizedPath.startsWith("/organization") || + normalizedPath.startsWith("/payroll") || + normalizedPath.startsWith("/performance") + ) { + return "/mobile/me"; + } + + if ( + normalizedPath.startsWith("/projects") || + normalizedPath.startsWith("/contracts-registry") || + normalizedPath.startsWith("/purchase-requests") || + normalizedPath.startsWith("/travel-requests") || + normalizedPath.startsWith("/payment-batches") || + normalizedPath.startsWith("/ap-operations") || + normalizedPath.startsWith("/weekly-reports") || + normalizedPath.startsWith("/todos") || + normalizedPath.startsWith("/team") || + normalizedPath.startsWith("/attendance") || + normalizedPath.startsWith("/budgets") || + normalizedPath.startsWith("/announcements") || + normalizedPath.startsWith("/delegations") + ) { + return "/mobile"; + } + + return search ? `${pathname}${search}` : pathname; +} diff --git a/client/src/pages/MobileAssetScanPage.tsx b/client/src/pages/MobileAssetScanPage.tsx index fb9b88f7..4e6f6373 100644 --- a/client/src/pages/MobileAssetScanPage.tsx +++ b/client/src/pages/MobileAssetScanPage.tsx @@ -382,11 +382,11 @@ export function MobileAssetScanPage() { const actions = (
{isInventoryMode ? ( - ) : ( - )} @@ -509,8 +509,8 @@ export function MobileAssetScanPage() {

{selectedAsset.assetNo}

{selectedAsset.name}

- diff --git a/client/src/pages/MobileMePage.tsx b/client/src/pages/MobileMePage.tsx index d8a08d09..78c037ab 100644 --- a/client/src/pages/MobileMePage.tsx +++ b/client/src/pages/MobileMePage.tsx @@ -101,7 +101,7 @@ export function MobileMePage() {
- + {me?.id ? ( ) : null} @@ -110,21 +110,21 @@ export function MobileMePage() {
- + {canAccessApprovals ? : null} - {canAccessAssets ? : null} - {canAccessProjects ? : null} + {canAccessAssets ? : null} + {canAccessProjects ? : null}
{canAccessAdmin ? (
- - - + + +
) : null} diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index 11e1a9f2..3081731f 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -356,7 +356,7 @@ export function MobilePortalPage() {
通知
@@ -374,7 +374,7 @@ export function MobilePortalPage() { {notifications.map((item) => (
diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index b3a70928..5d7e64df 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -303,6 +303,10 @@ export function AppRouter() { )} /> + } /> + + )} /> + false); + const accountLoginVisible = await accountLoginButton.isVisible().catch(() => false); + if (!credentialFormVisible && accountLoginVisible) { + await accountLoginButton.click({ timeout: 5_000 }); + } + + const identifierByTestId = page.getByTestId("login-identifier"); + const passwordByTestId = page.getByTestId("login-password"); + const submitByTestId = page.getByTestId("login-submit"); + const hasTestIdLogin = await identifierByTestId.isVisible({ timeout: 5_000 }).catch(() => false); + + if (hasTestIdLogin) { + await identifierByTestId.fill(user.identifier, { timeout: 10_000 }); + await passwordByTestId.fill(user.password, { timeout: 10_000 }); + await submitByTestId.click({ timeout: 10_000 }); + } else { + await credentialForm.fill(user.identifier, { timeout: 10_000 }); + await page + .locator("#login-password, input[type='password'], input[autocomplete='current-password']") + .first() + .fill(user.password, { timeout: 10_000 }); + await page.getByRole("button", { name: "登录", exact: true }).click({ timeout: 10_000 }); + } + + await page.waitForURL(/\/(portal|mobile)/, { timeout: 30_000 }); +} + +async function newWecomPage(browser: Browser) { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + userAgent: WECOM_UA, + locale: "zh-CN", + serviceWorkers: "block", + }); + return { context, page: await context.newPage() }; +} + +async function expectNoPcShell(page: Page) { + await expect(page.locator("aside")).toHaveCount(0); + await expect(page.getByTestId("global-search-trigger-mobile")).toHaveCount(0); +} + +async function firstEnabledExpenseTypeId(client: AuthenticatedApiClient) { + const response = await client.api.get("/api/expense-types?enabled=true"); + const payload = await response.json() as { data?: { items?: Array<{ id: string; enabled?: boolean }> } }; + const item = payload.data?.items?.find((candidate) => candidate.enabled !== false) ?? payload.data?.items?.[0]; + if (!response.ok() || !item?.id) { + throw new Error(`无法读取费用类型:HTTP ${response.status()}`); + } + return item.id; +} + +async function seedDraftReimbursement(): Promise { + const client = await createAuthenticatedApiClient(EMPLOYEE); + const expenseTypeId = await firstEnabledExpenseTypeId(client); + + const reimbursementResponse = await client.api.post("/api/reimbursements", { + data: { description: `R110 路由分流 ${Date.now()}` }, + }); + const reimbursementPayload = await reimbursementResponse.json() as { data?: { id?: string } }; + if (!reimbursementResponse.ok() || !reimbursementPayload.data?.id) { + throw new Error(`创建报销草稿失败:HTTP ${reimbursementResponse.status()}`); + } + + const itemResponse = await client.api.post(`/api/reimbursements/${reimbursementPayload.data.id}/items`, { + data: { + expenseTypeId, + expenseDate: "2026-05-08", + amount: "110.00", + description: "R110 移动路由分流明细", + }, + }); + if (!itemResponse.ok()) { + throw new Error(`创建报销明细失败:HTTP ${itemResponse.status()}`); + } + + return { id: reimbursementPayload.data.id, client }; +} + +test.describe.serial("R110 手机和 PC 路由分流", () => { + test.skip(process.env.E2E_MODE === "live", "R110 本地路由专项只在临时本地环境运行"); + + test("企业微信手机 UA 访问 PC 路由时进入移动页", async ({ browser }) => { + const seeded = await seedDraftReimbursement(); + const mobile = await newWecomPage(browser); + + try { + await login(mobile.page, EMPLOYEE); + + await mobile.page.goto("/reimbursements"); + await expect(mobile.page).toHaveURL(/\/mobile\/reimbursements$/); + await expect(mobile.page.getByTestId("mobile-reimbursements-list")).toBeVisible(); + await expectNoPcShell(mobile.page); + + await mobile.page.goto(`/reimbursements/${seeded.id}`); + await expect(mobile.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}$`)); + await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible(); + await expectNoPcShell(mobile.page); + + await mobile.page.goto(`/reimbursements/${seeded.id}/print`); + await expect(mobile.page).toHaveURL(new RegExp(`/mobile/reimbursements/${seeded.id}$`)); + await expectNoPcShell(mobile.page); + + await mobile.page.goto("/leave-requests"); + await expect(mobile.page).toHaveURL(/\/mobile\/leave$/); + await expect(mobile.page.getByTestId("mobile-leave-form")).toBeVisible(); + await expectNoPcShell(mobile.page); + + await mobile.page.goto("/invoice-pool"); + await expect(mobile.page).toHaveURL(/\/mobile\/capture$/); + await expect(mobile.page.getByTestId("mobile-capture-file")).toBeAttached(); + await expectNoPcShell(mobile.page); + + await mobile.page.goto("/assets"); + await expect(mobile.page).toHaveURL(/\/mobile\/assets\/scan$/); + await expect(mobile.page.getByTestId("mobile-asset-scan-search")).toBeVisible(); + await expectNoPcShell(mobile.page); + + await mobile.page.goto("/notifications"); + await expect(mobile.page).toHaveURL(/\/mobile\/notifications$/); + await expect(mobile.page.getByText("通知").first()).toBeVisible(); + await expectNoPcShell(mobile.page); + + await mobile.page.goto("/settings/profile"); + await expect(mobile.page).toHaveURL(/\/mobile\/profile$/); + await expectNoPcShell(mobile.page); + } finally { + await seeded.client.api.delete(`/api/reimbursements/${seeded.id}`).catch(() => undefined); + await Promise.all([seeded.client.dispose(), mobile.context.close()]); + } + }); + + test("桌面浏览器访问 PC 路由时不被移动分流", async ({ page }) => { + await login(page, EMPLOYEE); + await page.goto("/reimbursements"); + await expect(page).toHaveURL(/\/reimbursements$/); + await expect(page).not.toHaveURL(/\/mobile\/reimbursements/); + await expect(page.locator("aside")).toHaveCount(1); + }); +}); diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh index 7d6fb622..1e6367e2 100755 --- a/scripts/qa/run-web-gates.sh +++ b/scripts/qa/run-web-gates.sh @@ -36,7 +36,7 @@ qa_require_cmd() { qa_usage() { cat < +Usage: bash scripts/qa/run-web-gates.sh 可选环境变量: QA_PORT / QA_HOST @@ -488,6 +488,23 @@ qa_run_r109_mobile_local() { ) } +qa_run_r110_mobile_local() { + qa_select_port + export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1536}" + export JOBS_ENABLED="${JOBS_ENABLED:-true}" + qa_export_app_env + qa_prepare_db + qa_build_client + qa_start_server + + echo ">>> 执行 R110 手机和 PC 路由分流专项" + ( + cd "$CLIENT_DIR" + rm -rf test-results playwright-report + E2E_MODE=local npm run test:e2e:r110-mobile-routing + ) +} + qa_run_live_smoke() { local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" echo ">>> 探活 live /ready: ${live_base_url}/ready" @@ -550,6 +567,14 @@ case "$MODE" in trap qa_cleanup EXIT qa_run_r109_mobile_local ;; + r110-mobile-local) + qa_require_cmd curl + qa_require_db_client + qa_require_cmd node + qa_require_cmd npm + trap qa_cleanup EXIT + qa_run_r110_mobile_local + ;; smoke-live) qa_require_cmd curl qa_require_cmd node