R65 A1-A6:Session 稳定性与 409 资源错误修复
这个提交包含在:
@@ -9,7 +9,14 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { configureHttpAuth } from "../api/http";
|
||||
import type { AuthState, LoginResponse, MeResponse, ProfileCompleteness } from "../api/types";
|
||||
import {
|
||||
isApiError,
|
||||
isSessionExpiredError,
|
||||
type AuthState,
|
||||
type LoginResponse,
|
||||
type MeResponse,
|
||||
type ProfileCompleteness,
|
||||
} from "../api/types";
|
||||
import { changePassword as changePasswordApi } from "../api/auth";
|
||||
import { clearPwaRuntimeCaches } from "../pwa";
|
||||
import {
|
||||
@@ -76,6 +83,16 @@ function clearSessionHint() {
|
||||
document.cookie = "has_session=; path=/; max-age=0";
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminalBootstrapError(error: unknown) {
|
||||
return isSessionExpiredError(error) || (isApiError(error) && error.status === 401);
|
||||
}
|
||||
|
||||
function normalizeProfileCompleteness(value: unknown): ProfileCompleteness {
|
||||
if (!value || typeof value !== "object") {
|
||||
return DEFAULT_PROFILE_COMPLETENESS;
|
||||
@@ -353,19 +370,39 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
|
||||
if (!bootstrapPromiseRef.current) {
|
||||
bootstrapPromiseRef.current = (async () => {
|
||||
try {
|
||||
const refreshed = await runRefresh();
|
||||
const completeBootstrap = (refreshed: LoginResponse | null) => {
|
||||
if (refreshed?.expiresIn) {
|
||||
scheduleProactiveRefresh(refreshed.expiresIn);
|
||||
}
|
||||
|
||||
return refreshed?.accessToken ?? null;
|
||||
} catch {
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
};
|
||||
|
||||
try {
|
||||
return completeBootstrap(await runRefresh());
|
||||
} catch (error) {
|
||||
if (isTerminalBootstrapError(error)) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await wait(1000);
|
||||
return completeBootstrap(await runRefresh());
|
||||
} catch (retryError) {
|
||||
if (isTerminalBootstrapError(retryError)) {
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
return null;
|
||||
}
|
||||
|
||||
clearSession();
|
||||
return null;
|
||||
}
|
||||
clearSession();
|
||||
return null;
|
||||
} finally {
|
||||
bootstrapPromiseRef.current = null;
|
||||
}
|
||||
|
||||
+97
-1
@@ -7,9 +7,105 @@ import { ErrorBoundary } from "./components/common/ErrorBoundary";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import { queryClient } from "./query/queryClient";
|
||||
import { AppRouter } from "./router";
|
||||
import { registerPwaIfEnabled } from "./pwa";
|
||||
import { clearPwaRuntimeCaches, registerPwaIfEnabled } from "./pwa";
|
||||
import "./index.css";
|
||||
|
||||
const CHUNK_RELOAD_ATTEMPTED_KEY = "hyb_oa_chunk_reload_attempted";
|
||||
const CHUNK_LOAD_ERROR_PATTERNS = [
|
||||
"chunkloaderror",
|
||||
"loading chunk",
|
||||
"failed to fetch dynamically imported module",
|
||||
"importing a module script failed",
|
||||
"error loading dynamically imported module",
|
||||
];
|
||||
|
||||
function extractErrorMessages(value: unknown): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return [value.name, value.message];
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = value as {
|
||||
message?: unknown;
|
||||
reason?: unknown;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
return [
|
||||
...extractErrorMessages(record.message),
|
||||
...extractErrorMessages(record.reason),
|
||||
...extractErrorMessages(record.payload),
|
||||
...extractErrorMessages(record.error),
|
||||
];
|
||||
}
|
||||
|
||||
function isChunkLoadFailure(value: unknown) {
|
||||
return extractErrorMessages(value).some((message) => {
|
||||
const normalized = message.toLowerCase();
|
||||
return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => normalized.includes(pattern));
|
||||
});
|
||||
}
|
||||
|
||||
function installChunkLoadRecovery() {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
let reloadTriggered = false;
|
||||
const reloadForChunkFailure = () => {
|
||||
if (reloadTriggered || window.sessionStorage.getItem(CHUNK_RELOAD_ATTEMPTED_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reloadTriggered = true;
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_ATTEMPTED_KEY, "1");
|
||||
void clearPwaRuntimeCaches()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("vite:preloadError", (event) => {
|
||||
if (!isChunkLoadFailure(event.payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
reloadForChunkFailure();
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
if (!isChunkLoadFailure(event.error ?? event.message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reloadForChunkFailure();
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
if (!isChunkLoadFailure(event.reason)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
reloadForChunkFailure();
|
||||
});
|
||||
}
|
||||
|
||||
installChunkLoadRecovery();
|
||||
registerPwaIfEnabled();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
+36
-1
@@ -5,6 +5,29 @@ 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"];
|
||||
|
||||
function bindServiceWorkerUpdateCleanup(registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!canUseWindow() || !registration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const watchInstallingWorker = (worker: ServiceWorker | null) => {
|
||||
if (!worker) {
|
||||
return;
|
||||
}
|
||||
|
||||
worker.addEventListener("statechange", () => {
|
||||
if (worker.state === "installed" && window.navigator.serviceWorker.controller) {
|
||||
void clearPwaRuntimeCaches();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
watchInstallingWorker(registration.installing);
|
||||
registration.addEventListener("updatefound", () => {
|
||||
watchInstallingWorker(registration.installing);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerPwaIfEnabled() {
|
||||
if (import.meta.env.DEV) {
|
||||
return;
|
||||
@@ -14,13 +37,25 @@ export function registerPwaIfEnabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
registerSW({
|
||||
let updateServiceWorker: ((reloadPage?: boolean) => Promise<void>) | null = null;
|
||||
|
||||
updateServiceWorker = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
void clearPwaRuntimeCaches()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
void updateServiceWorker?.(true);
|
||||
});
|
||||
},
|
||||
onOfflineReady() {
|
||||
if (canUseWindow()) {
|
||||
window.dispatchEvent(new CustomEvent("hyb-oa:pwa-offline-ready"));
|
||||
}
|
||||
},
|
||||
onRegisteredSW(_swScriptUrl, registration) {
|
||||
bindServiceWorkerUpdateCleanup(registration);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,66 @@ function envelope(data: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
function mockPasswordLogin(page: import("@playwright/test").Page) {
|
||||
return Promise.all([
|
||||
page.route("**/api/auth/sso/providers", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope([])),
|
||||
});
|
||||
}),
|
||||
page.route("**/api/auth/wecom/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
enabled: false,
|
||||
entryEnabled: false,
|
||||
oauthEnabled: false,
|
||||
corpId: "",
|
||||
agentId: "",
|
||||
loginUrl: null,
|
||||
bindEnabled: false,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "disabled",
|
||||
statusReasons: ["disabled"],
|
||||
})),
|
||||
});
|
||||
}),
|
||||
page.route("**/api/auth/login", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
accessToken: "test-access-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
})),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function mockNotificationList(page: import("@playwright/test").Page) {
|
||||
return page.route((url) => {
|
||||
return url.pathname === "/api/notifications";
|
||||
}, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
total: 0,
|
||||
unreadOnly: true,
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mockMe(page: import("@playwright/test").Page, roleCodes: string[], name: string) {
|
||||
return page.route("**/api/me", async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -211,9 +271,11 @@ function mockPortalHome(page: import("@playwright/test").Page, mode: "employee"
|
||||
}
|
||||
|
||||
test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "employee");
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
@@ -241,9 +303,11 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "manager");
|
||||
await mockPortalHome(page, "manager");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["admin"], "Admin");
|
||||
|
||||
await login(page, ADMIN);
|
||||
@@ -274,25 +338,81 @@ test("Portal 新员工引导先显示欢迎弹窗,再显示快速上手清单"
|
||||
window.localStorage.removeItem("hyb_oa_getting_started_dismissed");
|
||||
});
|
||||
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "employee");
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).toBeVisible();
|
||||
await page.getByTestId(TEST_IDS.onboarding.welcomeClose).click();
|
||||
await page.getByTestId(TEST_IDS.onboarding.welcomeClose).evaluate((element: HTMLButtonElement) => {
|
||||
element.click();
|
||||
});
|
||||
await expect(page.getByTestId(TEST_IDS.onboarding.welcomeDialog)).not.toBeVisible();
|
||||
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).toBeVisible();
|
||||
await page.getByTestId(TEST_IDS.onboarding.gettingStartedDismiss).click();
|
||||
await expect(page.getByTestId(TEST_IDS.onboarding.gettingStartedCard)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录态", async ({ page }) => {
|
||||
let refreshAttempts = 0;
|
||||
|
||||
await page.addInitScript(() => {
|
||||
document.cookie = "has_session=1; path=/";
|
||||
window.sessionStorage.removeItem("hyb_oa_chunk_reload_attempted");
|
||||
});
|
||||
|
||||
await page.route("**/api/auth/refresh", async (route) => {
|
||||
refreshAttempts += 1;
|
||||
|
||||
if (refreshAttempts === 1) {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-request",
|
||||
error: {
|
||||
code: "SYS_001",
|
||||
message: "transient bootstrap error",
|
||||
details: {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope({
|
||||
accessToken: "bootstrap-token",
|
||||
expiresIn: 900,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
await mockNavigation(page, "employee");
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
|
||||
await page.goto("/portal");
|
||||
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible();
|
||||
expect(refreshAttempts).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "manager");
|
||||
await mockPortalHome(page, "manager");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["administration"], "Administration");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
@@ -309,9 +429,11 @@ test("企微 WebView 下顶栏显示入口提示", async ({ page }) => {
|
||||
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||
});
|
||||
});
|
||||
await mockPasswordLogin(page);
|
||||
await mockNavigation(page, "employee");
|
||||
await mockPortalHome(page, "employee");
|
||||
await mockAttendanceToday(page);
|
||||
await mockNotificationList(page);
|
||||
await mockMe(page, ["employee"], "Employee");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
在新工单中引用
屏蔽一个用户