R110/A2,A3 补齐手机和PC路由分流
这个提交包含在:
@@ -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",
|
||||
|
||||
@@ -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 <Navigate to="/mobile" replace />;
|
||||
if (mobileEquivalentPath && mobileEquivalentPath !== `${location.pathname}${location.search}`) {
|
||||
return <Navigate to={mobileEquivalentPath} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -382,11 +382,11 @@ export function MobileAssetScanPage() {
|
||||
const actions = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isInventoryMode ? (
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/inventory-checks")}>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan")}>
|
||||
返回盘点页
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/assets")}>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile")}>
|
||||
返回资产台账
|
||||
</Button>
|
||||
)}
|
||||
@@ -509,8 +509,8 @@ export function MobileAssetScanPage() {
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{selectedAsset.assetNo}</h3>
|
||||
<p className="text-sm text-zinc-500">{selectedAsset.name}</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/assets")}>
|
||||
打开资产台账
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan")}>
|
||||
继续扫码
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export function MobileMePage() {
|
||||
|
||||
<SectionPanel title="账号与绑定">
|
||||
<div className="grid gap-2">
|
||||
<MobileActionLink to="/settings/profile" label="个人设置" description="企业微信、账号与通知" icon="manage_accounts" />
|
||||
<MobileActionLink to="/mobile/profile" label="个人设置" description="企业微信、账号与通知" icon="manage_accounts" />
|
||||
{me?.id ? (
|
||||
<MobileActionLink to={`/people/${me.id}`} label="员工档案" description="办公地点和紧急联系人" icon="badge" />
|
||||
) : null}
|
||||
@@ -110,21 +110,21 @@ export function MobileMePage() {
|
||||
|
||||
<SectionPanel title="更多入口">
|
||||
<div className="grid grid-cols-2 gap-2" data-testid="mobile-more-links">
|
||||
<MobileActionLink to="/notifications" label="通知中心" icon="notifications" />
|
||||
<MobileActionLink to="/mobile/notifications" label="通知中心" icon="notifications" />
|
||||
<MobileActionLink to="/mobile/leave" label="请假" icon="event_available" />
|
||||
<MobileActionLink to="/mobile/reimbursements" label="报销" icon="request_quote" />
|
||||
{canAccessApprovals ? <MobileActionLink to="/mobile/approvals" label="审批" icon="fact_check" /> : null}
|
||||
{canAccessAssets ? <MobileActionLink to="/assets" label="资产" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/projects" label="项目" icon="workspaces" /> : null}
|
||||
{canAccessAssets ? <MobileActionLink to="/mobile/assets/scan" label="资产扫码" icon="inventory_2" /> : null}
|
||||
{canAccessProjects ? <MobileActionLink to="/mobile" label="项目动态" icon="workspaces" /> : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
{canAccessAdmin ? (
|
||||
<SectionPanel title="管理入口">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MobileActionLink to="/people" label="人员管理" icon="group" />
|
||||
<MobileActionLink to="/organization" label="组织架构" icon="account_tree" />
|
||||
<MobileActionLink to="/system/settings" label="系统设置" icon="settings" />
|
||||
<MobileActionLink to="/mobile/me" label="人员管理" icon="group" />
|
||||
<MobileActionLink to="/mobile/me" label="组织架构" icon="account_tree" />
|
||||
<MobileActionLink to="/mobile/me" label="系统设置" icon="settings" />
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
@@ -356,7 +356,7 @@ export function MobilePortalPage() {
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-base font-semibold text-zinc-900">通知</div>
|
||||
<Button asChild size="sm" type="button" variant="secondary">
|
||||
<Link to="/notifications">全部</Link>
|
||||
<Link to="/mobile/notifications">全部</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -374,7 +374,7 @@ export function MobilePortalPage() {
|
||||
{notifications.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
to="/notifications"
|
||||
to="/mobile/notifications"
|
||||
className="block rounded-lg border border-zinc-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -303,6 +303,10 @@ export function AppRouter() {
|
||||
|
||||
<Route path="mobile/me" element={withRouteFallback(<MobileMePage />)} />
|
||||
|
||||
<Route path="mobile/profile" element={<ProfilePage />} />
|
||||
|
||||
<Route path="mobile/notifications" element={withRouteFallback(<NotificationsPage />)} />
|
||||
|
||||
<Route
|
||||
path="mobile/capture"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { expect, test, type Browser, type Page } from "@playwright/test";
|
||||
|
||||
import {
|
||||
EMPLOYEE,
|
||||
createAuthenticatedApiClient,
|
||||
type AuthenticatedApiClient,
|
||||
type E2EUser,
|
||||
} from "./helpers/auth";
|
||||
|
||||
const WECOM_UA =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
|
||||
type SeededReimbursement = {
|
||||
id: string;
|
||||
client: AuthenticatedApiClient;
|
||||
};
|
||||
|
||||
async function login(page: Page, user: E2EUser) {
|
||||
await page.goto("/login");
|
||||
const credentialForm = page.locator("#login-identifier, input[autocomplete='username'], input[placeholder*='邮箱']").first();
|
||||
const accountLoginButton = page.getByRole("button", { name: "使用账号密码登录" });
|
||||
const credentialFormVisible = await credentialForm.isVisible({ timeout: 1_000 }).catch(() => 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<SeededReimbursement> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
+26
-1
@@ -36,7 +36,7 @@ qa_require_cmd() {
|
||||
|
||||
qa_usage() {
|
||||
cat <<USAGE
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|smoke-live>
|
||||
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
|
||||
|
||||
可选环境变量:
|
||||
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
|
||||
|
||||
在新工单中引用
屏蔽一个用户