- 招标文件解析后可以追加补遗 / 澄清:单独解析、扣 1 次;新条目标「补遗 N」进正式清单,清单回到草稿(AD1)
- 同一种日期、时间不同的旧条目标「已被补遗 N 更新」,逐条确认更新或保留两条(AD2、AD3)
- 项目字段的新值在概览「采用 / 不采用」:采用时以补遗为准、清掉人工值;采用截止时间时日期条目一起确认;
没确认前查标用旧值,检查预览提示(AD4)
- 补遗不改项目名称、编号、采购人、代理机构、法律体系(更正公告标题会被抽成项目名称);时间比较按北京时间补时区
- 接口:fields/{key}/pending、checklist-items/{id}/supersession;清单项 origin_document、superseded_by;
关键日期 replaces;解析结果 dates_superseded、fields_pending;检查预览 pending_updates
- 前端:补遗模式的上传页、解析页结果、概览确认、读标页来源筛选和日期确认、检查预览提示
- 决策 AD1–AD6;方案和落地记录 docs/architecture/addendum-plan.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
308 lines
13 KiB
Vue
308 lines
13 KiB
Vue
<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>
|
||
<li>报价形式:大写和小写金额、分项报价合计、超过最高限价、另外给的报价</li>
|
||
<li>形式要件:别的项目名称、没填的地方、落款日期、投标有效期、附加条件、响应表里的未响应</li>
|
||
</ul>
|
||
</div>
|
||
<div class="no">
|
||
<h3>这一版还不查</h3>
|
||
<ul>
|
||
<li>分项 / 单价上限、分项和采购需求逐项对照、格式文件固定文字被改、声明函、资质有效期:还没有上线</li>
|
||
<li>签章真伪(以后也只看有无,不鉴定真伪)</li>
|
||
<li>份数、密封、CA 加密、递交方式(文件里看不出来)</li>
|
||
<li>报价是否合理、该报多少,评分高低</li>
|
||
</ul>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<p v-if="project.data.value?.bidding_lots?.length" class="cap muted lots-line">
|
||
本次查:通用条目 + {{ project.data.value.bidding_lots.map((n) => `采购包 ${n}`).join("、") }}
|
||
<RouterLink class="lnk" :to="{ name: 'lots', params: { projectId } }">更改所投的包</RouterLink>
|
||
</p>
|
||
<!-- AD4:补遗带来的新值没确认前查标用旧值;只提示,不阻止检查 -->
|
||
<div v-if="pv?.pending_updates" class="alert warn" role="note">
|
||
<span class="ico w" aria-hidden="true">!</span>
|
||
<p>补遗带来的新值还有 <span class="fig">{{ pv.pending_updates }}</span> 处没确认,这次检查按原来的值。可以先在概览和读标页确认。</p>
|
||
<RouterLink class="btn sm" :to="{ name: 'overview', params: { projectId } }">去确认</RouterLink>
|
||
</div>
|
||
<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>
|