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:
19
apps/web/src/lib/api.ts
Normal file
19
apps/web/src/lib/api.ts
Normal 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);
|
||||
}
|
||||
24
apps/web/src/lib/config.ts
Normal file
24
apps/web/src/lib/config.ts
Normal 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,
|
||||
});
|
||||
21
apps/web/src/lib/crumbs.ts
Normal file
21
apps/web/src/lib/crumbs.ts
Normal 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()));
|
||||
}
|
||||
70
apps/web/src/lib/events.ts
Normal file
70
apps/web/src/lib/events.ts
Normal 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
12
apps/web/src/lib/keys.ts
Normal 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
134
apps/web/src/lib/labels.ts
Normal 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`;
|
||||
}
|
||||
92
apps/web/src/lib/queries.ts
Normal file
92
apps/web/src/lib/queries.ts
Normal 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[];
|
||||
}>,
|
||||
});
|
||||
}
|
||||
24
apps/web/src/lib/queryClient.ts
Normal file
24
apps/web/src/lib/queryClient.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user