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

14
apps/web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>标博士</title>
<!-- 运行时配置(接口地址、路由模式):部署时替换 config.js,不用重新构建(架构 §5.2 第 2 条) -->
<script src="%BASE_URL%config.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

29
apps/web/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@bid-doctor/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@bid-doctor/api-client": "workspace:*",
"@fontsource/barlow-semi-condensed": "5.3.0",
"@fontsource/noto-serif-sc": "5.3.0",
"@lucide/vue": "1.50.0",
"@tanstack/vue-query": "5.104.1",
"reka-ui": "2.10.5",
"vue": "3.5.43",
"vue-router": "4.6.4"
},
"devDependencies": {
"@types/node": "24.19.2",
"@vitejs/plugin-vue": "6.0.9",
"typescript": "5.8.3",
"vite": "7.3.7",
"vue-tsc": "3.3.12"
}
}

View File

@@ -0,0 +1,9 @@
/* 标博士用户端运行时配置。部署时按环境替换本文件,不用重新构建。
* apiBaseUrl:接口地址。网页版默认同源 "/api/v1";桌面版写用户选择的服务器,如 "https://saas.example.com/api/v1"。
* routerMode:"history"(网页版)或 "hash"(桌面外壳以 file:// 或自定义协议加载时)。
* routerBase:history 模式下的路由基础路径,部署在子路径时改成如 "/app/"。 */
window.__BD_CONFIG__ = {
apiBaseUrl: "/api/v1",
routerMode: "history",
routerBase: "/",
};

7
apps/web/src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
/**
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
*/
import { House, LogOut } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-vertical-reverse.svg?url";
import { api } from "@/lib/api";
import { provideCrumbs } from "@/lib/crumbs";
import { startEventStream } from "@/lib/events";
import { PLAN_KEY, quotaUnit } from "@/lib/labels";
import { useMe, useQuota } from "@/lib/queries";
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const crumbs = provideCrumbs();
const me = useMe();
const quota = useQuota();
const plan = computed(() => me.data.value?.plan);
const planKey = computed(() => (plan.value ? PLAN_KEY[plan.value.code] : "free"));
const name = computed(() => me.data.value?.user.display_name ?? "");
/** 顶栏额度:只列本期有发放的单位;用到 ≥ 80% 变赭色(Q9) */
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []);
const quotaTip = computed(
() => "本期剩余额度:" + accounts.value.map((a) => `${quotaUnit(a.unit)} ${a.balance} / ${a.period_granted} 次`).join(","),
);
const onWorkbench = computed(() => route.matched.some((r) => r.name === "workbench") || route.path.startsWith("/projects"));
const scrolled = ref(false);
const onScroll = () => (scrolled.value = window.scrollY > 4);
let stopEvents: (() => void) | null = null;
onMounted(() => {
window.addEventListener("scroll", onScroll, { passive: true });
stopEvents = startEventStream(qc);
});
onBeforeUnmount(() => {
window.removeEventListener("scroll", onScroll);
stopEvents?.();
});
const loggingOut = ref(false);
async function logout() {
loggingOut.value = true;
try {
await api.POST("/auth/logout");
} finally {
stopEvents?.();
stopEvents = null;
qc.clear();
await router.replace({ name: "login" });
}
}
</script>
<template>
<div class="app-frame">
<aside class="sidebar">
<RouterLink class="brand" :to="{ name: 'workbench' }" aria-label="标博士 · 工作台">
<img :src="logoUrl" alt="" />
</RouterLink>
<nav class="nav" aria-label="主导航">
<RouterLink :to="{ name: 'workbench' }" :aria-current="onWorkbench ? 'page' : undefined">
<House class="ic" :stroke-width="1.5" /><span>工作台</span>
</RouterLink>
</nav>
<div class="me">
<span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span>
<b class="uname" :title="name">{{ name }}</b>
<span v-if="plan" class="plan" :data-plan="planKey">{{ plan.name }}</span>
</div>
</aside>
<main class="main">
<header class="top" :class="{ scrolled }">
<div class="crumbs">
<span class="org">{{ me.data.value?.tenant.name }}</span>
<template v-for="(c, i) in crumbs" :key="i">
<span aria-hidden="true">/</span>
<RouterLink v-if="c.to && i < crumbs.length - 1" :to="c.to" :title="c.label">{{ c.label }}</RouterLink>
<b v-else :title="c.label">{{ c.label }}</b>
</template>
</div>
<div class="top-right">
<span v-if="accounts.length" class="quota-chip tip-below tip-left" tabindex="0" :data-tip="quotaTip" :aria-label="quotaTip">
<template v-for="(a, i) in accounts" :key="a.unit">
<span v-if="i > 0" class="sep"></span>
<span class="q" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ quotaUnit(a.unit) }}<span class="fig">{{ a.balance }}</span></span>
</template>
</span>
<span class="vsep" aria-hidden="true"></span>
<button class="icon-btn tip-below tip-left" type="button" aria-label="退出登录" data-tip="退出登录" :disabled="loggingOut" @click="logout">
<LogOut class="ic lg" :stroke-width="1.5" />
</button>
</div>
</header>
<RouterView />
</main>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
/**
* 清单项(设计规范 §7.5、§7.6、§7.16):重要性 + 标题 + 标签(来源、分类、包)+ 出处。
* AI 产出的文字带「AI」标签、正文墨色;待确认区的条目另加左侧 2px 蓝铅笔虚线(.ai-block)。
*/
import { computed } from "vue";
import CitationBlock from "./CitationBlock.vue";
import { category, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
const props = defineProps<{ item: ChecklistItem; pending?: boolean }>();
/** 一条结论最多默认展开 2 条出处,其余写「另有 N 处」(§7.3) */
const shown = computed(() => props.item.citations.slice(0, 2));
const more = computed(() => props.item.citations.length - shown.value.length);
const mark = computed(() => IMPORTANCE_MARK[props.item.importance] ?? "");
/** AI 摘要和标题不同时才单独显示,避免重复 */
const summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
</script>
<template>
<div class="item" :class="{ pq: pending }">
<span class="imp" :title="IMPORTANCE_NAME[item.importance]">
{{ mark }}<span v-if="mark" class="vh">{{ IMPORTANCE_NAME[item.importance] }}</span>
</span>
<div :class="{ 'ai-block': pending }">
<div class="t">{{ item.title }}</div>
<div class="meta">
<span v-if="item.origin === 'ai'" class="tag ai">AI</span>
<span v-else class="tag">{{ item.origin_label || (item.origin === "human" ? "人工添加" : "规则") }}</span>
<span v-for="c in item.categories" :key="c" class="tag">{{ category(c) }}</span>
<span class="tag">{{ item.lot_no ? `采购包 ${item.lot_no}` : "通用" }}</span>
<span v-if="pending && item.pending_reason" class="flag">{{ pendingReason(item.pending_reason) }}</span>
<span v-if="item.no_citation_ack" class="flag">人工确认、无出处</span>
</div>
<p v-if="summary" class="sum"><span class="tag ai">AI 摘要</span>{{ summary }}</p>
<div v-if="shown.length" class="cites">
<CitationBlock v-for="c in shown" :key="c.id" :citation="c" />
<p v-if="more > 0" class="cap muted">另有 <span class="fig">{{ more }}</span> 处出处</p>
</div>
<div v-else class="cites">
<div class="cite missing"><p>{{ item.no_citation_ack ? "人工确认、无出处" : "没有出处,确认前不参与检查" }}</p></div>
</div>
</div>
</div>
</template>
<style scoped>
.item {
grid-template-columns: var(--sp-6) minmax(0, 1fr);
padding: var(--sp-3) var(--card-x) var(--sp-3) var(--sp-3);
border-top: var(--line) solid var(--border-subtle);
}
.item .t {
font-weight: var(--fw-medium);
}
.cites {
margin-top: var(--sp-2);
}
.sum {
display: flex;
gap: var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.cites .cap {
margin: var(--sp-1) 0 0;
}
</style>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
/**
* 出处(设计规范 §7.3):左侧 2px 蓝铅笔竖线,上面是「文件 · 第 N 页」,下面照录原句。
* TODO(pdf.js):点击出处打开原文查看器(pdfjs-dist 渲染 GET /documents/{id}/file?variant=normalized,
* 按 citation.quads 画原句高亮;citation.normalized_sha256 和文档当前哈希不一致时不画高亮)。
* TODO:原句超过 3 行时截断,提供「展开原文」;OCR 识别出的原句前加「OCR」小字(等接口给出页的 text_source)。
*/
import type { Citation } from "@/lib/queries";
defineProps<{ citation: Citation }>();
</script>
<template>
<div class="cite">
<span class="cite-src">
<template v-if="citation.document_label">{{ citation.document_label }} · </template>第 <span class="fig">{{ citation.page_no }}</span> 页
</span>
<q>{{ citation.quote }}</q>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
/**
* 确认对话框(设计规范 §7.14):宽 480,标题写要做的事,正文写后果,主操作在右。
* 交互基础用 Reka UI 的 AlertDialog(焦点锁定、Esc 关闭、关闭后焦点回到原处),外观来自 app.css 的 .scrim / .dialog。
*/
import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
} from "reka-ui";
defineProps<{
title: string;
okText: string;
cancelText?: string;
/** 主操作进行中:按钮文字换成 busyText,宽度不变 */
busy?: boolean;
busyText?: string;
}>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ ok: [] }>();
</script>
<template>
<AlertDialogRoot v-model:open="open">
<AlertDialogPortal>
<AlertDialogOverlay class="scrim">
<AlertDialogContent class="dialog">
<AlertDialogTitle as="h2">{{ title }}</AlertDialogTitle>
<AlertDialogDescription as="div" class="body"><slot /></AlertDialogDescription>
<div class="foot">
<AlertDialogCancel class="btn" :disabled="busy">{{ cancelText ?? "取消" }}</AlertDialogCancel>
<button type="button" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy" @click="emit('ok')">
<span class="idle">{{ okText }}</span><span class="busy">{{ busyText ?? "正在处理…" }}</span>
</button>
</div>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialogPortal>
</AlertDialogRoot>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
/**
* 工作台项目行(设计规范 §7.9,原型 app.css B20):整行可点,「下一步」单独可点。
* 列:项目 · 投标截止 · 状态(含就绪)· 待确认 · 下一步。
*/
import { ChevronRight } from "@lucide/vue";
import { computed } from "vue";
import { RouterLink, type RouteLocationRaw } from "vue-router";
import { deadline, nextAction, projectStatus, READY_TIP } from "@/lib/labels";
import type { ProjectListItem } from "@/lib/queries";
const props = defineProps<{ project: ProjectListItem }>();
const p = computed(() => props.project);
const due = computed(() => deadline(p.value.deadline_at));
const sub = computed(() => [p.value.purchaser, p.value.code].filter(Boolean).join(" · "));
const job = computed(() => p.value.active_job ?? null);
/** 项目的默认入口:解析中去进度页;还没上传招标文件去上传;其余去清单(骨架阶段唯一的项目内页面) */
const home = computed<RouteLocationRaw>(() => {
const id = p.value.id;
if (job.value?.kind === "parse_job") return { name: "parse", params: { projectId: id, jobId: job.value.id } };
if (p.value.next_action === "upload_tender") return { name: "project-new", query: { project: id } };
return { name: "checklist", params: { projectId: id } };
});
/** 「下一步」:骨架阶段只有上传、解析进度、清单三个去处,其余动作先落到清单页 */
const next = computed<RouteLocationRaw>(() => home.value);
</script>
<template>
<li class="prow">
<span class="pt">
<RouterLink class="pname" :to="home" :title="p.name">{{ p.name }}</RouterLink>
<span class="psub">
<template v-if="job">{{ job.label || "任务进行中" }} <span class="fig">{{ job.progress ?? 0 }}%</span><template v-if="sub"> · </template></template>{{ sub }}<template v-if="!job && !sub">招标文件信息待解析</template>
</span>
</span>
<span class="dl">
<template v-if="due"><b :class="due.cls">{{ due.main }}</b><small>{{ due.sub }}</small></template>
<span v-else class="dash" aria-label="未填写">—</span>
</span>
<span class="st">
<span class="tag">{{ projectStatus(p.status) }}</span>
<template v-if="p.readiness.ready === true">
<span class="hint-u tip-below" tabindex="0" :data-tip="READY_TIP">就绪</span>
</template>
<template v-else-if="p.readiness.ready === false">
<span class="hint-u tip-below" tabindex="0" :data-tip="`还有 ${p.readiness.open_fatal_count} 个致命问题没处理。不影响导出,决定权在你。`">未就绪</span>
</template>
</span>
<span class="c">
<span v-if="p.pending_item_count" class="fig" :aria-label="`待确认 ${p.pending_item_count} 条`">{{ p.pending_item_count }}</span>
<span v-else class="dash" aria-label="无">—</span>
</span>
<span>
<RouterLink class="nx" :to="next"><span>{{ nextAction(p.next_action) }}</span><ChevronRight class="ic" :stroke-width="1.5" /></RouterLink>
</span>
<span></span>
</li>
</template>

