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>
This commit is contained in:
18
packages/api-client/package.json
Normal file
18
packages/api-client/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@bid-doctor/api-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"gen": "openapi-typescript ../../docs/architecture/openapi.yaml -o src/schema.d.ts",
|
||||
"typecheck": "tsc --noEmit -p ."
|
||||
},
|
||||
"dependencies": {
|
||||
"openapi-fetch": "0.13.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openapi-typescript": "7.8.0",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
146
packages/api-client/src/index.ts
Normal file
146
packages/api-client/src/index.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 接口客户端:类型由 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();
|
||||
}
|
||||
4467
packages/api-client/src/schema.d.ts
vendored
Normal file
4467
packages/api-client/src/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
13
packages/api-client/tsconfig.json
Normal file
13
packages/api-client/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user