增加企微旧包刷新护栏(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-31 17:40:25 +08:00
父节点 39c3e808a3
当前提交 e123d50f86
共修改 9 个文件,包含 308 行新增和 1 行删除
@@ -1764,6 +1764,90 @@ onNeedRefresh() {
}
```
## Scenario: Live WebView Build Freshness
### 1. Scope / Trigger
- Trigger: a production browser, PWA or Enterprise WeChat WebView can remain
alive across deployment and continue running an old SPA bundle even though
public `index.html`, service worker and immutable chunks are current.
- A public bundle containing new copy proves deployment, not that every live
WebView has loaded that bundle.
### 2. Signatures
- Client build: `document.documentElement.dataset.buildSha`, sourced from
`VITE_BUILD_SHA` and normalized to 12 hexadecimal characters.
- Server check: `GET /ready` -> `{ data: { ok: true, buildSha: string } }`.
- Resume event: document `visibilitychange` with `visibilityState="visible"`.
- Prompt event: `hyb-oa:pwa-update-ready` with `{ applyUpdate }`.
### 3. Contracts
- Check only when a loaded page returns to the foreground; do not add a timer
or poll continuously.
- Request `/ready` with `cache: "no-store"`; compare only valid hexadecimal
build identifiers and never display the identifiers to ordinary users.
- A mismatch dispatches the existing update-ready event once per page
lifecycle. The logged-in shell shows `发现新版本` with an explicit
`点击刷新`; it must not auto-reload an approval, form or auth input.
- The refresh handler clears known PWA runtime/precache caches and then reloads
the current URL. Existing auth-page update protection remains in force.
- This monitor does not replace deployment build checks or real-device
verification; it closes the live-client freshness gap after deployment.
- The first deployment of this monitor cannot notify a WebView that is still
executing an older bundle without the monitor. That rollout still requires
an explicit manual refresh instruction; later deployments gain the guard.
### 4. Validation & Error Matrix
- Client and server builds match -> no prompt.
- Valid builds differ -> show one prompt.
- `/ready` is unavailable, non-2xx, malformed or missing `buildSha` -> stay
silent and preserve the current page.
- Client build is `development`, `unknown`, empty or malformed -> stay silent.
- Repeated foreground events after a mismatch -> no duplicate request or
prompt in the same page lifecycle.
### 5. Good/Base/Bad Cases
- Good: a manager returns to an old approval WebView, sees one refresh prompt,
chooses refresh and then receives the deployed approval-detail bundle.
- Base: the deployed and client builds match, so returning to the app adds no
UI or network loop beyond the single readiness check.
- Bad: infer that a missing button is a permission problem when adjacent UI
from the same unconditional JSX block proves an old bundle is running.
- Bad: force-refresh every foreground event or build a general update center.
### 6. Tests Required
- Focused Playwright with synthetic builds covers mismatch, match and failed
`/ready` responses.
- Mismatch coverage asserts one prompt, no duplicate check in the same page,
user-triggered reload, a 44px refresh target and no horizontal overflow at
375px portrait and phone landscape widths.
- Run the full affected login/portal UX file, frontend typecheck and build.
- Production acceptance still requires the representative Enterprise WeChat
WebView to refresh and visibly receive the new business entry.
### 7. Wrong vs Correct
#### Wrong
```ts
setInterval(() => window.location.reload(), 60_000);
```
#### Correct
```ts
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
void checkBuildFreshness();
}
});
```
## Scenario: Auth Page Layout And Login Entry Hierarchy
### 1. Scope / Trigger
@@ -165,3 +165,12 @@
- PC 的明细、发票、附件各保留一个可扫描标题;附件上传限制只在可编辑时出现。关联事项仅在已有项目/差旅关联或可编辑时展示,预算/风险区域仅在存在真实提示时展示。
- 移动端头部已经包含状态,因此概况卡不重复状态;只保留金额、日期、报销人和说明。只读状态不显示编辑教学或空“操作”卡,编辑态仍保留新增、上传、删除和底部提交能力。
- 采用当前设计系统和现有组件,不引入新颜色、字体、动效、布局系统或后端字段;新版 `ui-ux-pro-max` 仅用于内容优先、密度和渐进披露判断。
## 11. 企微 WebView 旧包刷新护栏
- 诊断依据:生产构建的移动审批详情把报销日期和“查看完整报销单”放在同一个 `selectedTask.reimbursement` 条件块;真机截图只出现日期,说明 WebView 仍运行部署前 JS。公网 `/ready` 与当前移动 chunk 均已核对为正确构建,不把问题误判为权限、后端报销关联或列表未进入详情。
- 复用现有构建合同:前端使用 `VITE_BUILD_SHA`,公网 `/ready` 返回 server `buildSha`。页面只在 `visibilitychange` 回到 `visible` 时用 `cache: no-store` 请求 `/ready`,规范化为 12 位后比较;开发/unknown、空值、请求失败和解析失败均静默。
- 构建不一致时继续复用 `hyb-oa:pwa-update-ready` 事件及 `usePwaUpdatePrompt`,在已登录 AppShell 顶部显示一条用户控制的刷新提示;不自动刷新,避免打断审批意见、表单或其他未保存操作。
- 用户点击刷新后复用 `clearPwaRuntimeCaches()` 并重载当前 URL;同一次页面生命周期只提示一次,避免页面多次前后台切换反复提示。
- 护栏只能保护已经加载过本次新代码的页面,不能反向唤醒当前仍运行 `d2841b1` 之前旧包的 WebView;首次部署本护栏时仍需主动通知马建勇手工刷新,后续部署才由版本比对接管。
- 不新增后端 API、数据库字段、定时轮询、更新中心或强制升级状态;登录/改密页面继续沿用既有用户确认刷新逻辑。
@@ -99,6 +99,14 @@
- [x] 用合成数据补附件 UTC→上海时间和 KISS 信息层级聚焦回归;运行前端 typecheck、build 与相关 Playwright。
- [x] 运行 Trellis task/governance、流程合同、文档审计、敏感扫描和 `git diff --check`;生产部署另行批准。
## 6.4 企微 WebView 旧包刷新护栏
- [x] 在页面从后台恢复为可见时请求 `/ready`,比较规范化后的前端与 server 构建标识;一致、未知或请求失败时静默。
- [x] 构建不一致时复用现有 PWA 更新事件,在已登录 PC/移动壳显示“发现新版本,点击刷新”;不自动重载页面。
- [x] 点击刷新后清理旧 PWA runtime/precache 并重载;同一页面生命周期只提示一次。
- [x] 用合成 `/ready` 响应覆盖 mismatch、match 和 failure,运行 typecheck、build、聚焦 Playwright 与 Trellis 门禁。
- [x] 只提交并推送;生产部署和经理二次刷新另行执行,不在本步骤自动部署。
## 7. 完成条件
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
@@ -140,3 +148,6 @@
- KISS 视觉复核:PC 与移动合成截图确认重复概况、状态、教学和空辅助区已收敛;未读取生产真实报销。Wave3 移动整段 1/1 通过;PC 大用例先通过本轮报销/审批断言,随后在未改动的合同页因找不到“筛选”按钮超时,因此不把该大用例记录为全绿。
- 编辑态回归:R111 移动发票/附件能力 1/1 通过;PC 首轮因旧断言仍要求空“预算与审核提示”显示而失败,按新合同更新断言后,PC 发票与附件流程 1/1 通过。
- 本地质量门禁:前端 typecheck/build 通过;task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。
- 企微旧包诊断:真机截图中的报销日期与已部署按钮属于同一个无额外条件的 JSX 块;公网 `/ready` 为部署构建且当前移动 chunk 包含入口文案,因此排除权限、报销关联缺失和未进入详情,结论为存活 WebView 旧包。
- 旧包刷新护栏:页面回到前台时用 `/ready` 与前端构建标识做一次 no-store 比对;不一致只提示用户刷新,点击后清理 PWA 缓存并重载,同一生命周期不重复提示;一致、unknown/development、异常响应和网络失败静默。
- 刷新护栏回归:mismatch/match/failure 聚焦 Playwright 3/3 通过,完整 login/portal UX 15/15 通过,typecheck/build 通过;375px 竖屏与 667×375 横屏无水平溢出,刷新按钮高度 44px。手机专项首次因测试在 React 壳监听器挂载前人工触发事件而失败,等待移动工作台渲染后复跑通过;未改生产或真实业务数据。
@@ -39,6 +39,7 @@
- 真实首笔报销暴露审批可读性阻断:待办列表卡片能够进入审批任务详情,但任务详情只显示单号、申请人和金额摘要,没有通往费用明细、发票和附件的业务详情入口。移动端必须在审批任务详情区提供明确的“查看完整报销单”入口,复用既有 `/mobile/reimbursements/:id` 路由和 `state.from` 回跳约定;PC 审批任务详情同步提供报销单入口。
- 报销详情当前把后端 ISO UTC 时间直接截取为字符串,附件和审批记录会在中国时区显示早 8 小时;报销链路的日期时间必须复用共享格式化能力并固定按 `Asia/Shanghai` 展示,不修改数据库时间或 API ISO 合同。
- PC 与移动报销详情应按 KISS 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。
- 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。
## Scope
@@ -68,6 +69,7 @@
- [x] PC、移动报销详情和 PC 审批任务详情中的 ISO 时间按 `Asia/Shanghai` 正确展示,UTC 样例不再直接显示原始小时;数据库与 API 时间合同不变。
- [x] PC 报销详情合并重复概况,附件区不再出现“附件材料/补充附件”双标题;无关联、无预算/风险内容时不显示空辅助区块。
- [x] 移动报销详情顶部不重复状态和教学文案;无实际操作时不显示仅含“当前状态不可编辑”的操作卡,编辑态上传与提交能力保持可用。
- [x] 已登录页面从后台恢复到前台且前后端构建不一致时显示明确刷新提示;构建一致、检查失败或开发构建时不误提示,刷新动作复用缓存清理后重载能力。
## Out of Scope
@@ -88,3 +90,4 @@
- 使用既有报销 workflow/权限聚焦回归验证主管解析和 `expense.approve`,不写生产业务单据。
- 前端运行 `cd client && npm run typecheck && npm run build`,并用聚焦 Playwright 覆盖:移动审批任务详情进入完整报销单、返回原任务后审批动作仍可见,以及 PC 审批任务详情进入报销单。
- 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
@@ -0,0 +1,29 @@
import { Button } from "../ui/button";
import { usePwaUpdatePrompt } from "../../hooks/usePwaUpdatePrompt";
export function AppPwaUpdateNotice() {
const { updateReady, applyUpdate } = usePwaUpdatePrompt();
if (!updateReady) {
return null;
}
return (
<div
aria-label="系统更新"
className="mb-4 flex flex-col gap-3 rounded-xl border border-primary/20 bg-white px-4 py-3 text-sm text-slate-700 shadow-sm sm:flex-row sm:items-center sm:justify-between"
data-testid="app-pwa-update-notice"
role="region"
>
<div aria-live="polite">
<div className="font-semibold text-slate-900">发现新版本</div>
<div className="mt-1 leading-6">刷新后即可使用最新功能,当前页面不会自动重载。</div>
</div>
<div className="flex shrink-0 gap-2">
<Button className="min-w-24" type="button" onClick={applyUpdate}>
点击刷新
</Button>
</div>
</div>
);
}
+2
查看文件
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { AppPwaUpdateNotice } from "../components/common/AppPwaUpdateNotice";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { MobileBottomNav } from "../components/mobile/MobileBottomNav";
import { Sidebar } from "../components/navigation/Sidebar";
@@ -70,6 +71,7 @@ export function AppShellLayout() {
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? `min-h-0 overscroll-contain overflow-y-auto px-[var(--mobile-page-x)] py-[var(--mobile-page-y)] ${showMobileBottomNav ? "pb-[calc(var(--mobile-bottom-nav-height,68px)+env(safe-area-inset-bottom,0px)+var(--mobile-page-y)+0.5rem)]" : "pb-5 sm:pb-6"}` : "sm:px-7 sm:py-7 lg:px-8"}`}>
<div className="layout-container max-w-none">
<AppPwaUpdateNotice />
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
+2 -1
查看文件
@@ -6,7 +6,7 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary";
import { Toaster } from "./components/ui/sonner";
import { queryClient } from "./query/queryClient";
import { appRouter } from "./router";
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
import { clearPwaRuntimeCaches, installBuildFreshnessMonitor, registerPwaIfEnabled } from "./pwa";
import "./index.css";
document.documentElement.dataset.buildSha = String(import.meta.env.VITE_BUILD_SHA || "development").slice(0, 12);
@@ -107,6 +107,7 @@ function installChunkLoadRecovery() {
}
installChunkLoadRecovery();
installBuildFreshnessMonitor();
registerPwaIfEnabled();
ReactDOM.createRoot(document.getElementById("root")!).render(
+85
查看文件
@@ -6,6 +6,15 @@ const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
const CLEARABLE_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime", "workbox-precache"];
const AUTH_INPUT_PATHS = ["/login", "/change-password", "/forgot-password", "/reset-password"];
const BUILD_SHA_DISPLAY_LENGTH = 12;
type ReadyResponse = {
data?: {
buildSha?: unknown;
};
};
let buildFreshnessMonitorInstalled = false;
function isAuthInputPath(pathname: string) {
return AUTH_INPUT_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
@@ -34,6 +43,82 @@ function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration
});
}
function normalizeBuildSha(value: unknown) {
const normalized = String(value ?? "").trim().toLowerCase();
if (
!normalized ||
normalized === "unknown" ||
normalized === "development" ||
!/^[a-f0-9]{7,64}$/.test(normalized)
) {
return null;
}
return normalized.slice(0, BUILD_SHA_DISPLAY_LENGTH);
}
function buildUserControlledRefresh() {
return () => {
void clearPwaRuntimeCaches()
.catch(() => undefined)
.finally(() => {
window.location.reload();
});
};
}
export function installBuildFreshnessMonitor() {
if (!canUseWindow() || buildFreshnessMonitorInstalled) {
return;
}
buildFreshnessMonitorInstalled = true;
let checkInFlight = false;
let mismatchNotified = false;
const checkBuildFreshness = async () => {
if (document.visibilityState !== "visible" || checkInFlight || mismatchNotified) {
return;
}
const clientBuildSha = normalizeBuildSha(document.documentElement.dataset.buildSha);
if (!clientBuildSha) {
return;
}
checkInFlight = true;
try {
const response = await window.fetch("/ready", {
cache: "no-store",
credentials: "same-origin",
headers: { accept: "application/json" },
});
if (!response.ok) {
return;
}
const payload = await response.json() as ReadyResponse;
const serverBuildSha = normalizeBuildSha(payload.data?.buildSha);
if (!serverBuildSha || serverBuildSha === clientBuildSha) {
return;
}
mismatchNotified = true;
window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, {
detail: { applyUpdate: buildUserControlledRefresh() },
}));
} catch {
} finally {
checkInFlight = false;
}
};
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
void checkBuildFreshness();
}
});
}
export function registerPwaIfEnabled() {
if (import.meta.env.DEV) {
return;
+83
查看文件
@@ -551,6 +551,89 @@ test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page
await expect(page.getByText("试点观测摘要")).not.toBeVisible();
});
async function prepareEmployeePortal(page: import("@playwright/test").Page) {
await mockPasswordLogin(page);
await mockNavigation(page, "employee");
await mockPortalHome(page, "employee");
await mockAttendanceToday(page);
await mockNotificationList(page);
await mockMe(page, ["employee"], "陈工程");
await login(page, EMPLOYEE);
}
async function triggerVisibleBuildCheck(page: import("@playwright/test").Page, clientBuildSha: string) {
const readyRequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/ready");
await page.evaluate((buildSha) => {
document.documentElement.dataset.buildSha = buildSha;
document.dispatchEvent(new Event("visibilitychange"));
}, clientBuildSha);
await readyRequest;
}
test("已登录页面回到前台时发现旧构建并提示用户刷新", async ({ page }) => {
let readyChecks = 0;
await page.route("**/ready", async (route) => {
readyChecks += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ ok: true, buildSha: "222222222222" })),
});
});
await prepareEmployeePortal(page);
await page.setViewportSize({ width: 375, height: 667 });
await page.goto("/mobile");
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await triggerVisibleBuildCheck(page, "111111111111");
const notice = page.getByTestId("app-pwa-update-notice");
await expect(notice).toBeVisible();
await expect(notice).toContainText("发现新版本");
const refreshButton = notice.getByRole("button", { name: "点击刷新" });
await expect(refreshButton).toBeVisible();
await page.evaluate(() => document.dispatchEvent(new Event("visibilitychange")));
await expect.poll(() => readyChecks).toBe(1);
const refreshButtonBox = await refreshButton.boundingBox();
expect(refreshButtonBox?.height ?? 0).toBeGreaterThanOrEqual(44);
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
await page.setViewportSize({ width: 667, height: 375 });
await expect(notice).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(2);
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
refreshButton.click(),
]);
await expect(page).toHaveURL(/\/mobile$/);
});
test("已登录页面回到前台时构建一致保持静默", async ({ page }) => {
await page.route("**/ready", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope({ ok: true, buildSha: "333333333333" })),
});
});
await prepareEmployeePortal(page);
await triggerVisibleBuildCheck(page, "333333333333");
await expect(page.getByTestId("app-pwa-update-notice")).toHaveCount(0);
});
test("已登录页面回到前台时版本检查失败保持静默", async ({ page }) => {
await page.route("**/ready", async (route) => {
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
});
await prepareEmployeePortal(page);
await triggerVisibleBuildCheck(page, "444444444444");
await expect(page.getByTestId("app-pwa-update-notice")).toHaveCount(0);
});
test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清单", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("hyb_oa_show_welcome", "1");