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:
326
apps/web/src/components/QualificationDialog.vue
Normal file
326
apps/web/src/components/QualificationDialog.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 资质的添加 / 编辑对话框(宽 640,原型 qualifications.html、ui-demo forms.html「资质录入」)。
|
||||
* - 名称、证书编号、有效期至必填(长期有效打开开关);证书编号界面必填、接口可空(qualification-plan §2.3);
|
||||
* - 有效期至早于进行中项目的投标截止日:出赭色提醒小字,只提醒、不拦截保存;
|
||||
* - 附件可以稍后补录。新增时选的文件在保存资质后再上传挂上。
|
||||
*/
|
||||
import { unwrap, uploadFile } from "@bid-doctor/api-client";
|
||||
import { X } from "@lucide/vue";
|
||||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { api, apiOptions, errorTitle } from "@/lib/api";
|
||||
import { extOf, fmtMonthDay, fmtSize, parseDay, QUALIFICATION_KIND, QUALIFICATION_KIND_ORDER } from "@/lib/labels";
|
||||
import type { Qualification } from "@/lib/queries";
|
||||
|
||||
export interface ActiveDeadline {
|
||||
name: string;
|
||||
/** YYYY-MM-DD(北京时间) */
|
||||
day: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ item: Qualification | null; deadlines: ActiveDeadline[]; focusExpiry?: boolean }>();
|
||||
const open = defineModel<boolean>("open", { required: true });
|
||||
/** saved:created / updated 是点了「保存资质」,files 是在对话框里加删附件 */
|
||||
const emit = defineEmits<{ saved: [q: Qualification, how: "created" | "updated" | "files"] }>();
|
||||
|
||||
type Kind = Qualification["kind"];
|
||||
const name = ref("");
|
||||
const kind = ref<Kind>("certificate");
|
||||
const level = ref("");
|
||||
const certNo = ref("");
|
||||
const issuer = ref("");
|
||||
const from = ref("");
|
||||
const to = ref("");
|
||||
const longTerm = ref(false);
|
||||
const files = ref<Qualification["files"]>([]);
|
||||
const pending = ref<File | null>(null);
|
||||
const errs = ref<Record<string, string>>({});
|
||||
const err = ref("");
|
||||
const busy = ref(false);
|
||||
const fileBusy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const toInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(open, (v) => {
|
||||
if (!v) return;
|
||||
const q = props.item;
|
||||
name.value = q?.name ?? "";
|
||||
kind.value = q?.kind ?? "certificate";
|
||||
level.value = q?.level ?? "";
|
||||
certNo.value = q?.cert_no ?? "";
|
||||
issuer.value = q?.issuer ?? "";
|
||||
from.value = q?.valid_from ?? "";
|
||||
to.value = q?.valid_to ?? "";
|
||||
longTerm.value = !!q && !q.valid_to;
|
||||
files.value = q?.files ?? [];
|
||||
pending.value = null;
|
||||
errs.value = {};
|
||||
err.value = "";
|
||||
if (props.focusExpiry) setTimeout(() => toInput.value?.select());
|
||||
});
|
||||
|
||||
/** 有效期至早于哪些进行中项目的投标截止日(截止日当天到期仍算有效) */
|
||||
const late = computed(() => {
|
||||
const day = longTerm.value ? "" : parseDay(to.value);
|
||||
return day ? props.deadlines.filter((d) => day < d.day) : [];
|
||||
});
|
||||
|
||||
function tidy(field: "from" | "to") {
|
||||
const r = field === "from" ? from : to;
|
||||
const day = parseDay(r.value);
|
||||
if (day) r.value = day;
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
const e: Record<string, string> = {};
|
||||
if (!name.value.trim()) e.name = "请填写资质名称,和证书上的写法一致";
|
||||
if (!certNo.value.trim()) e.certNo = "请填写证书编号,查标时用它核对";
|
||||
const f = parseDay(from.value);
|
||||
if (f === null) e.from = "日期写法不对,例如:2023-11-16";
|
||||
if (!longTerm.value) {
|
||||
const t = parseDay(to.value);
|
||||
if (t === null) e.to = "日期写法不对,例如:2026-11-20";
|
||||
else if (!t) e.to = "请填写有效期至;长期有效请打开下方开关";
|
||||
else if (f && t <= f) e.to = "有效期至要晚于有效期起,请核对";
|
||||
}
|
||||
errs.value = e;
|
||||
return !Object.keys(e).length;
|
||||
}
|
||||
|
||||
async function attach(qid: string, file: File): Promise<Qualification> {
|
||||
const done = await uploadFile(api, apiOptions, file, "qualification_file");
|
||||
return unwrap(
|
||||
api.POST("/qualifications/{qualification_id}/files", { params: { path: { qualification_id: qid } }, body: { upload_id: done.id } }),
|
||||
);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate() || busy.value) return;
|
||||
busy.value = true;
|
||||
err.value = "";
|
||||
const body = {
|
||||
name: name.value.trim(),
|
||||
kind: kind.value,
|
||||
level: level.value.trim() || null,
|
||||
cert_no: certNo.value.trim() || null,
|
||||
issuer: issuer.value.trim() || null,
|
||||
valid_from: parseDay(from.value) || null,
|
||||
valid_to: longTerm.value ? null : parseDay(to.value) || null,
|
||||
};
|
||||
const how = props.item ? "updated" : "created";
|
||||
try {
|
||||
let q = props.item
|
||||
? await unwrap(api.PUT("/qualifications/{qualification_id}", { params: { path: { qualification_id: props.item.id } }, body }))
|
||||
: await unwrap(api.POST("/qualifications", { body }));
|
||||
if (pending.value) {
|
||||
try {
|
||||
q = await attach(q.id, pending.value);
|
||||
} catch (e) {
|
||||
// 资质已经存好了(再点保存是修改,不会重复添加);附件没挂上时关掉对话框会让人以为都好了,所以留在对话框里
|
||||
emit("saved", q, how);
|
||||
err.value = `资质已保存,附件没有上传成功:${errorTitle(e)}。可以稍后在列表里补录。`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
emit("saved", q, how);
|
||||
open.value = false;
|
||||
} catch (e) {
|
||||
err.value = errorTitle(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pick() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const f = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!f) return;
|
||||
if (!props.item) {
|
||||
pending.value = f;
|
||||
return;
|
||||
}
|
||||
fileBusy.value = true;
|
||||
err.value = "";
|
||||
try {
|
||||
const q = await attach(props.item.id, f);
|
||||
files.value = q.files;
|
||||
emit("saved", q, "files");
|
||||
} catch (e) {
|
||||
err.value = errorTitle(e);
|
||||
} finally {
|
||||
fileBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function view(fileId: string) {
|
||||
if (!props.item) return;
|
||||
try {
|
||||
const link = await unwrap(
|
||||
api.GET("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
|
||||
);
|
||||
window.open(link.url, "_blank", "noopener");
|
||||
} catch (e) {
|
||||
err.value = errorTitle(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFile(fileId: string) {
|
||||
if (!props.item) return;
|
||||
fileBusy.value = true;
|
||||
try {
|
||||
await unwrap(
|
||||
api.DELETE("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
|
||||
);
|
||||
files.value = files.value.filter((f) => f.id !== fileId);
|
||||
emit("saved", { ...props.item, files: files.value }, "files");
|
||||
} catch (e) {
|
||||
err.value = errorTitle(e);
|
||||
} finally {
|
||||
fileBusy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="scrim">
|
||||
<DialogContent class="dialog wide" :aria-describedby="undefined">
|
||||
<DialogTitle as="h2">{{ item ? "编辑资质" : "添加资质" }}</DialogTitle>
|
||||
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
|
||||
<form class="body" novalidate @submit.prevent="submit">
|
||||
<div class="form">
|
||||
<div class="field span2" :class="{ err: errs.name }">
|
||||
<label for="qd-name">名称 <small>(必填)</small></label>
|
||||
<input id="qd-name" v-model="name" class="input" maxlength="200" placeholder="和证书上的写法一致" autocomplete="off" />
|
||||
<span v-if="errs.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.name }}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="qd-kind">类型 <small>(必填)</small></label>
|
||||
<select id="qd-kind" v-model="kind" class="input">
|
||||
<option v-for="k in QUALIFICATION_KIND_ORDER" :key="k" :value="k">{{ QUALIFICATION_KIND[k] }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="qd-level">等级 <small>(选填)</small></label>
|
||||
<input id="qd-level" v-model="level" class="input" maxlength="40" placeholder="例如:CS3、二级" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field" :class="{ err: errs.certNo }">
|
||||
<label for="qd-no">证书编号 <small>(必填)</small></label>
|
||||
<input id="qd-no" v-model="certNo" class="input num" maxlength="80" autocomplete="off" />
|
||||
<span v-if="errs.certNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.certNo }}</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="qd-issuer">发证机构</label>
|
||||
<input id="qd-issuer" v-model="issuer" class="input" maxlength="120" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field" :class="{ err: errs.from }">
|
||||
<label for="qd-from">有效期起</label>
|
||||
<input id="qd-from" v-model="from" class="input num" placeholder="例如:2023-11-16" autocomplete="off" @blur="tidy('from')" />
|
||||
<span v-if="errs.from" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.from }}</span>
|
||||
</div>
|
||||
<div class="field" :class="{ err: errs.to }">
|
||||
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small></label>
|
||||
<input
|
||||
id="qd-to"
|
||||
ref="toInput"
|
||||
v-model="to"
|
||||
class="input num"
|
||||
:placeholder="longTerm ? '长期有效' : '例如:2026-11-20'"
|
||||
:disabled="longTerm"
|
||||
autocomplete="off"
|
||||
@blur="tidy('to')"
|
||||
/>
|
||||
<span v-if="errs.to" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.to }}</span>
|
||||
<span v-else-if="late.length" class="hint-warn">
|
||||
<span class="ico w" aria-hidden="true">!</span>
|
||||
<span>早于「{{ late[0]!.name }}」的投标截止 {{ fmtMonthDay(late[0]!.day) }}<template v-if="late.length > 1"> 等 <span class="fig">{{ late.length }}</span> 个项目</template></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="field span2">
|
||||
<button
|
||||
type="button"
|
||||
class="switch"
|
||||
role="switch"
|
||||
:aria-checked="longTerm"
|
||||
@click="
|
||||
longTerm = !longTerm;
|
||||
if (longTerm) to = '';
|
||||
"
|
||||
>
|
||||
<span class="track" aria-hidden="true"></span>长期有效
|
||||
</button>
|
||||
</div>
|
||||
<div class="field span2">
|
||||
<label>证书扫描件 <small>(可以稍后补录)</small></label>
|
||||
<div v-for="f in files" :key="f.id" class="file-row">
|
||||
<span class="ext">{{ extOf(f.filename) }}</span>
|
||||
<div><b>{{ f.filename }}</b><span class="cap muted">{{ fmtSize(f.size_bytes) }}</span></div>
|
||||
<span class="acts">
|
||||
<button type="button" class="btn sm" @click="view(f.id)">查看</button>
|
||||
<button type="button" class="btn sm" :disabled="fileBusy" @click="removeFile(f.id)">删除</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="pending" class="file-row">
|
||||
<span class="ext">{{ extOf(pending.name) }}</span>
|
||||
<div><b>{{ pending.name }}</b><span class="cap muted">{{ fmtSize(pending.size) }} · 保存资质后上传</span></div>
|
||||
<span class="acts"><button type="button" class="btn sm" @click="pending = null">移除</button></span>
|
||||
</div>
|
||||
<button v-if="!pending" type="button" class="drop" :aria-disabled="fileBusy" @click="pick">
|
||||
<span>{{ fileBusy ? "正在上传…" : files.length ? "再上传一个附件(PDF / JPG / PNG)" : "点击上传 PDF / JPG / PNG,单个不超过 20MB" }}</span>
|
||||
</button>
|
||||
<input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" hidden @change="onFile" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
|
||||
<div class="foot">
|
||||
<span class="cap muted left">不扣额度</span>
|
||||
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
|
||||
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
|
||||
<span class="idle">保存资质</span><span class="busy">正在保存…</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 编号、日期用数字字体(同原型 .dt > .input) */
|
||||
.input.num {
|
||||
font-family: var(--font-figures);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--fs-figure-sm);
|
||||
}
|
||||
.input.num::placeholder {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-body);
|
||||
}
|
||||
.file-row + .file-row,
|
||||
.file-row + .drop {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
.drop {
|
||||
width: 100%;
|
||||
padding: var(--sp-4);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-row .cap {
|
||||
display: block;
|
||||
}
|
||||
.hint-warn {
|
||||
margin: var(--sp-4) 0 0;
|
||||
}
|
||||
.field .hint-warn {
|
||||
margin: var(--sp-1) 0 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user