R92 A5-A6 修复手机报销列表并补自动化回归

这个提交包含在:
2026-05-05 22:16:48 +08:00
父节点 9d8c7e0478
当前提交 5cd52ee061
共修改 3 个文件,包含 434 行新增和 1 行删除
+86 -1
查看文件
@@ -587,7 +587,92 @@ export function ReimbursementsPage() {
{!listQuery.isLoading && !listQuery.isError ? (
<>
<PrimaryTable>
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
{items.length ? (
items.map((item) => {
const canSelectRow = Boolean(
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
);
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
return (
<article
className="card-std space-y-3 p-4"
data-testid="reimbursement-mobile-card"
key={item.id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">
{item.formNumber || item.id}
</div>
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
</div>
<StatusBadge className="shrink-0" status={item.status} />
</div>
{item.project ? (
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
项目:{item.project.projectNo} · {item.project.name}
</div>
) : null}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-xs text-zinc-500">金额</div>
<div className="font-semibold text-zinc-900">
{item.totalAmount != null
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
Number(item.totalAmount),
)
: "-"}
</div>
</div>
<div>
<div className="text-xs text-zinc-500">报销人</div>
<div className="truncate font-medium text-zinc-900">{item.owner?.name ?? "-"}</div>
</div>
</div>
{item.pendingWorkflow?.task?.id ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
当前待办:{item.pendingWorkflow.task.stepName}
</div>
) : null}
<div className="flex items-center gap-2 pt-1">
{batchMode ? (
<label className="flex min-h-10 items-center gap-2 rounded-lg border border-zinc-200 px-3 text-sm text-zinc-700">
<input
type="checkbox"
checked={selectedReimbursementIds.includes(item.id)}
disabled={!canSelectRow}
onChange={() => toggleBatchSelection(item.id)}
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
/>
选择
</label>
) : null}
<Button asChild className="min-h-10 flex-1" size="sm" variant="secondary">
<Link
to={`/reimbursements/${item.id}`}
data-testid="reimbursement-mobile-detail-link"
>
{actionLabel}
</Link>
</Button>
</div>
</article>
);
})
) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
暂无数据
</div>
)}
</div>
<PrimaryTable className="hidden md:block">
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
<thead className="table-header">
<tr>
@@ -0,0 +1,341 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { ADMIN } from "./helpers/auth";
import { TEST_IDS } from "./helpers/testIds";
function envelope(data: unknown) {
return { requestId: "r92-mock", data };
}
async function fulfillJson(route: Route, data: unknown) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
async function mockAuthenticatedShell(page: Page) {
await page.route((url) => url.pathname === "/api/auth/sso/providers", (route) => fulfillJson(route, []));
await page.route((url) => url.pathname === "/api/auth/wecom/config", (route) =>
fulfillJson(route, {
enabled: false,
entryEnabled: false,
oauthEnabled: false,
corpId: "",
agentId: "",
loginUrl: null,
bindEnabled: false,
defaultReturnTo: "/mobile",
status: "disabled",
statusReasons: ["disabled"],
}),
);
await page.route((url) => url.pathname === "/api/auth/login", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({
accessToken: "r92-access-token",
expiresIn: 900,
mustChangePassword: false,
})),
});
});
await page.route((url) => url.pathname === "/api/auth/refresh", (route) =>
fulfillJson(route, {
accessToken: "r92-access-token",
expiresIn: 900,
mustChangePassword: false,
}),
);
await page.route((url) => url.pathname === "/api/me", (route) =>
fulfillJson(route, {
id: "employee-mock-1",
employeeNo: "E001",
name: "Smoke Employee",
phone: "13800000000",
email: "[email protected]",
departmentId: "dept-1",
directManagerId: null,
status: "active",
notificationPushEnabled: true,
roleCodes: ["admin"],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 50,
},
wecomBinding: null,
}),
);
await page.route((url) => url.pathname === "/api/system/navigation", (route) =>
fulfillJson(route, {
workspace: {
id: "workspace-1",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "mobile",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: {
main: [
{ key: "portal", href: "/portal", label: "Portal", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "mobile", href: "/mobile", label: "移动工作台", icon: "phone_iphone", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "expense", href: "/reimbursements", label: "我的报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null },
{ key: "invoice", href: "/invoice-pool", label: "票据中心", icon: "receipt_long", placement: "main", permissionCodes: [], featureFlagKey: null },
],
admin: [],
backup: [],
},
recommendedRolePresets: [],
}),
);
await page.route((url) => url.pathname === "/api/notifications", (route) =>
fulfillJson(route, { page: 1, pageSize: 20, total: 0, unreadOnly: true, unreadCount: 0, items: [] }),
);
await page.route((url) => url.pathname === "/api/portal/home", (route) =>
fulfillJson(route, {
workspace: { id: "workspace-1", persisted: true, profile: "small_team_internal" },
greeting: { name: "Smoke Employee" },
cards: [],
pendingTasks: { count: 0, href: "/todos" },
myApplications: [],
riskSummary: null,
announcements: [],
quickLinks: [],
todayPresence: null,
teamPresence: null,
weeklyReport: null,
weeklySummary: null,
todoSummary: null,
projectSummary: null,
peopleLifecycle: null,
adoptionSummary: null,
recentActivities: [],
teamStatusBoard: [],
}),
);
}
function reimbursementListItem() {
return {
id: "19439274-66ff-4301-964d-fcb43c304454",
formNumber: null,
status: "draft",
reimbursementDate: "2026-05-05",
totalAmount: "0.00",
description: null,
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
submitter: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
project: null,
pendingWorkflow: null,
createdAt: null,
updatedAt: null,
};
}
async function mockReimbursements(page: Page) {
await page.route((url) => url.pathname === "/api/reimbursement-templates", (route) =>
fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] }),
);
await page.route((url) => url.pathname === "/api/reimbursements/linkable-travel-requests", (route) =>
fulfillJson(route, { items: [] }),
);
await page.route((url) => url.pathname === "/api/reimbursements", (route) =>
fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
scope: "own",
items: [reimbursementListItem()],
}),
);
}
const invoiceListItem = {
id: "invoice-pending-1",
status: "pending_ocr",
ocrType: "vat",
ocrProvider: "mock",
ocrAttempts: 0,
ocrLastError: null,
ocrCompletedAt: null,
invoiceCode: null,
invoiceNumber: null,
invoiceDate: null,
amount: null,
taxAmount: null,
totalAmount: null,
linkedReimbursementId: null,
linkedItemId: null,
linkedTravelOrderId: null,
originalName: "pending.pdf",
mimeType: "application/pdf",
fileSize: 128,
verification: null,
owner: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
uploadedBy: { id: "employee-mock-1", employeeNo: "E001", name: "Smoke Employee" },
createdAt: null,
updatedAt: null,
};
function invoiceDetail(id: string, status: "pending_ocr" | "available" = "available") {
return {
id,
status,
archivedReason: null,
archivedAt: null,
ownerEmployeeId: "employee-mock-1",
uploadedByEmployeeId: "employee-mock-1",
originalName: `${id}.pdf`,
mimeType: "application/pdf",
fileSize: 128,
fileSha256: `sha-${id}`,
remarks: null,
ocrType: "vat",
ocrProvider: "mock",
ocrAttempts: status === "pending_ocr" ? 0 : 1,
ocrLastRequestedAt: null,
ocrLastError: null,
ocrCompletedAt: status === "pending_ocr" ? null : "2026-05-05T13:00:00.000Z",
invoiceCode: status === "available" ? "MOCKCODE" : null,
invoiceNumber: status === "available" ? "MOCKNO" : null,
invoiceDate: status === "available" ? "2026-05-05" : null,
sellerName: status === "available" ? "Mock Seller" : null,
buyerName: status === "available" ? "Mock Buyer" : null,
amount: status === "available" ? "100.00" : null,
taxAmount: status === "available" ? "10.00" : null,
totalAmount: status === "available" ? "110.00" : null,
linkedReimbursementId: null,
linkedItemId: null,
linkedTravelOrderId: null,
verification: null,
isDuplicateOverride: false,
overrideRefFormNumber: null,
overrideReason: null,
overriddenBy: null,
createdAt: null,
updatedAt: null,
};
}
async function mockInvoicePool(page: Page, uploadCounter: { value: number }) {
await page.route((url) => url.pathname === "/api/travel-requests", (route) =>
fulfillJson(route, { page: 1, pageSize: 50, total: 0, items: [] }),
);
await page.route((url) => url.pathname.startsWith("/api/invoice-pool"), (route) => {
const pathname = new URL(route.request().url()).pathname;
if (route.request().method() === "POST" && pathname === "/api/invoice-pool/upload") {
uploadCounter.value += 1;
return fulfillJson(route, {
invoicePoolId: `invoice-upload-${uploadCounter.value}`,
status: uploadCounter.value === 1 ? "pending_ocr" : "available",
fileSha256: `sha-upload-${uploadCounter.value}`,
});
}
if (pathname.endsWith("/verification")) {
return fulfillJson(route, { invoiceId: "invoice-pending-1", items: [] });
}
if (pathname === "/api/invoice-pool") {
return fulfillJson(route, {
page: 1,
pageSize: 20,
total: 1,
ownerScope: "own",
items: [invoiceListItem],
});
}
const id = pathname.split("/").at(-1) ?? "invoice-pending-1";
return fulfillJson(
route,
invoiceDetail(id, id === "invoice-pending-1" || id === "invoice-upload-1" ? "pending_ocr" : "available"),
);
});
}
async function loginThenUseMobileViewport(page: Page) {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/login");
await page.getByTestId(TEST_IDS.login.identifier).fill(ADMIN.identifier);
await page.getByTestId(TEST_IDS.login.password).fill(ADMIN.password);
await page.getByTestId(TEST_IDS.login.submit).click();
await expect(page).toHaveURL(/\/portal/);
await page.setViewportSize({ width: 390, height: 844 });
}
test.describe("R92 手机端票据与报销可用性", () => {
test.beforeEach(async ({ page }) => {
await mockAuthenticatedShell(page);
await mockReimbursements(page);
});
test("报销草稿在手机视口使用卡片并暴露继续编辑入口", async ({ page }) => {
await loginThenUseMobileViewport(page);
await page.goto("/reimbursements?status=draft&scope=own");
await expect(page.getByTestId("reimbursement-mobile-card")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeHidden();
const editLink = page.getByTestId("reimbursement-mobile-detail-link");
await expect(editLink).toHaveText("继续编辑");
const box = await editLink.boundingBox();
expect(box).not.toBeNull();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390);
});
test("票据中心手机视口可批量选择并顺序上传", async ({ page }) => {
const uploadCounter = { value: 0 };
await mockInvoicePool(page, uploadCounter);
await loginThenUseMobileViewport(page);
await page.goto("/invoice-pool");
await expect(page.getByTestId("invoice-mobile-row")).toBeVisible();
await expect(page.getByTestId("invoice-pool-table")).toBeHidden();
await page.getByTestId("invoice-upload-file").setInputFiles([
{ name: "invoice-a.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-a") },
{ name: "invoice-b.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-b") },
]);
await expect(page.getByText("已选择 2 个文件")).toBeVisible();
await page.getByTestId("invoice-upload-submit").click();
await expect.poll(() => uploadCounter.value).toBe(2);
});
test("手机票据上传页支持一次选择多张并逐张上传", async ({ page }) => {
const uploadCounter = { value: 0 };
await mockInvoicePool(page, uploadCounter);
await loginThenUseMobileViewport(page);
await page.goto("/mobile/capture");
await page.getByTestId("mobile-capture-file").setInputFiles([
{ name: "mobile-a.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-a") },
{ name: "mobile-b.jpg", mimeType: "image/jpeg", buffer: Buffer.from("mobile-b") },
]);
await expect(page.getByText("已选择 2 个")).toBeVisible();
await page.getByTestId("mobile-capture-upload").click();
await expect.poll(() => uploadCounter.value).toBe(2);
});
});
@@ -0,0 +1,7 @@
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
"A1","R92 执行合同冻结","把移动端不可操作和发票上传/OCR 卡住问题收敛为本轮修复范围,不扩展到新业务模块。","Issue CSV 字段完整并通过校验。","命令:`python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv`。业务:确认本轮只处理手机可用性、发票上传/OCR 闭环和自动化回归。","manual","DONE","DONE","DONE","issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv","[]","2026-05-05:CSV 已创建并通过校验;回滚路径:删除本轮 CSV;不影响业务代码。"
"A2","修复 OCR provider 与二进制写库问题","OCR 作业使用当前配置 provider,mock OCR 不把 PDF 二进制原文写入 JSON,避免上传后长期 pending 或失败。","PDF/图片上传后 OCR job 可把 mock 结果稳定写库;ocr_provider 与运行配置一致;失败能进入明确 ocr_failed。","命令:`cd server && npm run prisma:validate && npm test -- tests/unit/jobs/ocrJob.test.js tests/unit/routes/invoicePool.upload.test.js`。业务:上传现有发票 PDF 后状态从 pending_ocr 进入 available 或明确失败。","official-sdk|node","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/20260505211000_r92_ocr_provider_mock/migration.sql|server/src/jobs/ocrJob.js|server/src/services/ocr/mock.js|server/src/routes/invoicePool.js|server/tests/unit/jobs/ocrJob.test.js|server/tests/unit/routes/invoicePool.upload.test.js","[""A1""]","2026-05-05:`prisma validate` 通过,OCR/upload 单测 5 passed;涉及 OCR 任务状态和 Prisma enum 增量迁移;回滚路径:代码恢复后不再写 mock,PostgreSQL enum 新增值保留但不影响旧逻辑;必要时从发布前备份恢复数据库。"
"A3","发票上传状态闭环与失败反馈","上传后列表/详情主动刷新 pending_ocr 状态,ocr_failed 明确提示原因并提供重试或人工修正入口。","用户不会长期只看到上传中或 OCR 处理中;失败原因可见;重试入口可达。","命令:`cd client && npm run typecheck && npm run build`;Playwright 手机视口验证上传后 pending/available/ocr_failed 文案与按钮可见。","playwright|typescript","DONE","DONE","DONE","client/src/pages/InvoicePoolPage.tsx|client/src/pages/invoice-pool/InvoiceDetailPanel.tsx|client/src/pages/invoice-pool/InvoiceListCard.tsx|client/src/lib/invoiceOcr.ts","[""A2""]","2026-05-05:typecheck/build 通过,R92 Playwright 手机回归 3 passed;回滚路径:恢复前端轮询和提示改动。"
"A4","支持批量上传发票","手机上传、票据池上传、报销详情上传支持一次选择多张,按文件展示逐项进度和失败项。","用户可一次选择多张图片/PDF;成功/失败逐项反馈;失败不阻塞其他文件。","命令:`cd client && npm run typecheck && npm run build`;Playwright 验证多文件选择后逐项上传状态。","playwright|typescript","DONE","DONE","DONE","client/src/api/invoicePool.ts|client/src/pages/MobileCapturePage.tsx|client/src/pages/invoice-pool/InvoiceUploadCard.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/ReimbursementDetailPage.tsx","[""A3""]","2026-05-05:票据中心和手机上传页批量上传 Playwright 通过;优先顺序上传,避免并发压垮 OCR;回滚路径:恢复单文件上传逻辑。"
"A5","手机报销列表改为卡片式可操作","小屏下报销列表不再使用横向表格,草稿/驳回单直接显示继续编辑按钮。","390px 手机视口下无横向不可达操作;草稿可直接进入详情继续编辑;列表文字不截断关键操作。","命令:`cd client && npm run typecheck && npm run build`;Playwright 打开 `/reimbursements?status=draft&scope=own` 验证 continue/edit 控件在 viewport 内。","playwright|typescript","DONE","DONE","DONE","client/src/pages/ReimbursementsPage.tsx|client/src/components/patterns/ListWorkspace.tsx","[""A1""]","2026-05-05:R92 Playwright 验证 390px 手机视口下继续编辑入口在 viewport 内;回滚路径:恢复桌面表格渲染。"
"A6","移动端 Playwright 回归与中文提交","用手机视口覆盖报销草稿、票据上传、OCR 状态、票据池详情等路径,修复通过后分批中文提交。","Playwright 产物保存在 output/playwright;本地构建和后端相关测试通过;commit message 中文并引用 R92 Issue ID。","命令:`cd client && npm run test:e2e -- tests/e2e/r92-mobile-invoice-usability.spec.ts --workers=1 --reporter=list`;补充 CLI 截图审查;`git status --short`。","playwright|git|manual","DONE","DONE","DONE","client/tests/e2e|output/playwright|N/A","[""A2"",""A3"",""A4"",""A5""]","2026-05-05:R92 Playwright 手机回归 3 passed;typecheck/build 和后端窄回归通过;输出产物未纳入 git。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R92 执行合同冻结 把移动端不可操作和发票上传/OCR 卡住问题收敛为本轮修复范围,不扩展到新业务模块。 Issue CSV 字段完整并通过校验。 命令:`python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv`。业务:确认本轮只处理手机可用性、发票上传/OCR 闭环和自动化回归。 manual DONE DONE DONE issues/2026-05-05_21-08-47-r92-mobile-invoice-usability.csv [] 2026-05-05:CSV 已创建并通过校验;回滚路径:删除本轮 CSV;不影响业务代码。
3 A2 修复 OCR provider 与二进制写库问题 OCR 作业使用当前配置 provider,mock OCR 不把 PDF 二进制原文写入 JSON,避免上传后长期 pending 或失败。 PDF/图片上传后 OCR job 可把 mock 结果稳定写库;ocr_provider 与运行配置一致;失败能进入明确 ocr_failed。 命令:`cd server && npm run prisma:validate && npm test -- tests/unit/jobs/ocrJob.test.js tests/unit/routes/invoicePool.upload.test.js`。业务:上传现有发票 PDF 后状态从 pending_ocr 进入 available 或明确失败。 official-sdk|node DONE DONE DONE server/prisma/schema.prisma|server/prisma/migrations/20260505211000_r92_ocr_provider_mock/migration.sql|server/src/jobs/ocrJob.js|server/src/services/ocr/mock.js|server/src/routes/invoicePool.js|server/tests/unit/jobs/ocrJob.test.js|server/tests/unit/routes/invoicePool.upload.test.js ["A1"] 2026-05-05:`prisma validate` 通过,OCR/upload 单测 5 passed;涉及 OCR 任务状态和 Prisma enum 增量迁移;回滚路径:代码恢复后不再写 mock,PostgreSQL enum 新增值保留但不影响旧逻辑;必要时从发布前备份恢复数据库。
4 A3 发票上传状态闭环与失败反馈 上传后列表/详情主动刷新 pending_ocr 状态,ocr_failed 明确提示原因并提供重试或人工修正入口。 用户不会长期只看到上传中或 OCR 处理中;失败原因可见;重试入口可达。 命令:`cd client && npm run typecheck && npm run build`;Playwright 手机视口验证上传后 pending/available/ocr_failed 文案与按钮可见。 playwright|typescript DONE DONE DONE client/src/pages/InvoicePoolPage.tsx|client/src/pages/invoice-pool/InvoiceDetailPanel.tsx|client/src/pages/invoice-pool/InvoiceListCard.tsx|client/src/lib/invoiceOcr.ts ["A2"] 2026-05-05:typecheck/build 通过,R92 Playwright 手机回归 3 passed;回滚路径:恢复前端轮询和提示改动。
5 A4 支持批量上传发票 手机上传、票据池上传、报销详情上传支持一次选择多张,按文件展示逐项进度和失败项。 用户可一次选择多张图片/PDF;成功/失败逐项反馈;失败不阻塞其他文件。 命令:`cd client && npm run typecheck && npm run build`;Playwright 验证多文件选择后逐项上传状态。 playwright|typescript DONE DONE DONE client/src/api/invoicePool.ts|client/src/pages/MobileCapturePage.tsx|client/src/pages/invoice-pool/InvoiceUploadCard.tsx|client/src/pages/InvoicePoolPage.tsx|client/src/pages/ReimbursementDetailPage.tsx ["A3"] 2026-05-05:票据中心和手机上传页批量上传 Playwright 通过;优先顺序上传,避免并发压垮 OCR;回滚路径:恢复单文件上传逻辑。
6 A5 手机报销列表改为卡片式可操作 小屏下报销列表不再使用横向表格,草稿/驳回单直接显示继续编辑按钮。 390px 手机视口下无横向不可达操作;草稿可直接进入详情继续编辑;列表文字不截断关键操作。 命令:`cd client && npm run typecheck && npm run build`;Playwright 打开 `/reimbursements?status=draft&scope=own` 验证 continue/edit 控件在 viewport 内。 playwright|typescript DONE DONE DONE client/src/pages/ReimbursementsPage.tsx|client/src/components/patterns/ListWorkspace.tsx ["A1"] 2026-05-05:R92 Playwright 验证 390px 手机视口下继续编辑入口在 viewport 内;回滚路径:恢复桌面表格渲染。
7 A6 移动端 Playwright 回归与中文提交 用手机视口覆盖报销草稿、票据上传、OCR 状态、票据池详情等路径,修复通过后分批中文提交。 Playwright 产物保存在 output/playwright;本地构建和后端相关测试通过;commit message 中文并引用 R92 Issue ID。 命令:`cd client && npm run test:e2e -- tests/e2e/r92-mobile-invoice-usability.spec.ts --workers=1 --reporter=list`;补充 CLI 截图审查;`git status --short`。 playwright|git|manual DONE DONE DONE client/tests/e2e|output/playwright|N/A ["A2","A3","A4","A5"] 2026-05-05:R92 Playwright 手机回归 3 passed;typecheck/build 和后端窄回归通过;输出产物未纳入 git。