Files
bid-doctor/packages/api-client/src/index.ts
YANG JIANKUAN 6aaf8e768b feat(web): 用户端骨架——登录、工作台、新建项目、解析进度、读标清单
- 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>
2026-11-03 18:48:10 +08:00

147 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 接口客户端:类型由 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();
}