增加企微旧包刷新护栏(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
在新工单中引用
屏蔽一个用户