feat(ui): 查标第 6 步前端补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 13:39:19 +08:00
parent 808259da87
commit 54cbb556d0
30 changed files with 956 additions and 143 deletions

View File

@@ -227,4 +227,9 @@ samples/
- 2026-11-05:查标结果页、概览改成和读标页一样的左右布局(左边内容,右边原文贴住视口),去掉原文侧边抽屉。查标右侧招标文件、投标文件左右切换(`components/DocStage.vue`):当前的占满,另一份露出一窄条(文件名、纸边、带方向箭头的标签,截断处淡入底色),点它或点另一边的出处就滑过去,两边各自记着页和高亮,J / K 时原文跟着翻。概览把关键日期和采购包合成一张卡放到最上面。修了原文查看器直接打开到某页时重复渲染出镜像乱字的问题。内容区四角改为直角(设计规范 §5.3 同步)。
- 2026-11-05:国动真实投标文件跑了一次(`check-eval.md` §14,代码 `5b77012`、规则集 r4、资质库空):重新读标 299 条,查标 26 秒报 44 个问题,逐条对原文后有用 9 个,**致命、重要各 1 个都是误报**(响应格「完全满足,无偏离。」被当成错位;「不……另行收费」被当成附加条件)。另外扫描页只按整页有没有字判断、不需要回答的条目还在出提示。人工看到评分索引表页码和实际页码对不上,产品还不查这一项。结果只记录,6 处问题修不修待用户定。
- 2026-11-05:按用户决定(E3)修了国动暴露的 6 处,规则集 2026.11-r5(`check-eval.md` §15)。国动重新检查 44 → 11 个,全是提示级,有用的 9 个都还在(看过结果才修,只作参考)。开发集不退化:召回 75% → 74%(模型重跑的波动),致命级 25 / 32 不变,规矩的投标文件报出的问题 239 → 213,规则问题 57 / 57。投标分册的文字层升到 v5(每页记图片占比,用来判断扫描件)。
- 下一步:可做的有:第 6 步前端补齐(键盘操作、批量处理、判断有误反馈、日期选择器的日历弹层、概览页的检查记录和文件卡)、M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。
- 2026-11-05:查标第 6 步前端补齐(`m3-plan.md` 进度)。
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;「已响应」也能反馈判断有误。证书要求写资质库里有没有、在截止日是否有效(新接口 `qualification-matches`,资质库空时不写)。
- 概览:加了检查记录卡和文件卡。
- 工作台:加了「致命」列、统计可点筛选、待处理的致命问题卡(「去处理」定位到那条问题)。
- 日期输入加了日历弹层;查标页加了 ? 键。
- 下一步:M6 剩下的 P0(额度流水、套餐开通、数据删除、运营后台)。

View File

@@ -13,6 +13,7 @@
"@bid-doctor/api-client": "workspace:*",
"@fontsource/barlow-semi-condensed": "5.3.0",
"@fontsource/noto-serif-sc": "5.3.0",
"@internationalized/date": "3.12.4",
"@lucide/vue": "1.50.0",
"@tanstack/vue-query": "5.104.1",
"pdfjs-dist": "6.4.299",

View File

