- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
(新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
453 lines
19 KiB
Vue
453 lines
19 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 项目概览(原型 project.html,PI1):关键日期(时间线和倒计时,FR-M2-09)和采购包、项目要点(基本信息字段,
|
||
* 带出处,可改、可恢复 AI 值)、检查记录(历次检查的结果和报告)、文件(招标方的文件和投标文件的最新版本)。
|
||
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
|
||
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
|
||
*/
|
||
import { unwrap, type Schemas } from "@bid-doctor/api-client";
|
||
import { Download, Pencil, Plus } from "@lucide/vue";
|
||
import { useQueryClient } from "@tanstack/vue-query";
|
||
import { computed, ref, watchEffect } from "vue";
|
||
import { RouterLink } from "vue-router";
|
||
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
|
||
import FieldDialog from "@/components/FieldDialog.vue";
|
||
import type { ViewerTarget } from "@/components/PdfViewer.vue";
|
||
import ProjectHead from "@/components/ProjectHead.vue";
|
||
import { api, errorTitle } from "@/lib/api";
|
||
import { useCrumbs } from "@/lib/crumbs";
|
||
import { qk } from "@/lib/keys";
|
||
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTime, fmtDateTimeFull, fmtYuan, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
|
||
import {
|
||
reportFileUrl,
|
||
useCheckRuns,
|
||
useLots,
|
||
useProject,
|
||
useProjectDocuments,
|
||
useReports,
|
||
type Citation,
|
||
type DocumentInfo,
|
||
type ProjectField,
|
||
} from "@/lib/queries";
|
||
import { toast } from "@/lib/toast";
|
||
|
||
const props = defineProps<{ projectId: string }>();
|
||
const qc = useQueryClient();
|
||
const project = useProject(() => props.projectId);
|
||
const lotsQ = useLots(() => props.projectId);
|
||
const p = computed(() => project.data.value);
|
||
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "概览" }]);
|
||
|
||
const readOnly = computed(() => p.value?.status === "submitted" || p.value?.status === "archived");
|
||
const byKey = computed(() => new Map((p.value?.fields ?? []).map((f) => [f.key, f])));
|
||
/** 字段的当前值;没有字段记录的(建项目时填的名称、改过的截止时间等)取项目本身的值 */
|
||
const COLUMN: Partial<Record<string, keyof NonNullable<typeof p.value>>> = {
|
||
name: "name",
|
||
code: "code",
|
||
purchaser: "purchaser",
|
||
agency: "agency",
|
||
procurement_method: "procurement_method",
|
||
deadline_at: "deadline_at",
|
||
};
|
||
const rows = computed(() =>
|
||
FIELD_ORDER.map((key) => {
|
||
const f = byKey.value.get(key) ?? null;
|
||
const col = COLUMN[key];
|
||
// 名称:建项目时填的名称优先于 AI 抽到的(解析不覆盖用户填的名称),和项目头一致
|
||
const value =
|
||
key === "name" && p.value && f?.source !== "human" ? p.value.name : f && f.value != null ? f.value : col && p.value ? (p.value[col] as unknown) : null;
|
||
return { key, f, value };
|
||
}),
|
||
);
|
||
const realLots = computed(() => (lotsQ.data.value?.items ?? []).filter((l) => !l.is_default));
|
||
const dates = computed(() => (p.value?.key_dates ?? []).map((d) => ({ ...d, due: deadline(d.at) })));
|
||
|
||
const dlg = ref<{ open: boolean; key: Schemas["ProjectFieldKey"] | null; field: ProjectField | null }>({ open: false, key: null, field: null });
|
||
function edit(key: Schemas["ProjectFieldKey"], f: ProjectField | null) {
|
||
dlg.value = { open: true, key, field: f };
|
||
}
|
||
function onSaved(f: ProjectField, restored: boolean) {
|
||
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
|
||
void qc.invalidateQueries({ queryKey: qk.projects });
|
||
toast(restored ? `已恢复${FIELD_LABEL[f.key]}的 AI 值` : `已修改${FIELD_LABEL[f.key]}`);
|
||
}
|
||
|
||
/* ---------- 补遗带来的新值:采用 / 不采用(AD3) ---------- */
|
||
const resolving = ref<string | null>(null);
|
||
async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean) {
|
||
resolving.value = key;
|
||
try {
|
||
await unwrap(api.POST("/projects/{project_id}/fields/{field_key}/pending", { params: { path: { project_id: props.projectId, field_key: key } }, body: { accept } }));
|
||
void qc.invalidateQueries({ queryKey: qk.project(props.projectId) });
|
||
void qc.invalidateQueries({ queryKey: qk.projects });
|
||
void qc.invalidateQueries({ queryKey: qk.checklistItems(props.projectId) });
|
||
toast(accept ? `已采用补遗的${FIELD_LABEL[key]}` : `保留原来的${FIELD_LABEL[key]}`);
|
||
} catch (e) {
|
||
toast(errorTitle(e), { ok: false });
|
||
} finally {
|
||
resolving.value = null;
|
||
}
|
||
}
|
||
const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" && !readOnly.value);
|
||
|
||
/* ---------- 检查记录:历次检查和对应的报告 ---------- */
|
||
const runsQ = useCheckRuns(() => props.projectId);
|
||
const runs = computed(() => runsQ.data.value?.items ?? []);
|
||
const reportsQ = useReports(() => props.projectId);
|
||
const reportsByRun = computed(() => {
|
||
const m = new Map<string, NonNullable<typeof reportsQ.data.value>["items"]>();
|
||
for (const r of reportsQ.data.value?.items ?? []) m.set(r.check_run_id, [...(m.get(r.check_run_id) ?? []), r]);
|
||
return m;
|
||
});
|
||
const RUN_STATE: Record<string, string> = { queued: "排队中", running: "检查中", failed: "检查失败", cancelled: "已取消" };
|
||
async function openReport(id: string) {
|
||
try {
|
||
window.open(await reportFileUrl(id), "_blank", "noopener");
|
||
} catch (e) {
|
||
toast(errorTitle(e), { ok: false });
|
||
}
|
||
}
|
||
|
||
/* ---------- 文件:招标方的文件全列;投标文件每个分册只列最新版本 ---------- */
|
||
const tenderDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role !== "bid_volume"));
|
||
const bidDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role === "bid_volume" && d.is_latest_version !== false));
|
||
function parseState(d: DocumentInfo): string {
|
||
const s = d.latest_parse_job?.status;
|
||
if (s === "succeeded") return "解析成功";
|
||
if (s === "queued" || s === "running") return "解析中";
|
||
if (s === "failed") return "解析失败";
|
||
if (s === "cancelled") return "已取消解析";
|
||
return d.status === "failed" ? "读取失败" : "未解析";
|
||
}
|
||
|
||
/* 右侧原文(左右布局,同读标页):默认摆招标文件,点出处翻到那一页并高亮;补遗的出处换成那份补遗 */
|
||
const docsQ = useProjectDocuments(() => props.projectId);
|
||
const shown = ref<{ doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }>({ doc: null, target: null });
|
||
watchEffect(() => {
|
||
const t = docsQ.data.value?.items.find((d) => d.role === "tender" && d.status === "ready");
|
||
if (!shown.value.doc && t) shown.value.doc = { id: t.id, label: "招标文件" };
|
||
});
|
||
const slots = computed<StageSlot[]>(() => [{ key: "tender", label: "招标文件", ...shown.value, emptyText: "招标文件解析完成后,在这里看原文。" }]);
|
||
const side = ref("tender");
|
||
function openCitation(c: Citation) {
|
||
shown.value = { doc: { id: c.document_id, label: c.document_label }, target: { id: c.id, page: c.page_no, quads: c.quads, sha: c.normalized_sha256 } };
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<ProjectHead :project="p" tab="overview" />
|
||
<div class="content">
|
||
<div class="split">
|
||
<div class="split-main ov">
|
||
<!-- 关键日期和采购包:放在最上面,一张卡片左右两栏(右边是原文,左边不够放两列卡片) -->
|
||
<section class="card flush glance" aria-label="关键日期和采购包">
|
||
<div class="g-col" aria-labelledby="h-dates">
|
||
<div class="g-h"><h3 id="h-dates">关键日期</h3></div>
|
||
<p v-if="!dates.length" class="cap muted none">招标文件解析后,这里列出投标截止、开标等日期</p>
|
||
<ol v-else class="timeline">
|
||
<li v-for="(d, i) in dates" :key="i">
|
||
<span class="fig when">{{ fmtDateTimeFull(d.at) }}</span>
|
||
<b>{{ d.label }}</b>
|
||
<span v-if="d.edited" class="flag box">已修改</span>
|
||
<span v-if="d.due && d.due.cls !== 'due-past'" class="cap" :class="d.due.cls || 'muted'">{{ d.due.main }}</span>
|
||
<span v-if="d.superseded_pending" class="cap warn-text rep">已被补遗更新,待确认</span>
|
||
<span v-if="d.replaces" class="cap muted rep">已按{{ d.replaces.document_label }} 更新 · 原为 {{ d.replaces.at ? fmtDateTimeFull(d.replaces.at) : "—" }}</span>
|
||
</li>
|
||
</ol>
|
||
</div>
|
||
<div class="g-col" aria-labelledby="h-lots">
|
||
<div class="g-h">
|
||
<h3 id="h-lots">采购包</h3>
|
||
<RouterLink v-if="!readOnly" class="lnk cap r" :to="{ name: 'lots', params: { projectId } }">{{ realLots.length > 1 ? "更改所投的包" : "调整分包" }}</RouterLink>
|
||
</div>
|
||
<p v-if="!realLots.length" class="cap muted none">不分包:清单里的条目都适用</p>
|
||
<ul v-else class="lots">
|
||
<li v-for="l in realLots" :key="l.id" :class="{ off: realLots.length > 1 && !l.is_bidding }">
|
||
<b>采购包 {{ l.lot_no }}</b>
|
||
<span v-if="realLots.length > 1" class="tag">{{ l.is_bidding ? "投" : "不投" }}</span>
|
||
<span v-if="l.name" class="name">{{ l.name }}</span>
|
||
<span class="cap muted">
|
||
<template v-if="l.budget_amount">预算 {{ fmtYuan(l.budget_amount) }}</template>
|
||
<template v-if="l.budget_amount && l.ceiling_amount"> · </template>
|
||
<template v-if="l.ceiling_amount">最高限价 {{ fmtYuan(l.ceiling_amount) }}</template>
|
||
<template v-if="l.edited_fields.length"> · 已修改</template>
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
<p v-if="realLots.length" class="cap muted note">限价是招标文件里的原文数字,不是报价建议。</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card flush" aria-labelledby="h-facts">
|
||
<div class="card-h">
|
||
<h2 id="h-facts">项目要点</h2>
|
||
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
|
||
</div>
|
||
<dl class="facts-list">
|
||
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
|
||
<dt>{{ FIELD_LABEL[key] }}</dt>
|
||
<dd>
|
||
<span v-if="value != null && value !== ''" :class="{ fig: key === 'deadline_at' }">{{ fieldText(key, value) }}</span>
|
||
<span v-else class="dash">没写明</span>
|
||
<span v-if="f?.source === 'human'" class="flag box">已修改</span>
|
||
<button v-if="f?.citation" type="button" class="cite-src inline" @click="openCitation(f.citation)">
|
||
<template v-if="f.citation.document_label && f.citation.document_label !== '招标文件'">{{ f.citation.document_label }} </template>第 <span class="fig">{{ f.citation.page_no }}</span> 页
|
||
</button>
|
||
<span v-if="f?.pending" class="pend-row">
|
||
<span class="cap warn-text">{{ f.pending.document_label }}:{{ fieldText(key, f.pending.value) }}<template v-if="value != null && value !== ''">(原为 {{ fieldText(key, value) }})</template>,没确认前查标用原值</span>
|
||
<button v-if="f.pending.citation" type="button" class="cite-src inline" @click="openCitation(f.pending.citation)">{{ f.pending.document_label }} 第 <span class="fig">{{ f.pending.citation.page_no }}</span> 页</button>
|
||
<template v-if="!readOnly">
|
||
<button type="button" class="btn sm" :disabled="resolving === key" @click="resolvePending(key, true)">采用</button>
|
||
<button type="button" class="btn sm text" :disabled="resolving === key" @click="resolvePending(key, false)">不采用</button>
|
||
</template>
|
||
</span>
|
||
</dd>
|
||
<button v-if="!readOnly" type="button" class="icon-btn sm" :aria-label="`修改${FIELD_LABEL[key]}`" @click="edit(key, f ?? { key, value: value as never, source: 'none' })">
|
||
<Pencil class="ic" :stroke-width="1.5" />
|
||
</button>
|
||
</div>
|
||
</dl>
|
||
</section>
|
||
|
||
<section class="card flush" aria-labelledby="h-runs">
|
||
<div class="card-h">
|
||
<h2 id="h-runs">检查记录</h2>
|
||
<RouterLink v-if="!readOnly && p && p.status !== 'tracking'" class="btn sm r" :to="{ name: 'bid-check', params: { projectId } }">
|
||
{{ runs.length ? "上传新版本并检查" : "上传投标文件并检查" }}
|
||
</RouterLink>
|
||
</div>
|
||
<p v-if="!runs.length" class="cap muted none pad">还没有检查过。上传投标文件后,每次检查的结果和导出的报告都列在这里。</p>
|
||
<table v-else class="data runs">
|
||
<thead>
|
||
<tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th>报告</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in runs" :key="r.id">
|
||
<td>第 <span class="fig">{{ r.run_no }}</span> 次</td>
|
||
<td class="fig nowrap">{{ fmtDateTime(r.created_at) }}</td>
|
||
<td>{{ (r.documents ?? []).map((d) => d.label).join("、") || "—" }}</td>
|
||
<td>
|
||
<template v-if="r.status === 'succeeded'">
|
||
<!-- 只列有问题的级别:「致命 0」不该是红色 -->
|
||
<span class="sevs">
|
||
<template v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s">
|
||
<span v-if="r.counts?.[s]" class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ r.counts[s] }}</span></span>
|
||
</template>
|
||
<span v-if="!r.counts?.fatal && !r.counts?.important && !r.counts?.hint" class="cap muted">没有发现问题</span>
|
||
</span>
|
||
<RouterLink class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">查看结果</RouterLink>
|
||
</template>
|
||
<RouterLink v-else class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">{{ RUN_STATE[r.status] ?? r.status }}</RouterLink>
|
||
</td>
|
||
<td>
|
||
<button v-for="rep in reportsByRun.get(r.id) ?? []" :key="rep.id" type="button" class="lnk cap rep" @click="openReport(rep.id)">
|
||
<Download class="ic" :stroke-width="1.5" />{{ fmtDateTime(rep.created_at) }}
|
||
</button>
|
||
<span v-if="!reportsByRun.get(r.id)?.length" class="dash" aria-label="没有导出">—</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="card flush" aria-labelledby="h-files">
|
||
<div class="card-h">
|
||
<h2 id="h-files">文件</h2>
|
||
<RouterLink v-if="canAddendum" class="btn sm r" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
|
||
</div>
|
||
<div class="files">
|
||
<h3 class="cap muted">招标方</h3>
|
||
<p v-if="!tenderDocs.length" class="cap muted none">还没有上传招标文件</p>
|
||
<div v-for="d in tenderDocs" :key="d.id" class="file-row">
|
||
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
|
||
<div>
|
||
<b>{{ d.label }}</b>
|
||
<span class="cap muted">{{ d.original_filename }}<template v-if="d.page_count"> · <span class="fig">{{ d.page_count }}</span> 页</template> · {{ parseState(d) }}</span>
|
||
</div>
|
||
<span class="acts"><RouterLink class="btn sm text" :to="{ name: 'checklist', params: { projectId } }">读标</RouterLink></span>
|
||
</div>
|
||
<h3 class="cap muted">投标文件(加密前的 Word / PDF 稿)</h3>
|
||
<p v-if="!bidDocs.length" class="cap muted none">还没有上传投标文件</p>
|
||
<div v-for="d in bidDocs" :key="d.id" class="file-row">
|
||
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
|
||
<div>
|
||
<b>{{ d.label }}</b>
|
||
<span class="cap muted"><template v-if="d.page_count"><span class="fig">{{ d.page_count }}</span> 页 · </template>{{ fmtDateTime(d.created_at) }} 上传</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
<aside class="split-doc" aria-label="原文">
|
||
<DocStage v-model:active="side" :slots="slots" />
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
|
||
<FieldDialog v-model:open="dlg.open" :project-id="projectId" :field-key="dlg.key" :field="dlg.field" @saved="onSaved" />
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 左边:项目要点,下面采购包和关键日期并排;右边原文 */
|
||
.ov {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--gap-card);
|
||
}
|
||
.ov > .card {
|
||
margin: 0;
|
||
}
|
||
.glance {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||
}
|
||
.g-col {
|
||
min-width: 0;
|
||
padding: var(--sp-4) var(--card-x);
|
||
}
|
||
.g-col + .g-col {
|
||
border-left: var(--line) solid var(--border-subtle);
|
||
}
|
||
.g-h {
|
||
display: flex;
|
||
align-items: baseline;
|
||
margin-bottom: var(--sp-2);
|
||
}
|
||
.g-h h3 {
|
||
margin: 0;
|
||
font-size: var(--fs-body);
|
||
font-weight: var(--fw-bold);
|
||
}
|
||
.g-h .r {
|
||
margin-left: auto;
|
||
}
|
||
.card-h .r {
|
||
margin-left: auto;
|
||
}
|
||
.pad {
|
||
padding: 0 var(--card-x) var(--sp-4);
|
||
}
|
||
.runs {
|
||
margin: 0;
|
||
}
|
||
.runs td {
|
||
vertical-align: baseline;
|
||
}
|
||
.runs .nowrap {
|
||
white-space: nowrap;
|
||
}
|
||
.sevs {
|
||
display: inline-flex;
|
||
gap: var(--sp-1);
|
||
margin-right: var(--sp-2);
|
||
}
|
||
.rep {
|
||
display: inline-flex;
|
||
gap: var(--sp-1);
|
||
align-items: center;
|
||
}
|
||
.rep + .rep {
|
||
margin-left: var(--sp-2);
|
||
}
|
||
.files {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--sp-2);
|
||
padding: 0 var(--card-x) var(--sp-4);
|
||
}
|
||
.files h3 {
|
||
margin: var(--sp-2) 0 0;
|
||
font-weight: var(--fw-regular);
|
||
}
|
||
.files .file-row b {
|
||
font-weight: var(--fw-medium);
|
||
}
|
||
.files .file-row .cap {
|
||
display: block;
|
||
}
|
||
.facts-list {
|
||
margin: 0;
|
||
}
|
||
.frow {
|
||
display: grid;
|
||
grid-template-columns: 140px minmax(0, 1fr) auto;
|
||
gap: var(--sp-4);
|
||
align-items: center;
|
||
padding: var(--sp-3) var(--sp-5);
|
||
border-top: var(--line) solid var(--border-subtle);
|
||
}
|
||
.frow dt {
|
||
color: var(--text-secondary);
|
||
}
|
||
.frow dd {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--sp-2);
|
||
align-items: baseline;
|
||
margin: 0;
|
||
}
|
||
.frow .icon-btn {
|
||
color: var(--text-secondary);
|
||
}
|
||
.frow:hover .icon-btn {
|
||
color: var(--text-primary);
|
||
}
|
||
.warn-text {
|
||
color: var(--warn-text);
|
||
}
|
||
.pend-row {
|
||
display: flex;
|
||
flex-basis: 100%;
|
||
flex-wrap: wrap;
|
||
gap: var(--sp-2);
|
||
align-items: baseline;
|
||
}
|
||
.timeline .rep {
|
||
flex-basis: 100%;
|
||
}
|
||
.none,
|
||
.note {
|
||
margin: 0;
|
||
}
|
||
.note {
|
||
margin-top: var(--sp-3);
|
||
}
|
||
.lots,
|
||
.timeline {
|
||
margin: 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
}
|
||
.lots li {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--sp-1) var(--sp-2);
|
||
align-items: baseline;
|
||
padding: var(--sp-2) 0;
|
||
}
|
||
.lots li + li {
|
||
border-top: var(--line) solid var(--border-subtle);
|
||
}
|
||
.lots li.off {
|
||
color: var(--text-secondary);
|
||
}
|
||
.lots .name {
|
||
flex-basis: 100%;
|
||
}
|
||
.lots .cap {
|
||
flex-basis: 100%;
|
||
}
|
||
.timeline li {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: var(--sp-1) var(--sp-3);
|
||
align-items: baseline;
|
||
padding: var(--sp-1) 0;
|
||
}
|
||
.timeline .when {
|
||
min-width: 11em;
|
||
font-size: var(--fs-figure-sm);
|
||
}
|
||
</style>
|