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:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user