7
apps/web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

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,
},
},
});

28
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,28 @@
import { VueQueryPlugin } from "@tanstack/vue-query";
import { createApp } from "vue";
import faviconUrl from "@design/logo/favicon.svg?url";
// 字体全部自托管(设计规范 §4.1):标题思源宋体只引 600;数字 Barlow Semi Condensed 500 / 600;正文用系统黑体
import "@fontsource/noto-serif-sc/600.css";
import "@fontsource/barlow-semi-condensed/500.css";
import "@fontsource/barlow-semi-condensed/600.css";
// 设计规范是唯一的样式来源(AR10):直接引用 docs/design/system,不复制
import "@design/system/tokens.css";
import "@design/system/components.css";
import "@/styles/app.css";
import App from "./App.vue";
import { queryClient, setUnauthorizedHandler } from "./lib/queryClient";
import { router } from "./router";
const icon = document.createElement("link");
icon.rel = "icon";
icon.href = faviconUrl;
document.head.append(icon);
setUnauthorizedHandler((e) => {
queryClient.clear();
const cur = router.currentRoute.value;
if (cur.name === "login") return;
void router.replace({ name: "login", query: { redirect: cur.fullPath, reason: e.code } });
});
createApp(App).use(router).use(VueQueryPlugin, { queryClient }).mount("#app");

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
/**
* 读标(原型 checklist.html):项目头 + 项目检查清单。
* 清单按分类分组,待确认区放在顶部、不和正式清单混排(§7.6);顶部写明标注规则和数量核对提示。
* 骨架阶段只读:采纳 / 修改 / 确认清单等写接口还没实现。
* TODO(pdf.js):右侧原文查看器(原型的 .cmp 对照布局)。现在每条直接显示「第 N 页」和原句。
*/
import { FileText, Info, Search } from "@lucide/vue";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue";
import { errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { category, CATEGORY_ORDER, deadline, fmtDateTime, legalRegime, MARKER, projectStatus } from "@/lib/labels";
import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type MarkingRule } from "@/lib/queries";
const props = defineProps<{ projectId: string }>();
const project = useProject(() => props.projectId);
const checklist = useChecklist(() => props.projectId);
const itemsQ = useChecklistItems(() => props.projectId);
const p = computed(() => project.data.value);
const cl = computed(() => checklist.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "读标" }]);
const meta = computed(() =>
p.value ? [p.value.purchaser, p.value.legal_regime !== "unknown" ? legalRegime(p.value.legal_regime) : "", p.value.procurement_method, p.value.code].filter(Boolean).join(" · ") : "",
);
const due = computed(() => deadline(p.value?.deadline_at));
/* ---------- 分组与筛选 ---------- */
const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
const pending = computed(() => all.value.filter((i) => i.zone === "pending"));
type Cat = "all" | "pending" | ChecklistItem["categories"][number];
const cat = ref<Cat>("all");
const kw = ref("");
const starOnly = ref(false);
const catCount = (c: string) => formal.value.filter((i) => i.categories.includes(c as ChecklistItem["categories"][number])).length;
const cats = computed(() => CATEGORY_ORDER.filter((c) => catCount(c) > 0));
const match = (i: ChecklistItem) => (!starOnly.value || i.importance === "substantive") && (!kw.value || i.title.includes(kw.value.trim()));
/** 「全部」视图下每条只出现一次,放在它的第一个分类里(一条要求可以属于多个分类) */
const groups = computed(() => {
const order = cat.value === "all" ? cats.value : cats.value.filter((c) => c === cat.value);
const seen = new Set<string>();
return order
.map((c) => {
const rows = formal.value.filter((i) => i.categories.includes(c) && match(i) && (cat.value !== "all" || !seen.has(i.id)));
rows.forEach((i) => seen.add(i.id));
return { id: c, name: category(c), rows };
})
.filter((g) => g.rows.length);
});
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value);
const showPending = computed(() => (cat.value === "all" && showTop.value) || cat.value === "pending");
/* ---------- 标注规则(如「打 ★ 的为实质性要求」)和数量核对 ---------- */
function ruleText(r: MarkingRule): string {
const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`;
const head = `标注${mk}的为${r.meaning === "substantive" ? "实质性要求" : "重要要求"}。`;
return r.declared_count == null
? `${head}文件没写这类条款的总数,已识别 ${r.found_count} 条,请对照原文核对有无遗漏。`
: `${head}文件写明共 ${r.declared_count} 条,已识别 ${r.found_count} 条。`;
}
const loading = computed(() => itemsQ.isPending.value || checklist.isPending.value);
const loadErr = computed(() => itemsQ.error.value ?? checklist.error.value);
</script>
<template>
<header class="ph">
<div class="ph-l1">
<div>
<h1 :title="p?.name">{{ p?.name ?? " " }}</h1>
<p class="ph-meta">{{ meta || "招标文件信息待解析" }}</p>
</div>
<dl v-if="p" class="facts">
<div>
<dt>投标截止</dt>
<dd>
<template v-if="due"><span class="fig m">{{ due.sub }}</span><span :class="due.cls || 'muted'">{{ due.main }}</span></template>
<span v-else class="muted">—</span>
</dd>
</div>
<div><dt>状态</dt><dd><span class="tag">{{ projectStatus(p.status) }}</span></dd></div>
</dl>
</div>
<!-- 骨架阶段只有「读标」一页;概览、查标、报告做到时在这里加标签页 -->
<nav class="tabs" aria-label="项目"><a role="tab" aria-selected="true" aria-current="page">读标</a></nav>
</header>
<div class="content">
<div class="cl-bar">
<h2>项目检查清单</h2>
<div v-if="cl" class="status">
<template v-if="cl.status === 'confirmed'">
<span class="tag">已确认 v{{ cl.version_no }}</span>
<span v-if="cl.confirmed_by" class="sign">
<span aria-hidden="true">✓</span><b>{{ cl.confirmed_by.name }}</b><span v-if="cl.confirmed_at" class="fig">{{ fmtDateTime(cl.confirmed_at) }}</span><span>确认</span>
</span>
</template>
<template v-else>
<span class="tag">草稿</span>
<span class="cap muted">{{ cl.version_no ? `上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
</template>
</div>
<span v-if="cl" class="counts cap muted">正式清单 <span class="fig">{{ cl.counts.formal }}</span> 条 · 待确认 <span class="fig">{{ cl.counts.pending }}</span> 条</span>
</div>
<div v-for="w in cl?.warnings ?? []" :key="w.code" class="alert warn warn-row" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ w.message }}</p>
</div>
<section class="card flush cmp-list" aria-label="项目检查清单">
<nav class="cats" aria-label="清单分类">
<button type="button" :aria-current="cat === 'all'" @click="cat = 'all'">全部<span class="fig">{{ formal.length }}</span></button>
<button v-for="c in cats" :key="c" type="button" :aria-current="cat === c" @click="cat = c">{{ category(c) }}<span class="fig">{{ catCount(c) }}</span></button>
<hr />
<button type="button" class="ai" :aria-current="cat === 'pending'" @click="cat = 'pending'">待确认<span class="fig">{{ pending.length }}</span></button>
</nav>
<div class="lst">
<div v-if="loading" class="lst-pad"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
<div v-else-if="loadErr" class="lst-pad">
<div class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(loadErr) }}</p></div>
</div>
<div v-else-if="!all.length" class="empty">
<FileText class="ic xl" :stroke-width="1.25" />
<b>还没有清单</b>
<p>上传招标文件并解析后,这里会逐条列出要求,每一条都带原文出处。</p>
<RouterLink class="btn primary" :to="{ name: 'project-new', query: { project: projectId } }">上传招标文件</RouterLink>
</div>
<template v-else>
<div v-if="cat !== 'pending'" class="lst-tb">
<label class="search">
<Search class="ic" :stroke-width="1.5" />
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
</label>
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</label>
</div>
<template v-if="showTop">
<div v-for="r in cl?.marking_rules ?? []" :key="r.id" class="rule">
<Info class="ic" :stroke-width="1.5" />
<div>
<p>{{ ruleText(r) }}</p>
<CitationBlock :citation="r.citation" />
</div>
</div>
</template>
<!-- 待确认(§7.6):清单顶部的分组,不和正式清单混排;确认前不参与检查 -->
<section v-if="showPending && pending.length" aria-label="待确认">
<div class="grp-h ai"><b>待确认</b><span class="fig">{{ pending.length }}</span><p>没找到可靠出处,或把握不大。确认前不参与检查。</p></div>
<ChecklistItemRow v-for="i in pending" :key="i.id" :item="i" pending />
</section>
<div v-if="cat === 'pending' && !pending.length" class="empty sm"><b>没有待确认的条目</b></div>
<template v-if="cat !== 'pending'">
<section v-for="g in groups" :key="g.id" :aria-label="g.name">
<div class="grp-h"><b>{{ g.name }}</b><span class="fig">{{ g.rows.length }}</span></div>
<ChecklistItemRow v-for="i in g.rows" :key="i.id" :item="i" />
</section>
<div v-if="!groups.length" class="empty sm">
<b>{{ kw ? `没有找到「${kw}」` : "这个分类下没有符合条件的条目" }}</b>
<p v-if="kw">只搜条目标题。</p>
</div>
</template>
<div class="end"></div>
</template>
</div>
</section>
</div>
</template>
<style scoped>
/* 清单栏:标题 + 清单状态 + 数量 */
.cl-bar {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
.cl-bar h2 {
margin: 0;
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
white-space: nowrap;
}
.cl-bar .status {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
white-space: nowrap;
overflow: hidden;
}
.cl-bar .status .sign {
margin: 0;
}
.cl-bar .counts {
margin-left: auto;
white-space: nowrap;
}
.warn-row {
margin-bottom: var(--sp-3);
}
/* 原型里工具条吸顶(整屏布局下列表自己滚动);这里页面整体滚动、卡片 overflow:hidden,先不吸顶,接原文查看器时恢复 */
.lst-tb {
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-3) var(--card-x);
background: var(--bg-surface);
border-bottom: var(--line) solid var(--border-subtle);
border-top-right-radius: var(--radius-card);
}
.lst-tb .search {
flex: 1;
min-width: 0;
}
.lst-tb .search .input {
height: var(--control-h-sm);
}
.lst-tb .search > .ic {
top: var(--sp-2);
}
.lst-tb .choice {
flex: none;
white-space: nowrap;
align-items: center;
}
.lst-tb .choice .ck {
margin-top: 0;
}
.lst-pad {
padding: var(--card-y) var(--card-x);
}
.rule {
display: flex;
gap: var(--sp-2);
align-items: flex-start;
padding: var(--sp-3) var(--card-x) 0;
}
.rule > .ic {
margin-top: var(--sp-1);
flex: none;
color: var(--text-secondary);
}
.rule p {
margin: 0 0 var(--sp-2);
}
.end {
height: var(--sp-8);
}
</style>

