增加移动报销查看范围(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-31 20:43:33 +08:00
父节点 20b20b100e
当前提交 8b376f4135
共修改 4 个文件,包含 139 行新增和 5 行删除
@@ -127,6 +127,14 @@
- [x] 记录证据最小化 finding:既有 external/runtime smoke 输出仍包含不必要的配置标识和测试管理员联系方式;本任务不复述这些值,后续应独立收敛为状态与计数输出。 - [x] 记录证据最小化 finding:既有 external/runtime smoke 输出仍包含不必要的配置标识和测试管理员联系方式;本任务不复述这些值,后续应独立收敛为状态与计数输出。
- [ ] 请用户在企微自建应用刷新后先用无业务数据 PDF 验证原生预览,再由马建勇查看当前真实报销的发票与附件;材料真实可见前不得完成主管审批。 - [ ] 请用户在企微自建应用刷新后先用无业务数据 PDF 验证原生预览,再由马建勇查看当前真实报销的发票与附件;材料真实可见前不得完成主管审批。
## 6.7 移动报销查看范围对齐
- [x] 在 `MobileReimbursementsPage` 读取 `expense.view_all`,仅对有权用户显示“我的 / 全部”范围切换;复用现有 Tabs/按钮样式,触控高度为 48px。
- [x] 范围默认和无权限回退均为 `own`;切换 `all` 时写入 URL 查询参数并纳入 React Query key,调用现有 `fetchReimbursements({ scope })`,不改后端或扩大权限。
- [x] 列表到详情的 `state.from` 保留当前 status 和 scope;普通员工不渲染“全部”入口,“全部”范围不显示仅限本人草稿的删除动作。
- [x] 增加聚焦 Playwright:管理员默认本人、切换全部后看到他人单据并进入详情;普通员工即使手工带 `scope=all` 也不显示范围切换且请求保持 `scope=own`。
- [x] 运行前端 typecheck/build、聚焦 Playwright、task/governance、流程/文档、敏感扫描和 `git diff --check`;部署另行批准。
## 7. 完成条件 ## 7. 完成条件
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。 - [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
@@ -180,3 +188,4 @@
- 本次发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `410d8cd`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。 - 本次发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `410d8cd`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
- 新增 finding:镜像构建审计为 client 18 个风险(10 high)、server 32 个风险(20 high、2 critical),未执行自动修复;既有部署 smoke 还会输出不必要的配置标识和测试管理员联系方式,后续需分别建立依赖升级回归和证据最小化收敛项。 - 新增 finding:镜像构建审计为 client 18 个风险(10 high)、server 32 个风险(20 high、2 critical),未执行自动修复;既有部署 smoke 还会输出不必要的配置标识和测试管理员联系方式,后续需分别建立依赖升级回归和证据最小化收敛项。
- 外部验收边界:生产已部署但企微真机中的无业务 PDF 原生预览、马建勇对真实发票与附件的可见性仍待人工验收;在材料真实可见前不得完成主管审批。 - 外部验收边界:生产已部署但企微真机中的无业务 PDF 原生预览、马建勇对真实发票与附件的可见性仍待人工验收;在材料真实可见前不得完成主管审批。
- 移动范围对齐:管理员移动报销页新增“我的 / 全部”,默认 `own`,选择“全部”后请求现有 `scope=all` 并把 status/scope 带入详情回跳来源;普通员工即使 URL 带 `scope=all` 仍请求 `own` 且看不到切换。首轮 Playwright 因未显式指定本地 Vite 地址而在登录前连接失败;指定 `E2E_BASE_URL` 后功能执行,首次严格 44px 断言测得浏览器子像素值略低于 44,控件提升为 48px 后最终聚焦 2/2、同文件完整回归 5/5 通过。375px 竖屏和 667×375 横屏无横向溢出,typecheck/build 通过。
@@ -41,6 +41,7 @@
- PC 与移动报销详情应按 KISS 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。 - PC 与移动报销详情应按 KISS 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。
- 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。 - 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。 - 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。 - 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
## Scope ## Scope
@@ -74,6 +75,7 @@
- [x] 已登录页面从后台恢复到前台且前后端构建不一致时显示明确刷新提示;构建一致、检查失败或开发构建时不误提示,刷新动作复用缓存清理后重载能力。 - [x] 已登录页面从后台恢复到前台且前后端构建不一致时显示明确刷新提示;构建一致、检查失败或开发构建时不误提示,刷新动作复用缓存清理后重载能力。
- [x] 移动企微报销详情中的发票和 PDF/图片附件均提供“查看原件”,通过官方 JS-SDK 原生预览打开;调用参数包含准确 `size` 和带扩展名的 `name`,不依赖异步弹窗或 WebView 原生 PDF 渲染。 - [x] 移动企微报销详情中的发票和 PDF/图片附件均提供“查看原件”,通过官方 JS-SDK 原生预览打开;调用参数包含准确 `size` 和带扩展名的 `name`,不依赖异步弹窗或 WebView 原生 PDF 渲染。
- [x] PC 报销详情中的发票和附件提供对应的“查看原件”,使用短时签名 URL 或现有认证下载能力;两端审批人均只能查看当前报销绑定材料,不能编辑、解绑、删除或获得票据池管理权限。 - [x] PC 报销详情中的发票和附件提供对应的“查看原件”,使用短时签名 URL 或现有认证下载能力;两端审批人均只能查看当前报销绑定材料,不能编辑、解绑、删除或获得票据池管理权限。
- [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。 - [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
- [ ] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,真实材料可见前不允许完成主管审批。 - [ ] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,真实材料可见前不允许完成主管审批。
@@ -98,3 +100,4 @@
- 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。 - 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。 - 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。 - 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
+36 -4
查看文件
@@ -160,17 +160,24 @@ export function MobileReimbursementsPage() {
const createIntentHandledRef = useRef(false); const createIntentHandledRef = useRef(false);
const canCreateExpense = hasPermission(permissions, "expense.create"); const canCreateExpense = hasPermission(permissions, "expense.create");
const canDeleteExpense = hasPermission(permissions, "expense.delete_own"); const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
const canViewAllExpense = hasPermission(permissions, "expense.view_all");
const scope: "own" | "all" = canViewAllExpense && searchParams.get("scope") === "all" ? "all" : "own";
const currentMobileSource = useMemo(() => { const currentMobileSource = useMemo(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.delete("intent"); params.delete("intent");
params.set("status", status); params.set("status", status);
if (canViewAllExpense) {
params.set("scope", scope);
} else {
params.delete("scope");
}
const query = params.toString(); const query = params.toString();
return `${location.pathname}${query ? `?${query}` : ""}`; return `${location.pathname}${query ? `?${query}` : ""}`;
}, [location.pathname, location.search, status]); }, [canViewAllExpense, location.pathname, location.search, scope, status]);
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["mobile", "reimbursements", status], queryKey: ["mobile", "reimbursements", status, scope],
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope: "own" }), queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope }),
}); });
const createMutation = useMutation({ const createMutation = useMutation({
@@ -203,6 +210,16 @@ export function MobileReimbursementsPage() {
setSearchParams(nextParams, { replace: true }); setSearchParams(nextParams, { replace: true });
} }
function handleScopeChange(value: string) {
if (!canViewAllExpense || (value !== "own" && value !== "all")) {
return;
}
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("intent");
nextParams.set("scope", value);
setSearchParams(nextParams, { replace: true });
}
useEffect(() => { useEffect(() => {
if (searchParams.get("intent") !== "create" || !canCreateExpense || createIntentHandledRef.current) { if (searchParams.get("intent") !== "create" || !canCreateExpense || createIntentHandledRef.current) {
return; return;
@@ -293,6 +310,21 @@ export function MobileReimbursementsPage() {
</div> </div>
</div> </div>
<div className="mobile-command-surface"> <div className="mobile-command-surface">
{canViewAllExpense ? (
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">查看范围</div>
<Tabs value={scope} onValueChange={handleScopeChange}>
<TabsList className="grid h-auto min-h-12 w-full grid-cols-2" aria-label="报销查看范围">
<TabsTrigger className="min-h-12" data-testid="mobile-reimbursements-scope-own" value="own">
我的
</TabsTrigger>
<TabsTrigger className="min-h-12" data-testid="mobile-reimbursements-scope-all" value="all">
全部
</TabsTrigger>
</TabsList>
</Tabs>
</div>
) : null}
<label className="text-xs font-medium text-zinc-500" htmlFor="mobile-reimbursements-current-search"> <label className="text-xs font-medium text-zinc-500" htmlFor="mobile-reimbursements-current-search">
当前列表搜索 当前列表搜索
</label> </label>
@@ -326,7 +358,7 @@ export function MobileReimbursementsPage() {
) : null} ) : null}
{visibleItems.map((item) => ( {visibleItems.map((item) => (
<ReimbursementCard <ReimbursementCard
canDeleteDraft={canDeleteExpense && item.status === "draft"} canDeleteDraft={canDeleteExpense && scope === "own" && item.status === "draft"}
currentMobileSource={currentMobileSource} currentMobileSource={currentMobileSource}
isDeleting={deleteMutation.isPending} isDeleting={deleteMutation.isPending}
item={item} item={item}
@@ -371,15 +371,105 @@ test.describe("R102 reimbursement main flow", () => {
test("移动报销卡片显示业务编号", async ({ page }) => { test("移动报销卡片显示业务编号", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/reimbursements"); const listRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return request.method() === "GET" && url.pathname === "/api/reimbursements";
});
await page.goto("/mobile/reimbursements?scope=all");
const requestedUrl = new URL((await listRequest).url());
expect(requestedUrl.searchParams.get("scope")).toBe("own");
await expect(page.getByRole("heading", { name: "移动报销", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "移动报销", exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible(); await expect(page.getByTestId("mobile-reimbursement-card")).toBeVisible();
await expect(page.getByText(formNumber)).toBeVisible(); await expect(page.getByText(formNumber)).toBeVisible();
await expect(page.getByText(reimbursementId)).toHaveCount(0); await expect(page.getByText(reimbursementId)).toHaveCount(0);
await expect(page.locator("table:visible")).toHaveCount(0); await expect(page.locator("table:visible")).toHaveCount(0);
}); });
test("移动管理员默认查看本人并可切换全部报销", async ({ page }) => {
const adminMe = {
...me,
id: "admin-r102",
employeeNo: "A102",
name: "测试管理员",
roleCodes: ["admin"],
};
const otherReimbursementId = "44444444-4444-4444-8444-444444444444";
const requestedScopes: Array<string | null> = [];
await page.route("**/api/me", async (route) => {
await fulfillJson(route, adminMe);
});
await page.route((url) => url.pathname === "/api/reimbursements", async (route) => {
const request = route.request();
if (request.method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(request.url());
const scope = url.searchParams.get("scope");
requestedScopes.push(scope);
const ownItem = {
...reimbursementListItem(),
status: "reviewing",
owner: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
submitter: { id: adminMe.id, employeeNo: adminMe.employeeNo, name: adminMe.name },
};
const otherItem = {
...ownItem,
id: otherReimbursementId,
formNumber: "BX-20260731-0002",
owner: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
submitter: { id: "employee-other-r102", employeeNo: "E103", name: "其他员工" },
};
const items = scope === "all" ? [ownItem, otherItem] : [ownItem];
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: scope ?? "own", items });
});
await page.reload();
await expect(page).toHaveURL(/\/portal/);
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/mobile/reimbursements?status=reviewing");
await expect(page.getByTestId("mobile-reimbursements-scope-own")).toHaveAttribute("data-state", "active");
await expect(page.getByText("其他员工")).toHaveCount(0);
expect(requestedScopes.at(-1)).toBe("own");
const ownBounds = await page.getByTestId("mobile-reimbursements-scope-own").boundingBox();
const allBounds = await page.getByTestId("mobile-reimbursements-scope-all").boundingBox();
expect(ownBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(allBounds?.height ?? 0).toBeGreaterThanOrEqual(44);
const allResponse = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.ok() && url.pathname === "/api/reimbursements" && url.searchParams.get("scope") === "all";
});
await page.getByTestId("mobile-reimbursements-scope-all").click();
await allResponse;
await expect(page).toHaveURL(/scope=all/);
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toHaveAttribute("data-state", "active");
await expect(page.getByText("其他员工")).toBeVisible();
expect(requestedScopes.at(-1)).toBe("all");
await expect(page.getByTestId("mobile-reimbursement-delete")).toHaveCount(0);
await page.getByText("其他员工").click();
await expect(page).toHaveURL(new RegExp(`/mobile/reimbursements/${otherReimbursementId}$`));
const routeState = await page.evaluate(() => window.history.state?.usr ?? null);
expect(routeState?.from).toContain("status=reviewing");
expect(routeState?.from).toContain("scope=all");
await page.goBack();
await page.setViewportSize({ width: 667, height: 375 });
await expect(page.getByTestId("mobile-reimbursements-scope-all")).toBeVisible();
const horizontalOverflow = await page.evaluate(
() => document.documentElement.scrollWidth - document.documentElement.clientWidth,
);
expect(horizontalOverflow).toBeLessThanOrEqual(2);
});
test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => { test("报销明细记录切换时支持取消保持与确认放弃", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/reimbursements/${reimbursementId}`); await page.goto(`/reimbursements/${reimbursementId}`);