-
+
{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