View File

@@ -0,0 +1,401 @@
<script setup lang="ts">
/**
* 登录 / 注册(原型 login.html):手机号 + 短信验证码。登录和注册共用一个验证码:
* 登录返回 404 phone_not_registered 时切到注册,验证码不用重新获取;
* 已有其他设备在线时返回 409 concurrent_session,用户确认后带 force: true 重发(API3)。
*/
import type { Schemas } from "@bid-doctor/api-client";
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, nextTick, onBeforeUnmount, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-horizontal-textured.svg?url";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { usePlans, type Me, type SessionInfo } from "@/lib/queries";
/** TODO:服务协议版本应由服务端下发(目前没有接口),暂时写死;协议文本定稿后同步。 */
const TERMS_VERSION = "2026-11-draft";
const PHONE_RE = /^1[3-9][0-9]{9}$/;
const CODE_RE = /^[0-9]{6}$/;
const isDev = import.meta.env.DEV;
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const plans = usePlans();
type Mode = "login" | "reg";
const mode = ref<Mode>("login");
const form = reactive({ phone: "", code: "", tenant: "", name: "", agreed: false });
const err = reactive<Partial<Record<"phone" | "code" | "tenant" | "name" | "agree" | "form", string>>>({});
const REASONS: Record<string, string> = {
session_revoked: "你的账号在其他设备登录,请重新登录。",
session_expired: "登录已过期,请重新登录。",
};
const notice = ref(REASONS[String(route.query.reason ?? "")] ?? "");
const free = computed(() => plans.data.value?.items.find((p) => p.code === "free"));
/* ---------- 发送验证码:60 秒内不能重发(以服务端返回为准) ---------- */
const countdown = ref(0);
const sending = ref(false);
let timer: number | undefined;
function startCountdown(s: number) {
countdown.value = s;
window.clearInterval(timer);
timer = window.setInterval(() => {
if (--countdown.value <= 0) window.clearInterval(timer);
}, 1000);
}
onBeforeUnmount(() => window.clearInterval(timer));
function checkPhone(): boolean {
err.phone = PHONE_RE.test(form.phone) ? "" : "手机号应为 11 位数字,以 1 开头";
return !err.phone;
}
async function sendCode() {
if (!checkPhone()) return;
sending.value = true;
err.code = "";
try {
const r = await unwrap(api.POST("/auth/sms-codes", { body: { phone: form.phone, purpose: mode.value === "reg" ? "register" : "login" } }));
startCountdown(r.resend_after_s);
await nextTick();
document.getElementById("cd")?.focus();
} catch (e) {
err.code = errorTitle(e);
const after = isApiError(e, "rate_limited") ? Number(e.problem.retry_after_s) : NaN;
if (Number.isFinite(after) && after > 0) startCountdown(after);
} finally {
sending.value = false;
}
}
/* ---------- 登录 / 注册 ---------- */
const submitting = ref(false);
const concurrent = reactive({ open: false, sessions: [] as SessionInfo[] });
function switchMode(m: Mode) {
mode.value = m;
err.form = err.tenant = err.name = err.agree = "";
}
function validate(): boolean {
checkPhone();
err.code = CODE_RE.test(form.code) ? "" : "验证码是 6 位数字";
if (mode.value === "reg") {
const t = form.tenant.trim();
err.tenant = t.length >= 2 && t.length <= 100 ? "" : "企业名称 2~100 个字";
const n = form.name.trim();
err.name = n.length >= 1 && n.length <= 40 ? "" : "请填写你的姓名,最多 40 个字";
err.agree = form.agreed ? "" : "请先阅读并同意服务协议";
}
return !err.phone && !err.code && !err.tenant && !err.name && !err.agree;
}
/** 接口校验错误按字段放回表单 */
function applyError(e: unknown) {
if (isApiError(e, "sms_code_invalid")) err.code = e.problem.title;
else if (isApiError(e, "validation_failed") && e.problem.errors?.length) {
const map: Record<string, keyof typeof err> = { phone: "phone", sms_code: "code", tenant_name: "tenant", display_name: "name" };
for (const fe of e.problem.errors) err[map[fe.field.replace(/^body\./, "")] ?? "form"] = fe.message;
} else err.form = errorTitle(e);
}
async function finish(me: Me, registered: boolean) {
qc.setQueryData(qk.me, me);
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
const redirect = typeof route.query.redirect === "string" && route.query.redirect.startsWith("/") ? route.query.redirect : "";
await router.replace(redirect || (registered ? { name: "project-new" } : { name: "workbench" }));
}
async function login(force: boolean) {
submitting.value = true;
err.form = "";
try {
const r = await unwrap(api.POST("/auth/login", { body: { phone: form.phone, sms_code: form.code, client_kind: "web", force } }));
concurrent.open = false;
await finish(r.me, false);
} catch (e) {
if (isApiError(e, "phone_not_registered")) {
switchMode("reg");
notice.value = "这个手机号还没有注册。填写企业名称和你的姓名完成注册,验证码不用重新获取。";
await nextTick();
document.getElementById("co")?.focus();
} else if (isApiError(e, "concurrent_session")) {
concurrent.sessions = (e.problem.sessions as Schemas["SessionInfo"][] | undefined) ?? [];
concurrent.open = true;
} else {
concurrent.open = false;
applyError(e);
}
} finally {
submitting.value = false;
}
}
async function register() {
submitting.value = true;
err.form = "";
try {
const r = await unwrap(
api.POST("/auth/register", {
body: {
phone: form.phone,
sms_code: form.code,
tenant_name: form.tenant.trim(),
display_name: form.name.trim(),
terms_version: TERMS_VERSION,
client_kind: "web",
},
}),
);
await finish(r.me, true);
} catch (e) {
if (isApiError(e, "phone_already_registered")) {
switchMode("login");
notice.value = "这个手机号已经注册,直接登录即可。";
} else applyError(e);
} finally {
submitting.value = false;
}
}
function submit() {
if (!validate()) return;
if (mode.value === "reg") void register();
else void login(false);
}
</script>
<template>
<div class="login-page">
<div class="wrap">
<section class="brand" aria-label="标博士">
<img :src="logoUrl" alt="标博士 BIAOBOSHI" />
<p class="slogan">标博士,你的AI投标专家</p>
<ol class="points">
<li><div><b>读标:每一条要求都有出处</b><span>资格、无效条款、评分、日期和 ★ 参数逐条列出,每条都能回到原文那一页。</span></div></li>
<li><div><b>查标:递交前逐条对一遍</b><span>找出没响应、疑似不满足、漏盖章的地方,由你逐条确认。</span></div></li>
<li><div><b>报告:谁在什么时候确认了什么</b><span>导出带处理记录的检查报告,写明查了什么、没查什么。</span></div></li>
</ol>
</section>
<form class="card login" novalidate aria-label="登录或注册" @submit.prevent="submit">
<div class="tabs" role="tablist" aria-label="登录方式">
<button type="button" role="tab" :aria-selected="mode === 'login'" @click="switchMode('login')">登录</button>
<button type="button" role="tab" :aria-selected="mode === 'reg'" @click="switchMode('reg')">注册</button>
</div>
<div v-if="notice" class="alert" role="status">
<span class="ico i" aria-hidden="true">i</span>
<p>{{ notice }}</p>
</div>
<div class="field" :class="{ err: err.phone }">
<label for="ph">手机号</label>
<input id="ph" v-model.trim="form.phone" class="input" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="11 位手机号" @blur="form.phone && checkPhone()" />
<span v-if="err.phone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.phone }}</span>
</div>
<div class="field" :class="{ err: err.code }">
<label for="cd">验证码</label>
<div class="code">
<input id="cd" v-model.trim="form.code" class="input" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位数字" />
<button class="btn" type="button" :disabled="sending || countdown > 0" @click="sendCode">
{{ countdown > 0 ? `${countdown} 秒后重发` : sending ? "正在发送…" : "发送验证码" }}
</button>
</div>
<span v-if="err.code" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.code }}</span>
<span v-else-if="isDev" class="msg">开发环境:验证码固定为 000000</span>
</div>
<template v-if="mode === 'reg'">
<div class="field" :class="{ err: err.tenant }">
<label for="co">企业名称</label>
<input id="co" v-model="form.tenant" class="input" placeholder="与开票信息一致" maxlength="100" />
<span class="msg"><span v-if="err.tenant" class="ico w" aria-hidden="true">!</span>{{ err.tenant || "额度和数据归属企业主体,一个手机号只能属于一个企业。" }}</span>
</div>
<div class="field" :class="{ err: err.name }">
<label for="nm">你的姓名</label>
<input id="nm" v-model="form.name" class="input" placeholder="确认记录里显示这个名字" maxlength="40" autocomplete="name" />
<span v-if="err.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.name }}</span>
</div>
<div class="agree">
<label class="choice">
<input v-model="form.agreed" type="checkbox" class="ck" @change="form.agreed && (err.agree = '')" />
<span>我已阅读并同意《服务协议》《隐私政策》。本产品只用于投标人对自己投标文件的投前自查,不得用于串通投标、帮助他人规避雷同等违法用途。</span>
</label>
</div>
<p v-if="err.agree" class="hint-warn agree-err"><span class="ico w" aria-hidden="true">!</span>{{ err.agree }}</p>
</template>
<p v-if="err.form" class="hint-warn form-err" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ err.form }}</span></p>
<button type="submit" class="btn primary lg go swap" :class="{ 'is-loading': submitting && !concurrent.open }" :aria-busy="submitting" :disabled="submitting">
<span class="idle">{{ mode === "reg" ? "注册并开通免费版" : "登录" }}</span>
<span class="busy">{{ mode === "reg" ? "正在注册…" : "正在登录…" }}</span>
</button>
<p v-if="mode === 'reg' && free" class="free">
注册即开通免费版:解析 <span class="fig">{{ free.quotas.parse ?? 0 }}</span> 次招标文件<template v-if="free.limits.max_projects">,限
<span class="fig">{{ free.limits.max_projects }}</span> 个项目</template>。不需要上传营业执照。
</p>
</form>
</div>
<p class="legal">辅助检查工具,不替代人工审核;检查结果仅供投标前自查参考,是否递交由投标人自行决定。</p>
<ConfirmDialog v-model:open="concurrent.open" title="账号已在其他设备登录" ok-text="继续登录" busy-text="正在登录…" :busy="submitting" @ok="login(true)">
<p>继续登录后,下面的设备会退出登录。</p>
<ul class="sessions">
<li v-for="s in concurrent.sessions" :key="s.id">
<b>{{ s.device || (s.client_kind === "desktop" ? "桌面客户端" : "网页") }}</b>
<span class="cap muted">{{ s.ip_region ? `${s.ip_region} · ` : "" }}最近活跃 <span class="fig">{{ fmtDateTime(s.last_seen_at) }}</span></span>
</li>
</ul>
</ConfirmDialog>
</div>
</template>
<style scoped>
/* 登录页:不用侧栏框架。浅灰页面上一张白卡片放表单;左侧品牌与三句话;按钮、输入框用 40 高(§7.1) */
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.wrap {
flex: 1;
display: grid;
/* 表单卡片宽 440(11 × 40),与原型一致 */
grid-template-columns: minmax(0, 1fr) calc(var(--sp-10) * 11);
gap: var(--sp-16);
align-items: center;
width: 100%;
max-width: var(--content-max);
margin: 0 auto;
padding: var(--sp-12) var(--pad-page);
}
.brand img {
width: calc(var(--sp-10) * 5);
display: block;
}
.brand .slogan {
font: var(--fw-bold) var(--fs-figure) / 1.4 var(--font-serif);
margin: var(--sp-10) 0 var(--sp-8);
}
.points {
list-style: none;
margin: 0;
padding: 0;
max-width: 30em;
counter-reset: p;
}
.points li {
display: grid;
grid-template-columns: var(--sp-8) minmax(0, 1fr);
padding: var(--sp-4) 0;
border-top: var(--line) solid var(--border-subtle);
counter-increment: p;
}
.points li:first-child {
border-top: 0;
padding-top: 0;
}
.points li::before {
content: counter(p);
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-figures);
color: var(--text-secondary);
}
.points b {
display: block;
font-size: var(--fs-title);
line-height: var(--lh-title);
}
.points span {
color: var(--text-secondary);
}
.login {
padding: var(--sp-8);
}
.login .tabs {
margin-bottom: var(--sp-6);
}
.login .tabs [role="tab"] {
font-size: var(--fs-title);
}
.login .alert {
margin-bottom: var(--sp-5);
}
.login .field {
max-width: none;
}
.login .field + .field {
margin-top: var(--sp-5);
}
.login .input,
.login .btn {
height: var(--control-h-lg);
}
.code {
display: flex;
gap: var(--sp-2);
}
.code .input {
flex: 1;
min-width: 0;
}
.code .btn {
min-width: calc(var(--sp-16) * 2);
}
.agree {
display: flex;
margin-top: var(--sp-5);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.agree-err {
margin-top: var(--sp-2);
}
.form-err {
margin-top: var(--sp-4);
}
.go {
width: 100%;
margin-top: var(--sp-6);
}
.free {
margin: var(--sp-5) 0 0;
padding-top: var(--sp-4);
border-top: var(--line) solid var(--border-subtle);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.free .fig {
color: var(--text-primary);
}
.legal {
padding: 0 var(--pad-page) var(--sp-6);
text-align: center;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.sessions {
list-style: none;
margin: 0;
padding: 0;
}
.sessions li {
display: flex;
flex-direction: column;
padding: var(--sp-2) 0;
border-top: var(--line) solid var(--border-subtle);
}
</style>

View File

@@ -0,0 +1,276 @@
<script setup lang="ts">
/**
* 解析进度(原型 parsing.html,设计规范 §7.11):分阶段列表 + 进度条。
* 进度来自事件流(AppShell 订阅,写进 parse-job 缓存),GET /parse-jobs/{id} 每 5 秒轮询兜底。
* 失败写明原因和额度是否已退回(error.refunded);取消、重试都在本页。
*/
import { unwrap } from "@bid-doctor/api-client";
import { RefreshCw, CircleAlert, Upload } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime, fmtDuration, PARSE_STEPS, parseStepIndex } from "@/lib/labels";
import { useDocument, useParseJob, useProject, useQuotaAccount } from "@/lib/queries";
const props = defineProps<{ projectId: string; jobId: string }>();
const router = useRouter();
const qc = useQueryClient();
const job = useParseJob(() => props.jobId);
const j = computed(() => job.data.value);
const doc = useDocument(() => j.value?.document_id);
const project = useProject(() => props.projectId);
const parse = useQuotaAccount("parse");
useCrumbs(() => [
{ label: "工作台", to: { name: "workbench" } },
{ label: project.data.value?.name ?? "项目", to: { name: "checklist", params: { projectId: props.projectId } } },
{ label: "解析" },
]);
const status = computed(() => j.value?.status);
const running = computed(() => status.value === "queued" || status.value === "running");
const heading = computed(
() =>
({ queued: "排队中", running: "正在解析", succeeded: "解析完成", failed: "解析失败", cancelled: "已取消解析" })[status.value ?? "queued"],
);
/** 当前在第几步;成功时全部完成 */
const cur = computed(() => (status.value === "succeeded" ? PARSE_STEPS.length : parseStepIndex(j.value?.stage)));
function stepClass(k: number) {
if (status.value === "failed" && k === Math.max(0, cur.value)) return "fail";
if (k < cur.value) return "done";
if (k === cur.value && running.value) return "now";
return "todo";
}
/* 已用时:从任务创建算起,进行中每秒刷新 */
const now = ref(Date.now());
const tick = window.setInterval(() => (now.value = Date.now()), 1000);
onBeforeUnmount(() => window.clearInterval(tick));
const elapsed = computed(() => {
if (!j.value) return null;
const end = j.value.finished_at ? new Date(j.value.finished_at).getTime() : now.value;
return fmtDuration(end - new Date(j.value.created_at).getTime());
});
/* 结束后刷新额度和项目列表(事件流断线时也能拿到最新结果) */
watch(running, (r, was) => {
if (was && !r) {
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
}
});
/* ---------- 取消 ---------- */
const cancelOpen = ref(false);
const cancelling = ref(false);
const actionErr = ref("");
async function cancel() {
cancelling.value = true;
actionErr.value = "";
try {
const r = await unwrap(api.POST("/parse-jobs/{job_id}/cancel", { params: { path: { job_id: props.jobId } } }));
qc.setQueryData(qk.parseJob(props.jobId), r);
} catch (e) {
if (!isApiError(e, "job_finished")) actionErr.value = errorTitle(e);
} finally {
cancelling.value = false;
cancelOpen.value = false;
void qc.invalidateQueries({ queryKey: qk.parseJob(props.jobId) });
}
}
/* ---------- 重试:新建一条任务并重新预扣 1 次,上一条已经退回(FR-M2-02) ---------- */
const retrying = ref(false);
async function retry() {
if (!j.value) return;
retrying.value = true;
actionErr.value = "";
try {
const r = await unwrap(
api.POST("/documents/{document_id}/parse-jobs", {
params: { path: { document_id: j.value.document_id } },
body: { expected_charge: 1 },
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
await router.replace({ name: "parse", params: { projectId: props.projectId, jobId: r.id } });
} catch (e) {
const other = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
if (typeof other === "string") await router.replace({ name: "parse", params: { projectId: props.projectId, jobId: other } });
else actionErr.value = `${errorTitle(e)}。本次没有扣点。`;
} finally {
retrying.value = false;
}
}
const result = computed(() => j.value?.result ?? null);
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>解析招标文件</h1>
<p v-if="doc.data.value">
{{ doc.data.value.original_filename }}<template v-if="doc.data.value.page_count"> · <span class="fig">{{ doc.data.value.page_count }}</span> 页</template>
</p>
</div>
</div>
<section class="card flush" aria-labelledby="h-task" aria-live="polite">
<div class="card-h">
<h2 id="h-task">{{ j ? heading : "正在读取进度" }}</h2>
<span v-if="elapsed" class="aside">
{{ running ? "已用时" : "用时" }} <span class="fig">{{ elapsed.min }}</span> 分 <span class="fig">{{ elapsed.sec }}</span> 秒
</span>
</div>
<div class="card-b">
<template v-if="job.isError.value && !j">
<div class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(job.error.value) }}</p></div>
</template>
<template v-else-if="!j">
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
</template>
<template v-else-if="status === 'succeeded'">
<dl class="kv result">
<dt>提取要求</dt>
<dd>
<span class="fig">{{ result?.items_added ?? 0 }}</span> 条<template v-if="result?.items_pending">
· 其中 <span class="fig">{{ result.items_pending }}</span> 条没找到可靠出处或把握不大,放在「待确认」里</template>
</dd>
<template v-if="result?.items_updated"><dt>更新</dt><dd><span class="fig">{{ result.items_updated }}</span> 条</dd></template>
<template v-if="result?.lots_found"><dt>采购包</dt><dd>识别到 <span class="fig">{{ result.lots_found }}</span> 个</dd></template>
</dl>
</template>
<template v-else-if="status === 'cancelled'">
<p class="plain">已取消解析。预扣的 <span class="fig">1</span> 次解析额度已退回。</p>
</template>
<template v-else>
<ol class="steps">
<li v-for="(s, k) in PARSE_STEPS" :key="s.label" :class="stepClass(k)">
<span class="dot" aria-hidden="true">{{ stepClass(k) === "done" ? "✓" : stepClass(k) === "fail" ? "!" : "" }}</span>
<span>{{ s.label }}<span v-if="stepClass(k) === 'now'" class="vh">(进行中)</span></span>
</li>
</ol>
<div v-if="running" class="progress">
<div class="bar" role="progressbar" :aria-valuenow="j.progress" aria-valuemin="0" aria-valuemax="100" aria-label="解析进度"><i :style="{ width: `${j.progress}%` }"></i></div>
<span class="fig">{{ j.progress }}%</span>
</div>
<p v-if="running" class="note">可以离开本页,完成或失败都会发站内通知。</p>
<!-- 失败说明(§7.11):赭色图标 + 赭色关键字,不用红 -->
<div v-if="status === 'failed'" class="fail-why" role="note">
<CircleAlert class="ic" :stroke-width="1.5" />
<p>
<b>{{ j.error?.message ?? "解析没有完成。" }}</b>
<template v-if="j.error?.refunded === true">已退回 <span class="fig">1</span> 次解析额度。</template>
<template v-else-if="j.error?.refunded === false">额度还没退回,请联系客服并提供下方错误编号。</template>
<span class="sub">可以重试,或者换一份文件。</span>
</p>
</div>
</template>
<div v-if="actionErr" class="alert warn act-err" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ actionErr }}</p></div>
</div>
<div v-if="j" class="card-f">
<template v-if="running">
<button class="btn sm" type="button" :disabled="j.cancel_requested" @click="cancelOpen = true">{{ j.cancel_requested ? "正在取消…" : "取消解析" }}</button>
<span class="cost">已预扣 <span class="fig">1</span> 次解析 · 失败、超时、取消都退回</span>
<span class="r"><RouterLink class="btn sm" :to="{ name: 'workbench' }">回工作台,完成后通知我</RouterLink></span>
</template>
<template v-else-if="status === 'succeeded'">
<RouterLink class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
<span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析 · {{ j.finished_at ? fmtDateTime(j.finished_at) : "" }}</span></span>
</template>
<template v-else>
<button class="btn primary swap" type="button" :class="{ 'is-loading': retrying }" :aria-busy="retrying" :disabled="retrying" @click="retry">
<span class="idle"><RefreshCw class="ic" :stroke-width="1.5" /> 重新解析</span><span class="busy">正在提交…</span>
</button>
<RouterLink class="btn" :to="{ name: 'project-new', query: { project: projectId } }"><Upload class="ic" :stroke-width="1.5" />换一个文件</RouterLink>
<span class="cost">重新解析预扣 <span class="fig">1</span> 次<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
<span v-if="j.error" class="r"><span class="errno">{{ j.error.code }} · {{ j.id.slice(0, 8) }}</span></span>
</template>
</div>
</section>
<ConfirmDialog v-model:open="cancelOpen" title="取消解析?" ok-text="取消解析" cancel-text="继续解析" busy-text="正在取消…" :busy="cancelling" @ok="cancel">
<p>已完成的阶段不会保留。取消后退回 1 次解析额度,可以稍后重新解析。</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.card-h .aside {
margin-left: auto;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
white-space: nowrap;
}
.card-h .aside .fig {
font-size: var(--fs-figure-xs);
}
.card-b {
padding-top: var(--sp-2);
}
.card-b .progress {
margin-top: var(--sp-3);
}
.card-f .r {
margin-left: auto;
display: flex;
gap: var(--sp-3);
align-items: center;
}
.btn.swap .idle {
display: inline-flex;
gap: var(--sp-2);
align-items: center;
}
.plain {
margin: 0;
}
.result {
grid-template-columns: calc(var(--sp-8) + var(--sp-12)) minmax(0, 1fr);
}
/* 失败说明(§7.11):赭色图标 + 赭色关键字,不用红 */
.fail-why {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-3);
padding-top: var(--sp-3);
border-top: var(--line) solid var(--border-subtle);
}
.fail-why > .ic {
flex: none;
margin-top: var(--sp-1);
color: var(--warn);
}
.fail-why b {
color: var(--warn-text);
font-weight: var(--fw-bold);
}
.fail-why p {
margin: 0;
}
.fail-why .sub {
display: block;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.act-err {
margin-top: var(--sp-4);
}
</style>

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
/**
* 新建项目(原型 project-new.html):项目名称(选填)+ 一份招标文件。
* 选好文件就开始分片上传(格式、加密、页数在上传完成时由服务端拦截,不扣点);
* 「开始解析」:建项目 → 把文件挂到项目上 → 发起解析(预扣 1 次,带 expected_charge,API4)。
* 带 ?project=<id> 时给已有的项目补传招标文件(例如上一次上传失败),不再建新项目。
*/
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Upload, X } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink, useRoute, useRouter } from "vue-router";
import { api, apiOptions, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { extOf, fmtSize } from "@/lib/labels";
import { usePlans, useProject, useQuotaAccount, useMe } from "@/lib/queries";
import { platform } from "@/platform";
const ACCEPT = [".pdf", ".docx", ".doc", ".wps"];
/** 解析招标文件固定扣 1 次;和服务端算的不一致时服务端返回 billing_changed,不扣点 */
const EXPECTED_CHARGE = 1 as const;
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const me = useMe();
const plans = usePlans();
const parse = useQuotaAccount("parse");
const projectId = ref(typeof route.query.project === "string" ? route.query.project : "");
const existing = useProject(() => (route.query.project ? projectId.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value ? `${existing.data.value.name} · 上传招标文件` : "新建项目" }]);
const limits = computed(() => plans.data.value?.items.find((p) => p.code === me.data.value?.plan.code)?.limits);
const name = ref("");
/* ---------- 上传 ---------- */
type UploadState =
| { kind: "idle" }
| { kind: "uploading"; file: File; ratio: number }
| { kind: "done"; file: File; result: Schemas["UploadCompleted"] }
| { kind: "error"; file: File; message: string };
const up = ref<UploadState>({ kind: "idle" });
let uploadSeq = 0;
const fileErr = ref("");
const over = ref(false);
async function startUpload(file: File) {
const seq = ++uploadSeq;
fileErr.value = "";
startErr.value = "";
documentId.value = "";
up.value = { kind: "uploading", file, ratio: 0 };
try {
const result = await uploadFile(api, apiOptions, file, "project_document", (ratio) => {
if (seq === uploadSeq) up.value = { kind: "uploading", file, ratio };
});
if (seq === uploadSeq) up.value = { kind: "done", file, result };
} catch (e) {
// 格式不支持、已加密、已损坏、超出页数或大小:都在这里拦下,不建任务、不扣点
if (seq === uploadSeq) up.value = { kind: "error", file, message: `${errorTitle(e)}${detailOf(e)}。这个文件不会解析,也不扣额度。` };
}
}
const detailOf = (e: unknown) => (e instanceof ApiError && e.problem.detail ? `(${e.problem.detail})` : "");
async function pick() {
if (busy.value) return;
const f = await platform.pickFile({ accept: ACCEPT });
if (f) void startUpload(f);
}
function onDrop(e: DragEvent) {
over.value = false;
const f = e.dataTransfer?.files[0];
if (f && !busy.value) void startUpload(f);
}
function removeFile() {
uploadSeq++; // 正在上传的分片结果作废;服务端的未完成上传 24 小时后自动过期
up.value = { kind: "idle" };
documentId.value = "";
}
const pct = computed(() => (up.value.kind === "uploading" ? Math.round(up.value.ratio * 100) : 100));
const fileOf = computed(() => (up.value.kind === "idle" ? null : up.value.file));
/* ---------- 开始解析 ---------- */
const documentId = ref("");
const busy = ref(false);
const startErr = ref("");
async function start() {
if (up.value.kind !== "done") {
fileErr.value = up.value.kind === "uploading" ? "文件还在上传,传完再开始解析" : "请先上传招标文件";
return;
}
const uploadId = up.value.result.id;
busy.value = true;
startErr.value = "";
try {
// 每一步成功后都记下结果,失败重试时不会重复建项目(免费版只能建 1 个)
if (!projectId.value) {
const p = await unwrap(api.POST("/projects", { body: name.value.trim() ? { name: name.value.trim() } : {} }));
projectId.value = p.id;
void router.replace({ query: { ...route.query, project: p.id } });
}
if (!documentId.value) {
const d = await unwrap(
api.POST("/projects/{project_id}/documents", {
params: { path: { project_id: projectId.value } },
body: { upload_id: uploadId, role: "tender" },
}),
);
documentId.value = d.id;
}
const job = await unwrap(
api.POST("/documents/{document_id}/parse-jobs", {
params: { path: { document_id: documentId.value } },
body: { expected_charge: EXPECTED_CHARGE },
}),
);
void qc.invalidateQueries({ queryKey: qk.projects });
void qc.invalidateQueries({ queryKey: qk.quota });
await router.push({ name: "parse", params: { projectId: projectId.value, jobId: job.id } });
} catch (e) {
const running = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
if (typeof running === "string") {
await router.push({ name: "parse", params: { projectId: projectId.value, jobId: running } });
return;
}
startErr.value = `${errorTitle(e)}${detailOf(e)}。本次没有扣点。`;
void qc.invalidateQueries({ queryKey: qk.quota });
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>{{ existing.data.value ? "上传招标文件" : "新建项目" }}</h1>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
</div>
</div>
<form class="card flush" novalidate aria-labelledby="h-form" @submit.prevent="start">
<h2 id="h-form" class="vh">新建项目</h2>
<div class="card-b form-b">
<div v-if="!route.query.project" class="field">
<label for="pn">项目名称 <small>(选填)</small></label>
<input id="pn" v-model="name" class="input" maxlength="200" placeholder="例如:某市政务服务中心智慧大厅建设项目" autocomplete="off" aria-describedby="pn-msg" :disabled="busy" />
<span id="pn-msg" class="msg">不填时显示为「未命名项目」,解析后按招标文件自动填写,可以再改</span>
</div>
<div v-else-if="existing.data.value" class="field">
<label>项目</label>
<span>{{ existing.data.value.name }}</span>
</div>
<div class="field" :class="{ err: fileErr }">
<label id="l-file">招标文件</label>
<div
v-if="!fileOf"
class="drop"
:class="{ 'is-over': over }"
tabindex="0"
role="button"
aria-labelledby="l-file"
aria-describedby="drop-sub"
@click="pick"
@keydown.enter.prevent="pick"
@keydown.space.prevent="pick"
@dragenter.prevent="over = true"
@dragover.prevent="over = true"
@dragleave.prevent="over = false"
@drop.prevent="onDrop"
>
<Upload class="ic lg" :stroke-width="1.5" />
<b>拖入或点击上传</b>
<span id="drop-sub">
PDF、Word(DOC / DOCX)、WPS,一次一份<template v-if="limits"><br />单个文件不超过 <span class="fig xs">{{ limits.max_pages }}</span> 页、<span class="fig xs">{{ Math.round((limits.max_file_bytes ?? 0) / 1048576) }}</span> MB</template>
</span>
</div>
<div v-else class="file-row" :class="{ off: up.kind === 'error' }">
<span class="ext">{{ up.kind === "done" ? up.result.detected_format.toUpperCase() : extOf(fileOf.name) }}</span>
<div>
<b :title="fileOf.name">{{ fileOf.name }}</b>
<span class="meta">
<template v-if="up.kind === 'done' && up.result.page_count"><span class="fig xs">{{ up.result.page_count }}</span> 页 · </template>{{ fmtSize(fileOf.size) }}
<template v-if="up.kind === 'done'"> · 已上传</template>
</span>
<div v-if="up.kind === 'uploading'" class="progress up">
<div class="bar" role="progressbar" :aria-valuenow="pct" aria-valuemin="0" aria-valuemax="100" aria-label="上传进度"><i :style="{ width: `${pct}%` }"></i></div>
<span class="fig xs">{{ pct }}%</span>
</div>
<p v-if="up.kind === 'done' && up.result.extension_mismatch" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>
<span>扩展名和实际格式不一致,会按实际格式({{ up.result.detected_format.toUpperCase() }})解析</span>
</p>
<p v-if="up.kind === 'error'" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ up.message }}</span></p>
</div>
<span class="acts">
<button type="button" class="icon-btn sm tip-left" :aria-label="`移除 ${fileOf.name}`" data-tip="移除" :disabled="busy" @click="removeFile"><X class="ic" :stroke-width="1.5" /></button>
</span>
</div>
<span v-if="fileErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ fileErr }}</span>
</div>
<div v-if="startErr" class="alert warn start-err" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ startErr }}</p>
</div>
</div>
<div class="card-f">
<!-- 红线 9:扣点规则写在按钮旁边 -->
<span class="cost">将扣 <span class="fig">1</span> 次解析;解析失败、超时或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
<RouterLink class="btn" :to="{ name: 'workbench' }">取消</RouterLink>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || up.kind !== 'done'">
<span class="idle">开始解析</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</div>
</template>
<style scoped>
.card-b.form-b {
padding-top: var(--sp-5);
padding-bottom: var(--sp-6);
}
.form-b > .field {
max-width: none;
}
.form-b > .field + .field {
margin-top: var(--sp-5);
}
.field.err .drop {
border-color: var(--warn);
}
.card-f .cost {
margin-right: auto;
white-space: normal;
}
.file-row .meta {
display: block;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.file-row.off b {
color: var(--text-secondary);
font-weight: var(--fw-regular);
}
.progress.up {
margin-top: var(--sp-1);
}
.start-err {
margin-top: var(--sp-5);
}
</style>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
/**
* 工作台(原型 workbench.html):页头 + 统计 + 项目列表;右栏是正在进行的任务和本期额度。
* 原型里的「待处理的致命问题」「最近处理」要等查标和操作记录的接口,骨架阶段不做。
*/
import { Plus } from "@lucide/vue";
import { computed } from "vue";
import { RouterLink } from "vue-router";
import ProjectRow from "@/components/ProjectRow.vue";
import { errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { quotaUnit } from "@/lib/labels";
import { useMe, useProjects, useQuota, useQuotaAccount } from "@/lib/queries";
useCrumbs(() => [{ label: "工作台" }]);
const me = useMe();
const projects = useProjects();
const quota = useQuota();
const parse = useQuotaAccount("parse");
const today = new Date();
const hello = computed(() => {
const name = me.data.value?.user.display_name ?? "";
return `你好,${name}。今天是 ${today.getMonth() + 1}月${today.getDate()}日,周${"日一二三四五六"[today.getDay()]}。`;
});
const list = computed(() => projects.data.value?.items ?? []);
const active = computed(() => list.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
const stats = computed(() => {
const now = Date.now();
const due7 = active.value.filter((p) => {
if (!p.deadline_at) return false;
const ms = new Date(p.deadline_at).getTime() - now;
return ms >= 0 && ms <= 7 * 86_400_000;
}).length;
const sum = (f: (p: (typeof list.value)[number]) => number) => active.value.reduce((a, p) => a + f(p), 0);
return [
{ label: "进行中的项目", value: active.value.length, cls: "" },
{ label: "7 天内截止", value: due7, cls: due7 ? "due" : "" },
// 致命红只用于致命问题(§3.2)
{ label: "待处理的致命问题", value: sum((p) => p.readiness.open_fatal_count), cls: "fatal" },
{ label: "待确认的清单条目", value: sum((p) => p.pending_item_count ?? 0), cls: "" },
];
});
const jobs = computed(() => list.value.filter((p) => p.active_job));
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []);
const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ?? "");
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>工作台</h1>
<p>{{ hello }}</p>
</div>
<div class="actions">
<span v-if="parse" class="cost">解析 1 份招标文件扣 <span class="fig">1</span> 次 · 剩 <span class="fig">{{ parse.balance }}</span> 次</span>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</div>
<section class="stats" aria-label="项目统计">
<div v-for="s in stats" :key="s.label" class="stat">
<small>{{ s.label }}</small><span class="big" :class="s.value ? s.cls : ''">{{ s.value }}</span>
</div>
</section>
<div class="wb">
<section class="card flush" aria-labelledby="h-proj">
<div class="card-h">
<h2 id="h-proj">项目</h2>
<span v-if="list.length" class="fig muted">{{ list.length }}</span>
</div>
<div v-if="projects.isPending.value" class="card-b">
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
</div>
<div v-else-if="projects.isError.value" class="card-b">
<div class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ errorTitle(projects.error.value) }}</p>
<button class="btn sm" type="button" @click="projects.refetch()">重试</button>
</div>
</div>
<template v-else-if="list.length">
<div class="prow-head" aria-hidden="true"><span>项目</span><span>投标截止</span><span>状态</span><span class="c">待确认</span><span>下一步</span><span></span></div>
<ul class="prows" aria-label="项目列表">
<ProjectRow v-for="p in list" :key="p.id" :project="p" />
</ul>
</template>
<div v-else class="empty">
<b>还没有项目</b>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</section>
<aside class="stack" aria-label="任务与额度">
<section class="card flush" aria-labelledby="h-task">
<div class="card-h"><h3 id="h-task">正在进行的任务</h3></div>
<div class="card-b">
<p v-if="!jobs.length" class="cap muted none">没有正在进行的任务</p>
<RouterLink
v-for="p in jobs"
:key="p.id"
class="task"
:to="p.active_job!.kind === 'parse_job' ? { name: 'parse', params: { projectId: p.id, jobId: p.active_job!.id } } : { name: 'checklist', params: { projectId: p.id } }"
>
<b>{{ p.active_job!.label || "任务进行中" }}</b>
<span class="cap muted">{{ p.name }}</span>
<div class="progress">
<div class="bar" role="progressbar" :aria-valuenow="p.active_job!.progress ?? 0" aria-valuemin="0" aria-valuemax="100" :aria-label="`${p.name}的进度`">
<i :style="{ width: `${p.active_job!.progress ?? 0}%` }"></i>
</div>
<span class="fig">{{ p.active_job!.progress ?? 0 }}%</span>
</div>
</RouterLink>
</div>
</section>
<section class="card flush quota" aria-labelledby="h-quota">
<div class="card-h">
<h3 id="h-quota">本期额度</h3>
<span v-if="planEnds" class="cap muted ends">至 {{ planEnds }}</span>
</div>
<div class="card-b">
<div v-for="a in accounts" :key="a.unit" class="acct">
<small class="muted">{{ quotaUnit(a.unit) }}剩余</small>
<div>
<span class="big" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ a.balance }}</span><span class="unit">/ {{ a.period_granted }}</span>
</div>
<div class="bar" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }" aria-hidden="true">
<i :style="{ width: `${Math.round((a.used_ratio ?? 0) * 100)}%` }"></i>
</div>
<p v-if="a.held" class="cap muted">含正在预扣的 <span class="fig">{{ a.held }}</span> 次,解析失败、超时或取消会退回</p>
</div>
</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.stats {
margin-bottom: var(--gap-card);
}
/* 主列 + 右侧信息栏(--aside-w),全页一个间距 --gap-card */
.wb {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.none {
margin: 0;
}
.task {
display: block;
color: inherit;
text-decoration: none;
}
.task + .task {
margin-top: var(--sp-3);
padding-top: var(--sp-3);
border-top: var(--line) solid var(--border-subtle);
}
.task b,
.task .cap {
display: block;
}
.task .progress {
margin-top: var(--sp-2);
}
.ends {
margin-left: auto;
}
.acct + .acct {
margin-top: var(--sp-4);
}
.quota .big.warn {
color: var(--warn-text);
}
.acct p {
margin: 0;
}
</style>

View File

@@ -0,0 +1,27 @@
/**
* 平台能力抽象(架构 §5.2 第 4 条):文件选择等都从这里走。现在只有网页版实现;
* 以后迁到 packages/platform,桌面外壳另给一份实现,业务代码不用改。
*/
export interface PickFileOptions {
/** 例如 [".pdf", ".docx"];只是选择框的过滤建议,真实格式以服务端按文件头识别为准 */
accept: readonly string[];
}
export interface Platform {
pickFile(opts: PickFileOptions): Promise<File | null>;
}
const web: Platform = {
pickFile({ accept }) {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.accept = accept.join(",");
input.addEventListener("change", () => resolve(input.files?.[0] ?? null), { once: true });
input.addEventListener("cancel", () => resolve(null), { once: true });
input.click();
});
},
};
export const platform: Platform = web;

