Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb

# Conflicts:
#	docs/technical/README.md
#	server-node/src/modules/assets/qwenSpriteRoutes.ts
#	src/components/CustomWorldResultView.test.tsx
#	src/components/CustomWorldResultView.tsx
#	src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx
#	src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx
#	src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx
#	src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx
#	src/components/rpg-entry/RpgEntryCharacterSelectView.tsx
#	src/components/rpg-entry/RpgEntryHomeView.tsx
#	src/services/apiClient.ts
#	src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
2026-04-21 20:16:01 +08:00
477 changed files with 38047 additions and 26570 deletions

View File

@@ -7,6 +7,7 @@ import {
parseApiErrorMessage,
unwrapApiResponse,
} from '../../packages/shared/src/http';
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
@@ -32,6 +33,8 @@ export type ApiRequestOptions = {
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
// 会话探测类请求需要静默处理 401避免 AuthGate 因自发广播再次触发 hydrate。
notifyAuthStateChange?: boolean;
};
type ResolvedRetryOptions = {
@@ -50,10 +53,6 @@ type ParsedApiErrorShape = {
meta: Partial<ApiMeta>;
};
type RefreshTokenResponse = {
token: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
@@ -317,7 +316,7 @@ function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function emitAuthStateChange() {
export function emitAuthStateChange() {
if (typeof window === 'undefined') {
return;
}
@@ -412,14 +411,20 @@ export function setStoredAutoAuthCredentials(credentials: {
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
}
export function clearStoredAutoAuthCredentials() {
export function clearStoredAutoAuthCredentials(
options: {
emit?: boolean;
} = {},
) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
emitAuthStateChange();
if (options.emit !== false) {
emitAuthStateChange();
}
}
function withAuthorizationHeaders(
@@ -454,24 +459,25 @@ async function refreshAccessToken() {
});
if (!response.ok) {
clearStoredAccessToken();
clearStoredAccessToken({ emit: false });
throw await buildApiClientError(response, '刷新登录状态失败');
}
const responseText = await response.text();
const payload = responseText
? unwrapApiResponse<RefreshTokenResponse>(
JSON.parse(responseText) as RefreshTokenResponse,
? unwrapApiResponse<AuthRefreshResponse>(
JSON.parse(responseText) as AuthRefreshResponse,
)
: null;
if (!payload?.token?.trim()) {
clearStoredAccessToken();
if (payload?.ok !== true || !payload.token?.trim()) {
clearStoredAccessToken({ emit: false });
throw new Error('刷新登录状态失败');
}
setStoredAccessToken(payload.token, { emit: false });
return payload.token;
const nextToken = payload.token.trim();
setStoredAccessToken(nextToken, { emit: false });
return nextToken;
})();
try {
@@ -488,6 +494,7 @@ export async function fetchWithApiAuth(
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
let attempt = 0;
let refreshAttempted = false;
@@ -514,13 +521,23 @@ export async function fetchWithApiAuth(
try {
await refreshAccessToken();
refreshAttempted = true;
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
continue;
} catch {
clearStoredAccessToken();
if (hasAuthHeader) {
clearStoredAccessToken({ emit: false });
}
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
}
} else if (response.status === 401 && hasAuthHeader && !options.skipAuth) {
// 公开只读请求不能因为服务端异常 401 顺手把正式登录态清空。
clearStoredAccessToken();
clearStoredAccessToken({ emit: false });
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
}
if (!shouldRetryResponse(response.status, attempt, retry)) {