@@ -7,11 +7,23 @@
import { computed } from "vue";
import CitationBlock from "./CitationBlock.vue";
import { groupCitations, type CitationGroup } from "@/lib/citations";
import { category, failureText, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
import type { ChecklistItem } from "@/lib/queries";
import { RouterLink } from "vue-router";
import { category, CHECK_VERDICT, failureText, fmtDay, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
import type { CheckItemResult, ChecklistItem, Citation, QualificationMatch } from "@/lib/queries";
const props = defineProps<{ item: ChecklistItem; pending?: boolean; selected?: boolean; activeCitation?: string | null }>();
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup] }>();
const props = defineProps<{
item: ChecklistItem;
pending?: boolean;
selected?: boolean;
activeCitation?: string | null;
/** 最近一次检查对这一条的结论(C9);没参与检查的不传 */
check?: CheckItemResult | null;
/** 资质库里的情况(资格条件、评分项里认出证书类别的条目);资质库是用户自己录的,墨色,不用蓝铅笔色 */
library?: QualificationMatch[] | null;
/** library 按哪天判断有效期:「投标截止日」或「今天」 */
libraryAsOf?: string;
}>();
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup]; openBid: [c: Citation]; feedback: []; findings: [] }>();
/** 一条结论最多默认展开 2 处出处,其余写「另有 N 处」(§7.3);跨页的一句话算一处 */
const groups = computed(() => groupCitations(props.item.citations));
@@ -22,6 +34,9 @@ const mark = computed(() => IMPORTANCE_MARK[props.item.importance] ?? "");
const summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
/** 结构化取值:分值、参数要求、日期等,都来自原文(取值校验通过的才进正式清单) */
const facts = computed(() => specFacts(props.item.spec));
/** 检查结论:已响应、默认接受是好的;其余在查标页有问题可处理 */
const checkOk = computed(() => props.check?.verdict === "responded" || props.check?.verdict === "implicit_accepted");
const bidCites = computed(() => groupCitations(props.check?.bid_citations ?? []).slice(0, 2));
</script>
<template>
@@ -54,6 +69,34 @@ const facts = computed(() => specFacts(props.item.spec));
<div v-else class="cites">
<div class="cite missing"><p>{{ item.no_citation_ack || item.origin === "human" ? "没有出处" : "没有出处,确认前不参与检查" }}</p></div>
</div>
<p v-for="m in library ?? []" :key="m.family" class="lib cap" @click.stop>
<span class="muted">资质库</span>
<template v-if="m.record">
<span>《{{ m.record.name }}》</span>
<span v-if="m.state === 'expired'" class="warn-text">有效期至 <span class="fig">{{ fmtDay(m.record.valid_to!) }}</span>,早于{{ libraryAsOf }}</span>
<span v-else>{{ m.record.valid_to ? `有效期至 ${fmtDay(m.record.valid_to)}` : "长期有效" }}</span>
<span v-if="m.count > 1" class="muted">(这一类共 <span class="fig">{{ m.count }}</span> 条,这里是有效期最晚的)</span>
</template>
<span v-else class="muted">没有录入「{{ m.family }}」</span>
<RouterLink v-if="m.state !== 'valid'" class="lnk" :to="{ name: 'qualifications' }">去资质库</RouterLink>
</p>
<p v-if="check" class="chk cap" :class="{ bad: !checkOk }" @click.stop>
<span class="muted">查标</span>
<b>{{ CHECK_VERDICT[check.verdict] }}</b>
<button
v-for="g in bidCites"
:key="g.head.id"
type="button"
class="cite-src inline"
:class="{ on: activeCitation === g.head.id }"
@click="emit('select'); emit('openBid', g.head)"
>
{{ g.head.document_label }} 第 <span class="fig">{{ g.head.page_no }}</span> 页
</button>
<span v-if="check.reused" class="muted">沿用上次结论</span>
<button v-if="!checkOk" type="button" class="lnk" @click="emit('findings')">去查标页处理</button>
<button v-else-if="check.verdict === 'responded' && selected" type="button" class="lnk feedback" @click="emit('feedback')">判断有误?</button>
</p>
<div v-if="$slots.actions && (pending || selected)" class="f-actions" @click.stop>
<slot name="actions" />
</div>
@@ -115,4 +158,35 @@ const facts = computed(() => specFacts(props.item.spec));
.cites .cap {
margin: var(--sp-1) 0 0;
}
/* 检查结论:有问题的加深,不用红(级别和处理都在查标页);投标侧出处同样是蓝铅笔色 */
.chk {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.chk b {
font-weight: var(--fw-medium);
}
.chk:not(.bad) b {
color: var(--text-secondary);
}
.chk .feedback {
margin-left: auto;
}
.lib {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1) var(--sp-2);
align-items: baseline;
margin: var(--sp-2) 0 0;
}
.lib + .lib,
.lib + .chk {
margin-top: var(--sp-1);
}
.warn-text {
color: var(--warn-text);
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
/**
* 日期输入(设计规范 §7.22、§9.3):可以直接键入(接受 2026/11/20、2026年11月20日 等,离开时整理成 2026-11-20),
* 右侧按钮打开日期选择器。选择器周一开始;今天 1px 边框,选中墨底白字;非本月的格子留空;不可选的日期只变灰。
* 交互基础用 Reka UI 的 Popover + Calendar(键盘方向键选日期、Esc 关闭、焦点回到按钮),外观来自 app.css 的 .dt / .cal。
*/
import { getLocalTimeZone, parseDate, today, type DateValue } from "@internationalized/date";
import { Calendar, ChevronLeft, ChevronRight } from "@lucide/vue";
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNext,
CalendarPrev,
CalendarRoot,
PopoverAnchor,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
} from "reka-ui";
import { computed, ref } from "vue";
import { parseDay } from "@/lib/labels";
const props = defineProps<{ id?: string; placeholder?: string; disabled?: boolean; label?: string }>();
const text = defineModel<string>({ required: true });
const emit = defineEmits<{ blur: [] }>();
const open = ref(false);
const input = ref<HTMLInputElement | null>(null);
defineExpose({ select: () => input.value?.select(), focus: () => input.value?.focus() });
/** 输入框里能认出的日期,选择器打开时停在这个月 */
const value = computed<DateValue | undefined>(() => {
const d = parseDay(text.value);
return d ? parseDate(d) : undefined;
});
function pick(v: DateValue | undefined) {
if (!v) return;
text.value = v.toString();
open.value = false;
}
function onBlur() {
const d = parseDay(text.value);
if (d) text.value = d;
emit("blur");
}
</script>
<template>
<PopoverRoot v-model:open="open">
<PopoverAnchor as-child>
<div class="dt">
<input
:id="id"
ref="input"
v-model="text"
class="input"
:placeholder="placeholder ?? '例如:2026-11-20'"
:disabled="disabled"
autocomplete="off"
@blur="onBlur"
/>
<PopoverTrigger as-child>
<button type="button" class="icon-btn sm" :aria-label="`打开日期选择器${label ? `:${label}` : ''}`" :disabled="disabled">
<Calendar class="ic" :stroke-width="1.5" />
</button>
</PopoverTrigger>
</div>
</PopoverAnchor>
<PopoverPortal>
<PopoverContent class="cal" align="start" :side-offset="4" :aria-label="label ?? '选择日期'">
<CalendarRoot
v-slot="{ grid, weekDays }"
:model-value="value"
:default-placeholder="value ?? today(getLocalTimeZone())"
locale="zh-CN"
:week-starts-on="1"
weekday-format="narrow"
initial-focus
@update:model-value="pick"
>
<CalendarHeader class="cal-h">
<CalendarPrev class="icon-btn sm" aria-label="上个月"><ChevronLeft class="ic" :stroke-width="1.5" /></CalendarPrev>
<CalendarHeading v-slot="{ headingValue }"><b>{{ headingValue }}</b></CalendarHeading>
<CalendarNext class="icon-btn sm" aria-label="下个月"><ChevronRight class="ic" :stroke-width="1.5" /></CalendarNext>
</CalendarHeader>
<CalendarGrid v-for="month in grid" :key="month.value.toString()" class="cal-grid">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="d in weekDays" :key="d" class="wd">{{ d }}</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(week, i) in month.rows" :key="i">
<CalendarCell v-for="d in week" :key="d.toString()" :date="d">
<CalendarCellTrigger :day="d" :month="month.value" class="day" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</CalendarRoot>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
/**
* 反馈:判断有误(J5)。查标页的问题(误报)和读标页的「已响应」结论(漏报)共用。
* 反馈只用于改进检查规则,不改变处理状态;理由和补充说明合成一句存进 note。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ref, watch } from "vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { toast } from "@/lib/toast";
export type FeedbackTarget = {
objectType: "finding" | "checklist_item";
objectId: string;
title: string;
/** false_positive:报了不该报的;false_negative:说已响应,其实没有 */
kind: "false_positive" | "false_negative";
reasons: string[];
/** 对话框底部那句说明:反馈不改变什么 */
note: string;
};
const props = defineProps<{ projectId: string; target: FeedbackTarget | null }>();
const emit = defineEmits<{ close: [] }>();
const reason = ref("");
const text = ref("");
const busy = ref(false);
watch(
() => props.target,
(t) => {
reason.value = t?.reasons[0] ?? "";
text.value = "";
},
);
async function send() {
const t = props.target;
if (!t) return;
busy.value = true;
try {
await unwrap(
api.POST("/feedback", {
body: {
project_id: props.projectId,
object_type: t.objectType,
object_id: t.objectId,
kind: t.kind,
note: [reason.value, text.value.trim()].filter(Boolean).join(":").slice(0, 1000),
},
}),
);
emit("close");
toast("已收到反馈");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
busy.value = false;
}
}
</script>
<template>
<ConfirmDialog
:open="!!target"
title="反馈:判断有误"
ok-text="提交反馈"
busy-text="正在提交…"
:busy="busy"
@update:open="(v) => { if (!v) emit('close'); }"
@ok="send"
>
<p class="muted fb-title">「{{ target?.title }}」</p>
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
<label v-for="r in target?.reasons ?? []" :key="r" class="choice"><input v-model="reason" type="radio" class="ck" name="fb" :value="r" />{{ r }}</label>
</div>
<div class="field fill fb-note">
<label for="fb-note">补充说明 <small>(选填)</small></label>
<textarea id="fb-note" v-model="text" class="input" maxlength="200"></textarea>
<span class="msg">{{ target?.note }}</span>
</div>
</ConfirmDialog>
</template>
<style scoped>
.fb-title {
margin: 0;
}
.fb-choices {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.fb-note {
margin-top: var(--sp-4);
}
</style>

View File

@@ -7,6 +7,7 @@ import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import DateInput from "@/components/DateInput.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { BOOL_FIELDS, FIELD_LABEL, fieldText, LEGAL_REGIME, MONEY_FIELDS, parseDay } from "@/lib/labels";
@@ -124,7 +125,7 @@ async function save(restore = false) {
<div v-else class="field fill">
<label for="fd-day">投标截止时间(北京时间)</label>
<div class="dt-row">
<input id="fd-day" v-model="day" class="input num" placeholder="例如:2026-11-20" autocomplete="off" @blur="day = parseDay(day) || day" />
<DateInput id="fd-day" v-model="day" label="投标截止日期" />
<input v-model="time" class="input num" aria-label="时分" placeholder="09:30" autocomplete="off" />
</div>
<span class="msg">查标按它核对证书有效期和落款日期,改了以后重新检查才生效</span>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
/**
* 工作台项目行(设计规范 §7.9,原型 app.css B20):整行可点,「下一步」单独可点。
* 列:项目 · 投标截止 · 状态(含就绪)· 待确认 · 下一步。
* 列:项目 · 投标截止 · 状态(含就绪)· 致命(待处理的致命问题数)· 下一步。
*/
import { ChevronRight } from "@lucide/vue";
import { computed } from "vue";
@@ -54,7 +54,7 @@ const next = computed<RouteLocationRaw>(() =>
</template>
</span>
<span class="c">
<span v-if="p.pending_item_count" class="fig" :aria-label="`待确认 ${p.pending_item_count} 条`">{{ p.pending_item_count }}</span>
<span v-if="p.readiness.open_fatal_count" class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span>
<span v-else class="dash" aria-label="无">—</span>
</span>
<span>

View File

@@ -9,6 +9,7 @@ import { unwrap, uploadFile } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import DateInput from "@/components/DateInput.vue";
import SelectBox from "@/components/SelectBox.vue";
import { api, apiOptions, errorTitle } from "@/lib/api";
import { extOf, fmtMonthDay, fmtSize, parseDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
@@ -42,7 +43,7 @@ const err = ref("");
const busy = ref(false);
const fileBusy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const toInput = ref<HTMLInputElement | null>(null);
const toInput = ref<InstanceType<typeof DateInput> | null>(null);
watch(open, (v) => {
if (!v) return;
@@ -68,12 +69,6 @@ const late = computed(() => {
return day ? props.deadlines.filter((d) => day < d.day) : [];
});
function tidy(field: "from" | "to") {
const r = field === "from" ? from : to;
const day = parseDay(r.value);
if (day) r.value = day;
}
function validate(): boolean {
const e: Record<string, string> = {};
if (!name.value.trim()) e.name = "请填写资质名称,和证书上的写法一致";
@@ -227,21 +222,12 @@ async function removeFile(fileId: string) {
</div>
<div class="field" :class="{ err: errs.from }">
<label for="qd-from">有效期起</label>
<input id="qd-from" v-model="from" class="input num" placeholder="例如:2023-11-16" autocomplete="off" @blur="tidy('from')" />
<DateInput id="qd-from" v-model="from" label="有效期起" placeholder="例如:2023-11-16" />
<span v-if="errs.from" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.from }}</span>
</div>
<div class="field" :class="{ err: errs.to }">
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small></label>
<input
id="qd-to"
ref="toInput"
v-model="to"
class="input num"
:placeholder="longTerm ? '长期有效' : '例如:2026-11-20'"
:disabled="longTerm"
autocomplete="off"
@blur="tidy('to')"
/>
<DateInput id="qd-to" ref="toInput" v-model="to" label="有效期至" :placeholder="longTerm ? '长期有效' : '例如:2026-11-20'" :disabled="longTerm" />
<span v-if="errs.to" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.to }}</span>
<span v-else-if="late.length" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>

View File

@@ -18,5 +18,7 @@ export const qk = {
reports: (projectId: string) => ["reports", projectId] as const,
itemResults: (runId: string) => ["item-results", runId] as const,
qualifications: ["qualifications"] as const,
/** 前缀和 qualifications 一样:资质库一改,读标页的资质库情况跟着失效 */
qualificationMatches: (projectId: string) => ["qualifications", "matches", projectId] as const,
lots: (projectId: string) => ["lots", projectId] as const,
};

View File

@@ -185,6 +185,19 @@ export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
ignored: "已忽略",
};
/** 每条清单项的检查结论(C9),读标页条目下面那一行 */
export const CHECK_VERDICT: Dict<Schemas["CheckItemResult"]["verdict"]> = {
responded: "已响应",
not_found: "未找到响应",
negative_deviation: "负偏离未说明",
suspected_unmet: "疑似不满足",
no_value: "未给出具体值",
undetermined: "未判定",
scanned: "文字部分没找到,请人工核对",
implicit_accepted: "按招标文件默认接受",
implicit_review: "默认接受,请人工核对",
};
export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"];

View File

@@ -148,6 +148,37 @@ export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOr
});
}
/** 每条清单项的检查结论(C9):「已响应」也带投标侧出处,读标页显示在条目下面 */
export function useItemResults(runId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.itemResults(toValue(runId))),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/item-results", { params: { path: { run_id: toValue(runId) } } })),
enabled: computed(() => !!toValue(runId)),
});
}
/** 项目的历次报告(新的在前) */
export function useReports(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.reports(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 报告文件的短时签名地址(预览、下载都现取,不缓存) */
export async function reportFileUrl(id: string): Promise<string> {
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
}
/** 读标清单上每条资质要求在资质库里的情况(资质库空时没有条目) */
export type QualificationMatch = Schemas["QualificationMatch"];
export function useQualificationMatches(projectId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => qk.qualificationMatches(toValue(projectId))),
queryFn: () => unwrap(api.GET("/projects/{project_id}/qualification-matches", { params: { path: { project_id: toValue(projectId) } } })),
});
}
/** 资质库:一次取全部(最小资质库条目不多),类型筛选和搜索在前端做 */
export function useQualifications() {
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });

