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:
262
apps/web/src/pages/ChecklistPage.vue
Normal file
262
apps/web/src/pages/ChecklistPage.vue
Normal 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>
|
||||
401
apps/web/src/pages/LoginPage.vue
Normal file
401
apps/web/src/pages/LoginPage.vue
Normal 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>
|
||||
276
apps/web/src/pages/ParsePage.vue
Normal file
276
apps/web/src/pages/ParsePage.vue
Normal 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>
|
||||
262
apps/web/src/pages/ProjectNewPage.vue
Normal file
262
apps/web/src/pages/ProjectNewPage.vue
Normal 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>
|
||||
188
apps/web/src/pages/WorkbenchPage.vue
Normal file
188
apps/web/src/pages/WorkbenchPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user