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:查标结果页、概览改成和读标页一样的左右布局(左边内容,右边原文贴住视口),去掉原文侧边抽屉。查标右侧招标文件、投标文件左右切换(`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:国动真实投标文件跑了一次(`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(每页记图片占比,用来判断扫描件)。 - 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:*", "@bid-doctor/api-client": "workspace:*",
"@fontsource/barlow-semi-condensed": "5.3.0", "@fontsource/barlow-semi-condensed": "5.3.0",
"@fontsource/noto-serif-sc": "5.3.0", "@fontsource/noto-serif-sc": "5.3.0",
"@internationalized/date": "3.12.4",
"@lucide/vue": "1.50.0", "@lucide/vue": "1.50.0",
"@tanstack/vue-query": "5.104.1", "@tanstack/vue-query": "5.104.1",
"pdfjs-dist": "6.4.299", "pdfjs-dist": "6.4.299",

View File

@@ -7,11 +7,23 @@
import { computed } from "vue"; import { computed } from "vue";
import CitationBlock from "./CitationBlock.vue"; import CitationBlock from "./CitationBlock.vue";
import { groupCitations, type CitationGroup } from "@/lib/citations"; import { groupCitations, type CitationGroup } from "@/lib/citations";
import { category, failureText, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels"; import { RouterLink } from "vue-router";
import type { ChecklistItem } from "@/lib/queries"; 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 props = defineProps<{
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup] }>(); 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);跨页的一句话算一处 */ /** 一条结论最多默认展开 2 处出处,其余写「另有 N 处」(§7.3);跨页的一句话算一处 */
const groups = computed(() => groupCitations(props.item.citations)); 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 summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
/** 结构化取值:分值、参数要求、日期等,都来自原文(取值校验通过的才进正式清单) */ /** 结构化取值:分值、参数要求、日期等,都来自原文(取值校验通过的才进正式清单) */
const facts = computed(() => specFacts(props.item.spec)); 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> </script>
<template> <template>
@@ -54,6 +69,34 @@ const facts = computed(() => specFacts(props.item.spec));
<div v-else class="cites"> <div v-else class="cites">
<div class="cite missing"><p>{{ item.no_citation_ack || item.origin === "human" ? "没有出处" : "没有出处,确认前不参与检查" }}</p></div> <div class="cite missing"><p>{{ item.no_citation_ack || item.origin === "human" ? "没有出处" : "没有出处,确认前不参与检查" }}</p></div>
</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> <div v-if="$slots.actions && (pending || selected)" class="f-actions" @click.stop>
<slot name="actions" /> <slot name="actions" />
</div> </div>
@@ -115,4 +158,35 @@ const facts = computed(() => specFacts(props.item.spec));
.cites .cap { .cites .cap {
margin: var(--sp-1) 0 0; 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> </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 { X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui"; import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import DateInput from "@/components/DateInput.vue";
import SelectBox from "@/components/SelectBox.vue"; import SelectBox from "@/components/SelectBox.vue";
import { api, errorTitle, isApiError } from "@/lib/api"; import { api, errorTitle, isApiError } from "@/lib/api";
import { BOOL_FIELDS, FIELD_LABEL, fieldText, LEGAL_REGIME, MONEY_FIELDS, parseDay } from "@/lib/labels"; 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"> <div v-else class="field fill">
<label for="fd-day">投标截止时间(北京时间)</label> <label for="fd-day">投标截止时间(北京时间)</label>
<div class="dt-row"> <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" /> <input v-model="time" class="input num" aria-label="时分" placeholder="09:30" autocomplete="off" />
</div> </div>
<span class="msg">查标按它核对证书有效期和落款日期,改了以后重新检查才生效</span> <span class="msg">查标按它核对证书有效期和落款日期,改了以后重新检查才生效</span>

View File

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

View File

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

View File

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

View File

@@ -185,6 +185,19 @@ export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
ignored: "已忽略", 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 READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"]; 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() { export function useQualifications() {
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) }); return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });

View File

