Files
bid-doctor/apps/web/src/pages/QualificationsPage.vue
YANG JIANKUAN 08e3fe7eff
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(qualification): 资质库和资质匹配(查标第 4 步,规则集 2026.11-r4)
- 资质库(FR-M6-08):增删改、证书扫描件(PDF/JPG/PNG,企业级密钥加密、签名下载)、
  late_for 按进行中项目的投标截止日提醒、审计;前端资质库页和对话框,侧栏入口
- 资质匹配(FR-M3-05):证书类别词表(16 类,产品证书、人员证书不进匹配),
  QUAL.EXPIRED / LEVEL_LOW / NOT_COVERED;投标文字层按清单项定级,只有资质库记录的降一级,
  出处显示「依据:资质库记录」(查标页、报告模板 v2);一类证书一个问题;评分项不逐条找响应的也查
- 响应错位规则不再把资质表的证书名称格当成照抄的要求
- 评测:qual_defects.py 派生资质情况表,D12 3/3、D18 2/2、规矩的资质表 0 误报;开发集不退化
- 决策 QL1–QL7;方案和落地记录 docs/architecture/qualification-plan.md,评测 check-eval.md §13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:22:50 +08:00

289 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
/**
* 资质库(FR-M6-08,原型 qualifications.html):列表 + 添加 / 编辑对话框 + 删除。
* - 到期按每个进行中项目的投标截止日判断,不按今天(服务端给 late_for);只提醒,不拦截;
* - 没有附件显示「待补录」,不生成内容(红线 5);
* - 删除只有确认框,没有撤销(QL6)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal, Plus, Search } from "@lucide/vue";
import { useQueryClient } from "@tanstack/vue-query";
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuSeparator, DropdownMenuTrigger } from "reka-ui";
import { computed, ref } from "vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import QualificationDialog, { type ActiveDeadline } from "@/components/QualificationDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
import { qk } from "@/lib/keys";
import { fmtDay, fmtMonthDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
import { useProjects, useQualifications, type Qualification } from "@/lib/queries";
import { toast } from "@/lib/toast";
useCrumbs(() => [{ label: "资质库" }]);
const qc = useQueryClient();
const quals = useQualifications();
const projects = useProjects();
const all = computed(() => quals.data.value?.items ?? []);
const kind = ref<Qualification["kind"] | "">("");
const kw = ref("");
const segs = computed(() => [
{ v: "" as const, label: "全部", n: all.value.length },
...QUALIFICATION_KIND_ORDER.map((k) => ({ v: k, label: QUALIFICATION_KIND[k], n: all.value.filter((q) => q.kind === k).length })).filter((s) => s.n),
]);
const rows = computed(() => {
const k = kw.value.trim().toLowerCase();
return all.value.filter(
(q) => (!kind.value || q.kind === kind.value) && (!k || [q.name, q.cert_no, q.issuer].some((x) => x?.toLowerCase().includes(k))),
);
});
const lateOnes = computed(() => all.value.filter((q) => q.late_for.length));
/** 进行中项目的投标截止日(北京时间的日期),给对话框做到期提醒 */
const deadlines = computed<ActiveDeadline[]>(() => {
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai" });
const today = fmt.format(new Date());
return (projects.data.value?.items ?? [])
.filter((p) => ["tracking", "drafting", "checked"].includes(p.status) && p.deadline_at)
.map((p) => ({ name: p.name, day: fmt.format(new Date(p.deadline_at!)) }))
.filter((d) => d.day >= today)
.sort((a, b) => (a.day < b.day ? -1 : 1));
});
const lateText = (q: Qualification) => {
const first = q.late_for[0]!;
return `早于「${first.name}」的投标截止 ${fmtMonthDay(first.deadline)}${q.late_for.length > 1 ? ` 等 ${q.late_for.length} 个项目` : ""}`;
};
const editing = ref<Qualification | null>(null);
const dialogOpen = ref(false);
const focusExpiry = ref(false);
function edit(q: Qualification | null, expiry = false) {
editing.value = q;
focusExpiry.value = expiry;
dialogOpen.value = true;
}
function onSaved(q: Qualification, how: "created" | "updated" | "files") {
void qc.invalidateQueries({ queryKey: qk.qualifications });
editing.value = q; // 新增后对话框还开着(附件没传上)时,再保存就是修改
if (how === "created") toast(`已添加「${q.name}」`);
else if (how === "updated") toast(`已保存「${q.name}」`);
}
/** 打开第一个附件(短时签名地址) */
async function view(q: Qualification) {
try {
const link = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: q.id, file_id: q.files[0]!.id } } }),
);
window.open(link.url, "_blank", "noopener");
} catch (e) {
toast(errorTitle(e), { ok: false });
}
}
const removing = ref<Qualification | null>(null);
const removeOpen = ref(false);
const removeBusy = ref(false);
function askRemove(q: Qualification) {
removing.value = q;
removeOpen.value = true;
}
async function remove() {
const q = removing.value;
if (!q) return;
removeBusy.value = true;
try {
await unwrap(api.DELETE("/qualifications/{qualification_id}", { params: { path: { qualification_id: q.id } } }));
removeOpen.value = false;
await qc.invalidateQueries({ queryKey: qk.qualifications });
toast(`已删除「${q.name}」`);
} catch (e) {
toast(errorTitle(e), { ok: false });
} finally {
removeBusy.value = false;
}
}
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>资质库</h1>
<p>查标时按每个项目的投标截止日核对有效期和等级。只用你录入的资质。</p>
</div>
<div class="actions">
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
</div>
<div v-if="lateOnes.length" class="alert warn" role="note">
<span class="ico w" aria-hidden="true">!</span>
<p><b>{{ lateOnes.length }} 张证书会在进行中项目的投标截止前到期。</b>到期按每个项目的投标截止日判断,不按今天。</p>
<button v-if="lateOnes.length === 1" class="btn sm" type="button" @click="edit(lateOnes[0]!, true)">更新有效期</button>
</div>
<section class="card flush" aria-labelledby="h-quals">
<div class="card-h">
<h2 id="h-quals">资质</h2>
<div v-if="all.length" class="seg" role="group" aria-label="按类型筛选">
<button v-for="s in segs" :key="s.v" type="button" :aria-pressed="kind === s.v" @click="kind = s.v">
{{ s.label }}<span class="fig">{{ s.n }}</span>
</button>
</div>
<div v-if="all.length" class="r">
<label class="search"><Search class="ic" :stroke-width="1.5" /><input v-model="kw" class="input" type="search" placeholder="名称、证书编号" aria-label="搜索资质" /></label>
</div>
</div>
<div v-if="quals.isPending.value" class="card-b"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
<div v-else-if="quals.isError.value" class="card-b">
<div class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ errorTitle(quals.error.value) }}</p>
<button class="btn sm" type="button" @click="quals.refetch()">重试</button>
</div>
</div>
<div v-else-if="!all.length" class="empty">
<b>还没有录入资质</b>
<p>录入资质证书、体系认证和软件著作权后,查标会按每个项目的投标截止日核对有效期和等级。</p>
<button class="btn primary" type="button" @click="edit(null)"><Plus class="ic" :stroke-width="1.5" />添加资质</button>
</div>
<table v-else class="data hover quals">
<thead>
<tr><th>名称</th><th>类型</th><th>等级</th><th>证书编号</th><th>发证机构</th><th>有效期至</th><th>附件</th><th><span class="vh">更多</span></th></tr>
</thead>
<tbody>
<tr v-for="q in rows" :key="q.id" @click="edit(q)">
<td><button type="button" class="nm" @click.stop="edit(q)">{{ q.name }}</button></td>
<td>{{ QUALIFICATION_KIND[q.kind] }}</td>
<td><template v-if="q.level">{{ q.level }}</template><span v-else class="dash">—</span></td>
<td class="num">{{ q.cert_no }}</td>
<td><template v-if="q.issuer">{{ q.issuer }}</template><span v-else class="dash">—</span></td>
<td class="exp">
<span>{{ q.valid_to ? fmtDay(q.valid_to) : "长期有效" }}</span>
<span v-if="q.late_for.length" class="warn">{{ lateText(q) }}</span>
</td>
<td>
<template v-if="q.files.length">
<button type="button" class="lnk" @click.stop="view(q)">查看</button>
<span v-if="q.files.length > 1" class="cap muted"> 等 <span class="fig">{{ q.files.length }}</span> 个</span>
</template>
<span v-else class="tag status">待补录</span>
</td>
<td class="more-cell" @click.stop>
<DropdownMenuRoot :modal="false">
<DropdownMenuTrigger class="icon-btn sm" aria-label="更多操作"><MoreHorizontal class="ic" :stroke-width="1.5" /></DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent class="menu popper" align="end" :side-offset="4">
<DropdownMenuItem @select="edit(q)">编辑</DropdownMenuItem>
<DropdownMenuItem @select="edit(q)">{{ q.files.length ? "管理附件" : "上传附件" }}</DropdownMenuItem>
<DropdownMenuSeparator as="hr" />
<DropdownMenuItem @select="askRemove(q)">删除</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</td>
</tr>
<tr v-if="!rows.length">
<td colspan="8" class="empty-cell">
<div class="empty sm">
<b>没有找到匹配的资质</b>
<p>换个关键词,或清除筛选。</p>
<button class="btn sm" type="button" @click="(kind = ''), (kw = '')">清除筛选</button>
</div>
</td>
</tr>
</tbody>
</table>
</section>
<p class="note below">业绩、人员、历史标书等资产库在 V1 提供。</p>
<QualificationDialog v-model:open="dialogOpen" :item="editing" :deadlines="deadlines" :focus-expiry="focusExpiry" @saved="onSaved" />
<ConfirmDialog v-model:open="removeOpen" :title="`删除资质「${removing?.name ?? ''}」?`" ok-text="删除" :busy="removeBusy" busy-text="正在删除…" @ok="remove">
<p>删除后,查标不再用它核对资质,附件一并删除,不能恢复。已导出的检查报告不受影响。</p>
</ConfirmDialog>
</div>
</template>
<style scoped>
.alert {
margin-bottom: var(--gap-card);
}
.card-h .seg {
margin-left: var(--sp-2);
}
.card-h .r {
margin-left: auto;
}
.card-h .search {
width: 240px;
}
.card-h .search .input {
height: var(--control-h-sm);
}
.card-h .search > .ic {
top: 8px;
}
.seg .fig {
margin-left: 2px;
}
table.quals {
cursor: default;
}
table.quals tbody tr {
cursor: pointer;
}
table.quals td:first-child {
white-space: normal;
min-width: 220px;
}
table.quals td {
vertical-align: middle;
}
table.quals .nm {
display: inline;
padding: 0;
border: 0;
background: none;
font: inherit;
font-weight: var(--fw-bold);
color: var(--text-primary);
text-align: left;
cursor: pointer;
}
table.quals .nm:hover {
text-decoration: underline;
text-decoration-color: var(--c-ink);
text-underline-offset: 3px;
}
table.quals td.more-cell {
width: 1%;
padding-left: 0;
}
table.quals .icon-btn {
color: var(--text-secondary);
}
table.quals tr:hover .icon-btn,
table.quals .icon-btn[aria-expanded="true"] {
color: var(--text-primary);
}
table.quals .exp .warn {
display: block;
color: var(--warn-text);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
white-space: nowrap;
}
table.data td.empty-cell {
height: auto;
padding: 0;
}
.note.below {
margin: var(--sp-3) 0 0;
}
</style>