feat(web): 前端逻辑骨架——http/router/stores/queries 与薄壳路由

This commit is contained in:
2026-10-07 14:36:06 +08:00
parent 9ecf817df2
commit fbb989c3fb
39 changed files with 1431 additions and 175 deletions
+104
View File
@@ -0,0 +1,104 @@
import axios, {
AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from "axios";
import type { ErrorEnvelope } from "@new-api-key-usage/core";
import { redirectAuth, type AuthWorld } from "./auth-redirect.ts";
const MEMBER_TOKEN_KEY = "member_token";
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly fields: Record<string, string> | undefined;
readonly keyId: number | undefined;
constructor(
status: number,
code: string,
message: string,
extra?: { fields?: Record<string, string>; key_id?: number },
) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.fields = extra?.fields;
this.keyId = extra?.key_id;
}
}
function parseErrorEnvelope(data: unknown): ErrorEnvelope | null {
if (!data || typeof data !== "object") return null;
const env = data as Partial<ErrorEnvelope>;
if (!env.error || typeof env.error !== "object") return null;
const { code, message } = env.error;
if (typeof code !== "string" || typeof message !== "string") return null;
return env as ErrorEnvelope;
}
function toApiError(error: AxiosError, world: AuthWorld): ApiError {
const envelope = parseErrorEnvelope(error.response?.data);
if (envelope) {
return new ApiError(
error.response?.status ?? 0,
envelope.error.code,
envelope.error.message,
{
fields: envelope.error.fields,
key_id: envelope.error.key_id,
},
);
}
const status = error.response?.status ?? 0;
if (status === 401) {
return new ApiError(401, "unauthorized", "凭证无效或已过期");
}
if (status === 0 || !error.response) {
return new ApiError(0, "network", "网络异常,请稍后重试");
}
// 中间人非 JSON 错误体(代理 502 页等)回退通用文案
return new ApiError(status, "internal", "服务暂时不可用,请稍后重试");
}
function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
if (world === "member") {
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = sessionStorage.getItem(MEMBER_TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
}
client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
const apiError = toApiError(error, world);
if (apiError.status === 401) {
redirectAuth(world, "expired");
}
return Promise.reject(apiError);
},
);
}
function createClient(world: AuthWorld, baseURL: string): AxiosInstance {
const client = axios.create({
baseURL,
timeout: 30_000,
// 管理面会话 cookie 需跨端口(Vite 5173 → API 18456)携带
withCredentials: true,
headers: { Accept: "application/json" },
});
attachInterceptors(client, world);
return client;
}
/** 管理面:`/api/admin/*`,会话 cookie */
export const adminHttp = createClient("admin", "/api/admin");
/** 成员视图:`/api/member/*`,Bearer 令牌 */
export const memberHttp = createClient("member", "/api/member");