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

19
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* 接口客户端单例。网页版走 cookie(写操作由 api-client 自动带 X-CSRF-Token);
* 桌面版以后在这里传 getToken(从系统钥匙串取 Bearer 令牌),业务代码不用改。
*/
import { ApiError, createApi, type ClientOptions } from "@bid-doctor/api-client";
import { runtimeConfig } from "./config";
export const apiOptions: ClientOptions = { baseUrl: runtimeConfig.apiBaseUrl };
export const api = createApi(apiOptions);
/** 网络中断等非接口错误也包成「请求失败」,界面统一处理。 */
export function errorTitle(e: unknown): string {
if (e instanceof ApiError) return e.problem.title;
return "网络连接失败,请检查网络后重试";
}
export function isApiError(e: unknown, code?: string): e is ApiError {
return e instanceof ApiError && (code === undefined || e.code === code);
}

View File

@@ -0,0 +1,24 @@
/**
* 运行时配置(架构 §5.2 第 2、6 条):接口地址和路由基础路径都不写死,
* 由 public/config.js 在页面加载前写入 window.__BD_CONFIG__;桌面外壳可以在加载前注入同名对象。
*/
export interface RuntimeConfig {
apiBaseUrl: string;
routerMode: "history" | "hash";
routerBase: string;
}
declare global {
interface Window {
__BD_CONFIG__?: Partial<RuntimeConfig>;
}
}
const raw = window.__BD_CONFIG__ ?? {};
const viteBase = import.meta.env.BASE_URL.startsWith("/") ? import.meta.env.BASE_URL : "/";
export const runtimeConfig: Readonly<RuntimeConfig> = Object.freeze({
apiBaseUrl: (raw.apiBaseUrl ?? "/api/v1").replace(/\/+$/, ""),
routerMode: raw.routerMode === "hash" ? "hash" : "history",
routerBase: raw.routerBase ?? viteBase,
});

View File

@@ -0,0 +1,21 @@
/** 顶栏面包屑:页面声明自己的位置,AppShell 显示。纯界面状态,用 provide / inject,不进 Pinia。 */
import { inject, provide, ref, watchEffect, type InjectionKey, type Ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
export interface Crumb {
label: string;
to?: RouteLocationRaw;
}
const KEY: InjectionKey<Ref<Crumb[]>> = Symbol("crumbs");
export function provideCrumbs(): Ref<Crumb[]> {
const crumbs = ref<Crumb[]>([]);
provide(KEY, crumbs);
return crumbs;
}
export function useCrumbs(get: () => Crumb[]) {
const crumbs = inject(KEY, null);
if (crumbs) watchEffect(() => (crumbs.value = get()));
}

View File

@@ -0,0 +1,70 @@
/**
* 事件流(SSE,用 fetch 读,API2):事件只是「有变化」的提示,以资源的 GET 结果为准。
* 进度事件直接写进解析任务和项目列表的缓存,让进度条即时动;其余事件让相关查询失效、重新请求。
* 断线由 api-client 自动重连;页面上的 5 秒轮询兜底。
*/
import { subscribeEvents, type BdEvent } from "@bid-doctor/api-client";
import type { QueryClient } from "@tanstack/vue-query";
import { apiOptions } from "./api";
import { qk } from "./keys";
import { TERMINAL_JOB, type ParseJob, type ProjectListItem } from "./queries";
type ProjectPage = { items: ProjectListItem[]; next_cursor: string | null };
function onEvent(qc: QueryClient, e: BdEvent) {
const job = e.job;
const jobId = job?.id;
if ((e.type === "job.progress" || e.type === "job.finished") && job && jobId && job.kind === "parse_job") {
// 事件可能比 GET 结果旧(重连补发、乱序):已结束的任务不再被进度事件改回「进行中」,进度只增不减
qc.setQueryData<ParseJob>(qk.parseJob(jobId), (old) =>
!old || TERMINAL_JOB.has(old.status) || (job.progress ?? 0) < old.progress
? old
: {
...old,
status: job.status ?? old.status,
stage: (job.stage as ParseJob["stage"]) ?? old.stage,
progress: job.progress ?? old.progress,
},
);
qc.setQueryData<ProjectPage>(qk.projects, (old) =>
old && {
...old,
items: old.items.map((p) =>
p.active_job && p.active_job.id === jobId && (job.progress ?? 0) >= (p.active_job.progress ?? 0) ? { ...p, active_job: { ...p.active_job, progress: job.progress ?? p.active_job.progress } } : p,
),
},
);
if (e.type === "job.finished") {
// 结束后要拿完整结果(error、result),并刷新项目、额度、清单
void qc.invalidateQueries({ queryKey: qk.parseJob(jobId) });
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
if (e.project_id) {
void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checklist(e.project_id) });
void qc.invalidateQueries({ queryKey: qk.checklistItems(e.project_id) });
}
}
return;
}
switch (e.type) {
case "quota.changed":
void qc.invalidateQueries({ queryKey: qk.quota });
break;
case "project.changed":
void qc.invalidateQueries({ queryKey: qk.projects });
if (e.project_id) void qc.invalidateQueries({ queryKey: qk.project(e.project_id) });
break;
case "resync":
void qc.invalidateQueries();
break;
default:
// notification.created、report.ready:骨架阶段还没有对应界面
break;
}
}
/** 登录后的页面框架挂载时开始订阅,返回取消函数。 */
export function startEventStream(qc: QueryClient): () => void {
return subscribeEvents(apiOptions, (e) => onEvent(qc, e));
}

