feat(web): 用户端骨架——登录、工作台、新建项目、解析进度、读标清单

- packages/api-client:从 openapi.yaml 生成类型;cookie + CSRF / Bearer 两种鉴权、
  分片上传、fetch 读事件流(API2)
- apps/web:Vue 3 + Vite + TanStack Query,样式只来自 tokens.css / components.css(AR10);
  扣点说明放在「开始解析」旁边(红线 9);接口地址、路由方式运行时可配(架构 §5.2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent ad7fba6021
commit 6aaf8e768b
36 changed files with 8684 additions and 0 deletions

View File

@@ -0,0 +1,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>