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:
107
apps/web/src/components/AppShell.vue
Normal file
107
apps/web/src/components/AppShell.vue
Normal 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>
|
||||
69
apps/web/src/components/ChecklistItemRow.vue
Normal file
69
apps/web/src/components/ChecklistItemRow.vue
Normal 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>
|
||||
20
apps/web/src/components/CitationBlock.vue
Normal file
20
apps/web/src/components/CitationBlock.vue
Normal 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>
|
||||
45
apps/web/src/components/ConfirmDialog.vue
Normal file
45
apps/web/src/components/ConfirmDialog.vue
Normal 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>
|
||||
60
apps/web/src/components/ProjectRow.vue
Normal file
60
apps/web/src/components/ProjectRow.vue
Normal 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>
|
||||
Reference in New Issue
Block a user