feat(web): W3-1 shared console routes, nav, api/sse/query-keys and shared components
- routes.ts registers all workspaces under /console (+ detail routes, engine, audit, login) - api.ts: cookie session first (credentials include, CSRF header), Bearer only with stored key, 401 -> /login - sse.ts: shared EventSource with ?after=seq catch-up, backoff reconnect, per-type subscribe - shared components: data-table, filter-bar, detail-sheet, timeline, json-view, confirm-dialog, form-field, kv-list, stat-tile, error/loading states; ui tabs and textarea Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a24bda952b
commit
e5e42b1f0d
32 files changed
+1286
-71
No files matched your search
+99
-27
@@ -1,13 +1,24 @@
|
||||
import type { ApiError, ApiResponse } from "@umt/contracts";
|
||||
import type { ApiError, ApiErrorCode, ApiResponse } from "@umt/contracts";
|
||||
|
||||
export type { ApiResponse };
|
||||
|
||||
// ─── API Key 存储 ───────────────────────────────────────────────
|
||||
// 控制台鉴权: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 {
|
||||
if (typeof localStorage === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
@@ -18,50 +29,85 @@ 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: "..." } 替换 /api/v1/media/:id */
|
||||
/** 路径变量,`:id` 与 `{id}` 两种写法都替换,如 { id: "..." } */
|
||||
pathParams?: Record<string, string | number>;
|
||||
params?: Record<string, string | number | boolean | undefined | null>;
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
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 },
|
||||
});
|
||||
|
||||
/**
|
||||
* 核心 fetcher:始终返回 ApiResponse 信封,不抛出网络/解析错误。
|
||||
* queryFn: () => apiFetch<HealthStatus>("/api/v1/health")
|
||||
* mutationFn: (body) => apiFetch("/api/v1/xxx", { method: "POST", body })
|
||||
*/
|
||||
export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}): Promise<ApiResponse<T>> {
|
||||
const { method = "GET", headers = {}, body, params, pathParams, signal } = arg;
|
||||
|
||||
let url = `${API_BASE.replace(/\/+$/, "")}${path}`;
|
||||
if (pathParams) {
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
|
||||
/** 拼完整 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 (params) {
|
||||
if (arg.params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) qs.append(key, String(value));
|
||||
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;
|
||||
}
|
||||
|
||||
const reqHeaders: Record<string, string> = { ...headers };
|
||||
/** 当前页不是登录页时跳过去,带 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) {
|
||||
@@ -73,7 +119,7 @@ export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}):
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { method, headers: reqHeaders, body: reqBody, signal });
|
||||
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", "无法连接服务");
|
||||
@@ -81,22 +127,48 @@ export async function apiFetch<T = unknown>(path: string, arg: FetcherArg = {}):
|
||||
|
||||
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("internal", `响应不是 JSON(HTTP ${res.status})`);
|
||||
return fail(res.status === 401 ? "unauthorized" : "internal", `响应不是 JSON(HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取出 data,失败时抛出(给 react-query 的 queryFn 用,错误进入 error 状态) */
|
||||
/** 带错误码的请求错误(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 Object.assign(new Error(r.error.message), { code: r.error.code });
|
||||
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 });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 查询 key 统一在这里生成,SSE 失效与跨页刷新靠它对齐。
|
||||
// 约定:[area, "list", params?] / [area, "detail", id] / [area, 其它子资源...];
|
||||
// invalidate 某区域全部数据用 qk.all(area)。
|
||||
|
||||
export type QueryArea =
|
||||
| "auth"
|
||||
| "health"
|
||||
| "dashboard"
|
||||
| "settings"
|
||||
| "api-keys"
|
||||
| "secrets"
|
||||
| "library-roots"
|
||||
| "downloader-instances"
|
||||
| "media-server-instances"
|
||||
| "path-mappings"
|
||||
| "sites"
|
||||
| "site-accounts"
|
||||
| "audit"
|
||||
| "engine"
|
||||
| "media"
|
||||
| "identity"
|
||||
| "organize"
|
||||
| "subscriptions"
|
||||
| "preferences"
|
||||
| "inbox"
|
||||
| "decisions"
|
||||
| "sources"
|
||||
| "feeds"
|
||||
| "tasks"
|
||||
| "downloads"
|
||||
| "agent"
|
||||
| (string & {});
|
||||
|
||||
export const qk = {
|
||||
all: (area: QueryArea) => [area] as const,
|
||||
list: (area: QueryArea, params?: object) => (params ? ([area, "list", params] as const) : ([area, "list"] as const)),
|
||||
detail: (area: QueryArea, id: string) => [area, "detail", id] as const,
|
||||
sub: (area: QueryArea, ...parts: (string | number | object)[]) => [area, ...parts] as const,
|
||||
|
||||
session: ["auth", "session"] as const,
|
||||
health: ["health"] as const,
|
||||
dashboard: ["dashboard"] as const,
|
||||
settings: ["settings"] as const,
|
||||
engineStats: ["engine", "stats"] as const,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { buildUrl } from "./api.ts";
|
||||
|
||||
// SSE 封装:整个页面共用一条 EventSource(按订阅引用计数开关)。
|
||||
// - 服务端按事件类型发命名事件(event: <type>),所以按 type 订阅;"*" 订阅全部已知类型。
|
||||
// - 记住最后一个 seq;断线后按指数退避重连 `/events?after=<seq>` 补读,不漏不重。
|
||||
// - 收到 system.resync(起点已被裁剪)时,订阅者应整页重取(useSseInvalidate 自动处理)。
|
||||
// 鉴权走 cookie 会话(EventSource 不能带 Authorization 头)。
|
||||
|
||||
export type SseEvent<P = unknown> = { seq: number; type: string; at: string; payload: P };
|
||||
export type SseHandler = (event: SseEvent) => void;
|
||||
export type SseStatus = "idle" | "connecting" | "open" | "reconnecting";
|
||||
|
||||
const KNOWN_TYPES: string[] = Object.values(EventType);
|
||||
const WILDCARD = "*";
|
||||
|
||||
type Sub = { types: Set<string>; handler: SseHandler };
|
||||
|
||||
class SseClient {
|
||||
private es: EventSource | null = null;
|
||||
private subs = new Set<Sub>();
|
||||
private listened = new Set<string>();
|
||||
private lastSeq: number | null = null;
|
||||
private retry = 0;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private statusListeners = new Set<(s: SseStatus) => void>();
|
||||
status: SseStatus = "idle";
|
||||
|
||||
subscribe(types: string[], handler: SseHandler): () => void {
|
||||
const sub: Sub = { types: new Set(types), handler };
|
||||
this.subs.add(sub);
|
||||
if (!this.es && !this.timer) this.connect();
|
||||
else for (const t of this.expand(sub.types)) this.listen(t);
|
||||
return () => {
|
||||
this.subs.delete(sub);
|
||||
if (this.subs.size === 0) this.close();
|
||||
};
|
||||
}
|
||||
|
||||
onStatus(fn: (s: SseStatus) => void): () => void {
|
||||
this.statusListeners.add(fn);
|
||||
return () => this.statusListeners.delete(fn);
|
||||
}
|
||||
|
||||
private setStatus(s: SseStatus) {
|
||||
this.status = s;
|
||||
for (const fn of this.statusListeners) fn(s);
|
||||
}
|
||||
|
||||
private expand(types: Set<string>): string[] {
|
||||
return types.has(WILDCARD) ? [...KNOWN_TYPES, ...[...types].filter((t) => t !== WILDCARD)] : [...types];
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (typeof EventSource === "undefined") return;
|
||||
this.timer = null;
|
||||
this.setStatus(this.retry ? "reconnecting" : "connecting");
|
||||
const url = buildUrl("/events", { params: { after: this.lastSeq ?? undefined } });
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
this.es = es;
|
||||
this.listened.clear();
|
||||
// resync 永远监听
|
||||
this.listen(EventType.resync);
|
||||
for (const sub of this.subs) for (const t of this.expand(sub.types)) this.listen(t);
|
||||
es.onopen = () => {
|
||||
this.retry = 0;
|
||||
this.setStatus("open");
|
||||
};
|
||||
es.onerror = () => {
|
||||
// 自己掌控重连(带 after=seq),不用浏览器默认重连
|
||||
es.close();
|
||||
if (this.es !== es) return;
|
||||
this.es = null;
|
||||
if (this.subs.size === 0) return;
|
||||
const delay = Math.min(30_000, 1000 * 2 ** this.retry);
|
||||
this.retry += 1;
|
||||
this.setStatus("reconnecting");
|
||||
this.timer = setTimeout(() => this.connect(), delay);
|
||||
};
|
||||
}
|
||||
|
||||
private listen(type: string) {
|
||||
if (!this.es || this.listened.has(type)) return;
|
||||
this.listened.add(type);
|
||||
this.es.addEventListener(type, (e) => this.dispatch(type, e as MessageEvent<string>));
|
||||
}
|
||||
|
||||
private dispatch(type: string, e: MessageEvent<string>) {
|
||||
let event: SseEvent;
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as Partial<SseEvent>;
|
||||
event =
|
||||
typeof parsed.seq === "number"
|
||||
? (parsed as SseEvent)
|
||||
: { seq: this.lastSeq ?? 0, type, at: new Date().toISOString(), payload: parsed };
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof event.seq === "number" && type !== EventType.resync) {
|
||||
if (this.lastSeq !== null && event.seq <= this.lastSeq) return; // 补读重叠去重
|
||||
this.lastSeq = event.seq;
|
||||
}
|
||||
for (const sub of this.subs) {
|
||||
if (type === EventType.resync || sub.types.has(type) || sub.types.has(WILDCARD)) sub.handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
private close() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.es?.close();
|
||||
this.es = null;
|
||||
this.retry = 0;
|
||||
this.setStatus("idle");
|
||||
}
|
||||
}
|
||||
|
||||
export const sse = new SseClient();
|
||||
|
||||
/**
|
||||
* 订阅 SSE 事件。types 为事件类型数组(见 contracts EventType),"*" 表示全部已知类型。
|
||||
* handler 可以每次渲染都变,不会重复订阅。system.resync 总会送达。
|
||||
*/
|
||||
export function useSse(types: string[], handler: SseHandler, enabled = true) {
|
||||
const ref = useRef(handler);
|
||||
ref.current = handler;
|
||||
const key = types.join("|");
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
return sse.subscribe(key.split("|"), (e) => ref.current(e));
|
||||
}, [key, enabled]);
|
||||
}
|
||||
|
||||
/** 连接状态(给页面角落的"实时"指示用) */
|
||||
export function useSseStatus(): SseStatus {
|
||||
const [s, setS] = useState<SseStatus>(sse.status);
|
||||
useEffect(() => sse.onStatus(setS), []);
|
||||
return s;
|
||||
}
|
||||
Reference in new issue
Block a user