View File

@@ -6,15 +6,18 @@
* - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录;
* - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上;
* - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。
* - 检查过的项目:每条下面写最近一次检查的结论(C9),点投标侧出处在右侧看投标原文;「已响应」也能反馈判断有误。
* - 资格条件、评分项里的证书要求:写资质库里有没有、在投标截止日是否有效(资质库空的不写)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink, useRoute } from "vue-router";
import { RouterLink, useRoute, useRouter } from "vue-router";
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
import HistoryDialog from "@/components/HistoryDialog.vue";
import ItemDialog from "@/components/ItemDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
@@ -26,7 +29,17 @@ import { groupCitations, type CitationGroup } from "@/lib/citations";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
import { useChecklist, useChecklistItems, useLots, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
import {
useChecklist,
useChecklistItems,
useItemResults,
useLots,
useProject,
useQualificationMatches,
type ChecklistItem,
type Citation,
type MarkingRule,
} from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
@@ -50,6 +63,30 @@ const readOnly = computed(() => p.value?.status === "submitted" || p.value?.stat
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "读标" }]);
/* ---------- 最近一次检查的结论(C9):只看已完成的那次 ---------- */
const router = useRouter();
const runId = computed(() => p.value?.latest_check_run_id ?? "");
const resultsQ = useItemResults(runId);
const checkOf = computed(() => new Map((resultsQ.data.value?.items ?? []).map((r) => [r.checklist_item_id, r])));
function goFindings() {
if (runId.value) void router.push({ name: "results", params: { projectId: props.projectId, runId: runId.value } });
}
/* 资质库的情况:资质库是空的时没有条目,什么都不显示(QL3) */
const libQ = useQualificationMatches(() => props.projectId);
const libOf = computed(() => new Map((libQ.data.value?.items ?? []).map((x) => [x.checklist_item_id, x.families])));
const libAsOf = computed(() => (libQ.data.value?.by_deadline === false ? "今天" : "投标截止日"));
const fbFor = ref<FeedbackTarget | null>(null);
function askFeedback(i: ChecklistItem) {
fbFor.value = {
objectType: "checklist_item",
objectId: i.id,
title: i.title,
kind: "false_negative",
reasons: ["投标文件里其实没有响应这条", "引的那句不是这条的响应", "响应了,但不满足要求", "其他"],
note: "反馈只用于改进检查规则,不改变这次检查的结论。",
};
}
/* ---------- 分组与筛选 ---------- */
const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
@@ -470,8 +507,14 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
:item="i"
:selected="selId === i.id"
:active-citation="target?.id"
:check="checkOf.get(i.id)"
:library="libOf.get(i.id)"
:library-as-of="libAsOf"
@select="select(i)"
@open-citation="(g) => { selId = i.id; openGroup(g); }"
@open-bid="(c) => openCitation(c)"
@findings="goFindings"
@feedback="askFeedback(i)"
>
<template #actions>
<template v-if="!readOnly && supPending(i)">
@@ -537,6 +580,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :lots="realLots" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
<HistoryDialog v-model:open="hist.open" :item="hist.item" />
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
<ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
<div v-if="pending.length" class="alert warn">

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
/**
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。
* 项目概览(原型 project.html,PI1):关键日期(时间线和倒计时,FR-M2-09)和采购包、项目要点(基本信息字段,
* 带出处,可改、可恢复 AI 值)、检查记录(历次检查的结果和报告)、文件(招标方的文件和投标文件的最新版本)。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/
import { unwrap, type Schemas } from "@bid-doctor/api-client";
import { Pencil, Plus } from "@lucide/vue";
import { Download, Pencil, Plus } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watchEffect } from "vue";
import { RouterLink } from "vue-router";
@@ -17,8 +17,18 @@ 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, fmtDateTimeFull, fmtYuan } from "@/lib/labels";
import { useLots, useProject, useProjectDocuments, type Citation, type ProjectField } from "@/lib/queries";
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 }>();
@@ -80,6 +90,36 @@ async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean)
}
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 });
@@ -142,7 +182,6 @@ function openCitation(c: Citation) {
<div class="card-h">
<h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
<RouterLink v-if="canAddendum" class="btn sm" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
</div>
<dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
@@ -170,6 +209,75 @@ function openCitation(c: Citation) {
</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" />
@@ -217,6 +325,47 @@ function openCitation(c: Citation) {
.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;
}

View File

@@ -7,7 +7,7 @@
*/
import { unwrap } from "@bid-doctor/api-client";
import { AlertCircle, Download, Eye } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import ProjectHead from "@/components/ProjectHead.vue";
@@ -15,7 +15,7 @@ import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { useCheckRun, useProject } from "@/lib/queries";
import { reportFileUrl, useCheckRun, useProject, useReports } from "@/lib/queries";
import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>();
@@ -27,10 +27,7 @@ const runQ = useCheckRun(runId);
const run = computed(() => (runId.value ? runQ.data.value : undefined));
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "报告" }]);
const reportsQ = useQuery({
queryKey: computed(() => qk.reports(props.projectId)),
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: props.projectId } } })),
});
const reportsQ = useReports(() => props.projectId);
const reports = computed(() => reportsQ.data.value?.items ?? []);
const citations = ref(true);
@@ -69,9 +66,7 @@ async function exportReport() {
/* ---------- 预览和下载:短时签名地址 ---------- */
const previewId = ref<string | null>(null);
const previewUrl = ref<string | null>(null);
async function fileUrl(id: string) {
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
}
const fileUrl = reportFileUrl;
async function preview(id: string) {
try {
previewUrl.value = await fileUrl(id);

View File

@@ -4,20 +4,21 @@
* - 检查进行中显示进度和取消;失败时写明原因和是否退回额度;
* - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3);
* - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4),
* 说明框在行内展开;处理后显示署名,可以撤销(U 键在第 6 步);
* 说明框在行内展开;处理后显示署名,可以撤销;
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
* - 只有最近一次检查的问题可以处理,历史批次只读。
* - 需人工核对(FR-M3-04、P2):份数、密封、递交、CA 加密等文件里看不出来的要求,按项目勾选,写勾选人和时间;
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。
* - 键盘(规范 §8.2):J / K 上下条、O / Enter 看原文、1 / 2 / 3 处理、X 勾选「提示」、U 撤销、? 快捷键说明;
* 「提示」可以批量确认无误;每个问题可以反馈判断有误;从工作台带 ?f=问题编号 进来时选中那一条。
*/
import { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, Download } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
import { RouterLink } from "vue-router";
import { RouterLink, useRoute } from "vue-router";
import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
import type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
@@ -261,11 +262,15 @@ function setCur(f: Finding, scroll = false) {
if (changed) follow(f);
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
}
// 第一次加载完:原文翻到第一条问题的出处
// 从工作台「去处理」进来(?f=问题编号):选中那一条;否则第一次加载完,原文翻到第一条问题的出处
const route = useRoute();
watch(
() => cur.value?.id,
(id, old) => {
if (id && !old && cur.value && !curId.value) follow(cur.value);
if (!id || old || curId.value) return;
const wanted = typeof route.query.f === "string" ? todo.value.find((x) => x.id === route.query.f) : undefined;
if (wanted) setCur(wanted, true);
else if (cur.value) follow(cur.value);
},
{ immediate: true },
);
@@ -279,6 +284,11 @@ function next(f: Finding) {
function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return;
if (e.key === "?") {
e.preventDefault();
document.querySelector<HTMLElement>(".kb-tip")?.focus(); // 快捷键说明是提示气泡,聚焦就显示
return;
}
const f = cur.value;
if (!f) return;
const k = e.key.toLowerCase();
@@ -309,40 +319,16 @@ onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
/* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */
const FEEDBACK_REASONS = ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"];
const fbFor = ref<Finding | null>(null);
const fbReason = ref(FEEDBACK_REASONS[0]!);
const fbNote = ref("");
const fbBusy = ref(false);
const fbOpen = computed({ get: () => !!fbFor.value, set: (v) => { if (!v) fbFor.value = null; } });
const fbFor = ref<FeedbackTarget | null>(null);
function askFeedback(f: Finding) {
fbFor.value = f;
fbReason.value = FEEDBACK_REASONS[0]!;
fbNote.value = "";
}
async function sendFeedback() {
const f = fbFor.value;
if (!f) return;
fbBusy.value = true;
try {
await unwrap(
api.POST("/feedback", {
body: {
project_id: props.projectId,
object_type: "finding",
object_id: f.id,
kind: "false_positive",
note: [fbReason.value, fbNote.value.trim()].filter(Boolean).join(":").slice(0, 1000),
},
}),
);
fbFor.value = null;
toast("已收到反馈");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
fbBusy.value = false;
}
fbFor.value = {
objectType: "finding",
objectId: f.id,
title: f.title,
kind: "false_positive",
reasons: ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"],
note: "反馈只用于改进检查规则,不改变这条问题的处理状态。",
};
}
/* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */
@@ -456,7 +442,7 @@ const errorText = computed(() => {
<input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
</label>
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span>
<span class="cap muted hint-u tip-below tip-left" tabindex="0" data-tip="J / K 上下条(右边原文跟着翻) · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销">快捷键</span>
<span class="cap muted hint-u tip-below tip-left kb-tip" tabindex="0" data-tip="J / K 上下条(右边原文跟着翻) · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销 · ? 显示这条说明">快捷键</span>
</div>
</div>
@@ -579,17 +565,7 @@ const errorText = computed(() => {
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
</div>
<ConfirmDialog v-model:open="fbOpen" title="反馈:判断有误" ok-text="提交反馈" busy-text="正在提交…" :busy="fbBusy" @ok="sendFeedback">
<p class="muted fb-title">「{{ fbFor?.title }}」</p>
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
<label v-for="r in FEEDBACK_REASONS" :key="r" class="choice"><input v-model="fbReason" type="radio" class="ck" name="fb" :value="r" />{{ r }}</label>
</div>
<div class="field fill fb-note">
<label for="fb-note">补充说明 <small>(选填)</small></label>
<textarea id="fb-note" v-model="fbNote" class="input" maxlength="200"></textarea>
<span class="msg">反馈只用于改进检查规则,不改变这条问题的处理状态。</span>
</div>
</ConfirmDialog>
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
</template>
@@ -715,18 +691,6 @@ const errorText = computed(() => {
margin-left: auto;
color: var(--text-secondary);
}
.fb-title {
margin: 0;
}
.fb-choices {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.fb-note {
margin-top: var(--sp-4);
}
.seg {
display: flex;
gap: var(--sp-2);

View File

@@ -1,16 +1,20 @@
<script setup lang="ts">
/**
* 工作台(原型 workbench.html):页头 + 统计 + 项目列表;右栏是正在进行的任务和本期额度。
* 原型里的「待处理的致命问题」「最近处理」要等查标和操作记录的接口,骨架阶段不做。
* 工作台(原型 workbench.html,ui-demo page-workbench.html):页头 + 统计 + 项目列表 + 待处理的致命问题;
* 右栏是正在进行的任务和本期额度。统计可以点,按它筛选项目列表(再点一下取消)。
* 「最近处理」要等操作记录的接口,还没做。
*/
import { Plus } from "@lucide/vue";
import { computed } from "vue";
import { unwrap } from "@bid-doctor/api-client";
import { Filter, Plus, X } from "@lucide/vue";
import { useQueries } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router";
import ProjectRow from "@/components/ProjectRow.vue";
import { errorTitle } from "@/lib/api";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { quotaUnit } from "@/lib/labels";
import { useMe, useProjects, useQuota, useQuotaAccount } from "@/lib/queries";
import { useMe, useProjects, useQuota, useQuotaAccount, type ProjectListItem } from "@/lib/queries";
useCrumbs(() => [{ label: "工作台" }]);
@@ -25,24 +29,50 @@ const hello = computed(() => {
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 all = computed(() => projects.data.value?.items ?? []);
const active = computed(() => all.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
const due7 = (p: ProjectListItem) => {
if (!p.deadline_at) return false;
const ms = new Date(p.deadline_at).getTime() - Date.now();
return ms >= 0 && ms <= 7 * 86_400_000;
};
type StatKey = "active" | "due7" | "fatal" | "pending";
const STAT: Record<StatKey, { label: string; fn: (p: ProjectListItem) => boolean; count: (p: ProjectListItem) => number; cls: string }> = {
active: { label: "进行中的项目", fn: () => true, count: () => 1, cls: "" },
due7: { label: "7 天内截止", fn: due7, count: (p) => (due7(p) ? 1 : 0), cls: "due" },
// 致命红只用于致命问题(§3.2)
fatal: { label: "待处理的致命问题", fn: (p) => p.readiness.open_fatal_count > 0, count: (p) => p.readiness.open_fatal_count, cls: "fatal" },
pending: { label: "待确认的清单条目", fn: (p) => (p.pending_item_count ?? 0) > 0, count: (p) => p.pending_item_count ?? 0, cls: "" },
};
const stats = computed(() =>
(Object.keys(STAT) as StatKey[]).map((key) => ({ key, ...STAT[key], value: active.value.reduce((a, p) => a + STAT[key].count(p), 0) })),
);
/** 点了哪个统计:按它筛选进行中的项目;没点时列全部项目(含已递交、已归档) */
const filter = ref<StatKey | null>(null);
function toggle(k: StatKey) {
filter.value = filter.value === k ? null : k;
}
const list = computed(() => (filter.value ? active.value.filter(STAT[filter.value].fn) : all.value));
/* ---------- 待处理的致命问题:有致命问题的项目,取最近一次检查里还没处理的致命问题 ---------- */
const withFatal = computed(() => active.value.filter((p) => p.readiness.open_fatal_count > 0 && p.latest_check_run_id));
const fatalQs = useQueries({
queries: computed(() =>
withFatal.value.map((p) => ({
queryKey: qk.findings(p.latest_check_run_id!),
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/findings", { params: { path: { run_id: p.latest_check_run_id! } } })),
})),
),
});
const TODO_MAX = 5;
const todos = computed(() =>
withFatal.value.flatMap((p, i) =>
(fatalQs.value[i]?.data?.items ?? [])
.filter((f) => f.severity === "fatal" && f.status === "pending" && f.presence !== "resolved")
.map((f) => ({ p, f, cite: f.bid_citations[0] ?? f.tender_citations[0] ?? null })),
),
);
const fatalTotal = computed(() => withFatal.value.reduce((a, p) => a + p.readiness.open_fatal_count, 0));
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) ?? "");
@@ -61,17 +91,22 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</div>
</div>
<section class="stats" aria-label="项目统计">
<div v-for="s in stats" :key="s.label" class="stat">
<section class="stats" aria-label="项目统计,点击可筛选项目列表">
<button v-for="s in stats" :key="s.key" type="button" class="stat" :aria-pressed="filter === s.key" @click="toggle(s.key)">
<small>{{ s.label }}</small><span class="big" :class="s.value ? s.cls : ''">{{ s.value }}</span>
</div>
<Filter class="ic" :stroke-width="1.5" />
</button>
</section>
<div class="wb">
<div class="stack">
<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>
<button v-if="filter" type="button" class="btn text sm chip-clear" @click="filter = null">
{{ STAT[filter].label }}<X class="ic" :stroke-width="1.5" /><span class="vh">,清除筛选</span>
</button>
</div>
<div v-if="projects.isPending.value" class="card-b">
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
@@ -84,11 +119,15 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</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>
<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-if="filter" class="empty sm">
<b>没有符合条件的项目</b>
<p>清除筛选,看全部项目。</p>
</div>
<div v-else class="empty">
<b>还没有项目</b>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
@@ -96,6 +135,26 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</div>
</section>
<section v-if="fatalTotal" class="card flush" aria-labelledby="h-todo">
<div class="card-h">
<h2 id="h-todo">待处理的致命问题</h2>
<span class="sev fatal">致命<span class="fig">{{ fatalTotal }}</span></span>
<button v-if="todos.length > TODO_MAX" type="button" class="lnk cap more" @click="filter = 'fatal'">只看有致命问题的项目</button>
</div>
<ul class="todos">
<li v-for="{ p, f, cite } in todos.slice(0, TODO_MAX)" :key="f.id" class="todo">
<span class="sev fatal">致命</span>
<div>
<b>{{ f.title }}</b>
<span class="psub">{{ p.name }}<template v-if="cite"> · {{ cite.document_label }} 第 <span class="fig">{{ cite.page_no }}</span> 页</template></span>
</div>
<RouterLink class="btn sm" :to="{ name: 'results', params: { projectId: p.id, runId: p.latest_check_run_id! }, query: { f: f.id } }">去处理</RouterLink>
</li>
<li v-if="!todos.length" class="todo"><span class="cap muted">正在读取…</span></li>
</ul>
</section>
</div>
<aside class="stack" aria-label="任务与额度">
<section class="card flush" aria-labelledby="h-task">
<div class="card-h"><h3 id="h-task">正在进行的任务</h3></div>
@@ -156,6 +215,31 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
.none {
margin: 0;
}
.chip-clear,
.more {
margin-left: auto;
}
/* 待处理的致命问题:不加左侧红竖线,靠实心「致命」标签扫读(ui-demo d5) */
.todos {
margin: 0;
padding: 0;
list-style: none;
}
.todo {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
column-gap: var(--sp-4);
align-items: center;
padding: var(--sp-3) var(--card-x);
border-top: var(--line) solid var(--border-subtle);
}
.todo b {
display: block;
font-weight: var(--fw-bold);
}
.todo .psub {
display: block;
}
.task {
display: block;
color: inherit;

View File

@@ -200,6 +200,16 @@
.stat .big{font:var(--fw-bold) var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
.stat .big.fatal{color:var(--sev-fatal)}
.stat .big.due{color:var(--warn-text)}
/* 可点的统计(ui-demo 工作台):点一下按它筛选项目列表,再点一下取消 */
button.stat{cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
button.stat:hover{background:var(--c-n50)}
button.stat:focus-visible{outline-offset:-2px;border-radius:0}
.stat::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--c-ink);transform:scaleY(0);transform-origin:bottom;transition:transform var(--dur-fast) var(--ease)}
.stat[aria-pressed="true"]::after{transform:scaleY(1)}
.stat[aria-pressed="true"] small{color:var(--text-primary);font-weight:var(--fw-bold)}
.stat > .ic{position:absolute;top:18px;right:var(--card-x);color:var(--text-secondary);opacity:0;transition:opacity var(--dur-fast) var(--ease)}
.stat:hover > .ic,.stat:focus-visible > .ic,.stat[aria-pressed="true"] > .ic{opacity:1}
.stat[aria-pressed="true"] > .ic{color:var(--text-primary)}
/* B19 列表卡片:左侧分类导航 + 列表;列表里的分组标题(.ai 是清单顶部的「待确认」分组) */
.cmp-list{display:grid;grid-template-columns:176px minmax(0,1fr);min-height:0;overflow:hidden}
@@ -339,3 +349,26 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
.switch .st{min-width:2em;font-size:var(--fs-caption);color:var(--text-secondary)}
.switch[aria-checked="true"] .st{color:var(--text-primary)}
.switch[disabled]{color:var(--text-disabled);cursor:not-allowed}
/* B6 日期输入与日期选择(§7.22、§9.3,ui-demo demo.css 的 .dt / .cal):DateInput.vue。
* 选择器是 Reka UI 的 Calendar(表格结构),格子状态看 data-today / data-selected / data-disabled / data-outside-view */
.dt{position:relative;display:block}
.dt > .input{width:100%;padding-right:40px;font-family:var(--font-figures);font-variant-numeric:tabular-nums;font-size:var(--fs-figure-sm)}
.dt > .input::placeholder{font-family:var(--font-sans);font-size:var(--fs-body)}
.dt > .icon-btn{position:absolute;top:2px;right:2px;color:var(--text-secondary)}
.dt > .icon-btn:hover,.dt > .icon-btn[aria-expanded="true"]{color:var(--text-primary)}
.cal{z-index:56;width:288px;padding:var(--sp-3);background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
.cal-h{display:flex;align-items:center;gap:var(--sp-1);margin-bottom:var(--sp-2)}
.cal-h > div,.cal-h > h2,.cal-h b{flex:1;text-align:center;font-weight:var(--fw-bold)}
.cal-grid{width:100%;border-collapse:separate;border-spacing:2px;table-layout:fixed}
.cal-grid .wd{font-size:var(--fs-caption);line-height:28px;font-weight:var(--fw-regular);color:var(--text-secondary)}
.cal-grid td{padding:0}
.cal-grid .day{display:grid;place-items:center;height:34px;border-radius:var(--radius-ctl);font:var(--fw-medium) var(--fs-figure-sm)/1 var(--font-figures);font-variant-numeric:tabular-nums;color:var(--text-primary);cursor:pointer;outline:none;transition:background-color var(--dur-fast) var(--ease)}
.cal-grid .day:hover{background:var(--c-n100)}
.cal-grid .day:focus-visible{box-shadow:0 0 0 2px var(--focus-ring)}
.cal-grid .day[data-today]{box-shadow:inset 0 0 0 1px var(--border-control)}
.cal-grid .day[data-selected]{background:var(--c-ink);color:var(--text-on-dark);box-shadow:none}
.cal-grid .day[data-selected]:hover{background:var(--primary-hover)}
.cal-grid .day[data-disabled]{color:var(--text-disabled);cursor:not-allowed;background:none;font-weight:var(--fw-regular)}
/* 非本月的格子留空(不显示日期),和不可选日期不混淆(v0.2) */
.cal-grid .day[data-outside-view]{visibility:hidden}

View File

@@ -46,15 +46,15 @@
| 步骤(原型页面) | 接口 |
|---|---|
| 注册 / 登录(login) | `POST /auth/sms-codes` → `POST /auth/register` 或 `POST /auth/login` → `GET /me` |
| 工作台(workbench) | `GET /projects`,每行带「下一步」`next_action`、就绪状态、正在跑的任务;`GET /quota` 给顶栏额度 |
| 工作台(workbench) | `GET /projects`,每行带「下一步」`next_action`、就绪状态、正在跑的任务;`GET /quota` 给顶栏额度;「待处理的致命问题」按有致命问题的项目取 `GET /check-runs/{id}/findings`,「去处理」带 `?f=问题编号` 进查标页 |
| 新建项目、上传招标文件(project-new) | `POST /projects` → `POST /uploads` → `PUT /uploads/{id}/parts/{n}` × N → `POST /uploads/{id}/complete` → `POST /projects/{id}/documents` → `POST /documents/{id}/parse-jobs`(预扣 1 次) |
| 解析进度(parsing) | `GET /events` 收 `job.progress`;断线时轮询 `GET /parse-jobs/{id}`;`POST /parse-jobs/{id}/cancel`;失败后重试再调一次 `POST /documents/{id}/parse-jobs` |
| 选包(lots) | `GET /projects/{id}/lots` → `PUT /projects/{id}/bidding-lots`;手工增删改分包 |
| 读标(checklist) | `GET /projects/{id}/checklist`、`GET /projects/{id}/checklist/items`;原文查看器用 `GET /documents/{id}/file?variant=normalized`、`GET /documents/{id}/pages`;选句补出处用 `GET .../pages/{n}/sentences` → `POST /projects/{id}/citations`;修改、采纳、删除、恢复、补遗更新、修改记录、确认清单 |
| 读标(checklist) | `GET /projects/{id}/checklist`、`GET /projects/{id}/checklist/items`;原文查看器用 `GET /documents/{id}/file?variant=normalized`、`GET /documents/{id}/pages`;选句补出处用 `GET .../pages/{n}/sentences` → `POST /projects/{id}/citations`;修改、采纳、删除、恢复、补遗更新、修改记录、确认清单;每条的检查结论用 `GET /check-runs/{id}/item-results`(「已响应」的反馈判断有误走 `POST /feedback`,`object_type = checklist_item`、`kind = false_negative`);资质库的情况用 `GET /projects/{id}/qualification-matches` |
| 上传投标文件、发起检查(bid-check) | 上传同上,`role = bid_volume`;`GET /projects/{id}/check-preview` 给出扣点说明 → `POST /projects/{id}/check-runs` |
| 查标(results) | `GET /check-runs/{id}`、`GET /check-runs/{id}/findings` → `POST /findings/{id}/resolve`、`/reopen`(U 键)、`POST /check-runs/{id}/findings/bulk-resolve`;需人工核对用 `GET/PUT /projects/{id}/manual-checks`;`POST /feedback` |
| 报告(report) | `POST /check-runs/{id}/reports` → 事件 `report.ready` → `GET /reports/{id}/file` |
| 项目概览(project) | `GET /projects/{id}`、基本信息字段的修改和补遗确认、`GET /projects/{id}/documents`、`GET /projects/{id}/check-runs`、`POST /projects/{id}/transitions`(递交 / 归档及撤销)、`DELETE /projects/{id}` |
| 项目概览(project) | `GET /projects/{id}`、基本信息字段的修改和补遗确认、`GET /projects/{id}/documents`(文件卡)、`GET /projects/{id}/check-runs` 和 `GET /projects/{id}/reports`(检查记录卡)、`POST /projects/{id}/transitions`(递交 / 归档及撤销)、`DELETE /projects/{id}` |
| 额度(quota) | `GET /quota`、`GET /quota/entries`、`GET /plans`、`POST /activation-requests` |
| 资质库(qualifications) | `/qualifications` 的增删改;附件先上传,再 `POST /qualifications/{id}/files` |

View File

@@ -14,7 +14,7 @@
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;送给模型的同一行按单元格、重复条目只合并显示(C5 修订);致命项重问按数据暂不做。见 `check-eval.md` §8–§9 |
| 第 4 步 资质库 | ✅ 2026-11-04 | 方案和落地记录见 `qualification-plan.md`(决策 QL1–QL7)。`qualification` 模块(增删改、附件按企业级密钥加密、`late_for` 到期提醒、审计)和前端资质库页;`check/qualification.py`:证书类别词表(16 类)、规则 `QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED`,投标文字层按清单项定级、只有资质库记录的降一级,规则集 2026.11-r4。评测见 `check-eval.md` §13 |
| 第 5 步 检查报告 | ✅ 2026-11-04 | `report` 模块:导出 → worker 用 Gotenberg(Chromium)把 HTML 转成 A4 PDF → 按项目数据密钥加密存对象存储 → `report`(不可改,存哈希);`report_job` 记生成过程(迁移 0009),完成推 `report.ready`、发通知。首页有项目信息、投标文件版本、检查依据、规则版本、导出人、检查覆盖范围、汇总、免责声明;问题明细带两侧出处和处理人;附录是需人工核对的勾选结果;页眉 logo、页脚「本报告为辅助检查结果,仅供投标前自查参考」+ 页码;测试检查模板文字不出现「通过 / 合格 / 保证」。前端「报告」标签页:选项、导出、内嵌预览、历次报告下载。「证据索引」页没有单独做(出处已在问题明细里逐条列出)。同时补了项目状态流转(递交 / 撤销递交 / 归档 / 撤销归档,FR-M6-02):致命问题未处理也能递交,确认框说明;递交后只读,撤销归档回到归档前的状态,都写审计 |
| 第 6 步 前端补齐 | 部分 | 需人工核对、重复问题折叠、报告页已做;键盘操作、批量处理提示、判断有误反馈还没做 |
| 第 6 步 前端补齐 | ✅ 2026-11-05 | 查标页:对照布局、键盘(J / K / O / 1 / 2 / 3 / X / U / ?)、「提示」批量确认、判断有误反馈、从工作台带 `?f=` 定位到问题。读标页:每条写最近一次检查的结论,点投标侧出处看投标原文,「已响应」也能反馈判断有误(漏报);资格、评分里的证书要求写资质库的情况(`GET /projects/{id}/qualification-matches`,资质库空时不写)。概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡(招标方文件、投标分册最新版本,追加补遗挪到这里)。工作台:「致命」列、统计可点筛选、待处理的致命问题卡。日期输入加日历弹层(`DateInput`,Reka UI Calendar,周一开始、非本月留空)。反馈对话框抽成 `FeedbackDialog` 两页共用 |
**第 1 步落地记录**(和本文设计不一致、或留到后面的,记在这里):

View File

@@ -1257,6 +1257,39 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/CheckPreview' }
/projects/{project_id}/qualification-matches:
parameters:
- $ref: '#/components/parameters/ProjectId'
get:
tags: [checks]
operationId: listQualificationMatches
summary: 读标清单上的资质库情况(qualification-plan §4,第 6 步)
description: |
正式清单里资格条件、评分项中认出证书类别的条目,每类给出资质库里有效期最晚的一条,按投标截止日判断是否过期
(没有截止时间按今天)。认类别、排除产品和人员证书的口径和查标一样;资质库是空的时 items 为空(QL3)。
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [as_of, by_deadline, items]
properties:
as_of: { type: string, format: date, description: 按哪天判断有效期 }
by_deadline: { type: boolean, description: as_of 是投标截止日(false 时是今天) }
items:
type: array
items:
type: object
required: [checklist_item_id, families]
properties:
checklist_item_id: { $ref: '#/components/schemas/Id' }
families:
type: array
items: { $ref: '#/components/schemas/QualificationMatch' }
'404': { $ref: '#/components/responses/NotFound' }
/projects/{project_id}/check-runs:
parameters:
- $ref: '#/components/parameters/ProjectId'
@@ -1997,6 +2030,27 @@ components:
QuotaEntryKind: { type: string, enum: [grant, hold, settle, release, expire, adjust, waive] }
PlanCode: { type: string, enum: [free, pro, enterprise] }
QualificationKind: { type: string, enum: [certificate, system_cert, software_copyright, other] }
QualificationMatch:
type: object
description: 一类证书在资质库里的情况(读标页用)
required: [family, count, record, state]
properties:
family: { type: string, examples: [ISO 9001 质量管理体系认证] }
count: { type: integer, description: 资质库里这一类有几条 }
state: { type: string, enum: [valid, expired, missing], description: 按 as_of 判断;missing 是资质库里没有这一类 }
record:
description: 有效期最晚的一条;没有时为 null
oneOf:
- type: 'null'
- type: object
required: [id, name, kind, valid_to]
properties:
id: { $ref: '#/components/schemas/Id' }
name: { type: string }
kind: { $ref: '#/components/schemas/QualificationKind' }
level: { type: [string, 'null'] }
cert_no: { type: [string, 'null'] }
valid_to: { type: [string, 'null'], format: date, description: 'null 表示长期有效' }
ProjectFieldKey:
type: string
enum: [name, code, purchaser, agency, procurement_method, legal_regime, budget_total, ceiling_total,

View File

@@ -79,7 +79,7 @@
- **项目要点**:13 个字段,每个带出处(点开看原文);人工改过的标「已修改」,可以恢复 AI 值;点「修改」打开对话框,按字段类型给输入控件。
- **采购包**:包号、名称、预算、限价、是否投;「更改所投的包」去选包页。不分包的项目这张卡写「不分包」。
- **关键日期**:时间线和倒计时(FR-M2-09)。
- 原型里的检查记录、文件、AI 摘要卡留到第 6 步。
- 原型里的检查记录、文件卡已在查标第 6 步做(2026-11-05)。AI 摘要还没做:原型「项目要点」里评分构成、保证金、递交方式这类概括,要另加抽取。
- **选包页**(原型 `lots.html`):
- 解析完成后,识别到 2 个及以上的包时,解析页的「下一步」进选包页,否则直接进清单(PI4)。
- 页面内容:每个包一张选择卡(包号、名称、预算、限价带出处、各包的条目数)、通用条目数、「识别得不对?手工调整」、「这个项目不分包」。

View File

@@ -146,7 +146,7 @@
| D15 业绩 / 人员证明的时间窗口 | 业绩库、人员库是 V1 |
| 产品证书核对 | 厂商的证书,不是用户的资质(红线 5:不从外部补数据) |
| 从投标文件或附件里自动识别、批量导入资质 | 要 OCR / 多模态;MVP 只手工录入(01-scope-roles:「手工录入 + 有效期」) |
| 读标清单上显示「资质库:ISO 9001 有效期至 …」 | 并入第 6 步前端补齐 |
| 读标清单上显示「资质库:ISO 9001 有效期至 …」 | 已在第 6 步做(2026-11-05):`GET /projects/{id}/qualification-matches`,按投标截止日判断,资质库空时不显示 |
| 软著按软件名称配对、按件数核对评分 | 写法太散,认不准的不报 |
## 5. 评测
@@ -185,5 +185,5 @@
- **响应错位规则收窄**(`check/deviation.py`):资质情况表的名称格(「安全生产许可证」,比要求短、以证书名结尾)不算「照抄的要求格」,右边的编号、有效期不算「响应对不上」。端到端测试里发现的。
- **营业执照、法人证书、联合体的资质认定规则**不算「没认出类别的资质要求」,不进覆盖范围的计数。
- **出处**:资质问题挂清单项自己的招标侧出处(`Hit.tender_refs`);问题接口的 `rule` 改为规则问题都带(原来挂了清单项就不带);报告模板 v2 在问题明细里写「依据:资质库记录《…》」。
- **前端**:资质库页和对话框(日期可直接键入,接受 2026/11/20、2026年11月20日等写法;日历弹层留到第 6 步);查标页资质问题显示「依据:资质库记录」块,墨色(用户录入的数据,不用 AI 出处的蓝铅笔色)。
- **前端**:资质库页和对话框(日期可直接键入,接受 2026/11/20、2026年11月20日等写法;日历弹层第 6 步已加);查标页资质问题显示「依据:资质库记录」块,墨色(用户录入的数据,不用 AI 出处的蓝铅笔色)。
- 评测见 `check-eval.md` §13。

View File

@@ -912,6 +912,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects/{project_id}/qualification-matches": {
parameters: {
query?: never;
header?: never;
path: {
project_id: components["parameters"]["ProjectId"];
};
cookie?: never;
};
/**
* 读标清单上的资质库情况(qualification-plan §4,第 6 步)
* @description 正式清单里资格条件、评分项中认出证书类别的条目,每类给出资质库里有效期最晚的一条,按投标截止日判断是否过期
* (没有截止时间按今天)。认类别、排除产品和人员证书的口径和查标一样;资质库是空的时 items 为空(QL3)。
*
*/
get: operations["listQualificationMatches"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/check-runs": {
parameters: {
query?: never;
@@ -1425,6 +1449,30 @@ export interface components {
PlanCode: "free" | "pro" | "enterprise";
/** @enum {string} */
QualificationKind: "certificate" | "system_cert" | "software_copyright" | "other";
/** @description 一类证书在资质库里的情况(读标页用) */
QualificationMatch: {
family: string;
/** @description 资质库里这一类有几条 */
count: number;
/**
* @description 按 as_of 判断;missing 是资质库里没有这一类
* @enum {string}
*/
state: "valid" | "expired" | "missing";
/** @description 有效期最晚的一条;没有时为 null */
record: null | {
id: components["schemas"]["Id"];
name: string;
kind: components["schemas"]["QualificationKind"];
level?: string | null;
cert_no?: string | null;
/**
* Format: date
* @description null 表示长期有效
*/
valid_to: string | null;
};
};
/** @enum {string} */
ProjectFieldKey: "name" | "code" | "purchaser" | "agency" | "procurement_method" | "legal_regime" | "budget_total" | "ceiling_total" | "eval_method" | "accept_consortium" | "accept_imported" | "sme_only" | "deadline_at";
/**
@@ -4040,6 +4088,41 @@ export interface operations {
};
};
};
listQualificationMatches: {
parameters: {
query?: never;
header?: never;
path: {
project_id: components["parameters"]["ProjectId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/**
* Format: date
* @description 按哪天判断有效期
*/
as_of: string;
/** @description as_of 是投标截止日(false 时是今天) */
by_deadline: boolean;
items: {
checklist_item_id: components["schemas"]["Id"];
families: components["schemas"]["QualificationMatch"][];
}[];
};
};
};
404: components["responses"]["NotFound"];
};
};
listCheckRuns: {
parameters: {
query?: never;

3
pnpm-lock.yaml generated
View File

@@ -19,6 +19,9 @@ importers:
'@fontsource/noto-serif-sc':
specifier: 5.3.0
version: 5.3.0
'@internationalized/date':
specifier: 3.12.4
version: 3.12.4
'@lucide/vue':
specifier: 1.50.0
version: 1.50.0(vue@3.5.43(typescript@5.8.3))

View File

@@ -42,6 +42,12 @@ async def get_check_preview(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
return await service.preview(s, ctx, project_id)
@router.get("/projects/{project_id}/qualification-matches")
async def get_qualification_matches(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:
return await service.qualification_matches(s, ctx, project_id)
@router.get("/projects/{project_id}/check-runs")
async def list_check_runs(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s:

View File

@@ -117,7 +117,7 @@ def _spec(result: Result, item: dict[str, Any]) -> Spec | None:
where = result.detail.get("scanned_where")
# 只说扫描件在哪几页,不说「材料可能在扫描件里」:扫描件上是不是这份材料,没有 OCR 分不出来
text = (
f"投标文件文字部分没有找到这份材料。{where} 是扫描件(共 {total} 页),系统读不出上面的文字;"
f"投标文件文字部分没有找到这份材料。{where}是扫描件(共 {total} 页),系统读不出上面的文字;"
"材料如果附在这些页上,请人工核对,没有附就是缺了。"
if where
else f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"

View File

@@ -419,6 +419,43 @@ def check_item(
return issues, notes
def library_matches(
items: list[dict[str, Any]], library: list[Record], deadline: date
) -> dict[str, list[dict[str, Any]]]:
"""读标清单上显示资质库的情况(qualification-plan §4,并入第 6 步):资格条件、评分项里认出证书类别的条目,
每类给出资质库里有效期最晚的那一条(state:valid / expired / missing,按投标截止日算)。
认类别、排除产品和人员证书的口径和查标一样;资质库是空的不给(QL3:没录资质库时什么都不说)。"""
if not library:
return {}
by_family: dict[str, list[Record]] = {}
for r in library:
if fam := family_of_record(r):
by_family.setdefault(fam.key, []).append(r)
out: dict[str, list[dict[str, Any]]] = {}
for item in items:
if not set(item.get("categories") or []) & {"qualification", "scoring"}:
continue
requirement = " ".join(str(item.get(k) or "") for k in ("title", "requirement_text", "evidence_required"))
fams = families_in(requirement)
if not fams or EXCLUDE.search(text_of(requirement)):
continue
rows: list[dict[str, Any]] = []
for fam in fams:
recs = by_family.get(fam.key, [])
best = _best_valid(recs)
if best is None:
state = "missing"
elif fam.key != "softcopy" and best.valid_to is not None and best.valid_to < deadline:
state = "expired"
else:
state = "valid"
rows.append(
{"family": fam.label, "count": len(recs), "record": best.snapshot() if best else None, "state": state}
)
out[str(item["id"])] = rows
return out
def run(
items: dict[UUID, dict[str, Any]],
results: dict[UUID, Result],

View File

@@ -12,6 +12,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
from zoneinfo import ZoneInfo
from fastapi.encoders import jsonable_encoder
from sqlalchemy import func, select
@@ -19,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bid_doctor.audit import service as audit
from bid_doctor.billing import service as billing
from bid_doctor.check import manual, rules
from bid_doctor.check import manual, qualification, rules
from bid_doctor.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation, ManualCheck
from bid_doctor.checklist import edit as checklist_edit
from bid_doctor.checklist import service as checklist
@@ -35,8 +36,10 @@ from bid_doctor.identity import service as identity
from bid_doctor.ops import service as feedback
from bid_doctor.project import service as projects
from bid_doctor.project.models import Project
from bid_doctor.qualification import service as qualifications
CHECK_TASK = "check_project"
LOCAL_TZ = ZoneInfo("Asia/Shanghai") # 投标截止时间按北京时间取日期(同 runner)
# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配;r5:国动真实文件暴露的 6 处
RULE_SET_VERSION = "2026.11-r5"
WAIVE_WINDOW = timedelta(hours=24)
@@ -124,6 +127,19 @@ async def preview(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict
}
async def qualification_matches(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> dict[str, Any]:
"""读标清单上每条资质要求在资质库里的情况。按投标截止日算有没有过期,没有截止时间按今天算。"""
p = await projects.get(s, ctx, project_id)
deadline = p.deadline_at.astimezone(LOCAL_TZ).date() if p.deadline_at else datetime.now(LOCAL_TZ).date()
items = await checklist.items(s, project_id, zone="formal")
matches = qualification.library_matches(items, await qualifications.records(s), deadline)
return {
"as_of": deadline,
"by_deadline": p.deadline_at is not None,
"items": [{"checklist_item_id": k, "families": v} for k, v in matches.items()],
}
async def start(
s: AsyncSession, ctx: TenantContext, project_id: UUID, document_ids: list[UUID], expected_charge: int
) -> CheckRun:

View File

@@ -285,3 +285,25 @@ def test_level_column_wins_over_certificate_number() -> None:
]
(h,) = run(items, {a: result("responded")}, sents, [])[0]
assert h.code == "QUAL.LEVEL_LOW" and "「CS2」" in h.explanation
def test_library_matches_for_checklist_page() -> None:
"""读标清单上的资质库情况:认出类别的资质 / 评分条目给出有效期最晚的那条,按截止日判断过期;
产品证书、技术参数不给;资质库空的什么都不给(QL3)。"""
iso_old = Record(uuid4(), "ISO9001质量管理体系认证证书", "system_cert", None, "Q-1", date(2025, 6, 30))
iso_new = Record(uuid4(), "质量管理体系认证证书(GB/T 19001)", "system_cert", None, "Q-2", date(2028, 6, 30))
itss = Record(uuid4(), "ITSS 信息技术服务标准符合性证书", "certificate", "三级", "ITSS-9", date(2026, 3, 1))
items: list[dict[str, Any]] = [
{"id": "a", "categories": ["qualification"], "title": "具有有效的 ISO9001 质量管理体系认证证书"},
{"id": "b", "categories": ["scoring"], "title": "具有 ITSS 运行维护三级及以上证书得 2 分"},
{"id": "c", "categories": ["scoring"], "title": "具有 CMMI 三级及以上证书得 1 分"},
{"id": "d", "categories": ["scoring"], "title": "所投产品具有节能产品认证证书"},
{"id": "e", "categories": ["tech_param"], "title": "支持 ISO9001 质量管理体系认证的流程模板"},
]
got = qualification.library_matches(items, [iso_old, iso_new, itss], date(2026, 11, 20))
assert set(got) == {"a", "b", "c"}
(a,) = got["a"]
assert (a["state"], a["count"], a["record"]["cert_no"]) == ("valid", 2, "Q-2")
assert got["b"][0]["state"] == "expired"
assert (got["c"][0]["state"], got["c"][0]["record"]) == ("missing", None)
assert qualification.library_matches(items, [], date(2026, 11, 20)) == {}

View File

@@ -167,7 +167,7 @@ def test_scanned_fallback_turns_missing_evidence_into_a_hint() -> None:
results[a].verdict == "scanned"
and spec is not None
and spec.severity == "hint"
and "资格证明册第 12–14、20 页 是扫描件(共 4 页)" in spec.explanation
and "资格证明册第 12–14、20 页是扫描件(共 4 页)" in spec.explanation
)
assert match.page_ranges([5, 3, 4, 9]) == "3–5、9"
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到