diff --git a/artifacts/r70/manual-trialrun-checklist.md b/artifacts/r70/manual-trialrun-checklist.md index 1b09515d..44cb1050 100644 --- a/artifacts/r70/manual-trialrun-checklist.md +++ b/artifacts/r70/manual-trialrun-checklist.md @@ -142,6 +142,20 @@ Codex 可代做: 2. 用真实手机企业微信打开 OA 应用一次,确认真实 OAuth 回调、cookie 与企业身份都正常。 3. 若仍失败,记录 `requestId`、错误截图和时间点给我,我继续抓线上日志定位。 +## R89 登录与移动端 UX 自动复核(2026-05-05) + +- 本轮目标:把登录页从说明型入口收敛为操作型入口,并修复手机端菜单/导航不可用的问题。 +- 本地验证地址:`http://127.0.0.1:5173` +- 自动化覆盖: + - 企业微信移动 UA 打开 `/login` 时,首屏只突出“进入工作台”,并生成 `returnTo=/mobile` 的企微 OAuth URL。 + - iPhone 视口密码登录后默认进入 `/mobile`,并完成:首页今日重点 -> 移动审批 -> 票据上传 -> 我的/更多入口。 + - Pixel 视口进入非 `/mobile` 页面时,顶部菜单抽屉可打开、可关闭。 +- 回归命令: + - `cd client && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list` + - `cd client && npm run typecheck` + - `cd client && npm run build` +- 当前结论:R89 移动端自动化关键路径已通过;仍需真实手机企业微信在公网应用中复核一次真实 OAuth、cookie 与移动工作台落点。 + ## 员工路径 账号:员工测试账号。 diff --git a/artifacts/r70/manual-trialrun-report.md b/artifacts/r70/manual-trialrun-report.md index 74a5ad8d..c9eb5316 100644 --- a/artifacts/r70/manual-trialrun-report.md +++ b/artifacts/r70/manual-trialrun-report.md @@ -230,3 +230,24 @@ R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。 - 企业微信未绑定 -> 回退密码登录 -> 继续进入目标页 - 仍需真实人工复核: - 将本轮代码部署到目标环境后,用真实手机企业微信完成 1 次首次进入,确认真实 OAuth 回调、cookie 与企业身份环境下也能自动完成首次绑定闭环。 + +## R89 登录与移动端 UX 收口 + +- 时间:`2026-05-05` +- 本地自动化环境:`http://127.0.0.1:5173` +- 本轮实现结果: + - `/mobile` 路由在手机端隐藏桌面 Topbar,使用底部四入口:`首页 / 审批 / 票据 / 我的`。 + - 普通小屏非 `/mobile` 页面保留顶部菜单抽屉作为兜底,并修复抽屉定位导致的点不开问题。 + - 移动首页调整为“今天”优先,首屏展示审批待办、未读通知和高频动作。 + - “我的”页承接低频入口、个人设置、完整员工档案、管理入口和退出登录。 + - 移动审批和票据上传的主按钮改为更适合手机的触控目标。 +- 自动化结果: + - `cd client && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list` + - `3 passed` + - `cd client && npm run typecheck` + - 通过 + - `cd client && npm run build` + - 通过;仍保留既有 Vite circular chunk 警告。 +- 当前结论: + - 本地自动化已覆盖登录极简、企微移动入口、移动底部导航、移动审批、票据上传、“我的/更多”和小屏菜单抽屉。 + - 真实公网手机企微仍需人工打开一次,确认企业微信真实容器内的 OAuth、cookie 与默认 `/mobile` 落点。 diff --git a/client/src/pages/MobileApprovalsPage.tsx b/client/src/pages/MobileApprovalsPage.tsx index a61ab633..e626bf82 100644 --- a/client/src/pages/MobileApprovalsPage.tsx +++ b/client/src/pages/MobileApprovalsPage.tsx @@ -230,6 +230,7 @@ export function MobileApprovalsPage() { const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false); const [isOnline, setIsOnline] = useState(true); const autoSyncRequestedRef = useRef(false); + const detailSectionRef = useRef(null); const ownerEmployeeId = me?.id ?? null; const canUseRemote = isOnline && Boolean(accessToken); @@ -258,6 +259,21 @@ export function MobileApprovalsPage() { setSearchParams(nextParams, { replace: true }); } + function scrollDetailIntoViewOnMobile() { + if (typeof window === "undefined" || !window.matchMedia("(max-width: 767px)").matches) { + return; + } + + window.requestAnimationFrame(() => { + detailSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + } + + function handleTaskSelection(taskId: string) { + updateTaskSelection(taskId); + scrollDetailIntoViewOnMobile(); + } + async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), @@ -781,11 +797,12 @@ export function MobileApprovalsPage() { /> {taskTab === "pending" ? ( -
+
) : null} - + 当前结果 {filteredTaskCards.length} 条 {batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""} @@ -820,10 +838,11 @@ export function MobileApprovalsPage() {
已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。
-
+
{canTransfer ? ( -
+

转审

{ const file = event.target.files?.[0] ?? null; @@ -159,12 +172,14 @@ export function MobileCapturePage() { type="file" /> -
- 当前文件:{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"} +
+ 当前文件: + {selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
-
+
-
+

离线上传队列

- {queuedCountLabel} + {queuedCountLabel}
{queueQuery.isLoading ?
加载中...
: null} @@ -226,11 +242,12 @@ export function MobileCapturePage() { ) : null} {queuedItems.map((item) => ( -
+
{item.fileName}
创建时间:{formatTime(item.createdAt)}
))}
-
+ ); } diff --git a/client/tests/e2e/r89-mobile-login-navigation.spec.ts b/client/tests/e2e/r89-mobile-login-navigation.spec.ts new file mode 100644 index 00000000..f68c292a --- /dev/null +++ b/client/tests/e2e/r89-mobile-login-navigation.spec.ts @@ -0,0 +1,435 @@ +import { devices, expect, test, type Page, type Route } from "@playwright/test"; + +function browserAgnosticDevice(name: keyof typeof devices) { + const device = devices[name]; + return { + viewport: device.viewport, + userAgent: device.userAgent, + deviceScaleFactor: device.deviceScaleFactor, + isMobile: device.isMobile, + hasTouch: device.hasTouch, + }; +} + +function envelope(data: unknown) { + return { + requestId: "mock-r89", + data, + }; +} + +async function fulfillJson(route: Route, data: unknown, status = 200) { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(envelope(data)), + }); +} + +const mockMe = { + id: "employee-mock-1", + employeeNo: "M001", + name: "Manager", + phone: "13800000000", + email: "manager@hyinda.com", + departmentId: "dept-1", + directManagerId: null, + status: "active", + notificationPushEnabled: true, + roleCodes: ["manager"], + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 50, + }, + wecomBinding: null, +}; + +const pendingTask = { + id: "task-r89-1", + instanceId: "instance-r89-1", + stepId: "manager_approve", + stepName: "经理审批", + assigneeId: "employee-mock-1", + assigneeRole: "manager", + status: "pending", + outcome: null, + createdAt: "2026-05-05T09:00:00.000Z", + completedAt: null, + workflowInstance: { + entityType: "reimbursement", + entityId: "reimbursement-r89-1", + currentStatus: "submitted", + }, + allowedActions: ["approve", "reject", "return"], +}; + +const pendingTaskDetail = { + task: { + id: pendingTask.id, + instanceId: pendingTask.instanceId, + stepId: pendingTask.stepId, + stepName: pendingTask.stepName, + assigneeId: pendingTask.assigneeId, + assigneeRole: pendingTask.assigneeRole, + status: "pending", + outcome: null, + comment: null, + createdAt: pendingTask.createdAt, + completedAt: null, + }, + workflowInstance: { + id: pendingTask.instanceId, + entityType: "reimbursement", + currentStatus: "submitted", + currentStepId: pendingTask.stepId, + contextData: { standardViolations: [] }, + startedAt: "2026-05-05T08:50:00.000Z", + completedAt: null, + }, + reimbursement: { + id: "reimbursement-r89-1", + formNumber: "BX-R89-001", + status: "submitted", + reimbursementDate: "2026-05-05", + totalAmount: "1683.00", + description: "R89 移动审批验证", + owner: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" }, + submitter: { id: "employee-mock-2", employeeNo: "E002", name: "Employee" }, + }, + leaveRequest: null, + purchaseRequest: null, + travelRequest: null, + allowedActions: ["approve", "reject", "return"], + history: [], +}; + +function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") { + return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null }; +} + +async function mockR89Apis(page: Page) { + await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + + if (path === "/api/auth/wecom/login") { + await route.fulfill({ + status: 200, + contentType: "text/plain", + body: "mock wecom login entry", + }); + return; + } + + if (path === "/api/auth/sso/providers") { + await fulfillJson(route, []); + return; + } + + if (path === "/api/auth/wecom/config") { + await fulfillJson(route, { + enabled: true, + entryEnabled: true, + oauthEnabled: true, + corpId: "wx-test-corp", + agentId: "1000002", + loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal", + bindEnabled: true, + defaultReturnTo: "/portal", + status: "ready", + statusReasons: [], + }); + return; + } + + if (path === "/api/auth/login" || path === "/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-r89-access-token", + expiresIn: 900, + mustChangePassword: false, + })), + }); + return; + } + + if (path === "/api/auth/logout") { + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" }, + body: JSON.stringify(envelope({})), + }); + return; + } + + if (path === "/api/me") { + await fulfillJson(route, mockMe); + return; + } + + if (path === "/api/system/navigation") { + await fulfillJson(route, { + workspace: { + id: "workspace-1", + persisted: true, + profile: "small_team_internal", + orgMode: "single_company", + primaryEntryMode: "password", + notificationStrategy: "in_app", + devicePriority: "desktop", + moduleVisibility: {}, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, + sections: { + main: [ + navigationItem("portal", "/portal", "Portal", "home_app_logo"), + navigationItem("team", "/team", "团队", "groups"), + navigationItem("leave", "/leave-requests", "请假", "event_available"), + navigationItem("expense", "/reimbursements", "报销", "request_quote"), + navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"), + navigationItem("invoice", "/invoice-pool", "票据中心", "receipt_long"), + ], + admin: [], + backup: [navigationItem("settings", "/settings/profile", "个人设置", "manage_accounts")], + }, + recommendedRolePresets: [], + }); + return; + } + + if (path === "/api/presence/me") { + await fulfillJson(route, { + date: "2026-05-05", + item: { + id: "presence-r89", + employeeId: mockMe.id, + date: "2026-05-05", + status: "office", + timeRange: "full_day", + note: null, + source: "manual", + updatedAt: "2026-05-05T08:30:00.000Z", + }, + }); + return; + } + + if (path === "/api/attendance/me/today") { + await fulfillJson(route, { + workDate: "2026-05-05", + needClockIn: false, + exemptionReason: "policy_disabled", + item: null, + }); + return; + } + + if (path === "/api/portal/team-status") { + await fulfillJson(route, [ + { + employeeId: mockMe.id, + name: mockMe.name, + avatarUrl: null, + presenceStatus: "office", + attendanceClockIn: "2026-05-05T00:30:00.000Z", + attendanceClockOut: null, + onLeave: false, + leaveType: null, + }, + ]); + return; + } + + if (path === "/api/notifications") { + const unreadOnly = url.searchParams.get("unreadOnly") === "1"; + await fulfillJson(route, { + page: 1, + pageSize: Number(url.searchParams.get("pageSize") ?? 5), + total: unreadOnly ? 1 : 2, + unreadOnly, + unreadCount: 1, + items: [ + { + id: "notification-r89-1", + type: "workflow", + title: "有新的审批待办", + body: "请处理 R89 测试单据", + targetType: "workflow_task", + targetId: pendingTask.id, + readAt: null, + createdAt: "2026-05-05T09:10:00.000Z", + }, + ], + }); + return; + } + + if (path === "/api/workflow/tasks") { + const status = url.searchParams.get("status") ?? "pending"; + await fulfillJson(route, { + page: 1, + pageSize: Number(url.searchParams.get("pageSize") ?? 100), + total: status === "pending" ? 1 : 0, + items: status === "pending" ? [pendingTask] : [], + }); + return; + } + + if (/^\/api\/workflow\/tasks\/[^/]+\/(approve|reject|return|transfer)$/.test(path)) { + await fulfillJson(route, { ok: true }); + return; + } + + if (/^\/api\/workflow\/tasks\/[^/]+$/.test(path)) { + await fulfillJson(route, pendingTaskDetail); + return; + } + + if (path === "/api/workflow/approvers") { + await fulfillJson(route, { page: 1, pageSize: 100, total: 0, items: [] }); + return; + } + + if (path === `/api/employees/${mockMe.id}/profile`) { + await fulfillJson(route, { + employee: { + id: mockMe.id, + employeeNo: mockMe.employeeNo, + name: mockMe.name, + phone: mockMe.phone, + email: mockMe.email, + status: "active", + employmentStatus: "active", + departmentId: "dept-1", + title: "负责人", + directManagerId: null, + hireDate: "2026-01-01", + probationEndDate: null, + lastWorkingDate: null, + workLocation: "北京", + emergencyContactName: null, + emergencyContactPhone: null, + notes: null, + avatarUrl: null, + archivedAt: null, + roleCodes: mockMe.roleCodes, + department: { id: "dept-1", name: "研发" }, + directManager: null, + }, + lifecycle: { events: [], checklistTasks: [], blockers: { openAssetCount: 0, pendingWorkflowTaskCount: 0, canCompleteOffboarding: true } }, + assignedAssets: [], + organization: { reportingChain: [], directReports: [] }, + }); + return; + } + + if (path === "/api/invoice-pool/upload") { + await fulfillJson(route, { + invoicePoolId: "invoice-r89-1", + status: "pending_ocr", + fileSha256: "mock-sha256", + }); + return; + } + + await fulfillJson(route, {}); + }); +} + +async function loginByPassword(page: Page) { + await page.goto("/login"); + await page.getByTestId("login-identifier").fill("manager@hyinda.com"); + await page.getByTestId("login-password").fill("ManagerPassword1234"); + await page.getByTestId("login-submit").click(); +} + +test.describe("R89 企业微信移动入口", () => { + test.use({ + ...browserAgnosticDevice("iPhone 13"), + userAgent: "Mozilla/5.0 iPhone wxwork/4.1.20 MicroMessenger/7.0.1", + }); + + test("企业微信移动场景默认生成移动工作台入口", async ({ page }) => { + await mockR89Apis(page); + + await page.goto("/login"); + await expect(page.getByTestId("login-wecom-primary-panel")).toBeVisible(); + await expect(page.getByTestId("wecom-entry-primary")).toBeVisible(); + await expect(page.getByTestId("login-password-primary-panel")).toHaveCount(0); + + await page.getByTestId("wecom-entry-primary").click(); + await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=%2Fmobile/); + }); +}); + +test.describe("R89 移动底部导航与核心页面", () => { + test.use(browserAgnosticDevice("iPhone 13")); + + test("密码登录后可完成移动首页、审批、票据和我的页关键路径", async ({ page }) => { + await mockR89Apis(page); + + await loginByPassword(page); + await expect(page).toHaveURL(/\/mobile$/); + await expect(page.getByTestId("mobile-today-focus")).toBeVisible(); + await expect(page.getByTestId("topbar-profile-trigger")).toBeHidden(); + + await expect(page.getByTestId("mobile-nav-home")).toBeVisible(); + await expect(page.getByTestId("mobile-nav-approvals")).toBeVisible(); + await expect(page.getByTestId("mobile-nav-capture")).toBeVisible(); + await expect(page.getByTestId("mobile-nav-me")).toBeVisible(); + + await page.getByTestId("mobile-nav-approvals").click(); + await expect(page).toHaveURL(/\/mobile\/approvals/); + await expect(page.getByTestId("mobile-approvals-list")).toBeVisible(); + await page.getByTestId(`mobile-approvals-task-${pendingTask.id}`).click(); + await expect(page.getByTestId("mobile-approvals-detail")).toBeVisible(); + await expect(page.getByTestId("mobile-approvals-approve")).toBeVisible(); + await expect(page.getByTestId("mobile-approvals-reject")).toBeVisible(); + + await page.getByTestId("mobile-nav-capture").click(); + await expect(page).toHaveURL(/\/mobile\/capture/); + await expect(page.getByText("拍照或选择文件")).toBeVisible(); + await page.getByTestId("mobile-capture-file").setInputFiles({ + name: "r89-invoice.pdf", + mimeType: "application/pdf", + buffer: Buffer.from("%PDF-1.4\n% R89"), + }); + await expect(page.getByTestId("mobile-capture-upload")).toBeEnabled(); + + await page.getByTestId("mobile-nav-me").click(); + await expect(page).toHaveURL(/\/mobile\/me/); + await expect(page.getByTestId("mobile-more-links")).toBeVisible(); + await expect(page.getByRole("link", { name: /个人设置/ })).toBeVisible(); + await expect(page.getByRole("button", { name: "退出登录" })).toBeVisible(); + }); +}); + +test.describe("R89 小屏桌面菜单兜底", () => { + test.use(browserAgnosticDevice("Pixel 5")); + + test("非移动路由的小屏菜单可以打开并关闭", async ({ page }) => { + await mockR89Apis(page); + + await loginByPassword(page); + await page.getByTestId("mobile-nav-me").click(); + await page.getByRole("link", { name: /通知中心/ }).click(); + + await expect(page).toHaveURL(/\/notifications/); + await page.getByRole("button", { name: "打开菜单" }).click(); + await expect(page.getByRole("button", { name: "关闭菜单" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Portal" })).toBeVisible(); + + await page.getByRole("button", { name: "关闭菜单" }).click(); + await expect(page.getByRole("button", { name: "关闭菜单" })).toBeHidden(); + }); +}); diff --git a/issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv b/issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv index eab58bc1..90c5c99c 100644 --- a/issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv +++ b/issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv @@ -2,7 +2,7 @@ "A1","R89 执行合同与体验基线冻结","建立登录极简化与移动端可用性改造合同,冻结本轮原则:企业微信优先、移动端任务优先、管理员低频入口后置、不引入新 UI 框架。","CSV 通过校验;A2-A7 依赖清晰;明确本轮不改业务权限模型、不做 Prisma 迁移、不替换设计系统。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv。业务:确认本轮覆盖登录→移动首页→移动导航→核心移动流程→回归证据。","manual","DONE","DONE","DONE","issues/2026-05-05_17-35-00-r89-mobile-login-ux.csv","[]","回滚路径:删除本 CSV;实现阶段每个子项独立提交。2026-05-05:CSV 已写入并通过 validate_issues_csv.py 校验。" "A2","登录页极简化","重构 AuthLayout 与 LoginPage,把登录页从说明型页面改为操作型页面;企业微信内只突出进入工作台,账号密码作为回退;普通浏览器只突出账号密码登录。","企业微信 WebView 中首屏只出现品牌、主按钮、必要错误提示和账号密码回退;普通浏览器首屏只出现账号密码表单和次级企微入口;不再默认展示企微配置状态和大段说明。","命令:cd client && npm run typecheck && npm run build。业务:PC 打开 /login 可账号密码登录;企业微信场景点击主按钮进入企微 OAuth;未绑定/失败时错误提示能指导下一步。","manual|tsc|vite","DONE","DONE","DONE","client/src/layouts/AuthLayout.tsx|client/src/pages/LoginPage.tsx|client/src/lib/wecomEntry.ts","[""A1""]","回滚路径:还原 AuthLayout/LoginPage 改动;风险低,仅影响入口文案与布局。2026-05-05:登录页已极简化,桌面显示账号密码+企微次级入口,企微 WebView 显示单一进入工作台主按钮;typecheck/build 与 focused wecom E2E 通过。" "A3","企业微信与手机默认进入移动工作台","将手机/企业微信登录后的默认落点从桌面 Portal 调整为 /mobile;PC 仍默认 /portal;保留 redirect 参数的安全校验。","企业微信应用主页或登录按钮默认 returnTo=/mobile;手机访问 /portal 时可引导到 /mobile;PC 登录不受影响。","命令:cd client && npm run typecheck && npm run build。业务:手机企微进入后落在移动首页;PC 浏览器登录后仍进入 /portal;手工确认企微后台应用主页需改为 https://oa.hyinda.com/api/auth/wecom/login?returnTo=%2Fmobile。","manual|tsc|vite","DONE","DONE","DONE","client/src/pages/LoginPage.tsx|client/src/router/guards.tsx|client/src/layouts/AppShellLayout.tsx|client/src/lib/runtimeContext.ts","[""A2""]","回滚路径:恢复默认 returnTo=/portal;注意需同步企业微信后台应用主页。2026-05-05:新增移动工作台偏好判断;企微/小屏默认 /mobile,PC 默认 /portal;Playwright 辅助检查确认企微登录 URL 使用 returnTo=/mobile。" -"A4","移动端导航可靠性修复","修复手机端菜单点不开和桌面菜单压缩到手机的问题;移动端优先使用底部导航和更多页,普通桌面菜单抽屉只作为兜底。","/mobile 下不依赖桌面侧边菜单;底部导航点击稳定;我的/更多页能进入低频模块;非 /mobile 小屏页面的菜单按钮可打开且可关闭。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下依次点击首页、审批、报销或票据、我的/更多,均能跳转;菜单抽屉不被遮罩或安全区挡住。","manual|tsc|vite","TODO","TODO","TODO","client/src/layouts/AppShellLayout.tsx|client/src/components/navigation/Topbar.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/features/mobile/navigation.ts|client/src/pages/MobileMePage.tsx|client/src/router/index.tsx","[""A3""]","回滚路径:恢复原 Topbar/Dialog 与 MobileBottomNav;风险中等,涉及全局壳布局。" -"A5","移动工作台信息架构重排","把移动首页改为今日任务优先:待审批、待补票/待提交报销、最近通知、快捷动作;弱化桌面 Portal 的模块展示方式。","移动首页首屏能回答今天要处理什么;快捷入口包含发起报销、上传发票、审批待办;管理员入口不出现在普通员工首屏。","命令:cd client && npm run typecheck && npm run build。业务:员工登录 /mobile 后 3 秒内能看到待办与快捷操作;经理能看到审批待办;无权限入口不展示或降级。","manual|tsc|vite","TODO","TODO","TODO","client/src/pages/MobilePortalPage.tsx|client/src/api/portal.ts|client/src/api/workflow.ts|client/src/api/invoicePool.ts","[""A4""]","回滚路径:恢复 MobilePortalPage 原布局;优先复用现有 API,避免新增后端接口。" -"A6","移动端核心流程触控优化","对审批、发票上传、我的页面做小屏触控收口,避免按钮太小、卡片过密、操作入口被底部导航遮挡。","审批详情主操作在手机上可见可点;发票拍照/上传入口清晰;我的页能找到个人设置、绑定企业微信、退出登录和更多模块。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下完成审批查看→同意/拒绝入口可达;进入票据上传;进入个人设置和退出登录。","manual|tsc|vite","TODO","TODO","TODO","client/src/pages/MobileApprovalsPage.tsx|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileMePage.tsx|client/src/components/mobile/MobileBottomNav.tsx","[""A5""]","回滚路径:恢复相关页面布局;不改变审批 API 和业务状态机。" -"A7","移动端 Playwright 回归补齐","新增或扩展 Playwright 移动视口回归,覆盖登录页极简、移动导航、移动首页、审批入口、发票上传入口和我的页。","CI/本地可复现移动端关键路径;至少覆盖 iPhone/Android 尺寸各一组;报告回填 manual trialrun artifacts。","命令:cd client && npm run test:e2e -- --grep mobile 或指定新增 spec;cd client && npm run typecheck && npm run build。业务:自动化能发现菜单点不开、底部导航不可达、登录页主要入口缺失等问题。","playwright|tsc|vite","TODO","TODO","TODO","client/tests/e2e/*mobile*.spec.ts|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md","[""A6""]","回滚路径:移除新增测试与报告回填;若线上企微 OAuth 不能全自动完成,则只自动验证跳转 URL 与本地登录后移动页。" +"A4","移动端导航可靠性修复","修复手机端菜单点不开和桌面菜单压缩到手机的问题;移动端优先使用底部导航和更多页,普通桌面菜单抽屉只作为兜底。","/mobile 下不依赖桌面侧边菜单;底部导航点击稳定;我的/更多页能进入低频模块;非 /mobile 小屏页面的菜单按钮可打开且可关闭。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下依次点击首页、审批、报销或票据、我的/更多,均能跳转;菜单抽屉不被遮罩或安全区挡住。","manual|tsc|vite","DONE","DONE","DONE","client/src/layouts/AppShellLayout.tsx|client/src/components/navigation/Topbar.tsx|client/src/components/mobile/MobileBottomNav.tsx|client/src/features/mobile/navigation.ts|client/src/pages/MobileMePage.tsx|client/src/router/index.tsx","[""A3""]","回滚路径:恢复原 Topbar/Dialog 与 MobileBottomNav;风险中等,涉及全局壳布局。2026-05-05:/mobile 手机端隐藏桌面 Topbar,底部导航补测试标识;Topbar DialogContent 固定左侧抽屉定位;Playwright Pixel 5 小屏非移动路由菜单打开/关闭通过。" +"A5","移动工作台信息架构重排","把移动首页改为今日任务优先:待审批、待补票/待提交报销、最近通知、快捷动作;弱化桌面 Portal 的模块展示方式。","移动首页首屏能回答今天要处理什么;快捷入口包含发起报销、上传发票、审批待办;管理员入口不出现在普通员工首屏。","命令:cd client && npm run typecheck && npm run build。业务:员工登录 /mobile 后 3 秒内能看到待办与快捷操作;经理能看到审批待办;无权限入口不展示或降级。","manual|tsc|vite","DONE","DONE","DONE","client/src/pages/MobilePortalPage.tsx|client/src/api/portal.ts|client/src/api/workflow.ts|client/src/api/invoicePool.ts","[""A4""]","回滚路径:恢复 MobilePortalPage 原布局;优先复用现有 API,避免新增后端接口。2026-05-05:移动首页首屏新增今日重点、审批待办、未读通知和发起报销/上传票据/处理审批入口;低频入口下沉到我的页;iPhone Playwright 路径通过。" +"A6","移动端核心流程触控优化","对审批、发票上传、我的页面做小屏触控收口,避免按钮太小、卡片过密、操作入口被底部导航遮挡。","审批详情主操作在手机上可见可点;发票拍照/上传入口清晰;我的页能找到个人设置、绑定企业微信、退出登录和更多模块。","命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下完成审批查看→同意/拒绝入口可达;进入票据上传;进入个人设置和退出登录。","manual|tsc|vite","DONE","DONE","DONE","client/src/pages/MobileApprovalsPage.tsx|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileMePage.tsx|client/src/components/mobile/MobileBottomNav.tsx","[""A5""]","回滚路径:恢复相关页面布局;不改变审批 API 和业务状态机。2026-05-05:审批任务点选后手机端滚到详情,审批/驳回/退回按钮改为大触控目标;票据上传改为大选择区和全宽主按钮;我的页补个人设置、档案、更多入口和退出登录。" +"A7","移动端 Playwright 回归补齐","新增或扩展 Playwright 移动视口回归,覆盖登录页极简、移动导航、移动首页、审批入口、发票上传入口和我的页。","CI/本地可复现移动端关键路径;至少覆盖 iPhone/Android 尺寸各一组;报告回填 manual trialrun artifacts。","命令:cd client && npm run test:e2e -- --grep mobile 或指定新增 spec;cd client && npm run typecheck && npm run build。业务:自动化能发现菜单点不开、底部导航不可达、登录页主要入口缺失等问题。","playwright|tsc|vite","DONE","DONE","DONE","client/tests/e2e/*mobile*.spec.ts|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md","[""A6""]","回滚路径:移除新增测试与报告回填;若线上企微 OAuth 不能全自动完成,则只自动验证跳转 URL 与本地登录后移动页。2026-05-05:新增 client/tests/e2e/r89-mobile-login-navigation.spec.ts,覆盖企业微信移动入口、iPhone 移动核心路径、Pixel 5 小屏菜单兜底;3 passed;typecheck/build 通过,build 仍有既有 circular chunk 警告。"