@@ -6,15 +6,18 @@
* - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录; * - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录;
* - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上; * - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上;
* - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。 * - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。
* - 检查过的项目:每条下面写最近一次检查的结论(C9),点投标侧出处在右侧看投标原文;「已响应」也能反馈判断有误。
* - 资格条件、评分项里的证书要求:写资质库里有没有、在投标截止日是否有效(资质库空的不写)。
*/ */
import { unwrap } from "@bid-doctor/api-client"; import { unwrap } from "@bid-doctor/api-client";
import { FileText, Info, Plus, Search } from "@lucide/vue"; import { FileText, Info, Plus, Search } from "@lucide/vue";
import { useQuery, useQueryClient } from "@tanstack/vue-query"; import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; 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 ChecklistItemRow from "@/components/ChecklistItemRow.vue";
import CitationBlock from "@/components/CitationBlock.vue"; import CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue";
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
import HistoryDialog from "@/components/HistoryDialog.vue"; import HistoryDialog from "@/components/HistoryDialog.vue";
import ItemDialog from "@/components/ItemDialog.vue"; import ItemDialog from "@/components/ItemDialog.vue";
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.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 { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys"; import { qk } from "@/lib/keys";
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels"; 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"; import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>(); 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: "读标" }]); 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 all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
const formal = computed(() => all.value.filter((i) => i.zone === "formal")); const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
@@ -470,8 +507,14 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
:item="i" :item="i"
:selected="selId === i.id" :selected="selId === i.id"
:active-citation="target?.id" :active-citation="target?.id"
:check="checkOf.get(i.id)"
:library="libOf.get(i.id)"
:library-as-of="libAsOf"
@select="select(i)" @select="select(i)"
@open-citation="(g) => { selId = i.id; openGroup(g); }" @open-citation="(g) => { selId = i.id; openGroup(g); }"
@open-bid="(c) => openCitation(c)"
@findings="goFindings"
@feedback="askFeedback(i)"
> >
<template #actions> <template #actions>
<template v-if="!readOnly && supPending(i)"> <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" /> <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" /> <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"> <ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p> <p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
<div v-if="pending.length" class="alert warn"> <div v-if="pending.length" class="alert warn">

View File

