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:
@@ -3,7 +3,7 @@
|
||||
* 登录后的页面框架(设计规范 v0.2 §5.3):96px 墨色渐变侧栏 + 透明顶栏(滚动后半透明白)+ 内容区。
|
||||
* 挂载期间保持一条事件流连接,把服务端的变化推进查询缓存。
|
||||
*/
|
||||
import { House, LogOut } from "@lucide/vue";
|
||||
import { Award, House, LogOut } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
||||
@@ -71,6 +71,9 @@ async function logout() {
|
||||
<RouterLink :to="{ name: 'workbench' }" :aria-current="onWorkbench ? 'page' : undefined">
|
||||
<House class="ic" :stroke-width="1.5" /><span>工作台</span>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'qualifications' }" :aria-current="route.name === 'qualifications' ? 'page' : undefined">
|
||||
<Award class="ic" :stroke-width="1.5" /><span>资质库</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="me">
|
||||
<span class="avatar" aria-hidden="true">{{ name.slice(0, 1) }}</span>
|
||||
|
||||
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>
|
||||
@@ -17,4 +17,5 @@ export const qk = {
|
||||
manualChecks: (projectId: string) => ["manual-checks", projectId] as const,
|
||||
reports: (projectId: string) => ["reports", projectId] as const,
|
||||
itemResults: (runId: string) => ["item-results", runId] as const,
|
||||
qualifications: ["qualifications"] as const,
|
||||
};
|
||||
|
||||
@@ -187,6 +187,15 @@ export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
|
||||
|
||||
export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
|
||||
|
||||
export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"];
|
||||
export const QUALIFICATION_KIND: Dict<Schemas["QualificationKind"]> = {
|
||||
certificate: "资质证书",
|
||||
system_cert: "体系认证",
|
||||
software_copyright: "软件著作权",
|
||||
other: "其他",
|
||||
};
|
||||
export const qualificationKind = (s: string) => pick(QUALIFICATION_KIND, s);
|
||||
|
||||
/* ---------- 时间 ---------- */
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
export const fmtDate = (d: Date) => `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
@@ -195,6 +204,30 @@ export const fmtDateTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${fmtDate(d)} ${fmtTime(d)}`;
|
||||
};
|
||||
/** 日期(YYYY-MM-DD,无时区)显示成「2026年11月15日」 */
|
||||
export const fmtDay = (day: string) => {
|
||||
const [y, m, d] = day.split("-").map(Number);
|
||||
return `${y}年${m}月${d}日`;
|
||||
};
|
||||
/** 近期的日期(投标截止日)省掉年份,显示成「11月20日」 */
|
||||
export const fmtMonthDay = (day: string) => {
|
||||
const [, m, d] = day.split("-").map(Number);
|
||||
return `${m}月${d}日`;
|
||||
};
|
||||
/**
|
||||
* 键入的日期整理成 YYYY-MM-DD(设计规范 Q10:可直接键入)。接受 2026-11-20、2026/11/20、2026.11.20、20261120、
|
||||
* 2026年11月20日;空串返回 "",写错或不存在的日期返回 null。
|
||||
*/
|
||||
export function parseDay(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (!t) return "";
|
||||
const m = /^(\d{4})(?:[-/.年]\s*(\d{1,2})[-/.月]\s*(\d{1,2})日?|(\d{2})(\d{2}))$/.exec(t);
|
||||
if (!m) return null;
|
||||
const [y, mo, d] = [Number(m[1]), Number(m[2] ?? m[4]), Number(m[3] ?? m[5])];
|
||||
const dt = new Date(Date.UTC(y, mo - 1, d));
|
||||
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null;
|
||||
return `${y}-${pad(mo)}-${pad(d)}`;
|
||||
}
|
||||
export const fmtDuration = (ms: number) => {
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
return { min: Math.floor(s / 60), sec: pad(s % 60) };
|
||||
|
||||
@@ -26,6 +26,7 @@ export type CheckRun = Schemas["CheckRun"];
|
||||
export type Finding = Schemas["Finding"];
|
||||
export type CheckItemResult = Schemas["CheckItemResult"];
|
||||
export type ManualCheck = Schemas["ManualCheck"];
|
||||
export type Qualification = Schemas["Qualification"];
|
||||
|
||||
export const fetchMe = () => unwrap(api.GET("/me"));
|
||||
|
||||
@@ -141,3 +142,8 @@ export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOr
|
||||
enabled: computed(() => toValue(enabled)),
|
||||
});
|
||||
}
|
||||
|
||||
/** 资质库:一次取全部(最小资质库条目不多),类型筛选和搜索在前端做 */
|
||||
export function useQualifications() {
|
||||
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -21,7 +21,7 @@ import ProjectHead from "@/components/ProjectHead.vue";
|
||||
import { api, errorTitle, isApiError } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
|
||||
import { CHECK_STEPS, checkStepIndex, FINDING_ACTION, fmtDateTime, fmtDay, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
|
||||
import { TERMINAL_JOB, useCheckRun, useFindings, useManualChecks, useProject, type Citation, type Finding, type ManualCheck } from "@/lib/queries";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
@@ -132,6 +132,21 @@ function refresh() {
|
||||
void qc.invalidateQueries({ queryKey: qk.projects });
|
||||
}
|
||||
|
||||
/* ---------- 资质问题:只按资质库记录判断的,出处是资质库那条记录(QL2,check/qualification.py 存的快照) ---------- */
|
||||
interface LibrarySnapshot {
|
||||
name: string;
|
||||
cert_no: string | null;
|
||||
level: string | null;
|
||||
valid_to: string | null;
|
||||
}
|
||||
function librarySnapshots(f: Finding): LibrarySnapshot[] {
|
||||
if (f.type !== "qualification") return [];
|
||||
const issues = (f.detail as { issues?: { source?: string; library?: LibrarySnapshot }[] }).issues ?? [];
|
||||
return issues.filter((x) => x.source === "library" && x.library).map((x) => x.library!);
|
||||
}
|
||||
const libLine = (r: LibrarySnapshot) =>
|
||||
[r.cert_no && `编号 ${r.cert_no}`, r.level && `等级 ${r.level}`, r.valid_to ? `有效期至 ${fmtDay(r.valid_to)}` : "长期有效"].filter(Boolean).join(" · ");
|
||||
|
||||
/* ---------- 需人工核对 ---------- */
|
||||
const manualQ = useManualChecks(() => props.projectId);
|
||||
const manual = computed(() => manualQ.data.value?.items ?? []);
|
||||
@@ -274,6 +289,10 @@ const errorText = computed(() => {
|
||||
<aside>
|
||||
<CitationBlock v-for="c in f.tender_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
|
||||
<CitationBlock v-for="c in f.bid_citations" :key="c.id" :citation="c" :active="viewer?.target.id === c.id" @open="openCitation(c)" />
|
||||
<div v-for="(r, i) in librarySnapshots(f)" :key="`lib-${i}`" class="cite lib">
|
||||
<RouterLink class="cite-src" :to="{ name: 'qualifications' }">依据:资质库记录</RouterLink>
|
||||
<p><b>{{ r.name }}</b><br />{{ libLine(r) }}</p>
|
||||
</div>
|
||||
<div v-if="f.type === 'unresponded' && f.search_scope" class="cite missing">
|
||||
<span class="cite-src">投标文件 · 未找到</span>
|
||||
<p>找过:{{ (f.search_scope.documents ?? []).join("、") || "全部分册" }}</p>
|
||||
@@ -478,6 +497,22 @@ const errorText = computed(() => {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* 资质库是用户自己录的数据:墨色(人工),不用 AI 出处的蓝铅笔色 */
|
||||
.cite.lib {
|
||||
border-left-color: var(--c-ink);
|
||||
}
|
||||
.cite.lib .cite-src {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.cite.lib p {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-caption);
|
||||
line-height: var(--lh-caption);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.cite.lib p b {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.group-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -24,6 +24,7 @@ export const router = createRouter({
|
||||
children: [
|
||||
{ path: "", name: "workbench", component: () => import("@/pages/WorkbenchPage.vue") },
|
||||
{ path: "projects/new", name: "project-new", component: () => import("@/pages/ProjectNewPage.vue") },
|
||||
{ path: "qualifications", name: "qualifications", component: () => import("@/pages/QualificationsPage.vue") },
|
||||
{
|
||||
path: "projects/:projectId/parse/:jobId",
|
||||
name: "parse",
|
||||
|
||||
@@ -272,3 +272,23 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
|
||||
.toast .ok{display:inline-grid;place-items:center;width:16px;height:16px;border-radius:50%;background:var(--c-green);color:var(--c-white);font-size:11px;line-height:1}
|
||||
.toast button.lnk{color:var(--text-on-dark);text-decoration-color:currentColor}
|
||||
.toast .sep{width:1px;align-self:stretch;margin:2px 0;background:var(--on-dark-line)}
|
||||
|
||||
/* =====================================================================
|
||||
* 4. 资质库用到的补充(照搬原型 docs/prototype/assets/app.css 的菜单、开关)
|
||||
* ===================================================================== */
|
||||
/* 下拉菜单:列表行的「更多」。定位交给 Reka UI 的 DropdownMenu,这里只管外观 */
|
||||
.menu{position:absolute;z-index:45;top:calc(100% + 4px);left:0;min-width:100%;max-height:300px;overflow:auto;margin:0;padding:6px;list-style:none;background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
|
||||
.menu.popper{position:relative;top:auto;left:auto;min-width:160px}
|
||||
.menu [role=option],.menu [role=menuitem]{display:flex;align-items:center;gap:var(--sp-2);width:100%;min-height:34px;padding:6px 10px;border:0;border-radius:var(--radius-tag);background:none;font:var(--fs-body)/var(--lh-body) var(--font-sans);color:var(--text-primary);text-align:left;cursor:pointer;white-space:nowrap}
|
||||
.menu [role=option]:hover,.menu [role=menuitem]:hover,.menu .is-active,.menu [role=menuitem]:focus-visible,.menu [data-highlighted]{background:var(--c-n100);outline:0}
|
||||
.menu hr{border:0;border-top:var(--line) solid var(--border-subtle);margin:6px 0}
|
||||
/* 开关(长期有效等) */
|
||||
.switch{display:inline-flex;align-items:center;gap:var(--sp-2);padding:0;border:0;background:none;font:var(--fs-body) var(--font-sans);color:var(--text-primary);cursor:pointer}
|
||||
.switch .track{position:relative;flex:none;width:34px;height:20px;border:var(--line) solid var(--border-control);border-radius:var(--radius-tag);background:var(--bg-surface);transition:background-color var(--dur-fast) var(--ease),border-color var(--dur-fast) var(--ease)}
|
||||
.switch .track::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:var(--radius-xs);background:var(--c-muted);transition:transform var(--dur-fast) var(--ease),background-color var(--dur-fast) var(--ease)}
|
||||
.switch:hover .track{border-color:var(--c-ink)}
|
||||
.switch[aria-checked="true"] .track{background:var(--c-ink);border-color:var(--c-ink)}
|
||||
.switch[aria-checked="true"] .track::after{transform:translateX(14px);background:var(--c-white)}
|
||||
.switch .st{min-width:2em;font-size:var(--fs-caption);color:var(--text-secondary)}
|
||||
.switch[aria-checked="true"] .st{color:var(--text-primary)}
|
||||
.switch[disabled]{color:var(--text-disabled);cursor:not-allowed}
|
||||
|
||||
Reference in New Issue
Block a user