- 设置 → 媒体库与目录 → 存储:登记 Alist / OpenList(令牌 / 账号密码 / 访客、路径密码、限速与监控选项), 测试连接显示版本、用户与容量;媒体库目录可选存储 - 文件管理左栏多一组「Alist 存储」,路径显示「存储名:/路径」;移动 / 复制目标可选 Alist 存储; 上传按钮;Alist 根点刷新才让 Alist 刷新目录缓存 - 运行状态的挂载根列出存储实例(令牌失效单独提示) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
184 lines
6.9 KiB
TypeScript
184 lines
6.9 KiB
TypeScript
import type { ApiError, ApiErrorCode, ApiResponse } from "@umt/contracts";
|
|
|
|
export type { ApiResponse };
|
|
|
|
// 控制台鉴权:cookie 会话优先(credentials: "include",写方法带 CSRF 头);
|
|
// 只有 localStorage 里存了 API Key 时才额外附加 Bearer(脚本/调试用,Bearer 调用服务端不校验 CSRF)。
|
|
// 任意请求收到 401 → 跳登录页(带 next 回跳),/auth/* 探测类请求除外。
|
|
|
|
// ─── API Key 存储(可选) ─────────────────────────────────────────
|
|
const TOKEN_KEY = "umt-api-key";
|
|
const CSRF_COOKIE = "umt_csrf";
|
|
const CSRF_HEADER = "x-csrf-token";
|
|
export const LOGIN_PATH = "/login";
|
|
|
|
export function getToken(): string {
|
|
try {
|
|
if (typeof localStorage === "undefined") return "";
|
|
return localStorage.getItem(TOKEN_KEY) || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function setToken(token: string) {
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
}
|
|
|
|
export function clearAuth() {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
}
|
|
|
|
function readCookie(name: string): string {
|
|
if (typeof document === "undefined") return "";
|
|
for (const part of document.cookie.split(";")) {
|
|
const [k, ...v] = part.trim().split("=");
|
|
if (k === name) return decodeURIComponent(v.join("="));
|
|
}
|
|
return "";
|
|
}
|
|
|
|
// ─── 请求参数 ────────────────────────────────────────────────────
|
|
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
export type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
|
|
|
export type FetcherArg<B = unknown> = {
|
|
/** 路径变量,`:id` 与 `{id}` 两种写法都替换,如 { id: "..." } */
|
|
pathParams?: Record<string, string | number>;
|
|
params?: QueryParams;
|
|
method?: HttpMethod;
|
|
body?: B;
|
|
headers?: Record<string, string>;
|
|
signal?: AbortSignal;
|
|
/** 401 时不跳登录页(登录页自己的探测/提交用) */
|
|
noAuthRedirect?: boolean;
|
|
};
|
|
|
|
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
|
/** 业务接口前缀,与 contracts 的 API_PREFIX 一致 */
|
|
export const API_V1 = "/api/v1";
|
|
|
|
const fail = (code: ApiError["code"], message: string): ApiResponse<never> => ({
|
|
ok: false,
|
|
error: { code, message },
|
|
});
|
|
|
|
/** 拼完整 URL(不含 origin):路径变量 + query */
|
|
export function buildUrl(path: string, arg: Pick<FetcherArg, "params" | "pathParams"> = {}): string {
|
|
let url = `${API_BASE.replace(/\/+$/, "")}${path.startsWith("/api/") ? path : `${API_V1}${path}`}`;
|
|
if (arg.pathParams) {
|
|
for (const [key, value] of Object.entries(arg.pathParams)) {
|
|
const v = encodeURIComponent(String(value));
|
|
url = url.replace(`:${key}`, v).replace(`{${key}}`, v);
|
|
}
|
|
}
|
|
if (arg.params) {
|
|
const qs = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(arg.params)) {
|
|
if (value !== undefined && value !== null && value !== "") qs.append(key, String(value));
|
|
}
|
|
const s = qs.toString();
|
|
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
|
}
|
|
return url;
|
|
}
|
|
|
|
/** 当前页不是登录页时跳过去,带 next 回跳 */
|
|
export function redirectToLogin() {
|
|
if (typeof window === "undefined") return;
|
|
if (window.location.pathname === LOGIN_PATH) return;
|
|
const next = window.location.pathname + window.location.search;
|
|
window.location.assign(`${LOGIN_PATH}?next=${encodeURIComponent(next)}`);
|
|
}
|
|
|
|
/**
|
|
* 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。
|
|
* path 可写 "/settings"(自动补 /api/v1)或完整 "/api/v1/settings"。
|
|
* queryFn: () => apiFetch<HealthStatus>("/health")
|
|
* mutationFn: (body) => apiFetch("/xxx", { method: "POST", body })
|
|
*/
|
|
export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}): Promise<ApiResponse<T>> {
|
|
const { method = "GET", headers = {}, body, signal } = arg;
|
|
const url = buildUrl(path, arg);
|
|
|
|
const reqHeaders: Record<string, string> = { Accept: "application/json", ...headers };
|
|
const token = getToken();
|
|
if (token) reqHeaders.Authorization = `Bearer ${token}`;
|
|
if (method !== "GET") {
|
|
const csrf = readCookie(CSRF_COOKIE);
|
|
if (csrf) reqHeaders[CSRF_HEADER] = csrf;
|
|
}
|
|
|
|
let reqBody: BodyInit | undefined;
|
|
if (body instanceof FormData) {
|
|
reqBody = body;
|
|
} else if (typeof Blob !== "undefined" && body instanceof Blob) {
|
|
// 原始字节(如 PUT /files/upload)
|
|
reqHeaders["Content-Type"] ??= "application/octet-stream";
|
|
reqBody = body;
|
|
} else if (body !== undefined && body !== null) {
|
|
reqHeaders["Content-Type"] ??= "application/json";
|
|
reqBody = JSON.stringify(body);
|
|
}
|
|
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, { method, headers: reqHeaders, body: reqBody, signal, credentials: "include" });
|
|
} catch (err) {
|
|
if ((err as Error).name === "AbortError") throw err;
|
|
return fail("upstream_unavailable", "连不上服务端");
|
|
}
|
|
|
|
if (res.status === 401 && typeof window !== "undefined") {
|
|
window.dispatchEvent(new CustomEvent("unauthorized"));
|
|
if (!arg.noAuthRedirect) redirectToLogin();
|
|
}
|
|
|
|
try {
|
|
return (await res.json()) as ApiResponse<T>;
|
|
} catch {
|
|
return fail(res.status === 401 ? "unauthorized" : "internal", `响应不是 JSON(HTTP ${res.status})`);
|
|
}
|
|
}
|
|
|
|
/** 带错误码的请求错误(react-query 的 error 就是它) */
|
|
export class ApiRequestError extends Error {
|
|
readonly code: ApiErrorCode;
|
|
constructor(code: ApiErrorCode, message: string) {
|
|
super(message);
|
|
this.name = "ApiRequestError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
/** 取出 data,失败时抛 ApiRequestError(给 react-query 的 queryFn / mutationFn 用) */
|
|
export async function apiData<T>(path: string, arg?: FetcherArg): Promise<T> {
|
|
const r = await apiFetch<T>(path, arg);
|
|
if (!r.ok) throw new ApiRequestError(r.error.code, r.error.message);
|
|
return r.data;
|
|
}
|
|
|
|
/** 便捷方法:都返回 data,失败抛 ApiRequestError */
|
|
export const api = {
|
|
get: <T>(path: string, params?: QueryParams, signal?: AbortSignal) => apiData<T>(path, { params, signal }),
|
|
post: <T>(path: string, body?: unknown) => apiData<T>(path, { method: "POST", body: body ?? {} }),
|
|
put: <T>(path: string, body?: unknown) => apiData<T>(path, { method: "PUT", body: body ?? {} }),
|
|
patch: <T>(path: string, body?: unknown) => apiData<T>(path, { method: "PATCH", body: body ?? {} }),
|
|
del: <T>(path: string) => apiData<T>(path, { method: "DELETE" }),
|
|
};
|
|
|
|
/** 从未知错误里取人话消息(toast 用) */
|
|
export function errorMessage(err: unknown, fallback = "操作失败"): string {
|
|
if (err instanceof Error && err.message) return err.message;
|
|
return fallback;
|
|
}
|
|
|
|
/** TanStack Query mutationFn 包装 */
|
|
export function mutateFetcher<B = unknown, T = unknown>(path: string, base?: Omit<FetcherArg<B>, "body">) {
|
|
return (body: B) => apiFetch<T>(path, { method: "POST", ...base, body });
|
|
}
|
|
|
|
export const TTL_SECOND = 1_000;
|
|
export const TTL_MINUTE = TTL_SECOND * 60;
|
|
export const TTL_HOUR = TTL_MINUTE * 60;
|