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:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent ad7fba6021
commit 6aaf8e768b
36 changed files with 8684 additions and 0 deletions

View 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"
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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"]
}