文件
hyb-oa/client/src/pwa.ts
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

217 行
6.8 KiB
TypeScript

import { canUseWindow, isEnterpriseMessengerContainer, matchesMedia } from "./lib/runtimeContext";
import { PWA_UPDATE_READY_EVENT } from "./lib/pwaUpdateEvents";
const MOBILE_APPROVAL_TASK_PATH = /^\/workflow\/tasks\/([^/]+)$/i;
const OFFLINE_ALLOWED_PATHS = ["/mobile/approvals"];
const RUNTIME_CACHE_PREFIXES = ["app-api-runtime", "app-static-runtime"];
const CLEARABLE_CACHE_PREFIXES = [...RUNTIME_CACHE_PREFIXES, "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;
let pwaRegistrationStarted = false;
function isAuthInputPath(pathname: string) {
return AUTH_INPUT_PATHS.some((path) => pathname === path || pathname.startsWith(`${path}/`));
}
function waitForActivatedWorker(worker: ServiceWorker): Promise<boolean> {
if (worker.state === "activated") return Promise.resolve(true);
if (worker.state === "redundant") return Promise.resolve(false);
return new Promise((resolve) => {
const onStateChange = () => {
if (worker.state !== "activated" && worker.state !== "redundant") return;
worker.removeEventListener("statechange", onStateChange);
resolve(worker.state === "activated");
};
worker.addEventListener("statechange", onStateChange);
});
}
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 || import.meta.env.VITE_PWA_ENABLED === "false" ||
!canUseWindow() || !("serviceWorker" in window.navigator) || pwaRegistrationStarted) {
return;
}
pwaRegistrationStarted = true;
const serviceWorker = window.navigator.serviceWorker;
let previousController = serviceWorker.controller;
let refreshScheduled = false;
let refreshApplied = false;
// Native navigation may start an update before register() resolves. Subscribe
// first: updatefound/activated listeners installed afterward can miss it.
serviceWorker.addEventListener("controllerchange", () => {
const controller = serviceWorker.controller;
if (!controller || controller === previousController) return;
const isUpdate = previousController !== null;
previousController = controller;
void waitForActivatedWorker(controller).then((activated) => {
if (!activated || controller !== serviceWorker.controller) return;
if (!isUpdate) {
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
return;
}
if (refreshScheduled) return;
refreshScheduled = true;
const applyUpdate = () => {
if (refreshApplied) return;
refreshApplied = true;
// Workbox owns new precache installation and old-revision cleanup.
// Deleting it here races activation and destroys the new offline shell.
void clearPwaCaches(RUNTIME_CACHE_PREFIXES)
.catch(() => undefined)
.finally(() => window.location.reload());
};
if (isAuthInputPath(window.location.pathname)) {
window.dispatchEvent(new CustomEvent(PWA_UPDATE_READY_EVENT, { detail: { applyUpdate } }));
} else {
applyUpdate();
}
});
});
void serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`, { scope: import.meta.env.BASE_URL })
.catch(() => undefined);
}
export function buildMobileApprovalsPath(taskId?: string | null) {
const normalizedTaskId = String(taskId ?? "").trim();
if (!normalizedTaskId) {
return "/mobile/approvals";
}
return `/mobile/approvals/${encodeURIComponent(normalizedTaskId)}`;
}
export function extractWorkflowTaskId(pathname: string) {
const match = pathname.match(MOBILE_APPROVAL_TASK_PATH);
return match?.[1] ?? null;
}
export function isOfflineSnapshotAllowedPath(pathname: string) {
if (OFFLINE_ALLOWED_PATHS.includes(pathname)) {
return true;
}
if (/^\/mobile\/approvals\/[^/]+$/.test(pathname)) {
return true;
}
return Boolean(extractWorkflowTaskId(pathname));
}
export function shouldPreferMobileApprovalExperience() {
if (!canUseWindow()) {
return false;
}
const isStandalone = matchesMedia("(display-mode: standalone)") || (window.navigator as Navigator & { standalone?: boolean }).standalone === true;
const isTouchSmallScreen = window.navigator.maxTouchPoints > 0 && matchesMedia("(max-width: 1024px)");
return isStandalone || isEnterpriseMessengerContainer() || isTouchSmallScreen;
}
async function clearPwaCaches(prefixes: readonly string[]) {
if (!canUseWindow() || !("caches" in window)) {
return;
}
const cacheNames = await window.caches.keys();
const cachesToDelete = cacheNames.filter((cacheName) =>
prefixes.some((prefix) => cacheName.startsWith(prefix)),
);
await Promise.all(cachesToDelete.map(async (cacheName) => {
try {
await window.caches.delete(cacheName);
} catch {
}
}));
}
export async function clearPwaRuntimeCaches() {
await clearPwaCaches(CLEARABLE_CACHE_PREFIXES);
}