55
apps/web/src/router.ts Normal file
View File

@@ -0,0 +1,55 @@
import { ApiError } from "@bid-doctor/api-client";
import { createRouter, createWebHashHistory, createWebHistory } from "vue-router";
import AppShell from "@/components/AppShell.vue";
import { runtimeConfig } from "@/lib/config";
import { qk } from "@/lib/keys";
import { fetchMe } from "@/lib/queries";
import { queryClient } from "@/lib/queryClient";
declare module "vue-router" {
interface RouteMeta {
/** 不需要登录 */
public?: boolean;
}
}
export const router = createRouter({
// 路由基础路径可配置;桌面外壳用 hash 模式(架构 §5.2 第 6 条)
history: runtimeConfig.routerMode === "hash" ? createWebHashHistory() : createWebHistory(runtimeConfig.routerBase),
routes: [
{ path: "/login", name: "login", component: () => import("@/pages/LoginPage.vue"), meta: { public: true } },
{
path: "/",
component: AppShell,
children: [
{ path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") },
{ path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") },
{
path: "projects/:projectId/parse/:jobId",
name: "parse",
component: () => import("@/pages/ParsePage.vue"),
props: true,
},
{
path: "projects/:projectId/checklist",
name: "checklist",
component: () => import("@/pages/ChecklistPage.vue"),
props: true,
},
],
},
{ path: "/:pathMatch(.*)*", redirect: "/" },
],
});
router.beforeEach(async (to) => {
if (to.meta.public) return true;
try {
await queryClient.fetchQuery({ queryKey: qk.me, queryFn: fetchMe, staleTime: 60_000 });
return true;
} catch (e) {
// 被其他设备挤下线(session_revoked)、过期(session_expired)时,登录页说明原因
const reason = e instanceof ApiError && e.code !== "unauthenticated" ? { reason: e.code } : {};
return { name: "login", query: { ...(to.fullPath === "/" ? {} : { redirect: to.fullPath }), ...reason } };
}
});

242
apps/web/src/styles/app.css Normal file
View File

@@ -0,0 +1,242 @@
/* 标博士用户端 · 页面框架与共用小件
* 视觉来源:docs/design/system/tokens.css + components.css(main.ts 通过 @design 别名直接引用,不复制)。
* 本文件只放这两份文件里还没有的「页面框架层」:侧栏、顶栏、页头、项目头,以及原型 docs/prototype/assets/app.css
* 第 1、2 部分里本应用用到的那几块(B1 图标、B3 按钮状态、B4 输入、B7 复选、B8 标签页、B11 对话框、B13 提示气泡、
* B14 空状态与进度、B15 杂项、B16 AI 块、B17 统计、B19 分类导航、B20 项目行),选择器和写法照搬原型。
* 只用 tokens.css 的变量,不写裸色值。
* TODO:这一层应提升到 docs/design/system(或 packages/ui)作为唯一来源,届时删掉本文件里的副本。
* 与原型的差别:原型把框架挂在 body.app 上,这里挂在 AppShell 的根元素 .app-frame 上。 */
/* =====================================================================
* 1. 页面框架:96px 墨色渐变侧栏 + 透明顶栏 + 内容区左上角圆角
* ===================================================================== */
.app-frame{display:grid;grid-template-columns:var(--rail-w) minmax(0,1fr);min-height:100vh;min-width:1280px;background:linear-gradient(180deg,var(--side-top) 0%,var(--side-bottom) 100%) fixed}
.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;padding:0 0 var(--sp-4) 10px;color:var(--side-fg);min-width:0}
.sidebar::after{content:"";position:absolute;left:0;right:0;bottom:0;height:260px;pointer-events:none;background-image:radial-gradient(var(--side-dots) 1px,transparent 1.2px);background-size:14px 14px;-webkit-mask-image:linear-gradient(to top,black,transparent);mask-image:linear-gradient(to top,black,transparent)}
.sidebar > *{position:relative;z-index:1}
.sidebar .brand{display:flex;justify-content:center;padding:var(--sp-5) 10px var(--sp-5) 0;border-radius:var(--radius-ctl)}
.sidebar .brand img{width:60px;height:auto;display:block}
.sidebar .nav{display:flex;flex-direction:column;gap:var(--sp-1)}
.sidebar .nav a{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--sp-1);height:62px;margin-right:10px;border-radius:var(--radius-card);font-size:var(--fs-caption);line-height:16px;color:var(--side-fg);text-align:center;text-decoration:none;white-space:nowrap;transition:background-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.sidebar .nav a .ic{width:22px;height:22px}
.sidebar .nav a:hover{background:var(--side-hover);color:var(--side-fg-strong)}
.sidebar .nav a[aria-current]{background:var(--side-active);color:var(--side-fg-strong);font-weight:var(--fw-bold)}
.sidebar a:focus-visible,.sidebar button:focus-visible{outline:2px solid var(--side-fg-strong);outline-offset:-2px}
.sidebar .me{display:flex;flex-direction:column;align-items:center;margin-top:auto;padding:var(--sp-4) 10px var(--sp-1) 0;color:var(--side-muted);text-align:center}
.sidebar .avatar{display:inline-grid;place-items:center;width:44px;height:44px;margin-bottom:var(--sp-2);border-radius:50%;background:var(--side-avatar-bg);box-shadow:0 0 0 1px var(--side-avatar-ring);color:var(--side-fg-strong);font-size:var(--fs-title);font-weight:var(--fw-bold)}
.sidebar .uname{color:var(--side-fg-strong);font-size:var(--fs-body);line-height:var(--lh-body);font-weight:var(--fw-bold);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 套餐标签:只在账户区使用;免费版中性、专业版浅金、企业版金色实底 */
.plan{display:inline-block;margin-top:var(--sp-1);padding:0 var(--sp-2);border-radius:var(--radius-xs);font-size:var(--fs-caption);line-height:20px;font-weight:var(--fw-bold);white-space:nowrap}
.plan[data-plan="free"]{background:var(--plan-free-bg);color:var(--side-fg)}
.plan[data-plan="pro"]{background:var(--plan-pro-bg);color:var(--plan-pro-fg);box-shadow:inset 0 0 0 1px var(--plan-pro-line)}
.plan[data-plan="ent"]{background:var(--plan-ent-bg);color:var(--c-ink)}
.main{min-width:0;display:flex;flex-direction:column;min-height:100vh;background:var(--bg-page);border-top-left-radius:var(--radius-card)}
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:var(--sp-3);height:var(--topbar-h);padding:0 var(--pad-page);background:transparent;border-bottom:var(--line) solid transparent;border-top-left-radius:var(--radius-card);transition:background-color var(--dur-base) var(--ease),border-color var(--dur-base) var(--ease)}
.top.scrolled{background:var(--topbar-glass);-webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);border-bottom-color:var(--border-subtle)}
.crumbs{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text-secondary);white-space:nowrap;overflow:hidden}
.crumbs > *{min-width:0;overflow:hidden;text-overflow:ellipsis}
.crumbs > span{flex:none}
.crumbs .org{flex:none;color:var(--text-primary);font-weight:var(--fw-bold)}
.crumbs a{color:var(--text-secondary);text-decoration:none}
.crumbs a:hover{color:var(--text-primary);text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px}
.crumbs b{font-weight:var(--fw-regular)}
.top-right{display:flex;align-items:center;gap:var(--sp-1);flex:none;margin-left:auto}
/* 顶栏额度:用到 ≥ 80% 时该项数字变赭色加粗(.warn);不用红 */
.quota-chip{display:inline-flex;align-items:center;gap:6px;height:var(--control-h-sm);padding:0 10px;border-radius:var(--radius-ctl);color:var(--text-secondary);text-decoration:none;white-space:nowrap}
.quota-chip .q{display:inline-flex;align-items:baseline;gap:6px}
.quota-chip .fig{color:var(--text-primary);font-size:var(--fs-figure-sm)}
.quota-chip .q.warn .fig{color:var(--warn-text);font-weight:var(--fw-bold)}
.quota-chip .sep{width:1px;height:12px;margin:0 var(--sp-1);background:var(--border-subtle)}
.top .vsep{flex:none;width:1px;height:20px;margin:0 var(--sp-1);background:var(--border-subtle)}
.content{width:100%;padding:20px var(--pad-page) var(--sp-12)}
.content.narrow{max-width:800px}
/* 页头:标题 + 一句话;右侧放页面级主按钮(Q12:页面级一个) */
.page-title{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--sp-6);margin-bottom:20px}
.page-title h1{font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);margin:0}
.page-title p{margin:2px 0 0;color:var(--text-secondary)}
.page-title .actions{margin-left:auto;display:flex;gap:var(--sp-3);align-items:center}
.stack > * + *{margin-top:var(--gap-card)}
/* 项目头(项目内页面):名称 + 信息一行;右侧截止、状态;下方标签页 */
.ph{padding:var(--sp-1) var(--pad-page) 0}
.ph-l1{display:flex;align-items:flex-end;gap:var(--sp-8)}
.ph-l1 > div{min-width:0}
.ph h1{margin:0;font:var(--fw-bold) var(--fs-h1)/var(--lh-h1) var(--font-serif);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ph-meta{margin:2px 0 0;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ph .facts{display:flex;gap:var(--sp-8);margin:0 0 0 auto;flex:none}
.ph .facts > div{display:flex;flex-direction:column;gap:2px}
.ph .facts dt{font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.ph .facts dd{display:flex;align-items:center;gap:6px;min-height:var(--lh-body);margin:0;white-space:nowrap}
.ph .tabs{margin-top:var(--sp-3)}
.ph + .content{padding-top:var(--sp-5)}
/* =====================================================================
* 2. 共用小件
* ===================================================================== */
/* B1 图标(Lucide 线性,1.5px 描边,16 / 20px;侧栏菜单 22px) */
.ic{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;vertical-align:-3px}
.ic.lg{width:20px;height:20px}
.ic.xl{width:32px;height:32px;stroke-width:1.25}
.vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* B3 按钮补充状态:加载中两个文字叠在同一格,切换时宽度不变 */
.btn .ic{margin:0 -2px}
.btn.swap{display:inline-grid;place-items:center}
.btn.swap > span{grid-area:1/1;white-space:nowrap}
.btn.swap > .busy,.btn.swap.is-loading > .idle{visibility:hidden}
.btn.swap.is-loading > .busy{visibility:visible}
.btn.is-loading{cursor:progress}
.btn.primary.is-loading{background:var(--primary-hover);border-color:var(--primary-hover)}
.icon-btn{position:relative;flex:none;display:inline-grid;place-items:center;width:var(--control-h);height:var(--control-h);padding:0;border:var(--line) solid transparent;border-radius:var(--radius-ctl);background:none;color:var(--text-primary);cursor:pointer;transition:background-color var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.icon-btn:hover{background:var(--c-n100)}
.icon-btn.sm{width:var(--control-h-sm);height:var(--control-h-sm)}
/* B4 输入框 */
.input{width:100%;transition:border-color var(--dur-fast) var(--ease)}
.input:hover{border-color:var(--c-ink)}
.input:focus{outline:0;border-color:var(--c-ink);box-shadow:0 0 0 1px var(--c-ink)}
.field.err .input:focus{border-color:var(--warn);box-shadow:0 0 0 1px var(--warn)}
.input[disabled]{background:var(--c-n100);border-color:var(--c-n100);color:var(--text-disabled);cursor:not-allowed}
.field label{display:flex;gap:var(--sp-1);align-items:baseline}
.field label small{margin-left:2px}
.field .choice{display:inline-flex;gap:var(--sp-2);align-items:flex-start;font-weight:var(--fw-regular)}
.hint-warn{display:flex;gap:6px;align-items:flex-start;margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--warn-text)}
.hint-warn .ico{flex:none;width:14px;height:14px;font-size:10px;margin:3px 0 0}
.field + .field{margin-top:0}
.search{position:relative;display:block}
.search > .ic{position:absolute;left:11px;top:10px;color:var(--text-secondary);pointer-events:none}
.search .input{padding-left:34px}
/* B7 复选框 */
.ck{appearance:none;flex:none;display:inline-grid;place-content:center;width:16px;height:16px;margin:0;border:var(--line) solid var(--border-control);border-radius:var(--radius-xs);background:var(--bg-surface);cursor:pointer;vertical-align:-3px}
.ck:hover{border-color:var(--c-ink)}
.ck::after{content:"";display:none}
.ck:checked{background:var(--c-ink);border-color:var(--c-ink)}
.ck:checked::after{display:block;width:8px;height:4px;border:solid var(--c-white);border-width:0 0 1.75px 1.75px;transform:translateY(-1px) rotate(-45deg)}
.choice{display:inline-flex;gap:var(--sp-2);align-items:flex-start;cursor:pointer;line-height:var(--lh-body)}
.choice .ck{margin-top:3px}
/* B8 标签页 */
.tabs{display:flex;gap:var(--sp-6);border-bottom:var(--line) solid var(--border-subtle)}
.tabs [role=tab]{display:flex;gap:6px;align-items:center;margin-bottom:-1px;padding:var(--sp-2) 0 10px;border:0;border-bottom:2px solid transparent;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-secondary);cursor:pointer;text-decoration:none;white-space:nowrap}
.tabs [role=tab]:hover{color:var(--text-primary)}
.tabs [role=tab][aria-selected="true"]{color:var(--text-primary);font-weight:var(--fw-bold);border-bottom-color:var(--c-ink)}
/* B11 对话框(480 / 640,圆角 12;对话框里可以有自己的主按钮) */
.scrim{position:fixed;inset:0;z-index:50;display:grid;overflow:auto;padding:var(--sp-8) var(--sp-4);background:var(--scrim)}
.dialog{position:relative;margin:auto;width:480px;max-width:100%;padding:var(--sp-6);background:var(--bg-surface);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
.dialog h2{font:var(--fw-bold) var(--fs-title)/var(--lh-title) var(--font-sans);margin:0 var(--sp-8) var(--sp-3) 0}
.dialog .body > p{margin:0 0 var(--sp-3)}
.dialog .foot{display:flex;justify-content:flex-end;align-items:center;gap:var(--sp-2);margin-top:var(--sp-6)}
/* B12 提醒补充 */
.alert > div{flex:1;min-width:0}
.alert > p{flex:1}
.alert .sub{display:block;margin-top:2px;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
.errno{font:var(--fw-medium) var(--fs-figure-xs)/1 var(--font-figures);color:var(--text-secondary);letter-spacing:.02em}
/* B13 提示气泡(悬停 / 聚焦显示) */
[data-tip]{position:relative}
[data-tip]:hover::after,[data-tip]:focus-visible::after{content:attr(data-tip);position:absolute;z-index:55;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);width:max-content;max-width:280px;padding:6px 10px;border-radius:var(--radius-ctl);background:var(--c-ink);color:var(--text-on-dark);font:var(--fw-regular) var(--fs-caption)/var(--lh-caption) var(--font-sans);white-space:normal;text-align:left;pointer-events:none}
[data-tip].tip-below:hover::after,[data-tip].tip-below:focus-visible::after{bottom:auto;top:calc(100% + 8px)}
[data-tip].tip-left:hover::after,[data-tip].tip-left:focus-visible::after{left:auto;right:0;transform:none}
/* B14 空状态、骨架屏、进度(骨架屏静止,Q8) */
.empty{display:flex;flex-direction:column;align-items:center;text-align:center;padding:var(--sp-12) var(--sp-6)}
.empty > .ic{color:var(--text-secondary);margin-bottom:var(--sp-3)}
.empty b{font-size:var(--fs-title);line-height:var(--lh-title)}
.empty p{margin:var(--sp-1) 0 var(--sp-5);color:var(--text-secondary);max-width:30em}
.empty.sm{padding:var(--sp-8) var(--sp-4)}
.empty.sm b{font-size:var(--fs-body);line-height:var(--lh-body)}
.empty.sm p{margin-bottom:0}
.empty.sm > .btn{margin-top:var(--sp-4)}
.skel{display:block;height:12px;border-radius:var(--radius-xs);background:var(--c-n100)}
.skel.t{height:16px;width:40%}
.skel + .skel{margin-top:var(--sp-2)}
.progress{display:flex;align-items:center;gap:10px}
.progress .bar{flex:1;margin:0}
.progress .fig{min-width:3em;text-align:right}
.steps .dot{line-height:1;font-family:var(--font-sans)}
.steps li > span:nth-child(2){flex:1}
.steps .fail .dot{border-color:var(--warn);color:var(--warn-text);font-weight:var(--fw-bold)}
.steps .fail{color:var(--warn-text)}
/* B15 杂项小件 */
.kbd{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:0 6px;border-radius:var(--radius-tag);background:var(--c-n100);font:var(--fw-bold) var(--fs-caption)/1 var(--font-figures);color:var(--text-secondary);white-space:nowrap}
.muted{color:var(--text-secondary)}
.cap{font-size:var(--fs-caption);line-height:var(--lh-caption)}
.fig.m{font-weight:var(--fw-medium)}
.fig.xs{font-size:var(--fs-figure-xs)}
:is(.hint-warn,.stat p,.quota p) .fig{font-size:var(--fs-figure-xs)}
.kv{display:grid;grid-template-columns:96px minmax(0,1fr);gap:var(--sp-2) var(--sp-4);margin:0}
.kv dt{color:var(--text-secondary)}
.kv dd{margin:0}
.drop{display:flex;flex-direction:column;align-items:center;gap:var(--sp-1);border:1.5px dashed var(--border-control);border-radius:var(--radius-card);background:var(--bg-surface);padding:var(--sp-8) var(--sp-6);text-align:center;cursor:pointer;transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}
.drop:hover,.drop.is-over{border-color:var(--c-ink);background:var(--c-n50)}
.drop > .ic{color:var(--text-secondary)}
.drop b{font-size:var(--fs-title);line-height:var(--lh-title)}
.drop span{color:var(--text-secondary);font-size:var(--fs-caption);line-height:var(--lh-caption)}
.drop[aria-disabled="true"]{pointer-events:none;color:var(--text-disabled);border-color:var(--border-subtle);background:var(--c-n50)}
.file-row{display:grid;grid-template-columns:40px minmax(0,1fr);grid-auto-flow:column;grid-auto-columns:auto;gap:var(--sp-3);align-items:center;padding:var(--sp-3) var(--sp-4);background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card)}
.file-row .ext{font:var(--fw-bold) 11px/1 var(--font-figures);background:var(--c-n100);border-radius:var(--radius-xs);padding:5px 0;text-align:center}
.file-row b{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.file-row .acts{display:flex;gap:var(--sp-1);align-items:center}
/* B16 AI 产出块:确认前「AI」小标签 + 左侧 2px 蓝铅笔虚线;正文始终墨色(Q2) */
.ai-block{border-left:2px dashed var(--ai);padding:var(--sp-1) 0 var(--sp-1) var(--sp-3);color:var(--text-primary)}
/* B17 卡片头、统计 */
.card-h{display:flex;align-items:center;gap:var(--sp-3);margin-bottom:var(--sp-3)}
.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);overflow:hidden}
.stat{position:relative;display:flex;flex-direction:column;align-items:flex-start;gap:2px;min-width:0;padding:var(--sp-4) var(--card-x);border:0;border-left:var(--line) solid var(--border-subtle);background:none;font:inherit;color:inherit;text-align:left}
.stat:first-child{border-left:0}
.stat small{display:block;font-size:var(--fs-body);line-height:var(--lh-body);color:var(--text-secondary)}
.stat .big{font:var(--fw-bold) var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
.stat .big.fatal{color:var(--sev-fatal)}
.stat .big.due{color:var(--warn-text)}
/* B19 列表卡片:左侧分类导航 + 列表;列表里的分组标题(.ai 是清单顶部的「待确认」分组) */
.cmp-list{display:grid;grid-template-columns:176px minmax(0,1fr);min-height:0;overflow:hidden}
@media (max-width:1399px){.cmp-list{grid-template-columns:152px minmax(0,1fr)}}
.cmp-list > .lst{position:relative;min-width:0}
.cats{display:flex;flex-direction:column;gap:2px;border-right:var(--line) solid var(--border-subtle);padding:var(--sp-3) var(--sp-2)}
.cats button{display:flex;flex:none;width:100%;align-items:center;justify-content:space-between;height:var(--control-h-sm);padding:0 10px;border:0;background:none;border-radius:var(--radius-ctl);font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer;text-align:left;transition:background-color var(--dur-fast) var(--ease)}
.cats button:hover{background:var(--c-n100)}
.cats button[aria-current="true"]{background:var(--c-n100);font-weight:var(--fw-bold)}
.cats button .fig{font-size:var(--fs-figure-sm);font-weight:var(--fw-medium);color:var(--text-secondary)}
.cats button.ai,.cats button.ai .fig{color:var(--ai)}
.cats hr{width:100%;margin:var(--sp-2) 0;border:0;border-top:var(--line) solid var(--border-subtle)}
.grp-h{display:flex;align-items:baseline;gap:var(--sp-2);padding:var(--sp-5) var(--card-x) var(--sp-2)}
.grp-h b{font-weight:var(--fw-bold)}
.grp-h .fig{font-weight:var(--fw-medium);color:var(--text-secondary)}
.grp-h.ai b,.grp-h.ai .fig{color:var(--ai)}
.grp-h p{margin:0;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
/* B20 项目行:栅格对齐的列表,整行可点 */
:root{--prow-cols:minmax(0,1fr) 120px 120px 64px 140px var(--control-h-sm);--prow-gap:var(--sp-4)}
@media (max-width:1399px){:root{--prow-cols:minmax(0,1fr) 108px 108px 56px 132px var(--control-h-sm);--prow-gap:var(--sp-3)}}
.prow-head,.prow{display:grid;grid-template-columns:var(--prow-cols);column-gap:var(--prow-gap);align-items:center;padding:0 var(--card-x)}
.prow-head{padding-bottom:var(--sp-2);font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary)}
:is(.prow-head,.prow) > .c{justify-self:center;text-align:center}
.prows{list-style:none;margin:0;padding:0}
.prows > .empty{border-top:var(--line) solid var(--border-subtle)}
.prow{position:relative;min-height:var(--row-h);padding-top:10px;padding-bottom:10px;border-top:var(--line) solid var(--border-subtle);cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
.prow:hover,.prow:focus-within{background:var(--bg-hover)}
.prow:last-child{border-radius:0 0 var(--radius-card) var(--radius-card)}
.prow:has(.pname:focus-visible){outline:2px solid var(--focus-ring);outline-offset:-2px}
.prow .pt{min-width:0}
.pname{display:block;font-weight:var(--fw-bold);color:var(--text-primary);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;outline:0}
.prow .pname::after{content:"";position:absolute;inset:0}
.psub{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.prow .dl b{display:block;font-weight:var(--fw-regular);white-space:nowrap}
.prow .dl b.due-today{font-weight:var(--fw-bold)}
.prow .dl small{display:block;font-size:var(--fs-caption);line-height:var(--lh-caption);color:var(--text-secondary);white-space:nowrap}
.prow .st{display:flex;align-items:center;gap:var(--sp-2);white-space:nowrap}
.prow .st .hint-u{position:relative;z-index:1}
.dash{color:var(--text-secondary)}
.nx{position:relative;z-index:1;display:inline-flex;align-items:center;gap:var(--sp-1);height:var(--control-h-sm);padding:0;border:0;background:none;font:inherit;color:var(--text-primary);white-space:nowrap;cursor:pointer;text-decoration:none}
.nx span{text-decoration:underline;text-decoration-color:var(--link-underline);text-underline-offset:3px;transition:text-decoration-color var(--dur-fast) var(--ease)}
.nx:hover span{text-decoration-color:var(--c-ink)}
.nx .ic{color:var(--text-secondary);transition:transform var(--dur-fast) var(--ease),color var(--dur-fast) var(--ease)}
.prow:hover .nx .ic,.prow:focus-within .nx .ic{transform:translateX(3px);color:var(--text-primary)}

20
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"jsx": "preserve",
"noEmit": true,
"skipLibCheck": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

29
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
/**
* 开发:Vite 跑在 127.0.0.1:5173,把 /api 代理到后端 127.0.0.1:8009,网页版同源、cookie 直接可用。
* 构建:资源基础路径默认 "/";桌面外壳(file:// 或自定义协议)构建时设 VITE_BASE=./,
* 同时在 public/config.js 里把 routerMode 改成 "hash"(架构 §5.2 第 6 条)。
*/
export default defineConfig({
base: process.env.VITE_BASE ?? "/",
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
// 设计规范是唯一的样式来源:tokens.css、components.css、logo 直接从 docs/design 引用,不复制
"@design": fileURLToPath(new URL("../../docs/design", import.meta.url)),
},
},
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
proxy: {
"/api": { target: "http://127.0.0.1:8009", changeOrigin: false },
},
},
preview: { host: "127.0.0.1", port: 4173 },
});

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "bid-doctor",
"private": true,
"packageManager": "pnpm@10.34.5",
"engines": { "node": ">=22" },
"scripts": {
"dev": "pnpm --filter @bid-doctor/web dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"gen:api": "pnpm --filter @bid-doctor/api-client gen"
}
}

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

1472
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
packages:
- apps/*
- packages/*