@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、 * 项目概览(原型 project.html,PI1):关键日期(时间线和倒计时,FR-M2-09)和采购包、项目要点(基本信息字段,
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。 * 带出处,可改、可恢复 AI 值)、检查记录(历次检查的结果和报告)、文件(招标方的文件和投标文件的最新版本)。
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。 * 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。 * 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
*/ */
import { unwrap, type Schemas } from "@bid-doctor/api-client"; 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 { useQueryClient } from "@tanstack/vue-query";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
@@ -17,8 +17,18 @@ import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle } from "@/lib/api"; import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs"; import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys"; import { qk } from "@/lib/keys";
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels"; import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTime, fmtDateTimeFull, fmtYuan, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
import { useLots, useProject, useProjectDocuments, type Citation, type ProjectField } from "@/lib/queries"; import {
reportFileUrl,
useCheckRuns,
useLots,
useProject,
useProjectDocuments,
useReports,
type Citation,
type DocumentInfo,
type ProjectField,
} from "@/lib/queries";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
const props = defineProps<{ projectId: string }>(); 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 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 docsQ = useProjectDocuments(() => props.projectId);
const shown = ref<{ doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }>({ doc: null, target: null }); 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"> <div class="card-h">
<h2 id="h-facts">项目要点</h2> <h2 id="h-facts">项目要点</h2>
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span> <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> </div>
<dl class="facts-list"> <dl class="facts-list">
<div v-for="{ key, f, value } in rows" :key="key" class="frow"> <div v-for="{ key, f, value } in rows" :key="key" class="frow">
@@ -170,6 +209,75 @@ function openCitation(c: Citation) {
</dl> </dl>
</section> </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> </div>
<aside class="split-doc" aria-label="原文"> <aside class="split-doc" aria-label="原文">
<DocStage v-model:active="side" :slots="slots" /> <DocStage v-model:active="side" :slots="slots" />
@@ -217,6 +325,47 @@ function openCitation(c: Citation) {
.card-h .r { .card-h .r {
margin-left: auto; 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 { .facts-list {
margin: 0; margin: 0;
} }

View File

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

View File

@@ -4,20 +4,21 @@
* - 检查进行中显示进度和取消;失败时写明原因和是否退回额度; * - 检查进行中显示进度和取消;失败时写明原因和是否退回额度;
* - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3); * - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3);
* - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4), * - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4),
* 说明框在行内展开;处理后显示署名,可以撤销(U 键在第 6 步); * 说明框在行内展开;处理后显示署名,可以撤销;
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文; * - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
* - 只有最近一次检查的问题可以处理,历史批次只读。 * - 只有最近一次检查的问题可以处理,历史批次只读。
* - 需人工核对(FR-M3-04、P2):份数、密封、递交、CA 加密等文件里看不出来的要求,按项目勾选,写勾选人和时间; * - 需人工核对(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 { unwrap } from "@bid-doctor/api-client";
import { ChevronDown, ChevronRight, Download } from "@lucide/vue"; import { ChevronDown, ChevronRight, Download } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue"; 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 CitationBlock from "@/components/CitationBlock.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import DocStage, { type StageSlot } from "@/components/DocStage.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 type { ViewerTarget } from "@/components/PdfViewer.vue";
import ProjectHead from "@/components/ProjectHead.vue"; import ProjectHead from "@/components/ProjectHead.vue";
import { api, errorTitle, isApiError } from "@/lib/api"; import { api, errorTitle, isApiError } from "@/lib/api";
@@ -261,11 +262,15 @@ function setCur(f: Finding, scroll = false) {
if (changed) follow(f); if (changed) follow(f);
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" })); if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
} }
// 第一次加载完:原文翻到第一条问题的出处 // 从工作台「去处理」进来(?f=问题编号):选中那一条;否则第一次加载完,原文翻到第一条问题的出处
const route = useRoute();
watch( watch(
() => cur.value?.id, () => cur.value?.id,
(id, old) => { (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 }, { immediate: true },
); );
@@ -279,6 +284,11 @@ function next(f: Finding) {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return; 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.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; const f = cur.value;
if (!f) return; if (!f) return;
const k = e.key.toLowerCase(); const k = e.key.toLowerCase();
@@ -309,40 +319,16 @@ onMounted(() => window.addEventListener("keydown", onKey));
onBeforeUnmount(() => window.removeEventListener("keydown", onKey)); onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
/* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */ /* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */
const FEEDBACK_REASONS = ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"]; const fbFor = ref<FeedbackTarget | null>(null);
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; } });
function askFeedback(f: Finding) { function askFeedback(f: Finding) {
fbFor.value = f; fbFor.value = {
fbReason.value = FEEDBACK_REASONS[0]!; objectType: "finding",
fbNote.value = ""; objectId: f.id,
} title: f.title,
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", kind: "false_positive",
note: [fbReason.value, fbNote.value.trim()].filter(Boolean).join(":").slice(0, 1000), reasons: ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"],
}, note: "反馈只用于改进检查规则,不改变这条问题的处理状态。",
}), };
);
fbFor.value = null;
toast("已收到反馈");
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
fbBusy.value = false;
}
} }
/* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */ /* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */
@@ -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)" />全选「提示」 <input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
</label> </label>
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span> <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>
</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 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> </div>
<ConfirmDialog v-model:open="fbOpen" title="反馈:判断有误" ok-text="提交反馈" busy-text="正在提交…" :busy="fbBusy" @ok="sendFeedback"> <FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
<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>
</template> </template>
@@ -715,18 +691,6 @@ const errorText = computed(() => {
margin-left: auto; margin-left: auto;
color: var(--text-secondary); 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 { .seg {
display: flex; display: flex;
gap: var(--sp-2); gap: var(--sp-2);

View File

@@ -1,16 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 工作台(原型 workbench.html):页头 + 统计 + 项目列表;右栏是正在进行的任务和本期额度。 * 工作台(原型 workbench.html,ui-demo page-workbench.html):页头 + 统计 + 项目列表 + 待处理的致命问题;
* 原型里的「待处理的致命问题」「最近处理」要等查标和操作记录的接口,骨架阶段不做。 * 右栏是正在进行的任务和本期额度。统计可以点,按它筛选项目列表(再点一下取消)。
* 「最近处理」要等操作记录的接口,还没做。
*/ */
import { Plus } from "@lucide/vue"; import { unwrap } from "@bid-doctor/api-client";
import { computed } from "vue"; import { Filter, Plus, X } from "@lucide/vue";
import { useQueries } from "@tanstack/vue-query";
import { computed, ref } from "vue";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import ProjectRow from "@/components/ProjectRow.vue"; import ProjectRow from "@/components/ProjectRow.vue";
import { errorTitle } from "@/lib/api"; import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs"; import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { quotaUnit } from "@/lib/labels"; 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: "工作台" }]); useCrumbs(() => [{ label: "工作台" }]);
@@ -25,24 +29,50 @@ const hello = computed(() => {
return `你好,${name}。今天是 ${today.getMonth() + 1}月${today.getDate()}日,周${"日一二三四五六"[today.getDay()]}。`; return `你好,${name}。今天是 ${today.getMonth() + 1}月${today.getDate()}日,周${"日一二三四五六"[today.getDay()]}。`;
}); });
const list = computed(() => projects.data.value?.items ?? []); const all = computed(() => projects.data.value?.items ?? []);
const active = computed(() => list.value.filter((p) => p.status !== "submitted" && p.status !== "archived")); const active = computed(() => all.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
const stats = computed(() => { const due7 = (p: ProjectListItem) => {
const now = Date.now();
const due7 = active.value.filter((p) => {
if (!p.deadline_at) return false; if (!p.deadline_at) return false;
const ms = new Date(p.deadline_at).getTime() - now; const ms = new Date(p.deadline_at).getTime() - Date.now();
return ms >= 0 && ms <= 7 * 86_400_000; 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); type StatKey = "active" | "due7" | "fatal" | "pending";
return [ const STAT: Record<StatKey, { label: string; fn: (p: ProjectListItem) => boolean; count: (p: ProjectListItem) => number; cls: string }> = {
{ label: "进行中的项目", value: active.value.length, cls: "" }, active: { label: "进行中的项目", fn: () => true, count: () => 1, cls: "" },
{ label: "7 天内截止", value: due7, cls: due7 ? "due" : "" }, due7: { label: "7 天内截止", fn: due7, count: (p) => (due7(p) ? 1 : 0), cls: "due" },
// 致命红只用于致命问题(§3.2) // 致命红只用于致命问题(§3.2)
{ label: "待处理的致命问题", value: sum((p) => p.readiness.open_fatal_count), cls: "fatal" }, fatal: { label: "待处理的致命问题", fn: (p) => p.readiness.open_fatal_count > 0, count: (p) => p.readiness.open_fatal_count, cls: "fatal" },
{ label: "待确认的清单条目", value: sum((p) => p.pending_item_count ?? 0), cls: "" }, 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 jobs = computed(() => list.value.filter((p) => p.active_job));
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []); 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) ?? ""); 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>
</div> </div>
<section class="stats" aria-label="项目统计"> <section class="stats" aria-label="项目统计,点击可筛选项目列表">
<div v-for="s in stats" :key="s.label" class="stat"> <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> <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> </section>
<div class="wb"> <div class="wb">
<div class="stack">
<section class="card flush" aria-labelledby="h-proj"> <section class="card flush" aria-labelledby="h-proj">
<div class="card-h"> <div class="card-h">
<h2 id="h-proj">项目</h2> <h2 id="h-proj">项目</h2>
<span v-if="list.length" class="fig muted">{{ list.length }}</span> <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>
<div v-if="projects.isPending.value" class="card-b"> <div v-if="projects.isPending.value" class="card-b">
<span class="skel t"></span><span class="skel"></span><span class="skel"></span> <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>
</div> </div>
<template v-else-if="list.length"> <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="项目列表"> <ul class="prows" aria-label="项目列表">
<ProjectRow v-for="p in list" :key="p.id" :project="p" /> <ProjectRow v-for="p in list" :key="p.id" :project="p" />
</ul> </ul>
</template> </template>
<div v-else-if="filter" class="empty sm">
<b>没有符合条件的项目</b>
<p>清除筛选,看全部项目。</p>
</div>
<div v-else class="empty"> <div v-else class="empty">
<b>还没有项目</b> <b>还没有项目</b>
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p> <p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
@@ -96,6 +135,26 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
</div> </div>
</section> </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="任务与额度"> <aside class="stack" aria-label="任务与额度">
<section class="card flush" aria-labelledby="h-task"> <section class="card flush" aria-labelledby="h-task">
<div class="card-h"><h3 id="h-task">正在进行的任务</h3></div> <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 { .none {
margin: 0; 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 { .task {
display: block; display: block;
color: inherit; 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{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.fatal{color:var(--sev-fatal)}
.stat .big.due{color:var(--warn-text)} .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 是清单顶部的「待确认」分组) */ /* B19 列表卡片:左侧分类导航 + 列表;列表里的分组标题(.ai 是清单顶部的「待确认」分组) */
.cmp-list{display:grid;grid-template-columns:176px minmax(0,1fr);min-height:0;overflow:hidden} .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 .st{min-width:2em;font-size:var(--fs-caption);color:var(--text-secondary)}
.switch[aria-checked="true"] .st{color:var(--text-primary)} .switch[aria-checked="true"] .st{color:var(--text-primary)}
.switch[disabled]{color:var(--text-disabled);cursor:not-allowed} .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` | | 注册 / 登录(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 次) | | 新建项目、上传招标文件(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` | | 解析进度(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`;手工增删改分包 | | 选包(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` | | 上传投标文件、发起检查(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` | | 查标(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` | | 报告(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` | | 额度(quota) | `GET /quota`、`GET /quota/entries`、`GET /plans`、`POST /activation-requests` |
| 资质库(qualifications) | `/qualifications` 的增删改;附件先上传,再 `POST /qualifications/{id}/files` | | 资质库(qualifications) | `/qualifications` 的增删改;附件先上传,再 `POST /qualifications/{id}/files` |

View File

@@ -14,7 +14,7 @@
| 第 3 步 AI 响应匹配 | 进行中 | 整行上下文、提示词 v2、偏离栏直读、响应错位、复查复用、硬预算;送给模型的同一行按单元格、重复条目只合并显示(C5 修订);致命项重问按数据暂不做。见 `check-eval.md` §8–§9 | | 第 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 | | 第 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):致命问题未处理也能递交,确认框说明;递交后只读,撤销归档回到归档前的状态,都写审计 | | 第 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 步落地记录**(和本文设计不一致、或留到后面的,记在这里): **第 1 步落地记录**(和本文设计不一致、或留到后面的,记在这里):

View File

@@ -1257,6 +1257,39 @@ paths:
application/json: application/json:
schema: { $ref: '#/components/schemas/CheckPreview' } 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: /projects/{project_id}/check-runs:
parameters: parameters:
- $ref: '#/components/parameters/ProjectId' - $ref: '#/components/parameters/ProjectId'
@@ -1997,6 +2030,27 @@ components:
QuotaEntryKind: { type: string, enum: [grant, hold, settle, release, expire, adjust, waive] } QuotaEntryKind: { type: string, enum: [grant, hold, settle, release, expire, adjust, waive] }
PlanCode: { type: string, enum: [free, pro, enterprise] } PlanCode: { type: string, enum: [free, pro, enterprise] }
QualificationKind: { type: string, enum: [certificate, system_cert, software_copyright, other] } 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: ProjectFieldKey:
type: string type: string
enum: [name, code, purchaser, agency, procurement_method, legal_regime, budget_total, ceiling_total, enum: [name, code, purchaser, agency, procurement_method, legal_regime, budget_total, ceiling_total,

View File

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

View File

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

View File

@@ -912,6 +912,30 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/projects/{project_id}/check-runs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1425,6 +1449,30 @@ export interface components {
PlanCode: "free" | "pro" | "enterprise"; PlanCode: "free" | "pro" | "enterprise";
/** @enum {string} */ /** @enum {string} */
QualificationKind: "certificate" | "system_cert" | "software_copyright" | "other"; 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} */ /** @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"; 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: { listCheckRuns: {
parameters: { parameters: {
query?: never; query?: never;

3
pnpm-lock.yaml generated
View File

@@ -19,6 +19,9 @@ importers:
'@fontsource/noto-serif-sc': '@fontsource/noto-serif-sc':
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
'@internationalized/date':
specifier: 3.12.4
version: 3.12.4
'@lucide/vue': '@lucide/vue':
specifier: 1.50.0 specifier: 1.50.0
version: 1.50.0(vue@3.5.43(typescript@5.8.3)) 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) 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") @router.get("/projects/{project_id}/check-runs")
async def list_check_runs(project_id: UUID, ctx: Ctx) -> dict[str, Any]: async def list_check_runs(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
async with tenant_tx(ctx) as s: 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") where = result.detail.get("scanned_where")
# 只说扫描件在哪几页,不说「材料可能在扫描件里」:扫描件上是不是这份材料,没有 OCR 分不出来 # 只说扫描件在哪几页,不说「材料可能在扫描件里」:扫描件上是不是这份材料,没有 OCR 分不出来
text = ( text = (
f"投标文件文字部分没有找到这份材料。{where} 是扫描件(共 {total} 页),系统读不出上面的文字;" f"投标文件文字部分没有找到这份材料。{where}是扫描件(共 {total} 页),系统读不出上面的文字;"
"材料如果附在这些页上,请人工核对,没有附就是缺了。" "材料如果附在这些页上,请人工核对,没有附就是缺了。"
if where if where
else f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字," else f"投标文件文字部分没有找到这份材料。投标文件里有 {total} 页扫描件,系统读不出上面的文字,"

View File

@@ -419,6 +419,43 @@ def check_item(
return issues, notes 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( def run(
items: dict[UUID, dict[str, Any]], items: dict[UUID, dict[str, Any]],
results: dict[UUID, Result], results: dict[UUID, Result],

View File

@@ -12,6 +12,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from zoneinfo import ZoneInfo
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from sqlalchemy import func, select 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.audit import service as audit
from bid_doctor.billing import service as billing 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.check.models import CheckItemResult, CheckRun, CheckRunDocument, Finding, FindingCitation, ManualCheck
from bid_doctor.checklist import edit as checklist_edit from bid_doctor.checklist import edit as checklist_edit
from bid_doctor.checklist import service as checklist 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.ops import service as feedback
from bid_doctor.project import service as projects from bid_doctor.project import service as projects
from bid_doctor.project.models import Project from bid_doctor.project.models import Project
from bid_doctor.qualification import service as qualifications
CHECK_TASK = "check_project" CHECK_TASK = "check_project"
LOCAL_TZ = ZoneInfo("Asia/Shanghai") # 投标截止时间按北京时间取日期(同 runner)
# 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配;r5:国动真实文件暴露的 6 处 # 规则集有版本,检查批次记下当时用的版本(架构 §3.7);r3:留出集暴露的 5 处;r4:资质匹配;r5:国动真实文件暴露的 6 处
RULE_SET_VERSION = "2026.11-r5" RULE_SET_VERSION = "2026.11-r5"
WAIVE_WINDOW = timedelta(hours=24) 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( async def start(
s: AsyncSession, ctx: TenantContext, project_id: UUID, document_ids: list[UUID], expected_charge: int s: AsyncSession, ctx: TenantContext, project_id: UUID, document_ids: list[UUID], expected_charge: int
) -> CheckRun: ) -> CheckRun:

View File

@@ -285,3 +285,25 @@ def test_level_column_wins_over_certificate_number() -> None:
] ]
(h,) = run(items, {a: result("responded")}, sents, [])[0] (h,) = run(items, {a: result("responded")}, sents, [])[0]
assert h.code == "QUAL.LEVEL_LOW" and "「CS2」" in h.explanation 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" results[a].verdict == "scanned"
and spec is not None and spec is not None
and spec.severity == "hint" 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 match.page_ranges([5, 3, 4, 9]) == "3–5、9"
assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到 assert results[b].verdict == "not_found" # 技术参数不是证明材料,照常报未找到