chore: scaffold monorepo and migrate uhd_web design system (W0-A)
- pnpm workspace: apps/{web,server}, packages/{contracts,db,domain,adapters,tasks,agent,testkit}
- server: Hono + zod-openapi, API Key auth with scopes, redacting pino logger, SSE event bus with replay, worker heartbeat
- db: Kysely + pg, file migrator, 0001_init (settings/api_key/audit_event/outbox_event)
- web: React Router 7 SPA console shell with 12 workspace placeholders, uhd_web tokens/ui/guard scripts
- docs: ADR-001 (Node over Deno), dev runbook, design system rules
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
b22c440139
138 files changed
+14455
No files matched your search
@@ -0,0 +1,107 @@
|
||||
import type { ApiError, ApiResponse } from "@umt/contracts";
|
||||
|
||||
export type { ApiResponse };
|
||||
|
||||
// ─── API Key 存储 ───────────────────────────────────────────────
|
||||
const TOKEN_KEY = "umt-api-key";
|
||||
|
||||
export function getToken(): string {
|
||||
if (typeof localStorage === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// ─── 请求参数 ────────────────────────────────────────────────────
|
||||
export type FetcherArg<B = unknown> = {
|
||||
/** 路径变量,如 { id: "..." } 替换 /api/v1/media/:id */
|
||||
pathParams?: Record<string, string | number>;
|
||||
params?: Record<string, string | number | boolean | undefined | null>;
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
body?: B;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
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)));
|
||||
}
|
||||
}
|
||||
if (params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) qs.append(key, String(value));
|
||||
}
|
||||
const s = qs.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
|
||||
const reqHeaders: Record<string, string> = { ...headers };
|
||||
const token = getToken();
|
||||
if (token) reqHeaders.Authorization = `Bearer ${token}`;
|
||||
|
||||
let reqBody: BodyInit | undefined;
|
||||
if (body instanceof FormData) {
|
||||
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 });
|
||||
} 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"));
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
} catch {
|
||||
return fail("internal", `响应不是 JSON(HTTP ${res.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取出 data,失败时抛出(给 react-query 的 queryFn 用,错误进入 error 状态) */
|
||||
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 });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -0,0 +1,68 @@
|
||||
// 由 scripts/gen-design-tokens.mjs 从 app/app.css 生成,不要手改;改了 app.css 跑 pnpm gen:tokens
|
||||
export const DESIGN_TOKENS = {
|
||||
"fontSizes": [
|
||||
"micro",
|
||||
"label",
|
||||
"meta",
|
||||
"caption",
|
||||
"body",
|
||||
"control",
|
||||
"lead",
|
||||
"lead-lg",
|
||||
"title-xs",
|
||||
"title-sm",
|
||||
"title",
|
||||
"title-lg",
|
||||
"headline-xs",
|
||||
"headline-sm",
|
||||
"headline",
|
||||
"headline-lg",
|
||||
"display-xs",
|
||||
"display-sm",
|
||||
"display",
|
||||
"display-lg",
|
||||
"display-xl",
|
||||
"hero-stream",
|
||||
"hero-stream-sm",
|
||||
"hero-stream-md",
|
||||
"detail-title",
|
||||
"notfound-code",
|
||||
"pricing-hero",
|
||||
"pricing-title",
|
||||
"auth-title",
|
||||
"landing-hero",
|
||||
"landing-platforms-title",
|
||||
"landing-immersive-title",
|
||||
"landing-service-hero",
|
||||
"landing-service-title",
|
||||
"landing-service-title-lg",
|
||||
"landing-service-sub"
|
||||
],
|
||||
"eases": [
|
||||
"standard",
|
||||
"spring",
|
||||
"spring-soft",
|
||||
"sheet",
|
||||
"pop"
|
||||
],
|
||||
"radii": [
|
||||
"sm",
|
||||
"md",
|
||||
"lg",
|
||||
"xl",
|
||||
"2xl",
|
||||
"3xl",
|
||||
"4xl",
|
||||
"tight",
|
||||
"drawer",
|
||||
"slab"
|
||||
],
|
||||
"durations": [
|
||||
"micro",
|
||||
"base",
|
||||
"layout",
|
||||
"slow",
|
||||
"hero",
|
||||
"cinematic"
|
||||
]
|
||||
} as const;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { extendTailwindMerge } from "tailwind-merge";
|
||||
|
||||
import { DESIGN_TOKENS } from "./design-tokens.generated";
|
||||
|
||||
/**
|
||||
* 自定义工具类的 token(字号 / 圆角 / 缓动 / 时长)要告诉 tailwind-merge 它们属于哪一组:
|
||||
* 不登记的话 `text-control text-muted-foreground` 会被当成两个颜色类冲突,字号那个被丢掉。
|
||||
* 登记表由 scripts/gen-design-tokens.mjs 从 app.css 生成,CI 校验它是最新的。
|
||||
*/
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
theme: {
|
||||
radius: [...DESIGN_TOKENS.radii],
|
||||
ease: [...DESIGN_TOKENS.eases],
|
||||
},
|
||||
classGroups: {
|
||||
"font-size": [{ text: [...DESIGN_TOKENS.fontSizes] }],
|
||||
duration: [{ duration: [...DESIGN_TOKENS.durations] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
if (!+bytes) return "0 B";
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return d.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
Reference in new issue
Block a user