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,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>