feat(P21PWA2-1,P21PWA2-2,P21PWA2-3): 深链路由守卫/离线审批队列/SW缓存策略升级

这个提交包含在:
2026-03-12 12:34:56 +08:00
父节点 102d3dcc2b
当前提交 9112383d23
共修改 10 个文件,包含 975 行新增和 163 行删除
+133 -14
查看文件
@@ -11,6 +11,7 @@ import {
import { configureHttpAuth } from "../api/http";
import type { AuthState, MeResponse } from "../api/types";
import { changePassword as changePasswordApi } from "../api/auth";
import { clearPwaRuntimeCaches } from "../pwa";
import {
fetchCurrentEmployee,
loginWithPassword,
@@ -19,12 +20,20 @@ import {
} from "./authService";
import { resolvePermissionsForRoles } from "./permissions";
type OfflineAuthSnapshot = {
version: 1;
me: MeResponse;
permissions: string[];
savedAt: string;
};
type AuthContextValue = AuthState & {
login: (identifier: string, password: string) => Promise<{ mustChangePassword: boolean }>;
changePassword: (oldPassword: string, newPassword: string) => Promise<void>;
logout: () => Promise<void>;
bootstrapByRefresh: () => Promise<void>;
clearSession: () => void;
offlineAccessMode: boolean;
};
const AuthContext = createContext<AuthContextValue | null>(null);
@@ -38,13 +47,23 @@ const EMPTY_STATE: AuthState = {
};
const MUST_CHANGE_PASSWORD_STORAGE_KEY = "hyb-oa.must_change_password";
const OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY = "hyb-oa.auth_snapshot";
const OFFLINE_AUTH_SNAPSHOT_VERSION = 1;
const OFFLINE_AUTH_SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000;
function isWindowAvailable() {
return typeof window !== "undefined";
}
function readMustChangePasswordFromStorage() {
return typeof window !== "undefined" && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1";
return isWindowAvailable() && window.localStorage.getItem(MUST_CHANGE_PASSWORD_STORAGE_KEY) === "1";
}
function writeMustChangePasswordToStorage(value: boolean) {
if (typeof window === "undefined") return;
if (!isWindowAvailable()) {
return;
}
if (value) {
window.localStorage.setItem(MUST_CHANGE_PASSWORD_STORAGE_KEY, "1");
} else {
@@ -52,22 +71,77 @@ function writeMustChangePasswordToStorage(value: boolean) {
}
}
function buildStateWithUser(
accessToken: string,
me: MeResponse,
mustChangePassword: boolean,
): AuthState {
return {
accessToken,
function readOfflineAuthSnapshot() {
if (!isWindowAvailable()) {
return null;
}
try {
const rawValue = window.localStorage.getItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
if (!rawValue) {
return null;
}
const parsed = JSON.parse(rawValue) as Partial<OfflineAuthSnapshot> | null;
if (!parsed || parsed.version !== OFFLINE_AUTH_SNAPSHOT_VERSION || !parsed.me || !Array.isArray(parsed.permissions) || typeof parsed.savedAt !== "string") {
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
return null;
}
const savedAt = new Date(parsed.savedAt);
if (Number.isNaN(savedAt.getTime()) || Date.now() - savedAt.getTime() > OFFLINE_AUTH_SNAPSHOT_TTL_MS) {
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
return null;
}
return {
version: OFFLINE_AUTH_SNAPSHOT_VERSION,
me: parsed.me,
permissions: parsed.permissions.map((item) => String(item)),
savedAt: savedAt.toISOString(),
} satisfies OfflineAuthSnapshot;
} catch {
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
return null;
}
}
function writeOfflineAuthSnapshot(me: MeResponse, permissions: string[]) {
if (!isWindowAvailable()) {
return;
}
const snapshot: OfflineAuthSnapshot = {
version: OFFLINE_AUTH_SNAPSHOT_VERSION,
me,
permissions: resolvePermissionsForRoles(me.roleCodes ?? []),
mustChangePassword,
permissions,
savedAt: new Date().toISOString(),
};
window.localStorage.setItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot));
}
function clearOfflineAuthSnapshot() {
if (!isWindowAvailable()) {
return;
}
window.localStorage.removeItem(OFFLINE_AUTH_SNAPSHOT_STORAGE_KEY);
}
function buildStateWithOfflineSnapshot(snapshot: OfflineAuthSnapshot): AuthState {
return {
accessToken: null,
me: snapshot.me,
permissions: snapshot.permissions,
mustChangePassword: false,
ready: true,
};
}
export function AuthProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<AuthState>(EMPTY_STATE);
const [offlineAccessMode, setOfflineAccessMode] = useState(false);
const tokenRef = useRef<string | null>(null);
useEffect(() => {
@@ -76,7 +150,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
const clearSession = useCallback(() => {
tokenRef.current = null;
setOfflineAccessMode(false);
writeMustChangePasswordToStorage(false);
clearOfflineAuthSnapshot();
void clearPwaRuntimeCaches();
setState((previous) => ({
...previous,
accessToken: null,
@@ -89,23 +166,48 @@ export function AuthProvider({ children }: PropsWithChildren) {
const commitAuthenticatedUser = useCallback(
(accessToken: string, me: MeResponse, mustChangePassword: boolean) => {
const permissions = resolvePermissionsForRoles(me.roleCodes ?? []);
tokenRef.current = accessToken;
setOfflineAccessMode(false);
writeMustChangePasswordToStorage(mustChangePassword);
setState(buildStateWithUser(accessToken, me, mustChangePassword));
writeOfflineAuthSnapshot(me, permissions);
setState({
accessToken,
me,
permissions,
mustChangePassword,
ready: true,
});
},
[],
);
const activateOfflineSnapshot = useCallback((snapshot: OfflineAuthSnapshot) => {
tokenRef.current = null;
setOfflineAccessMode(true);
setState(buildStateWithOfflineSnapshot(snapshot));
}, []);
const bootstrapByRefresh = useCallback(async () => {
const offlineSnapshot = readOfflineAuthSnapshot();
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
activateOfflineSnapshot(offlineSnapshot);
return;
}
try {
const refreshed = await refreshSession();
const me = await fetchCurrentEmployee(refreshed.accessToken);
const mustChangePassword = readMustChangePasswordFromStorage();
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
} catch {
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
activateOfflineSnapshot(offlineSnapshot);
return;
}
clearSession();
}
}, [clearSession, commitAuthenticatedUser]);
}, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]);
const login = useCallback(
async (identifier: string, password: string) => {
@@ -142,6 +244,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
try {
const refreshed = await refreshSession();
tokenRef.current = refreshed.accessToken;
setOfflineAccessMode(false);
setState((previous) => ({
...previous,
accessToken: refreshed.accessToken,
@@ -161,6 +264,21 @@ export function AuthProvider({ children }: PropsWithChildren) {
void bootstrapByRefresh();
}, [bootstrapByRefresh]);
useEffect(() => {
if (!offlineAccessMode) {
return undefined;
}
const handleOnline = () => {
void bootstrapByRefresh();
};
window.addEventListener("online", handleOnline);
return () => {
window.removeEventListener("online", handleOnline);
};
}, [bootstrapByRefresh, offlineAccessMode]);
const value = useMemo<AuthContextValue>(
() => ({
...state,
@@ -169,8 +287,9 @@ export function AuthProvider({ children }: PropsWithChildren) {
logout,
bootstrapByRefresh,
clearSession,
offlineAccessMode,
}),
[bootstrapByRefresh, changePassword, clearSession, login, logout, state],
[bootstrapByRefresh, changePassword, clearSession, login, logout, offlineAccessMode, state],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;