R89 A6-A7 移动触控与回归补齐

这个提交包含在:
2026-05-05 19:52:04 +08:00
父节点 ee64909811
当前提交 99ad4c6aa6
共修改 6 个文件,包含 546 行新增和 31 行删除
@@ -142,6 +142,20 @@ Codex 可代做:
2. 用真实手机企业微信打开 OA 应用一次,确认真实 OAuth 回调、cookie 与企业身份都正常。 2. 用真实手机企业微信打开 OA 应用一次,确认真实 OAuth 回调、cookie 与企业身份都正常。
3. 若仍失败,记录 `requestId`、错误截图和时间点给我,我继续抓线上日志定位。 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 与移动工作台落点。
## 员工路径 ## 员工路径
账号:员工测试账号。 账号:员工测试账号。
+21
查看文件
@@ -230,3 +230,24 @@ R70 初次试运行暂停项已在 2026-04-28 的 R71 定向修复后关闭。
- 企业微信未绑定 -> 回退密码登录 -> 继续进入目标页 - 企业微信未绑定 -> 回退密码登录 -> 继续进入目标页
- 仍需真实人工复核: - 仍需真实人工复核:
- 将本轮代码部署到目标环境后,用真实手机企业微信完成 1 次首次进入,确认真实 OAuth 回调、cookie 与企业身份环境下也能自动完成首次绑定闭环。 - 将本轮代码部署到目标环境后,用真实手机企业微信完成 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` 落点。
+40 -12
查看文件
@@ -230,6 +230,7 @@ export function MobileApprovalsPage() {
const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false); const [offlineDraftSubmitting, setOfflineDraftSubmitting] = useState(false);
const [isOnline, setIsOnline] = useState(true); const [isOnline, setIsOnline] = useState(true);
const autoSyncRequestedRef = useRef(false); const autoSyncRequestedRef = useRef(false);
const detailSectionRef = useRef<HTMLElement | null>(null);
const ownerEmployeeId = me?.id ?? null; const ownerEmployeeId = me?.id ?? null;
const canUseRemote = isOnline && Boolean(accessToken); const canUseRemote = isOnline && Boolean(accessToken);
@@ -258,6 +259,21 @@ export function MobileApprovalsPage() {
setSearchParams(nextParams, { replace: true }); 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) { async function invalidateWorkflowQueries(taskId: string | null = selectedTaskId) {
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }), queryClient.invalidateQueries({ queryKey: ["mobile", "workflow", "tasks"] }),
@@ -781,11 +797,12 @@ export function MobileApprovalsPage() {
/> />
{taskTab === "pending" ? ( {taskTab === "pending" ? (
<div className="flex flex-wrap items-center gap-2"> <div className="grid gap-2 sm:flex sm:flex-wrap sm:items-center">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant={batchMode ? "default" : "secondary"} variant={batchMode ? "default" : "secondary"}
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length} disabled={!filteredTaskCards.length}
onClick={() => { onClick={() => {
setBatchMode((current) => !current); setBatchMode((current) => !current);
@@ -800,13 +817,14 @@ export function MobileApprovalsPage() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!filteredTaskCards.length} disabled={!filteredTaskCards.length}
onClick={toggleSelectAllVisibleTasks} onClick={toggleSelectAllVisibleTasks}
> >
{allVisibleSelected ? "清空选择" : "全选当前筛选结果"} {allVisibleSelected ? "清空选择" : "全选当前筛选结果"}
</Button> </Button>
) : null} ) : null}
<span className="text-sm text-zinc-500"> <span className="text-sm text-zinc-500 sm:ml-1">
当前结果 {filteredTaskCards.length} 条 当前结果 {filteredTaskCards.length} 条
{batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""} {batchMode ? `,已选 ${selectedBatchTaskIds.length} 条` : ""}
</span> </span>
@@ -820,10 +838,11 @@ export function MobileApprovalsPage() {
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm"> <div className="rounded-2xl border border-zinc-200 bg-zinc-50 p-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div> <div className="text-zinc-700">已选择 {selectedBatchTaskIds.length} 条待办,可在线串行处理或离线逐条入队。</div>
<div className="flex flex-wrap gap-2"> <div className="grid w-full gap-2 sm:flex sm:w-auto sm:flex-wrap">
<Button <Button
type="button" type="button"
size="sm" size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending} disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending}
onClick={() => handleBatchAction("approve")} onClick={() => handleBatchAction("approve")}
> >
@@ -833,6 +852,7 @@ export function MobileApprovalsPage() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()} disabled={!selectedBatchTaskIds.length || batchActionMutation.isPending || !batchRejectComment.trim()}
onClick={() => handleBatchAction("reject")} onClick={() => handleBatchAction("reject")}
> >
@@ -899,7 +919,7 @@ export function MobileApprovalsPage() {
/> />
<button <button
type="button" type="button"
className="min-w-0 flex-1 text-left" className="min-h-16 min-w-0 flex-1 text-left"
data-testid={`mobile-approvals-task-${task.id}`} data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => toggleBatchTask(task.id)} onClick={() => toggleBatchTask(task.id)}
> >
@@ -918,11 +938,11 @@ export function MobileApprovalsPage() {
return ( return (
<button <button
key={task.id} key={task.id}
className={`w-full rounded-xl border px-3 py-3 text-left ${ className={`min-h-20 w-full rounded-xl border px-3 py-3 text-left transition-colors ${
isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white" isSelected ? "border-primary bg-primary-50" : "border-zinc-200 bg-white"
}`} }`}
data-testid={`mobile-approvals-task-${task.id}`} data-testid={`mobile-approvals-task-${task.id}`}
onClick={() => updateTaskSelection(task.id)} onClick={() => handleTaskSelection(task.id)}
type="button" type="button"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -941,7 +961,7 @@ export function MobileApprovalsPage() {
</div> </div>
</section> </section>
<section className="card-std space-y-3 p-4" data-testid="mobile-approvals-detail"> <section ref={detailSectionRef} className="card-std scroll-mt-4 space-y-3 p-4" data-testid="mobile-approvals-detail">
<h3 className="text-base font-semibold text-zinc-900">任务详情与动作</h3> <h3 className="text-base font-semibold text-zinc-900">任务详情与动作</h3>
{!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null} {!selectedTaskId ? <p className="text-sm text-zinc-500">请先选择任务。</p> : null}
@@ -1021,8 +1041,9 @@ export function MobileApprovalsPage() {
<p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p> <p className="text-xs text-zinc-500">离线状态下仅支持通过 / 驳回草稿,退回与转审需联网执行。</p>
) : null} ) : null}
<div className="flex flex-wrap gap-2"> <div className="grid gap-2 sm:grid-cols-3">
<Button <Button
className="min-h-12 w-full"
data-testid="mobile-approvals-approve" data-testid="mobile-approvals-approve"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")} disabled={isActionSubmitting || !selectedTask.allowedActions.includes("approve")}
onClick={() => { onClick={() => {
@@ -1037,6 +1058,7 @@ export function MobileApprovalsPage() {
{canUseRemote ? "通过" : "离线加入通过草稿"} {canUseRemote ? "通过" : "离线加入通过草稿"}
</Button> </Button>
<Button <Button
className="min-h-12 w-full"
data-testid="mobile-approvals-reject" data-testid="mobile-approvals-reject"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")} disabled={isActionSubmitting || !selectedTask.allowedActions.includes("reject")}
onClick={() => { onClick={() => {
@@ -1052,6 +1074,7 @@ export function MobileApprovalsPage() {
{canUseRemote ? "驳回" : "离线加入驳回草稿"} {canUseRemote ? "驳回" : "离线加入驳回草稿"}
</Button> </Button>
<Button <Button
className="min-h-12 w-full"
data-testid="mobile-approvals-return" data-testid="mobile-approvals-return"
disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")} disabled={isActionSubmitting || !canUseRemote || !selectedTask.allowedActions.includes("return")}
onClick={() => actionMutation.mutate("return")} onClick={() => actionMutation.mutate("return")}
@@ -1063,7 +1086,7 @@ export function MobileApprovalsPage() {
</div> </div>
{canTransfer ? ( {canTransfer ? (
<div className="space-y-2 rounded-lg border border-zinc-200 p-3 text-sm"> <div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3 text-sm">
<p className="font-medium text-zinc-800">转审</p> <p className="font-medium text-zinc-800">转审</p>
<select <select
className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm" className="h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm"
@@ -1088,6 +1111,7 @@ export function MobileApprovalsPage() {
/> />
<Button <Button
className="min-h-12 w-full"
data-testid="mobile-approvals-transfer" data-testid="mobile-approvals-transfer"
disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")} disabled={isActionSubmitting || !selectedTask.allowedActions.includes("transfer")}
onClick={() => actionMutation.mutate("transfer")} onClick={() => actionMutation.mutate("transfer")}
@@ -1111,8 +1135,9 @@ export function MobileApprovalsPage() {
</div> </div>
{currentQueueItems.length ? ( {currentQueueItems.length ? (
<div className="flex flex-wrap gap-2"> <div className="grid gap-2 sm:flex sm:flex-wrap">
<Button <Button
className="min-h-11 w-full sm:w-auto"
data-testid="mobile-approvals-sync-queue" data-testid="mobile-approvals-sync-queue"
disabled={!canUseRemote || syncMutation.isPending || !retryableQueueItems.length} disabled={!canUseRemote || syncMutation.isPending || !retryableQueueItems.length}
onClick={() => syncMutation.mutate(retryableQueueItems)} onClick={() => syncMutation.mutate(retryableQueueItems)}
@@ -1135,12 +1160,13 @@ export function MobileApprovalsPage() {
<div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div> <div className="text-zinc-500">状态:{queueStatusLabel(item.status)} · 更新时间:{formatDateTime(item.updatedAt)}</div>
<div className="text-zinc-500">意见:{item.comment ?? "-"}</div> <div className="text-zinc-500">意见:{item.comment ?? "-"}</div>
<div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div> <div className="text-zinc-500">最近错误:{item.lastError ?? "-"}</div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 grid gap-2 sm:flex sm:flex-wrap">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => updateTaskSelection(item.taskId)} className="min-h-11 w-full sm:w-auto"
onClick={() => handleTaskSelection(item.taskId)}
> >
查看任务 查看任务
</Button> </Button>
@@ -1149,6 +1175,7 @@ export function MobileApprovalsPage() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="min-h-11 w-full sm:w-auto"
disabled={syncMutation.isPending} disabled={syncMutation.isPending}
onClick={async () => { onClick={async () => {
await updateWorkflowActionDraftStatus({ await updateWorkflowActionDraftStatus({
@@ -1170,6 +1197,7 @@ export function MobileApprovalsPage() {
type="button" type="button"
size="sm" size="sm"
variant="secondary" variant="secondary"
className="min-h-11 w-full sm:w-auto"
onClick={async () => { onClick={async () => {
await removeWorkflowActionDraft(item.taskId, ownerEmployeeId); await removeWorkflowActionDraft(item.taskId, ownerEmployeeId);
await refreshDraftQueueCache(); await refreshDraftQueueCache();
+32 -15
查看文件
@@ -5,6 +5,7 @@ import { uploadInvoice } from "../api/invoicePool";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { Spinner } from "../components/ui/spinner"; import { Spinner } from "../components/ui/spinner";
import { import {
@@ -125,10 +126,10 @@ export function MobileCapturePage() {
}, [queuedItems, syncMutation]); }, [queuedItems, syncMutation]);
return ( return (
<div className="space-y-4"> <PageScaffold className="space-y-4">
<PageHeader <PageHeader
title="移动拍照上传" title="票据上传"
subtitle="支持离线缓存与联网自动重放。" subtitle="拍照或选择发票文件,离线时自动加入待上传队列。"
/> />
{error ? ( {error ? (
@@ -139,18 +140,30 @@ export function MobileCapturePage() {
<p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p> <p className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{message}</p>
) : null} ) : null}
<div className="card-std space-y-3 p-4"> <div className="card-std space-y-4 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">拍照/选择文件</h3> <div>
<span className={isOnline ? "text-emerald-600" : "text-orange-600"}> <h3 className="text-base font-semibold text-zinc-900">选择票据</h3>
<p className="mt-1 text-sm leading-5 text-zinc-500">支持图片和 PDF,手机会优先打开相机。</p>
</div>
<span className={isOnline ? "shrink-0 text-sm text-emerald-600" : "shrink-0 text-sm text-orange-600"}>
{isOnline ? "在线" : "离线"} {isOnline ? "在线" : "离线"}
</span> </span>
</div> </div>
<label
htmlFor="mobile-capture-file"
className="flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-2xl border border-dashed border-zinc-300 bg-zinc-50 px-4 py-6 text-center transition-colors hover:border-zinc-400 hover:bg-white"
>
<span className="material-symbols-outlined text-[32px] text-zinc-700">add_photo_alternate</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选择文件</span>
<span className="mt-1 text-xs leading-5 text-zinc-500">JPG、PNG、PDF 均可上传</span>
</label>
<input <input
id="mobile-capture-file"
accept="image/*,application/pdf" accept="image/*,application/pdf"
capture="environment" capture="environment"
className="w-full text-sm" className="sr-only"
data-testid="mobile-capture-file" data-testid="mobile-capture-file"
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0] ?? null; const file = event.target.files?.[0] ?? null;
@@ -159,12 +172,14 @@ export function MobileCapturePage() {
type="file" type="file"
/> />
<div className="text-sm text-zinc-600"> <div className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-600">
当前文件:{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"} <span className="font-medium text-zinc-800">当前文件:</span>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="grid gap-2 sm:grid-cols-2">
<Button <Button
className="min-h-12 w-full"
data-testid="mobile-capture-upload" data-testid="mobile-capture-upload"
disabled={uploadMutation.isPending || !selectedFile} disabled={uploadMutation.isPending || !selectedFile}
onClick={async () => { onClick={async () => {
@@ -202,6 +217,7 @@ export function MobileCapturePage() {
</Button> </Button>
<Button <Button
className="min-h-12 w-full"
data-testid="mobile-capture-sync-queue" data-testid="mobile-capture-sync-queue"
disabled={!queuedItems.length || syncMutation.isPending || !isOnline} disabled={!queuedItems.length || syncMutation.isPending || !isOnline}
onClick={() => syncMutation.mutate(queuedItems)} onClick={() => syncMutation.mutate(queuedItems)}
@@ -214,9 +230,9 @@ export function MobileCapturePage() {
</div> </div>
<div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue"> <div className="card-std space-y-3 p-4" data-testid="mobile-capture-queue">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3> <h3 className="text-base font-semibold text-zinc-900">离线上传队列</h3>
<span className="text-sm text-zinc-500">{queuedCountLabel}</span> <span className="shrink-0 text-sm text-zinc-500">{queuedCountLabel}</span>
</div> </div>
{queueQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div> : null} {queueQuery.isLoading ? <div className="flex items-center gap-2 text-sm text-zinc-500"><Spinner size="sm" />加载中...</div> : null}
@@ -226,11 +242,12 @@ export function MobileCapturePage() {
) : null} ) : null}
{queuedItems.map((item) => ( {queuedItems.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm"> <div key={item.id} className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm">
<div className="font-medium text-zinc-800">{item.fileName}</div> <div className="font-medium text-zinc-800">{item.fileName}</div>
<div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div> <div className="text-zinc-500">创建时间:{formatTime(item.createdAt)}</div>
<div className="mt-2"> <div className="mt-2">
<Button <Button
className="min-h-11 w-full sm:w-auto"
data-testid={`mobile-capture-remove-${item.id}`} data-testid={`mobile-capture-remove-${item.id}`}
onClick={async () => { onClick={async () => {
await removeQueuedInvoiceUpload(item.id); await removeQueuedInvoiceUpload(item.id);
@@ -246,6 +263,6 @@ export function MobileCapturePage() {
</div> </div>
))} ))}
</div> </div>
</div> </PageScaffold>
); );
} }
@@ -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: "[email protected]",
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("[email protected]");
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();
});
});
@@ -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 校验。" "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 通过。" "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。" "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;风险中等,涉及全局壳布局。" "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","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,避免新增后端接口。" "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","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 和业务状态机。" "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","TODO","TODO","TODO","client/tests/e2e/*mobile*.spec.ts|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md","[""A6""]","回滚路径:移除新增测试与报告回填;若线上企微 OAuth 不能全自动完成,则只自动验证跳转 URL 与本地登录后移动页。" "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 警告。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 校验。
3 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 通过。
4 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。
5 A4 移动端导航可靠性修复 修复手机端菜单点不开和桌面菜单压缩到手机的问题;移动端优先使用底部导航和更多页,普通桌面菜单抽屉只作为兜底。 /mobile 下不依赖桌面侧边菜单;底部导航点击稳定;我的/更多页能进入低频模块;非 /mobile 小屏页面的菜单按钮可打开且可关闭。 命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下依次点击首页、审批、报销或票据、我的/更多,均能跳转;菜单抽屉不被遮罩或安全区挡住。 manual|tsc|vite TODO DONE TODO DONE TODO 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;风险中等,涉及全局壳布局。 回滚路径:恢复原 Topbar/Dialog 与 MobileBottomNav;风险中等,涉及全局壳布局。2026-05-05:/mobile 手机端隐藏桌面 Topbar,底部导航补测试标识;Topbar DialogContent 固定左侧抽屉定位;Playwright Pixel 5 小屏非移动路由菜单打开/关闭通过。
6 A5 移动工作台信息架构重排 把移动首页改为今日任务优先:待审批、待补票/待提交报销、最近通知、快捷动作;弱化桌面 Portal 的模块展示方式。 移动首页首屏能回答今天要处理什么;快捷入口包含发起报销、上传发票、审批待办;管理员入口不出现在普通员工首屏。 命令:cd client && npm run typecheck && npm run build。业务:员工登录 /mobile 后 3 秒内能看到待办与快捷操作;经理能看到审批待办;无权限入口不展示或降级。 manual|tsc|vite TODO DONE TODO DONE TODO DONE client/src/pages/MobilePortalPage.tsx|client/src/api/portal.ts|client/src/api/workflow.ts|client/src/api/invoicePool.ts ["A4"] 回滚路径:恢复 MobilePortalPage 原布局;优先复用现有 API,避免新增后端接口。 回滚路径:恢复 MobilePortalPage 原布局;优先复用现有 API,避免新增后端接口。2026-05-05:移动首页首屏新增今日重点、审批待办、未读通知和发起报销/上传票据/处理审批入口;低频入口下沉到我的页;iPhone Playwright 路径通过。
7 A6 移动端核心流程触控优化 对审批、发票上传、我的页面做小屏触控收口,避免按钮太小、卡片过密、操作入口被底部导航遮挡。 审批详情主操作在手机上可见可点;发票拍照/上传入口清晰;我的页能找到个人设置、绑定企业微信、退出登录和更多模块。 命令:cd client && npm run typecheck && npm run build。业务:手机宽度 390px 下完成审批查看→同意/拒绝入口可达;进入票据上传;进入个人设置和退出登录。 manual|tsc|vite TODO DONE TODO DONE TODO DONE client/src/pages/MobileApprovalsPage.tsx|client/src/pages/MobileCapturePage.tsx|client/src/pages/MobileMePage.tsx|client/src/components/mobile/MobileBottomNav.tsx ["A5"] 回滚路径:恢复相关页面布局;不改变审批 API 和业务状态机。 回滚路径:恢复相关页面布局;不改变审批 API 和业务状态机。2026-05-05:审批任务点选后手机端滚到详情,审批/驳回/退回按钮改为大触控目标;票据上传改为大选择区和全宽主按钮;我的页补个人设置、档案、更多入口和退出登录。
8 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 DONE TODO DONE TODO DONE client/tests/e2e/*mobile*.spec.ts|artifacts/r70/manual-trialrun-checklist.md|artifacts/r70/manual-trialrun-report.md ["A6"] 回滚路径:移除新增测试与报告回填;若线上企微 OAuth 不能全自动完成,则只自动验证跳转 URL 与本地登录后移动页。 回滚路径:移除新增测试与报告回填;若线上企微 OAuth 不能全自动完成,则只自动验证跳转 URL 与本地登录后移动页。2026-05-05:新增 client/tests/e2e/r89-mobile-login-navigation.spec.ts,覆盖企业微信移动入口、iPhone 移动核心路径、Pixel 5 小屏菜单兜底;3 passed;typecheck/build 通过,build 仍有既有 circular chunk 警告。