feat: 查标第 1 步——上传投标分册 → 检查 → 逐条处理 → 复查沿用

后端
- check 模块:检查预览、发起、进度、取消、记录;六个阶段(准备投标文件、响应匹配、规则、证据校验、
  状态沿用、汇总);问题列表、处理、撤销、批量处理(只限提示级)。
- 每条清单项的检查结论 check_item_result(C9,迁移 0005):「已响应」也带投标原文出处;
  没有校验通过的证据不能判已响应(C3)。级别从清单项算,不让模型定;说明不写整改建议。
- 响应匹配先只有第三层:内存 BM25 检索候选句 + check.match v1 提示词,检索不到不调模型,
  漏答记 missing_answer,模型临时错误只影响那一批。
- 计费:免费版不记流水;专业版 24 小时内复查免扣(记一行 0);expected_charge 不一致时拒绝不扣点;
  失败、取消退回;投标文件读不出文字按输入问题失败、不扣点。
- 投标分册:类型(volume_kind)和版本;电子投标平台加密格式在上传时拦下。
- 解析流水线前几个阶段拆到 document/pipeline/normalize.py,解析和检查共用。
- ops 模块:反馈接口;致命问题确认无误的说明自动进误报反馈(U4)。
- SK1:Valkey 客户端短超时、SSE 连接池上限。SK2:CI 加 Gotenberg、直接跑 init-roles.sh。

前端
- 上传投标文件并检查页、查标页(进度、覆盖范围、按级别筛选、逐条处理和行内说明、撤销、原文抽屉、
  已处理、本次已解决);项目头抽成公共组件,加「查标」标签页;工作台「下一步」覆盖查标阶段。

契约、文档:openapi.yaml 加 item-results、volume_kind、coverage 字段;data-model、api-design §10、
架构 §8 / §13(SK14)、AGENTS.md 同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-04 11:48:10 +08:00
parent 6ab42959df
commit e499de7486
56 changed files with 3735 additions and 183 deletions

View File

