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>
This commit is contained in:
288
apps/web/src/pages/QualificationsPage.vue
Normal file
288
apps/web/src/pages/QualificationsPage.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user