- packages/api-client:从 openapi.yaml 生成类型;cookie + CSRF / Bearer 两种鉴权、 分片上传、fetch 读事件流(API2) - apps/web:Vue 3 + Vite + TanStack Query,样式只来自 tokens.css / components.css(AR10); 扣点说明放在「开始解析」旁边(红线 9);接口地址、路由方式运行时可配(架构 §5.2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
5.5 KiB
TypeScript
147 lines
5.5 KiB
TypeScript
/**
|
||
* 接口客户端:类型由 openapi.yaml 生成(pnpm gen:api),不手写。
|
||
*
|
||
* 守住架构 §5.2 的约束:
|
||
* - 接口地址由调用方传入(网页版同源,桌面版用用户选择的服务器);
|
||
* - 网页版用 cookie,写操作自动带 X-CSRF-Token;桌面版用 Bearer 令牌;
|
||
* - 事件流用 fetch 读(API2),EventSource 带不了 Bearer 头。
|
||
*/
|
||
import createClient, { type Middleware } from "openapi-fetch";
|
||
import type { components, paths } from "./schema";
|
||
|
||
export type { components, paths };
|
||
export type Schemas = components["schemas"];
|
||
export type Problem = Schemas["Problem"];
|
||
export type BdEvent = Schemas["Event"];
|
||
|
||
export interface ClientOptions {
|
||
/** 例如 "/api/v1" 或 "https://saas.example.com/api/v1" */
|
||
baseUrl: string;
|
||
/** 桌面版:返回 Bearer 令牌;网页版不传,走 cookie */
|
||
getToken?: () => string | null;
|
||
}
|
||
|
||
const SAFE = new Set(["GET", "HEAD", "OPTIONS"]);
|
||
|
||
function readCookie(name: string): string | null {
|
||
if (typeof document === "undefined") return null;
|
||
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
||
return m?.[1] ? decodeURIComponent(m[1]) : null;
|
||
}
|
||
|
||
/** 服务端返回的 problem+json,带稳定的 code(api-design §5)。前端按 code 判断,不解析文案。 */
|
||
export class ApiError extends Error {
|
||
constructor(public readonly status: number, public readonly problem: Problem) {
|
||
super(problem.title);
|
||
}
|
||
get code(): string {
|
||
return this.problem.code;
|
||
}
|
||
}
|
||
|
||
export function createApi(opts: ClientOptions) {
|
||
const auth: Middleware = {
|
||
onRequest({ request }) {
|
||
const token = opts.getToken?.();
|
||
if (token) {
|
||
request.headers.set("Authorization", `Bearer ${token}`);
|
||
} else if (!SAFE.has(request.method)) {
|
||
const csrf = readCookie("bd_csrf");
|
||
if (csrf) request.headers.set("X-CSRF-Token", csrf);
|
||
}
|
||
return request;
|
||
},
|
||
};
|
||
const client = createClient<paths>({ baseUrl: opts.baseUrl, credentials: "include" });
|
||
client.use(auth);
|
||
return client;
|
||
}
|
||
|
||
export type Api = ReturnType<typeof createApi>;
|
||
|
||
/** 取出数据,失败时抛 ApiError。 */
|
||
export async function unwrap<T>(p: Promise<{ data?: T; error?: unknown; response: Response }>): Promise<T> {
|
||
const { data, error, response } = await p;
|
||
if (error !== undefined || !response.ok) {
|
||
const problem = (error ?? { title: "请求失败", status: response.status, code: "internal_error" }) as Problem;
|
||
throw new ApiError(response.status, problem);
|
||
}
|
||
return data as T;
|
||
}
|
||
|
||
/** 按分片上传一个文件(api-design §4.3),返回完成后的上传信息。 */
|
||
export async function uploadFile(
|
||
api: Api,
|
||
opts: ClientOptions,
|
||
file: Blob & { name: string },
|
||
purpose: "project_document" | "qualification_file",
|
||
onProgress?: (ratio: number) => void,
|
||
): Promise<Schemas["UploadCompleted"]> {
|
||
const up = await unwrap(
|
||
api.POST("/uploads", { body: { filename: file.name, size_bytes: file.size, purpose } }),
|
||
);
|
||
for (let n = 1; n <= up.part_count; n++) {
|
||
if (up.received_parts.includes(n)) continue;
|
||
const part = file.slice((n - 1) * up.part_size, n * up.part_size);
|
||
const res = await fetch(`${opts.baseUrl}/uploads/${up.id}/parts/${n}`, {
|
||
method: "PUT",
|
||
body: part,
|
||
credentials: "include",
|
||
headers: authHeaders(opts, "PUT"),
|
||
});
|
||
if (!res.ok) throw new ApiError(res.status, (await res.json()) as Problem);
|
||
onProgress?.(n / up.part_count);
|
||
}
|
||
return unwrap(api.POST("/uploads/{upload_id}/complete", { params: { path: { upload_id: up.id } } }));
|
||
}
|
||
|
||
function authHeaders(opts: ClientOptions, method: string): Record<string, string> {
|
||
const token = opts.getToken?.();
|
||
if (token) return { Authorization: `Bearer ${token}` };
|
||
const csrf = SAFE.has(method) ? null : readCookie("bd_csrf");
|
||
return csrf ? { "X-CSRF-Token": csrf } : {};
|
||
}
|
||
|
||
/**
|
||
* 订阅事件流(SSE,用 fetch 读)。断线后带 Last-Event-ID 自动重连;
|
||
* 事件只是「有变化」的提示,收到后让相关查询重新请求即可(api-design §4.2)。
|
||
* 返回取消函数。
|
||
*/
|
||
export function subscribeEvents(opts: ClientOptions, onEvent: (e: BdEvent) => void): () => void {
|
||
const ctrl = new AbortController();
|
||
let lastId = "";
|
||
const loop = async () => {
|
||
while (!ctrl.signal.aborted) {
|
||
try {
|
||
const headers: Record<string, string> = { Accept: "text/event-stream", ...authHeaders(opts, "GET") };
|
||
if (lastId) headers["Last-Event-ID"] = lastId;
|
||
const res = await fetch(`${opts.baseUrl}/events`, { headers, credentials: "include", signal: ctrl.signal });
|
||
if (!res.ok || !res.body) throw new Error(`events ${res.status}`);
|
||
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
||
let buf = "";
|
||
for (;;) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += value;
|
||
let i: number;
|
||
while ((i = buf.indexOf("\n\n")) >= 0) {
|
||
const block = buf.slice(0, i);
|
||
buf = buf.slice(i + 2);
|
||
let data = "";
|
||
for (const line of block.split("\n")) {
|
||
if (line.startsWith("id: ")) lastId = line.slice(4);
|
||
else if (line.startsWith("data: ")) data += line.slice(6);
|
||
}
|
||
if (data) onEvent(JSON.parse(data) as BdEvent);
|
||
}
|
||
}
|
||
} catch {
|
||
if (ctrl.signal.aborted) return;
|
||
}
|
||
await new Promise((r) => setTimeout(r, 3000)); // 断线:3 秒后重连;页面上的轮询会兜底
|
||
}
|
||
};
|
||
void loop();
|
||
return () => ctrl.abort();
|
||
}
|