@@ -0,0 +1,295 @@
<script setup lang="ts">
/**
* 上传投标文件并检查(原型 bid-check.html,FR-M3-01、02)。
* - 按分册上传;同一分册重新上传是新版本,检查默认用每个分册的最新版本;
* - 「检查范围」如实写会查什么、不查什么,报告首页照此写明(红线 3);
* - 扣点说明写在按钮旁边(红线 9),来自 check-preview;发起时带 expected_charge,和服务端算的不一致时不扣点(API4)。
*/
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
import { Info, Upload } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink, useRouter } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, apiOptions, errorTitle, isApiError } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime, fmtSize, VOLUME_KIND, VOLUME_KINDS } from "@/lib/labels";
import { useCheckPreview, useProject, useQuotaAccount, type DocumentInfo } from "@/lib/queries";
import { platform } from "@/platform";
const props = defineProps<{ projectId: string }>();
const ACCEPT = [".pdf", ".docx", ".doc", ".wps"];
const router = useRouter();
const qc = useQueryClient();
const project = useProject(() => props.projectId);
const previewQ = useCheckPreview(() => props.projectId);
const parse = useQuotaAccount("parse");
const p = computed(() => project.data.value);
const pv = computed(() => previewQ.data.value);
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "上传投标文件并检查" }]);
const vols = computed<DocumentInfo[]>(() => pv.value?.bid_documents ?? []);
/* ---------- 上传 ---------- */
const kind = ref<Schemas["VolumeKind"]>("whole");
const volName = ref("");
const replacing = ref<DocumentInfo | null>(null);
const uploading = ref<{ name: string; ratio: number } | null>(null);
const upErr = ref("");
const over = ref(false);
async function upload(file: File) {
upErr.value = "";
uploading.value = { name: file.name, ratio: 0 };
try {
const done = await uploadFile(api, apiOptions, file, "project_document", (ratio) => {
uploading.value = { name: file.name, ratio };
});
const target = replacing.value;
await unwrap(
api.POST("/projects/{project_id}/documents", {
params: { path: { project_id: props.projectId } },
body: target
? { upload_id: done.id, role: "bid_volume", replaces_document_id: target.id }
: { upload_id: done.id, role: "bid_volume", volume_kind: kind.value, ...(volName.value.trim() ? { volume_name: volName.value.trim() } : {}) },
}),
);
volName.value = "";
replacing.value = null;
await qc.invalidateQueries({ queryKey: qk.checkPreview(props.projectId) });
void qc.invalidateQueries({ queryKey: qk.projects });
} catch (e) {
// 格式不支持、加密、电子投标平台加密格式:在上传完成时就拦下,不扣点
const detail = e instanceof ApiError && e.problem.detail ? `(${e.problem.detail})` : "";
upErr.value = `${file.name}:${errorTitle(e)}${detail}`;
} finally {
uploading.value = null;
}
}
async function pick(target: DocumentInfo | null = null) {
if (uploading.value) return;
replacing.value = target;
const f = await platform.pickFile({ accept: ACCEPT });
if (f) void upload(f);
else replacing.value = null;
}
function onDrop(e: DragEvent) {
over.value = false;
const f = e.dataTransfer?.files[0];
if (f && !uploading.value) void upload(f);
}
/* ---------- 发起检查 ---------- */
const busy = ref(false);
const startErr = ref("");
const blockerText: Record<string, string> = {
no_parsed_tender: "还没有解析成功的招标文件",
checklist_empty: "正式清单还没有条目",
no_bid_documents: "先上传投标文件",
job_already_running: "这个项目正在检查",
quota_exhausted: "解析额度已用完",
project_read_only: "项目已递交或已归档",
};
const blockers = computed(() => (pv.value?.blockers ?? []).map((b) => blockerText[b] ?? b));
async function start() {
if (!pv.value?.can_start) return;
busy.value = true;
startErr.value = "";
try {
const run = await unwrap(
api.POST("/projects/{project_id}/check-runs", {
params: { path: { project_id: props.projectId } },
body: { document_ids: vols.value.map((d) => d.id), expected_charge: pv.value.charge },
}),
);
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
await router.push({ name: "results", params: { projectId: props.projectId, runId: run.id } });
} catch (e) {
const running = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
if (typeof running === "string") {
await router.push({ name: "results", params: { projectId: props.projectId, runId: running } });
return;
}
if (isApiError(e, "billing_changed")) {
startErr.value = "扣点情况有变化(例如 24 小时免扣窗口刚好过了),已刷新说明,确认后再点一次。本次没有扣点。";
void qc.invalidateQueries({ queryKey: qk.checkPreview(props.projectId) });
} else {
startErr.value = `${errorTitle(e)}。本次没有扣点。`;
}
} finally {
busy.value = false;
}
}
</script>
<template>
<ProjectHead :project="p" tab="check" />
<div class="content narrow">
<div class="page-title">
<div>
<h1>上传投标文件并检查</h1>
<p>只读加密前的 Word / PDF 稿。每次上传都是新版本,检查结果对应到具体版本。</p>
</div>
</div>
<div v-if="pv && !pv.checklist_confirmed" class="alert" role="note">
<Info class="ic lg" :stroke-width="1.5" />
<p>清单还没确认(或确认后又改过)。这次检查以当前清单为准<template v-if="pv.pending_item_count">;待确认区的 <span class="fig">{{ pv.pending_item_count }}</span> 条不参与检查</template>。</p>
<RouterLink class="btn sm" :to="{ name: 'checklist', params: { projectId } }">去确认清单</RouterLink>
</div>
<form class="card flush" novalidate aria-labelledby="h-form" @submit.prevent="start">
<div class="card-h"><h2 id="h-form">投标文件分册</h2></div>
<div class="card-b form-b">
<div v-for="v in vols" :key="v.id" class="file-row">
<span class="ext">{{ v.detected_format.toUpperCase() }}</span>
<div>
<span class="name"><b>{{ v.label }}</b></span>
<span class="meta">{{ v.original_filename }}<template v-if="v.page_count"> · <span class="fig xs">{{ v.page_count }}</span> 页</template> · {{ fmtSize(v.size_bytes) }} · {{ fmtDateTime(v.created_at) }}</span>
</div>
<span class="acts"><button type="button" class="btn sm" :disabled="!!uploading" @click="pick(v)">上传新版本</button></span>
</div>
<div class="new-vol">
<div class="field">
<label for="vk">分册类型</label>
<select id="vk" v-model="kind" class="input" :disabled="!!uploading">
<option v-for="k in VOLUME_KINDS" :key="k" :value="k">{{ VOLUME_KIND[k] }}</option>
</select>
</div>
<div class="field">
<label for="vn">分册名称 <small>(选填)</small></label>
<input id="vn" v-model="volName" class="input" maxlength="40" placeholder="例如:技术册" autocomplete="off" :disabled="!!uploading" />
</div>
</div>
<div
class="drop"
:class="{ 'is-over': over }"
tabindex="0"
role="button"
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>{{ uploading ? `正在上传 ${uploading.name}(${Math.round(uploading.ratio * 100)}%)` : "拖入或点击上传分册" }}</b>
<span id="drop-sub">Word / PDF;电子投标客户端加密后的文件读不了,请上传加密前的稿子</span>
</div>
<p v-if="upErr" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ upErr }}</span></p>
<fieldset class="form-sec scope">
<legend>检查范围<small>报告首页会照此写明</small></legend>
<div>
<h3>会查</h3>
<ul>
<li>正式清单每一条在投标文件里有没有响应:未找到响应、负偏离未说明、疑似不满足</li>
<li>每条结论都带投标文件的原文出处;出处核对不上的,不算「已响应」</li>
</ul>
</div>
<div class="no">
<h3>这一版还不查</h3>
<ul>
<li>报价形式(三层限价、合计、大小写)、形式要件、资质有效期:规则检查还没有上线</li>
<li>签章真伪(以后也只看有无,不鉴定真伪)</li>
<li>份数、密封、CA 加密、递交方式(文件里看不出来)</li>
<li>报价是否合理、该报多少,评分高低</li>
</ul>
</div>
</fieldset>
<div v-if="startErr" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ startErr }}</p></div>
</div>
<div class="card-f">
<span v-if="pv" class="cost">
<template v-if="pv.billing === 'charged'">扣 <span class="fig">1</span> 次解析额度;检查失败、中断或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></template>
<template v-else-if="pv.billing === 'waived_recheck'">24 小时内复查,不扣额度<template v-if="pv.waive_until">(到 {{ fmtDateTime(pv.waive_until) }},复查不延长窗口)</template></template>
<template v-else>免费版:这个项目内检查不限次数</template>
<template v-if="blockers.length"> · <span class="hint-warn inline">{{ blockers.join(";") }}</span></template>
</span>
<RouterLink class="btn" :to="{ name: 'checklist', params: { projectId } }">取消</RouterLink>
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || !pv?.can_start">
<span class="idle">开始检查</span><span class="busy">正在提交…</span>
</button>
</div>
</form>
</div>
</template>
<style scoped>
.alert {
margin-bottom: var(--sp-4);
}
.card-b.form-b {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding-top: var(--sp-5);
padding-bottom: var(--sp-6);
}
.file-row .name {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.file-row .meta {
display: block;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.new-vol {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: var(--sp-4);
}
.new-vol .field {
max-width: none;
}
.scope {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--sp-6);
border: 0;
margin: var(--sp-2) 0 0;
padding: 0;
}
.scope legend {
grid-column: 1 / -1;
font-weight: var(--fw-bold);
margin-bottom: var(--sp-2);
}
.scope legend small {
margin-left: var(--sp-2);
font-weight: var(--fw-regular);
color: var(--text-secondary);
}
.scope h3 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-body);
}
.scope ul {
margin: 0;
padding-left: 1.2em;
}
.scope .no {
color: var(--text-secondary);
}
.card-f .cost {
margin-right: auto;
white-space: normal;
}
.hint-warn.inline {
display: inline;
}
</style>