12
apps/web/src/lib/keys.ts Normal file
View File

@@ -0,0 +1,12 @@
/** TanStack Query 的查询键,集中定义,事件流按这些键让缓存失效。 */
export const qk = {
me: ["me"] as const,
quota: ["quota"] as const,
plans: ["plans"] as const,
projects: ["projects"] as const,
project: (id: string) => ["project", id] as const,
document: (id: string) => ["document", id] as const,
parseJob: (id: string) => ["parse-job", id] as const,
checklist: (projectId: string) => ["checklist", projectId] as const,
checklistItems: (projectId: string) => ["checklist-items", projectId] as const,
};

134
apps/web/src/lib/labels.ts Normal file
View File

@@ -0,0 +1,134 @@
/** 枚举的中文显示名由前端维护(api-design §1);不认识的枚举值原样显示,不报错。 */
import type { Schemas } from "@bid-doctor/api-client";
type Dict<K extends string> = Readonly<Record<K, string>>;
const pick = <K extends string>(d: Dict<K>, k: string | null | undefined): string => (k ? (d as Record<string, string>)[k] ?? k : "");
export const PROJECT_STATUS: Dict<Schemas["ProjectStatus"]> = {
tracking: "跟进中",
drafting: "编制中",
checked: "已检查",
submitted: "已递交",
archived: "已归档",
};
export const projectStatus = (s: string) => pick(PROJECT_STATUS, s);
export const LEGAL_REGIME: Dict<Schemas["LegalRegime"]> = {
gov_procurement: "政府采购",
tendering: "招标投标",
unknown: "法律体系待识别",
};
export const legalRegime = (s: string) => pick(LEGAL_REGIME, s);
export const CATEGORY_ORDER: readonly Schemas["Category"][] = [
"qualification",
"invalidity",
"scoring",
"schedule",
"tech_param",
"format_doc",
"price",
];
export const CATEGORY: Dict<Schemas["Category"]> = {
qualification: "资格条件",
invalidity: "无效条款",
scoring: "评分办法",
schedule: "日期与递交",
tech_param: "技术参数",
format_doc: "格式文件",
price: "报价",
};
export const category = (s: string) => pick(CATEGORY, s);
/** 界面显示为 ★ / ▲ / 一般(一般不显示符号) */
export const IMPORTANCE_MARK: Dict<Schemas["Importance"]> = { substantive: "★", important: "▲", normal: "" };
export const IMPORTANCE_NAME: Dict<Schemas["Importance"]> = { substantive: "实质性要求", important: "重要", normal: "一般" };
export const PENDING_REASON: Dict<Schemas["PendingReason"]> = {
no_citation: "没找到可靠出处",
low_confidence: "把握不大",
verify_failed: "出处核对没通过",
marker_uncovered: "标注句没被提取覆盖,由规则补出",
absent_in_rerun: "重新解析时没再出现",
};
export const pendingReason = (s: string | null | undefined) => pick(PENDING_REASON, s);
export const MARKER: Dict<Schemas["MarkingRule"]["marker"]> = {
star: "★",
asterisk: "*",
triangle: "▲",
italic_underline: "斜体加下划线",
bold: "加粗",
underline: "下划线",
keyword: "关键词",
};
export const QUOTA_UNIT: Dict<Schemas["QuotaUnit"]> = { parse: "解析", check: "检查", generate: "生成", dedupe: "查重" };
export const quotaUnit = (s: string) => pick(QUOTA_UNIT, s);
export const PLAN_KEY: Dict<Schemas["PlanCode"]> = { free: "free", pro: "pro", enterprise: "ent" };
/** 工作台「下一步」。target 为空表示骨架阶段还没有对应页面,只显示文字。 */
export const NEXT_ACTION: Dict<Schemas["NextAction"]> = {
upload_tender: "上传招标文件",
wait_parse: "查看解析进度",
select_lots: "选择所投的包",
confirm_checklist: "确认清单",
upload_bid: "上传投标文件",
run_check: "发起检查",
handle_fatal: "处理致命问题",
export_report: "导出检查报告",
archive: "归档项目",
none: "查看项目",
};
export const nextAction = (s: string) => pick(NEXT_ACTION, s);
/** 解析阶段(§7.11):服务端的 8 个 stage 合成界面上的 5 步 */
export const PARSE_STEPS: readonly { label: string; stages: readonly string[] }[] = [
{ label: "识别文件格式", stages: ["detect", "convert"] },
{ label: "提取文字", stages: ["text_layer", "ocr"] },
{ label: "识别章节结构", stages: ["structure"] },
{ label: "提取清单", stages: ["extract"] },
{ label: "核对出处", stages: ["verify", "merge"] },
];
export const parseStepIndex = (stage: string | null | undefined) =>
stage ? PARSE_STEPS.findIndex((s) => s.stages.includes(stage)) : -1;
export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
/* ---------- 时间 ---------- */
const pad = (n: number) => String(n).padStart(2, "0");
export const fmtDate = (d: Date) => `${d.getMonth() + 1}月${d.getDate()}日`;
export const fmtTime = (d: Date) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
export const fmtDateTime = (iso: string) => {
const d = new Date(iso);
return `${fmtDate(d)} ${fmtTime(d)}`;
};
export const fmtDuration = (ms: number) => {
const s = Math.max(0, Math.round(ms / 1000));
return { min: Math.floor(s / 60), sec: pad(s % 60) };
};
/** 投标截止的两行显示和颜色类(components.css 的 .due-*) */
export function deadline(iso: string | null | undefined, now = new Date()): { main: string; sub: string; cls: string } | null {
if (!iso) return null;
const d = new Date(iso);
const ms = d.getTime() - now.getTime();
const when = `${fmtDate(d)} ${fmtTime(d)}`;
if (ms < 0) return { main: "已截止", sub: when, cls: "due-past" };
const hours = Math.floor(ms / 3_600_000);
const days = Math.floor(hours / 24);
if (d.toDateString() === now.toDateString()) return { main: `今天 ${fmtTime(d)} 截止`, sub: `剩 ${hours} 小时`, cls: "due-today" };
if (hours <= 72) return { main: `剩 ${days} 天 ${hours % 24} 小时`, sub: when, cls: "due-soon" };
return { main: `剩 ${days} 天`, sub: when, cls: "" };
}
export function extOf(name: string): string {
const m = /\.([a-z0-9]{1,5})$/i.exec(name);
return m?.[1] ? m[1].toUpperCase() : "文件";
}
export function fmtSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,92 @@
/** 服务端数据:全部由 TanStack Query 管理,不放进 Pinia(架构 §4.2)。 */
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { useQuery } from "@tanstack/vue-query";
import { computed, toValue, type MaybeRefOrGetter } from "vue";
import { api } from "./api";
import { qk } from "./keys";
export type Me = Schemas["Me"];
export type Quota = Schemas["QuotaOverview"];
export type Plan = Schemas["Plan"];
export type ProjectListItem = Schemas["ProjectListItem"];
export type ProjectDetail = Schemas["ProjectDetail"];
export type DocumentInfo = Schemas["Document"];
export type ParseJob = Schemas["ParseJob"];
export type Checklist = Schemas["Checklist"];
/**
* 契约里 ChecklistItem 把 categories、title、importance 列为必填,但它们定义在 allOf 的 ChecklistItemFields 里,
* openapi-typescript 生成的类型仍是可选。这里按契约的 required 收窄,不改字段本身。
*/
export type ChecklistItem = Schemas["ChecklistItem"] & Required<Pick<Schemas["ChecklistItemFields"], "categories" | "title" | "importance">>;
export type Citation = Schemas["Citation"];
export type MarkingRule = Schemas["MarkingRule"];
export type SessionInfo = Schemas["SessionInfo"];
export const fetchMe = () => unwrap(api.GET("/me"));
export function useMe() {
return useQuery({ queryKey: qk.me, queryFn: fetchMe, staleTime: 60_000 });
}
export function useQuota() {
return useQuery({ queryKey: qk.quota, queryFn: () => unwrap(api.GET("/quota")) });
}
/** 某个额度单位(如 parse)的本期账户;没有这个单位时为 undefined */
export function useQuotaAccount(unit: Schemas["QuotaUnit"]) {
const q = useQuota();
return computed(() => q.data.value?.accounts.find((a) => a.unit === unit));
}
export function usePlans() {
return useQuery({ queryKey: qk.plans, queryFn: () => unwrap(api.GET("/plans")), staleTime: 10 * 60_000 });
}
export function useProjects() {
// 骨架阶段只取第一页(默认按截止时间排序);项目多了再接游标分页
return useQuery({ queryKey: qk.projects, queryFn: () => unwrap(api.GET("/projects", { params: { query: { limit: 100 } } })) });
}
export function useProject(id: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => qk.project(toValue(id) ?? "")),
queryFn: () => unwrap(api.GET("/projects/{project_id}", { params: { path: { project_id: toValue(id)! } } })),
enabled: computed(() => !!toValue(id)),
});
}
export function useDocument(id: MaybeRefOrGetter<string | undefined>) {
return useQuery({
queryKey: computed(() => qk.document(toValue(id) ?? "")),
queryFn: () => unwrap(api.GET("/documents/{document_id}", { params: { path: { document_id: toValue(id)! } } })),
enabled: computed(() => !!toValue(id)),
});
}
export const TERMINAL_JOB: ReadonlySet<Schemas["JobStatus"]> = new Set(["succeeded", "failed", "cancelled"]);
/** 解析进度:事件流推送为主(lib/events.ts 直接改这条缓存),每 5 秒轮询兜底,结束后停止轮询。 */
export function useParseJob(id: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.parseJob(toValue(id))),
queryFn: () => unwrap(api.GET("/parse-jobs/{job_id}", { params: { path: { job_id: toValue(id) } } })),
refetchInterval: (query) => (query.state.data && TERMINAL_JOB.has(query.state.data.status) ? false : 5000),
});
}
export function useChecklist(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.checklist(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/checklist", { params: { path: { project_id: toValue(projectId) } } })),
});
}
export function useChecklistItems(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.checklistItems(toValue(projectId))),
queryFn: () =>
unwrap(api.GET("/projects/{project_id}/checklist/items", { params: { path: { project_id: toValue(projectId) } } })) as Promise<{
items: ChecklistItem[];
}>,
});
}

View File

@@ -0,0 +1,24 @@
import { ApiError } from "@bid-doctor/api-client";
import { MutationCache, QueryCache, QueryClient } from "@tanstack/vue-query";
let onUnauthorized: (e: ApiError) => void = () => {};
/** 会话失效(401:过期、被其他设备挤下线)时的统一处理,由 main.ts 注入(需要 router)。 */
export function setUnauthorizedHandler(fn: (e: ApiError) => void) {
onUnauthorized = fn;
}
const handle = (e: unknown) => {
if (e instanceof ApiError && e.status === 401) onUnauthorized(e);
};
export const queryClient = new QueryClient({
queryCache: new QueryCache({ onError: handle }),
mutationCache: new MutationCache({ onError: handle }),
defaultOptions: {
queries: {
// 4xx 是确定的结果,不重试;网络和 5xx 重试两次
retry: (count, e) => !(e instanceof ApiError && e.status < 500) && count < 2,
refetchOnWindowFocus: false,
},
},
});