feat(ocr): 扫描件与图片识别——OCR 进文字层、资质库上传识别、大图浏览

计划见 docs/architecture/vision-plan.md(VS1–VS13,docs/decisions.md)。

- 验证:OCR(RapidOCR + PP-OCRv6 small,CPU)和 DeepSeek Flash 看图在国动投标文件
  和 7 张证书上初测(tech-validation §7,代码 spikes/s5-ocr/)
- OCR 服务 services/ocr(镜像、Compose 服务):整页转正(含 180°)、红章附近按红色通道重读
- 扫描页和插图识别后并入文字层 v6,句子标 source=ocr;出处标「OCR」(迁移 0013);
  依据识别文字的问题降级,规则不查扫描附件;国动端到端见 check-eval §16
- 资质库上传识别:添加资质对话框最上面传证书照片,识别后填表,只预填和 OCR 文字
  对得上的字段;识别值留痕、照片挂成附件(迁移 0014,提示词 vision.cert_fields v1)
- 资质库改为查看详情(只读键值 + 附件缩略图),附件用大图浏览控件看,不开新标签页
  (设计规范 §7.23);PDF 附件按页出预览图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-05 17:50:13 +08:00
parent 0b783ababa
commit 3344f09563
63 changed files with 4973 additions and 345 deletions

View File

@@ -3,7 +3,7 @@
* 出处(设计规范 §7.3):左侧 2px 蓝铅笔竖线,上面是「文件 · 第 N 页」,下面照录原句。
* 点出处在原文查看器里翻到那一页、高亮原句;当前打开的出处竖线加粗(.on)。
* 一句话跨页时(tail 是下一页的接续部分)合成一处显示:「第 47–48 页」,原句接起来。
* TODO:OCR 识别出的原句前加「OCR」小字(等接口给出页的 text_source)。
* 原句是扫描页或插图上 OCR 识别出来的,前面加「OCR」小字,提醒核对原图(§7.3)。
*/
import { computed } from "vue";
import type { Citation } from "@/lib/queries";
@@ -11,6 +11,7 @@ import type { Citation } from "@/lib/queries";
const props = defineProps<{ citation: Citation; tail?: Citation[]; active?: boolean }>();
const lastPage = computed(() => props.tail?.at(-1)?.page_no);
const quote = computed(() => [props.citation, ...(props.tail ?? [])].map((c) => c.quote).join(""));
const ocr = computed(() => [props.citation, ...(props.tail ?? [])].some((c) => c.ocr));
const emit = defineEmits<{ open: [] }>();
</script>
@@ -19,7 +20,7 @@ const emit = defineEmits<{ open: [] }>();
<span class="cite-src">
<template v-if="citation.document_label">{{ citation.document_label }} · </template>第 <span class="fig">{{ citation.page_no }}</span><template v-if="lastPage">–<span class="fig">{{ lastPage }}</span></template> 页
</span>
<q>{{ quote }}</q>
<q><abbr v-if="ocr" class="cite-ocr" title="这句是从扫描件或图片上识别出来的,可能有错字,请对照原文核对">OCR</abbr>{{ quote }}</q>
</div>
</template>
@@ -32,6 +33,12 @@ const emit = defineEmits<{ open: [] }>();
.cite:hover {
background: var(--ai-soft);
}
.cite-ocr {
margin-right: var(--sp-1);
font-size: var(--fs-caption);
color: var(--text-secondary);
text-decoration: none;
}
.cite:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
/**
* 大图浏览(设计规范 §7.23):深色蒙层上看图,不开新标签页。
* - 打开时整张适应窗口;放大、缩小、适应窗口三个按钮,滚轮按光标位置缩放,双击在适应窗口和 2 倍之间切换;
* - 放大后可以拖动;多张图时左右切换,顶栏写「文件名 · 第几张 / 共几张」;
* - 键盘:Esc 关闭,+ / − 缩放,0 适应窗口,← / → 切换。对话框基础用 Reka UI(焦点锁定、Esc、焦点回到触发处)。
*/
import { ChevronLeft, ChevronRight, Maximize, Minus, Plus, X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, nextTick, ref, watch } from "vue";
export interface ViewerImage {
url: string;
title: string;
}
const props = defineProps<{ images: ViewerImage[] }>();
const open = defineModel<boolean>("open", { required: true });
const index = defineModel<number>("index", { default: 0 });
const MIN = 0.5; // 相对「适应窗口」
const MAX_NATURAL = 6; // 最多放大到原图像素的 6 倍
const stage = ref<HTMLElement | null>(null);
const natural = ref({ w: 0, h: 0 });
const fit = ref(1); // 适应窗口时,图片显示尺寸 / 原图尺寸
const zoom = ref(1); // 相对适应窗口
const tx = ref(0);
const ty = ref(0);
const loaded = ref(false);
let drag: { x: number; y: number; tx: number; ty: number; id: number } | null = null;
const current = computed(() => props.images[index.value]);
const percent = computed(() => Math.round(fit.value * zoom.value * 100));
const maxZoom = computed(() => Math.max(1, MAX_NATURAL / (fit.value || 1)));
watch([open, index], () => {
loaded.value = false;
reset();
});
function reset() {
zoom.value = 1;
tx.value = 0;
ty.value = 0;
}
function measure() {
const el = stage.value;
if (!el || !natural.value.w) return;
fit.value = Math.min(1, (el.clientWidth - 48) / natural.value.w, (el.clientHeight - 48) / natural.value.h);
}
function onLoad(ev: Event) {
const img = ev.target as HTMLImageElement;
natural.value = { w: img.naturalWidth, h: img.naturalHeight };
measure();
loaded.value = true;
}
/** 以舞台上的一点(相对舞台中心)为中心缩放到 z */
function zoomTo(z: number, px = 0, py = 0) {
const next = Math.min(maxZoom.value, Math.max(MIN, z));
const k = next / zoom.value;
tx.value = px - (px - tx.value) * k;
ty.value = py - (py - ty.value) * k;
zoom.value = next;
if (next <= 1) {
tx.value = 0;
ty.value = 0;
}
}
function centerOffset(ev: { clientX: number; clientY: number }): [number, number] {
const r = stage.value!.getBoundingClientRect();
return [ev.clientX - r.left - r.width / 2, ev.clientY - r.top - r.height / 2];
}
function onWheel(ev: WheelEvent) {
ev.preventDefault();
zoomTo(zoom.value * Math.exp(-ev.deltaY * 0.0015), ...centerOffset(ev));
}
function onDblClick(ev: MouseEvent) {
if (zoom.value > 1) zoomTo(1);
else zoomTo(2, ...centerOffset(ev));
}
function onDown(ev: PointerEvent) {
if (zoom.value <= 1 || ev.button !== 0) return;
drag = { x: ev.clientX, y: ev.clientY, tx: tx.value, ty: ty.value, id: ev.pointerId };
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
}
function onMove(ev: PointerEvent) {
if (!drag || ev.pointerId !== drag.id) return;
tx.value = drag.tx + ev.clientX - drag.x;
ty.value = drag.ty + ev.clientY - drag.y;
}
function onUp() {
drag = null;
}
function go(step: number) {
const n = props.images.length;
if (n > 1) index.value = (index.value + step + n) % n;
}
function onKey(ev: KeyboardEvent) {
if (ev.key === "+" || ev.key === "=") zoomTo(zoom.value * 1.25);
else if (ev.key === "-" || ev.key === "_") zoomTo(zoom.value / 1.25);
else if (ev.key === "0") zoomTo(1);
else if (ev.key === "ArrowLeft") go(-1);
else if (ev.key === "ArrowRight") go(1);
else return;
ev.preventDefault();
}
function onOpen() {
void nextTick(measure);
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="iv-scrim" />
<DialogContent class="iv" :aria-describedby="undefined" @keydown="onKey" @open-auto-focus="onOpen">
<div class="iv-bar">
<DialogTitle as="h2" class="iv-title">
{{ current?.title }}<template v-if="images.length > 1"> · <span class="fig">{{ index + 1 }}</span> / <span class="fig">{{ images.length }}</span></template>
</DialogTitle>
<div class="iv-tools" role="group" aria-label="缩放">
<button type="button" class="iv-btn" aria-label="缩小(−)" :disabled="zoom <= MIN" @click="zoomTo(zoom / 1.25)"><Minus class="ic" :stroke-width="1.5" /></button>
<button type="button" class="iv-pct" :aria-label="`当前 ${percent}%,点击适应窗口`" @click="zoomTo(1)"><span class="fig">{{ percent }}</span>%</button>
<button type="button" class="iv-btn" aria-label="放大(+)" :disabled="zoom >= maxZoom" @click="zoomTo(zoom * 1.25)"><Plus class="ic" :stroke-width="1.5" /></button>
<button type="button" class="iv-btn" aria-label="适应窗口(0)" :disabled="zoom === 1" @click="zoomTo(1)"><Maximize class="ic" :stroke-width="1.5" /></button>
<DialogClose class="iv-btn" aria-label="关闭(Esc)"><X class="ic" :stroke-width="1.5" /></DialogClose>
</div>
</div>
<div
ref="stage"
class="iv-stage"
:class="{ grab: zoom > 1 }"
@wheel="onWheel"
@dblclick="onDblClick"
@pointerdown="onDown"
@pointermove="onMove"
@pointerup="onUp"
@pointercancel="onUp"
>
<span v-if="!loaded" class="iv-spin" aria-hidden="true"></span>
<img
v-if="current"
:key="current.url"
:src="current.url"
:alt="current.title"
draggable="false"
:style="{
width: natural.w ? `${natural.w * fit}px` : undefined,
transform: `translate(-50%, -50%) translate(${tx}px, ${ty}px) scale(${zoom})`,
opacity: loaded ? 1 : 0,
}"
@load="onLoad"
/>
<template v-if="images.length > 1">
<button type="button" class="iv-btn iv-nav prev" aria-label="上一张(←)" @click.stop="go(-1)"><ChevronLeft class="ic" :stroke-width="1.5" /></button>
<button type="button" class="iv-btn iv-nav next" aria-label="下一张(→)" @click.stop="go(1)"><ChevronRight class="ic" :stroke-width="1.5" /></button>
</template>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
<style>
/* 不加 scoped:Reka 的 DialogOverlay / DialogContent 渲染在 body 下 */
.iv-scrim {
position: fixed;
inset: 0;
z-index: 60;
background: color-mix(in srgb, var(--c-ink) 96%, transparent);
}
.iv {
position: fixed;
inset: 0;
z-index: 61;
display: flex;
flex-direction: column;
color: var(--text-on-dark);
outline: none;
}
.iv-bar {
display: flex;
align-items: center;
gap: var(--sp-4);
height: 56px;
padding: 0 var(--sp-4) 0 var(--sp-6);
}
.iv-title {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
font-size: var(--fs-body);
font-weight: var(--fw-regular);
white-space: nowrap;
text-overflow: ellipsis;
}
.iv-tools {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.iv-btn,
.iv-pct {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--control-h);
min-width: var(--control-h);
padding: 0 var(--sp-2);
border: 0;
border-radius: var(--radius-ctl);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.iv-pct {
min-width: 64px;
font-size: var(--fs-caption);
}
.iv-btn:hover:not(:disabled),
.iv-pct:hover {
background: color-mix(in srgb, var(--text-on-dark) 14%, transparent);
}
.iv-btn:disabled {
opacity: 0.4;
cursor: default;
}
.iv-btn:focus-visible,
.iv-pct:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.iv-stage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
touch-action: none;
user-select: none;
}
.iv-stage.grab {
cursor: grab;
}
.iv-stage.grab:active {
cursor: grabbing;
}
.iv-stage img {
position: absolute;
top: 50%;
left: 50%;
max-width: none;
height: auto;
box-shadow: var(--shadow-overlay);
transform-origin: center;
transition: opacity var(--dur-fast) var(--ease);
}
.iv-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: color-mix(in srgb, var(--c-ink) 60%, transparent);
}
.iv-nav.prev {
left: var(--sp-4);
}
.iv-nav.next {
right: var(--sp-4);
}
.iv-spin {
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
border: 2px solid color-mix(in srgb, var(--text-on-dark) 30%, transparent);
border-top-color: var(--text-on-dark);
border-radius: 50%;
animation: iv-spin 0.8s linear infinite;
}
@keyframes iv-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,190 @@
<script setup lang="ts">
/**
* 资质详情(只读):字段的键值视图 + 附件缩略图。点缩略图在大图浏览里看(ImageViewer,不开新标签页);
* PDF 附件按页出预览图(最多前 10 页),和图片一样浏览。底部「编辑」进编辑对话框。
*/
import { unwrap } 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 ImageViewer, { type ViewerImage } from "@/components/ImageViewer.vue";
import { api, errorTitle } from "@/lib/api";
import { fmtDay, fmtMonthDay, QUALIFICATION_KIND } from "@/lib/labels";
import type { Qualification } from "@/lib/queries";
interface Preview {
fileId: string;
filename: string;
pages: { page: number; url: string }[];
}
const props = defineProps<{ item: Qualification | null }>();
const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ edit: [q: Qualification] }>();
const previews = ref<Preview[]>([]);
const loading = ref(false);
const err = ref("");
const viewerOpen = ref(false);
const viewerIndex = ref(0);
watch(open, async (v) => {
const q = props.item;
if (!v || !q) return;
previews.value = [];
err.value = "";
if (!q.files.length) return;
loading.value = true;
try {
previews.value = await Promise.all(
q.files.map(async (f) => {
const p = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}/previews", {
params: { path: { qualification_id: q.id, file_id: f.id } },
}),
);
return { fileId: f.id, filename: p.filename, pages: p.pages };
}),
);
} catch (e) {
err.value = errorTitle(e);
} finally {
loading.value = false;
}
});
/** 大图浏览的图片:所有附件的所有页排成一列 */
const images = computed<ViewerImage[]>(() =>
previews.value.flatMap((p) => p.pages.map((pg) => ({ url: pg.url, title: p.pages.length > 1 ? `${p.filename} 第 ${pg.page} 页` : p.filename }))),
);
function show(fileIdx: number) {
viewerIndex.value = previews.value.slice(0, fileIdx).reduce((n, p) => n + p.pages.length, 0);
viewerOpen.value = true;
}
const validity = computed(() => {
const q = props.item;
if (!q) return "";
if (!q.valid_to) return q.valid_from ? `${fmtDay(q.valid_from)} 起,长期有效` : "长期有效";
return q.valid_from ? `${fmtDay(q.valid_from)} 至 ${fmtDay(q.valid_to)}` : `至 ${fmtDay(q.valid_to)}`;
});
function edit() {
const q = props.item;
if (!q) return;
open.value = false;
emit("edit", q);
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="scrim">
<DialogContent v-if="item" class="dialog wide" :aria-describedby="undefined">
<DialogTitle as="h2">{{ item.name }}</DialogTitle>
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
<dl class="kv">
<dt>类型</dt>
<dd>{{ QUALIFICATION_KIND[item.kind] }}</dd>
<dt>等级</dt>
<dd><template v-if="item.level">{{ item.level }}</template><span v-else class="dash">—</span></dd>
<dt>证书编号</dt>
<dd class="num"><template v-if="item.cert_no">{{ item.cert_no }}</template><span v-else class="dash">—</span></dd>
<dt>发证机构</dt>
<dd><template v-if="item.issuer">{{ item.issuer }}</template><span v-else class="dash">—</span></dd>
<dt>证书主体</dt>
<dd><template v-if="item.holder">{{ item.holder }}</template><span v-else class="dash">—</span></dd>
<dt>有效期</dt>
<dd>
{{ validity }}
<span v-if="item.late_for.length" class="warn">
早于「{{ item.late_for[0]!.name }}」的投标截止 {{ fmtMonthDay(item.late_for[0]!.deadline) }}<template v-if="item.late_for.length > 1"> 等 <span class="fig">{{ item.late_for.length }}</span> 个项目</template>
</span>
</dd>
<template v-if="item.recognized">
<dt>录入方式</dt>
<dd>从证书照片识别,经人工核对</dd>
</template>
</dl>
<h3 class="sub-h">附件</h3>
<p v-if="!item.files.length" class="cap muted">还没有附件,可以在编辑里补录。</p>
<div v-else-if="loading" class="thumbs"><span v-for="f in item.files" :key="f.id" class="skel thumb"></span></div>
<p v-else-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
<div v-else class="thumbs">
<button v-for="(p, i) in previews" :key="p.fileId" type="button" class="thumb" :title="`查看 ${p.filename}`" @click="show(i)">
<img v-if="p.pages[0]" :src="p.pages[0].url" :alt="p.filename" loading="lazy" />
<span class="cap">{{ p.filename }}<template v-if="p.pages.length > 1"> · <span class="fig">{{ p.pages.length }}</span> 页</template></span>
</button>
</div>
<div class="foot">
<DialogClose class="btn" type="button">关闭</DialogClose>
<button type="button" class="btn primary" @click="edit">编辑</button>
</div>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</DialogRoot>
<ImageViewer v-model:open="viewerOpen" v-model:index="viewerIndex" :images="images" />
</template>
<style scoped>
.kv {
margin-top: var(--sp-4);
}
.kv .num {
font-family: var(--font-figures);
font-variant-numeric: tabular-nums;
}
.kv .warn {
display: block;
color: var(--warn-text);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
}
.sub-h {
margin: var(--sp-6) 0 var(--sp-2);
font-size: var(--fs-body);
}
.thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: var(--sp-3);
}
.thumb {
display: flex;
flex-direction: column;
gap: var(--sp-1);
padding: 0;
border: 0;
background: none;
font: inherit;
text-align: left;
cursor: zoom-in;
}
.thumb img,
.skel.thumb {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-xs);
background: var(--bg-page);
}
.thumb:hover img {
border-color: var(--border-strong);
}
.thumb:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.thumb .cap {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--text-secondary);
}
</style>

View File

@@ -1,19 +1,23 @@
<script setup lang="ts">
/**
* 资质的添加 / 编辑对话框(宽 640,原型 qualifications.html、ui-demo forms.html「资质录入」)。
* - 最上面传证书照片:后台识别(vision-plan 第 2 步),识别结果填进下面的表单。只预填和图上文字对得上的项,标「识别」;
* 打码、模糊、对不上的只给建议,点「采用」才填(红线 5)。一张图按一张证书处理,认出几张时只填第一张;
* 保存时识别值留痕,证书照片挂成附件。编辑时传新证书就是换证。识别不扣额度;
* - 名称、证书编号、有效期至必填(长期有效打开开关);证书编号界面必填、接口可空(qualification-plan §2.3);
* - 有效期至早于进行中项目的投标截止日:出赭色提醒小字,只提醒、不拦截保存;
* - 附件可以稍后补录。新增时选的文件在保存资质后再上传挂上。
*/
import { unwrap, uploadFile } from "@bid-doctor/api-client";
import { X } from "@lucide/vue";
import { ScanText, X } from "@lucide/vue";
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
import { computed, ref, watch } from "vue";
import { computed, reactive, ref, watch, type Ref } from "vue";
import DateInput from "@/components/DateInput.vue";
import ImageViewer, { type ViewerImage } from "@/components/ImageViewer.vue";
import SelectBox from "@/components/SelectBox.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";
import type { Qualification, QualificationScan, ScanCertificate } from "@/lib/queries";
export interface ActiveDeadline {
name: string;
@@ -27,12 +31,14 @@ const open = defineModel<boolean>("open", { required: true });
const emit = defineEmits<{ saved: [q: Qualification, how: "created" | "updated" | "files"] }>();
type Kind = Qualification["kind"];
type Key = "name" | "level" | "cert_no" | "issuer" | "holder" | "valid_from" | "valid_to";
const KIND_OPTIONS = QUALIFICATION_KIND_ORDER.map((k) => ({ value: k, label: QUALIFICATION_KIND[k] }));
const name = ref("");
const kind = ref<Kind>("certificate");
const level = ref("");
const certNo = ref("");
const issuer = ref("");
const holder = ref("");
const from = ref("");
const to = ref("");
const longTerm = ref(false);
@@ -44,15 +50,41 @@ const busy = ref(false);
const fileBusy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const toInput = ref<InstanceType<typeof DateInput> | null>(null);
const FIELD: Record<Key, Ref<string>> = { name, level, cert_no: certNo, issuer, holder, valid_from: from, valid_to: to };
/* ---------- 识别(vision-plan 第 2 步) ---------- */
const WHY: Record<string, string> = { low_conf: "可能被打码或看不清", mismatch: "和图上的文字对不上", bad_line: "没找到出处", bad_format: "看起来不像这一项" };
const scanInput = ref<HTMLInputElement | null>(null);
const scanFile = ref<File | null>(null);
const phase = ref<"idle" | "uploading" | "recognizing" | "done" | "failed">("idle");
const scanMsg = ref("");
const scan = ref<QualificationScan | null>(null);
const cert = ref<ScanCertificate | null>(null);
/** 识别填上的值(用户改过就不再标「识别」) */
const filled = reactive<Partial<Record<Key, string>>>({});
/** 识别出了、但不可靠的值:点「采用」才填 */
const hints = reactive<Partial<Record<Key, { value: string; why: string }>>>({});
const masked = ref<Key[]>([]);
/** 资质库里已有编号相同的一条,用户选了更新它 */
const target = ref<string | null>(null);
const focusKey = ref<Key | null>(null);
/** 缩略图点一下放大到对话框全宽,方便对照核对 */
const big = ref(false);
let timer: ReturnType<typeof setTimeout> | undefined;
let run = 0;
watch(open, (v) => {
if (!v) return;
if (!v) {
void dropScan();
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 ?? "";
holder.value = q?.holder ?? "";
from.value = q?.valid_from ?? "";
to.value = q?.valid_to ?? "";
longTerm.value = !!q && !q.valid_to;
@@ -60,9 +92,170 @@ watch(open, (v) => {
pending.value = null;
errs.value = {};
err.value = "";
resetScan();
if (props.focusExpiry) setTimeout(() => toInput.value?.select());
});
function resetScan() {
run++;
clearTimeout(timer);
phase.value = "idle";
scanMsg.value = "";
scanFile.value = null;
scan.value = null;
cert.value = null;
masked.value = [];
target.value = null;
focusKey.value = null;
big.value = false;
for (const k of Object.keys(filled) as Key[]) delete filled[k];
for (const k of Object.keys(hints) as Key[]) delete hints[k];
}
/** 丢弃识别结果(关掉对话框、重新上传)。已保存的资质里有证书照片的副本 */
async function dropScan() {
const s = scan.value;
resetScan();
if (s) await api.DELETE("/qualification-scans/{scan_id}", { params: { path: { scan_id: s.id } } }).catch(() => undefined);
}
async function onScanFile(ev: Event) {
const input = ev.target as HTMLInputElement;
const f = input.files?.[0];
input.value = "";
if (!f) return;
await dropScan();
const my = run;
scanFile.value = f;
phase.value = "uploading";
try {
const done = await uploadFile(api, apiOptions, f, "qualification_file");
const s = await unwrap(api.POST("/qualification-scans", { body: { upload_id: done.id } }));
if (my !== run) return;
scan.value = s;
phase.value = "recognizing";
poll(my);
} catch (e) {
if (my !== run) return;
phase.value = "failed";
scanMsg.value = errorTitle(e);
}
}
function poll(my: number) {
timer = setTimeout(async () => {
if (my !== run || !scan.value) return;
try {
const s = await unwrap(api.GET("/qualification-scans/{scan_id}", { params: { path: { scan_id: scan.value.id } } }));
if (my !== run) return;
scan.value = s;
if (s.status === "succeeded") apply(s);
else if (s.status === "failed") {
phase.value = "failed";
scanMsg.value = s.error?.message ?? "识别出错了,请手动填写";
} else poll(my);
} catch (e) {
if (my !== run) return;
phase.value = "failed";
scanMsg.value = errorTitle(e);
}
}, 1200);
}
/** 识别结果填进表单:一张图按一张证书处理,只取第一张 */
function apply(s: QualificationScan) {
const c = s.certificates[0];
if (!c) {
phase.value = "failed";
scanMsg.value = "没有在图里认出证书,请换一张清晰完整的照片,或者手动填写";
return;
}
phase.value = "done";
cert.value = c;
const src: Record<Key, string> = {
name: "name",
level: "level",
cert_no: c.cert_no_field,
issuer: "issuer",
holder: "holder",
valid_from: "valid_from",
valid_to: "valid_to",
};
for (const key of Object.keys(src) as Key[]) {
const f = c.fields[src[key]];
if (!f) {
if (!props.item) FIELD[key].value = "";
continue;
}
const value = key === "name" ? c.name : key === "valid_from" || key === "valid_to" ? (f.day ?? f.value) : f.value;
if (f.check === "ok" || f.check === "date_ok") {
FIELD[key].value = value;
filled[key] = value;
} else {
if (!props.item) FIELD[key].value = "";
hints[key] = { value, why: WHY[f.check] ?? "请核对" };
}
}
masked.value = c.masked.map((m) => (Object.keys(src) as Key[]).find((k) => src[k] === m)).filter((k): k is Key => !!k);
kind.value = c.kind;
if (filled.valid_to) longTerm.value = false;
else if (c.kind === "software_copyright") longTerm.value = true; // 软著没有有效期(qualification-plan §3)
errs.value = {};
}
// 用户改过的字段不再标「识别」
for (const key of Object.keys(FIELD) as Key[]) {
watch(FIELD[key], (v) => {
if (filled[key] !== undefined && v !== filled[key]) delete filled[key];
});
}
function adopt(key: Key) {
const h = hints[key];
if (!h) return;
FIELD[key].value = h.value;
delete hints[key];
if (key === "valid_to") longTerm.value = false;
}
const notes = computed(() => {
const c = cert.value;
if (!c) return [];
const out: string[] = [];
const n = scan.value?.certificates.length ?? 0;
if (n > 1) out.push(`图里认出 ${n} 张证书,只填了第一张。`);
for (const x of c.notes) {
if (x.code === "personnel") out.push("这是人员证书,查标的资质匹配不使用。");
else if (x.code === "product") out.push("这是产品证书,查标的资质匹配不使用。");
else if (x.code === "business_license") out.push("营业执照一般不需要录进资质库。");
else out.push(`证书主体「${x.holder}」和本企业名称不一致,如果是曾用名或子公司请核对。`);
}
return out;
});
/** 发证日期(和图上文字对得上的):有效期起没写时给「填入」建议 */
const issued = computed(() => {
const f = cert.value?.fields.issued_on;
return f && (f.check === "ok" || f.check === "date_ok") ? (f.day ?? "") : "";
});
const existing = computed(() => (!props.item && cert.value?.existing) || null);
/** 缩略图上要画的框:当前字段的出处(识别时用的同一张图,按宽高换成比例) */
const page = computed(() => (cert.value ? scan.value?.pages.find((p) => p.page === cert.value!.page) ?? null : null));
const boxes = computed(() => {
const p = page.value;
const c = cert.value;
if (!p || !c || !focusKey.value) return [];
const k = focusKey.value === "cert_no" ? c.cert_no_field : focusKey.value;
return (c.fields[k]?.boxes ?? []).map(([x0, y0, x1, y1]) => ({
left: `${(x0! / p.width) * 100}%`,
top: `${(y0! / p.height) * 100}%`,
width: `${((x1! - x0!) / p.width) * 100}%`,
height: `${((y1! - y0!) / p.height) * 100}%`,
}));
});
/* ---------- 表单 ---------- */
/** 有效期至早于哪些进行中项目的投标截止日(截止日当天到期仍算有效) */
const late = computed(() => {
const day = longTerm.value ? "" : parseDay(to.value);
@@ -92,8 +285,10 @@ async function attach(qid: string, file: File): Promise<Qualification> {
);
}
const scanning = computed(() => phase.value === "uploading" || phase.value === "recognizing");
async function submit() {
if (!validate() || busy.value) return;
if (!validate() || busy.value || scanning.value) return;
busy.value = true;
err.value = "";
const body = {
@@ -102,13 +297,17 @@ async function submit() {
level: level.value.trim() || null,
cert_no: certNo.value.trim() || null,
issuer: issuer.value.trim() || null,
holder: holder.value.trim() || null,
valid_from: parseDay(from.value) || null,
valid_to: longTerm.value ? null : parseDay(to.value) || null,
// 从识别结果保存:识别值留痕,证书照片挂成附件
scan: scan.value && cert.value ? { scan_id: scan.value.id, index: 0 } : null,
};
const how = props.item ? "updated" : "created";
const id = props.item?.id ?? target.value;
const how = id ? "updated" : "created";
try {
let q = props.item
? await unwrap(api.PUT("/qualifications/{qualification_id}", { params: { path: { qualification_id: props.item.id } }, body }))
let q = id
? await unwrap(api.PUT("/qualifications/{qualification_id}", { params: { path: { qualification_id: id } }, body }))
: await unwrap(api.POST("/qualifications", { body }));
if (pending.value) {
try {
@@ -155,13 +354,21 @@ async function onFile(ev: Event) {
}
}
/** 附件在大图浏览里看(PDF 按页出预览图),不开新标签页 */
const viewerOpen = ref(false);
const viewerIndex = ref(0);
const viewerImages = ref<ViewerImage[]>([]);
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 } } }),
const p = await unwrap(
api.GET("/qualifications/{qualification_id}/files/{file_id}/previews", {
params: { path: { qualification_id: props.item.id, file_id: fileId } },
}),
);
window.open(link.url, "_blank", "noopener");
viewerImages.value = p.pages.map((pg) => ({ url: pg.url, title: p.pages.length > 1 ? `${p.filename} 第 ${pg.page} 页` : p.filename }));
viewerIndex.value = 0;
viewerOpen.value = true;
} catch (e) {
err.value = errorTitle(e);
}
@@ -192,11 +399,47 @@ async function removeFile(fileId: string) {
<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="scan">
<button v-if="page" type="button" class="thumb" :class="{ big }" :title="big ? '点击缩小' : '点击放大'" @click="big = !big">
<img :src="page.url" :alt="scanFile?.name ?? '证书'" />
<span v-for="(b, k) in boxes" :key="k" class="box" :style="b"></span>
</button>
<div class="scan-b">
<template v-if="phase === 'idle'">
<b>{{ item ? "换证:上传新证书照片,自动填写下面的内容" : "上传证书照片,自动填写下面的内容" }}</b>
<span class="cap muted">PDF / JPG / PNG / WEBP</span>
</template>
<template v-else-if="scanning">
<b role="status"><span class="spin" aria-hidden="true"></span>正在识别…</b>
<span class="cap muted">{{ scanFile?.name }}</span>
</template>
<template v-else-if="phase === 'done'">
<b>已自动填写,标「识别」的请对照证书核对</b>
<span class="cap muted">{{ scanFile?.name }} · 保存后挂成附件</span>
</template>
<p v-else class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ scanMsg }}</p>
<p v-for="(n, k) in notes" :key="k" class="hint-warn"><span class="ico w" aria-hidden="true">!</span>{{ n }}</p>
<p v-if="existing" class="hint-warn">
<span class="ico w" aria-hidden="true">!</span>
<span>
资质库里已有编号相同的「{{ existing.name }}」。
<button v-if="target !== existing.id" type="button" class="lnk" @click="target = existing.id">改为更新它</button>
<template v-else>保存会更新它。<button type="button" class="lnk" @click="target = null">改为另存</button></template>
</span>
</p>
</div>
<button type="button" class="btn sm" :disabled="scanning" @click="scanInput?.click()">
<ScanText class="ic" :stroke-width="1.5" />{{ phase === "idle" ? "上传" : "重新上传" }}
</button>
<input ref="scanInput" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp" hidden @change="onScanFile" />
</div>
<div class="form">
<div class="field span2" :class="{ err: errs.name }">
<label for="qd-name">名称 <small>(必填)</small></label>
<div class="field span2" :class="{ err: errs.name }" @mouseenter="focusKey = 'name'" @focusin="focusKey = 'name'">
<label for="qd-name">名称 <small>(必填)</small><span v-if="filled.name !== undefined" class="tag ai">识别</span></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>
<span v-else-if="hints.name" class="sug">识别为「{{ hints.name.value }}」,{{ hints.name.why }}<button type="button" class="lnk" @click="adopt('name')">采用</button></span>
</div>
<div class="field">
<label for="qd-kind">类型 <small>(必填)</small></label>
@@ -207,32 +450,48 @@ async function removeFile(fileId: string) {
@update:model-value="(v) => (kind = v as Kind)"
/>
</div>
<div class="field">
<label for="qd-level">等级 <small>(选填)</small></label>
<div class="field" @mouseenter="focusKey = 'level'" @focusin="focusKey = 'level'">
<label for="qd-level">等级 <small>(选填)</small><span v-if="filled.level !== undefined" class="tag ai">识别</span></label>
<input id="qd-level" v-model="level" class="input" maxlength="40" placeholder="例如:CS3、二级" autocomplete="off" />
<span v-if="hints.level" class="sug">识别为「{{ hints.level.value }}」,{{ hints.level.why }}<button type="button" class="lnk" @click="adopt('level')">采用</button></span>
</div>
<div class="field" :class="{ err: errs.certNo }">
<label for="qd-no">证书编号 <small>(必填)</small></label>
<div class="field" :class="{ err: errs.certNo }" @mouseenter="focusKey = 'cert_no'" @focusin="focusKey = 'cert_no'">
<label for="qd-no">证书编号 <small>(必填)</small><span v-if="filled.cert_no !== undefined" class="tag ai">识别</span></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>
<span v-else-if="hints.cert_no" class="sug">识别为「{{ hints.cert_no.value }}」,{{ hints.cert_no.why }}<button type="button" class="lnk" @click="adopt('cert_no')">采用</button></span>
<span v-else-if="masked.includes('cert_no') && !certNo" class="sug">证书上被遮挡,请手动填写</span>
</div>
<div class="field">
<label for="qd-issuer">发证机构</label>
<div class="field" @mouseenter="focusKey = 'issuer'" @focusin="focusKey = 'issuer'">
<label for="qd-issuer">发证机构<span v-if="filled.issuer !== undefined" class="tag ai">识别</span></label>
<input id="qd-issuer" v-model="issuer" class="input" maxlength="120" autocomplete="off" />
<span v-if="hints.issuer" class="sug">识别为「{{ hints.issuer.value }}」,{{ hints.issuer.why }}<button type="button" class="lnk" @click="adopt('issuer')">采用</button></span>
</div>
<div class="field" :class="{ err: errs.from }">
<label for="qd-from">有效期起</label>
<div class="field span2" @mouseenter="focusKey = 'holder'" @focusin="focusKey = 'holder'">
<label for="qd-holder">证书主体<span v-if="filled.holder !== undefined" class="tag ai">识别</span></label>
<input id="qd-holder" v-model="holder" class="input" maxlength="120" placeholder="证书上写的企业名或著作权人" autocomplete="off" />
<span v-if="hints.holder" class="sug">识别为「{{ hints.holder.value }}」,{{ hints.holder.why }}<button type="button" class="lnk" @click="adopt('holder')">采用</button></span>
<span v-else-if="masked.includes('holder') && !holder" class="sug">证书上被遮挡,请手动填写</span>
</div>
<div class="field" :class="{ err: errs.from }" @mouseenter="focusKey = 'valid_from'" @focusin="focusKey = 'valid_from'">
<label for="qd-from">有效期起<span v-if="filled.valid_from !== undefined" class="tag ai">识别</span></label>
<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-else-if="hints.valid_from" class="sug">识别为「{{ hints.valid_from.value }}」,{{ hints.valid_from.why }}<button type="button" class="lnk" @click="adopt('valid_from')">采用</button></span>
<span v-else-if="!from && issued" class="sug">证书的发证日期是 <span class="fig">{{ issued }}</span><button type="button" class="lnk" @click="from = issued">填入</button></span>
</div>
<div class="field" :class="{ err: errs.to }">
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small></label>
<div class="field" :class="{ err: errs.to }" @mouseenter="focusKey = 'valid_to'" @focusin="focusKey = 'valid_to'">
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small><span v-if="filled.valid_to !== undefined" class="tag ai">识别</span></label>
<DateInput id="qd-to" ref="toInput" v-model="to" label="有效期至" :placeholder="longTerm ? '长期有效' : '例如:2026-11-20'" :disabled="longTerm" />
<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>
<span v-else-if="hints.valid_to" class="sug">识别为「{{ hints.valid_to.value }}」,{{ hints.valid_to.why }}<button type="button" class="lnk" @click="adopt('valid_to')">采用</button></span>
<span v-else-if="!to && cert?.suggest.valid_to" class="sug">
证书写的有效期是「{{ cert.fields.validity_text?.value }}」<button type="button" class="lnk" @click="(to = cert.suggest.valid_to!), (longTerm = false)">按发证日期推算:<span class="fig">{{ cert.suggest.valid_to }}</span></button>
</span>
</div>
<div class="field span2">
<button
@@ -258,23 +517,26 @@ async function removeFile(fileId: string) {
<button type="button" class="btn sm" :disabled="fileBusy" @click="removeFile(f.id)">删除</button>
</span>
</div>
<div v-if="phase === 'done' && scanFile" class="file-row">
<span class="ext">{{ extOf(scanFile.name) }}</span>
<div><b>{{ scanFile.name }}</b><span class="cap muted">{{ fmtSize(scanFile.size) }} · 识别用的照片,保存后挂上</span></div>
</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>
<span>{{ fileBusy ? "正在上传…" : files.length || phase === "done" ? "再上传一个附件(PDF / JPG / PNG / WEBP)" : "点击上传 PDF / JPG / PNG / WEBP,单个不超过 20MB" }}</span>
</button>
<input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" hidden @change="onFile" />
<input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp" 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 type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy || scanning">
<span class="idle">{{ target && !item ? "更新资质" : "保存资质" }}</span><span class="busy">正在保存…</span>
</button>
</div>
</form>
@@ -282,6 +544,7 @@ async function removeFile(fileId: string) {
</DialogOverlay>
</DialogPortal>
</DialogRoot>
<ImageViewer v-model:open="viewerOpen" v-model:index="viewerIndex" :images="viewerImages" />
</template>
<style scoped>
@@ -314,4 +577,86 @@ async function removeFile(fileId: string) {
.field .hint-warn {
margin: var(--sp-1) 0 0;
}
/* 识别区:缩略图(字段的出处画在上面)+ 状态 + 上传按钮 */
.scan {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
padding: var(--sp-3);
border: var(--line) dashed var(--border-subtle);
border-radius: var(--radius-ctl);
}
.scan-b {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.scan-b b {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.scan-b .hint-warn {
margin: var(--sp-1) 0 0;
}
.thumb {
position: relative;
flex: none;
width: 168px;
padding: 0;
border: var(--line) solid var(--border-subtle);
border-radius: var(--radius-xs);
background: none;
overflow: hidden;
cursor: zoom-in;
}
.thumb.big {
order: 9;
width: 100%;
cursor: zoom-out;
}
.thumb img {
display: block;
width: 100%;
height: auto;
}
.box {
position: absolute;
border-radius: 1px;
background: var(--ai-soft);
box-shadow: 0 0 0 2px var(--ai);
mix-blend-mode: multiply;
pointer-events: none;
}
.spin {
width: 14px;
height: 14px;
border: 2px solid var(--border-subtle);
border-top-color: var(--ai);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
label .tag.ai {
margin-left: var(--sp-2);
vertical-align: 1px;
}
.sug {
display: block;
margin-top: var(--sp-1);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.sug .lnk {
margin-left: var(--sp-1);
}
</style>

View File

@@ -27,6 +27,8 @@ export type Finding = Schemas["Finding"];
export type CheckItemResult = Schemas["CheckItemResult"];
export type ManualCheck = Schemas["ManualCheck"];
export type Qualification = Schemas["Qualification"];
export type QualificationScan = Schemas["QualificationScan"];
export type ScanCertificate = Schemas["ScanCertificate"];
export type Lot = Schemas["Lot"];
export type ProjectField = Schemas["ProjectField"];

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
/**
* 资质库(FR-M6-08,原型 qualifications.html):列表 + 添加 / 编辑对话框 + 删除。
* 资质库(FR-M6-08,原型 qualifications.html):列表 + 详情(只读,附件用大图浏览看)+ 添加 / 编辑对话框 + 删除。
* - 到期按每个进行中项目的投标截止日判断,不按今天(服务端给 late_for);只提醒,不拦截;
* - 没有附件显示「待补录」,不生成内容(红线 5);
* - 删除只有确认框,没有撤销(QL6)。
* - 删除只有确认框,没有撤销(QL6);
* - 添加资质时可以先传证书照片,识别后自动填写(vision-plan 第 2 步,在对话框里做)。
*/
import { unwrap } from "@bid-doctor/api-client";
import { MoreHorizontal, Plus, Search } from "@lucide/vue";
@@ -11,6 +12,7 @@ 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 QualificationDetail from "@/components/QualificationDetail.vue";
import QualificationDialog, { type ActiveDeadline } from "@/components/QualificationDialog.vue";
import { api, errorTitle } from "@/lib/api";
import { useCrumbs } from "@/lib/crumbs";
@@ -72,16 +74,12 @@ function onSaved(q: Qualification, how: "created" | "updated" | "files") {
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 viewing = ref<Qualification | null>(null);
const detailOpen = ref(false);
function detail(q: Qualification) {
viewing.value = q;
detailOpen.value = true;
}
const removing = ref<Qualification | null>(null);
@@ -149,7 +147,7 @@ async function remove() {
</div>
<div v-else-if="!all.length" class="empty">
<b>还没有录入资质</b>
<p>录入资质证书、体系认证和软件著作权后,查标会按每个项目的投标截止日核对有效期和等级。</p>
<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">
@@ -157,8 +155,8 @@ async function remove() {
<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>
<tr v-for="q in rows" :key="q.id" @click="detail(q)">
<td><button type="button" class="nm" @click.stop="detail(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>
@@ -168,10 +166,7 @@ async function remove() {
<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-if="q.files.length"><span class="fig">{{ q.files.length }}</span> 个</span>
<span v-else class="tag status">待补录</span>
</td>
<td class="more-cell" @click.stop>
@@ -179,8 +174,8 @@ async function remove() {
<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="detail(q)">查看详情</DropdownMenuItem>
<DropdownMenuItem @select="edit(q)">编辑</DropdownMenuItem>
<DropdownMenuItem @select="edit(q)">{{ q.files.length ? "管理附件" : "上传附件" }}</DropdownMenuItem>
<DropdownMenuSeparator as="hr" />
<DropdownMenuItem @select="askRemove(q)">删除</DropdownMenuItem>
</DropdownMenuContent>
@@ -202,6 +197,7 @@ async function remove() {
</section>
<p class="note below">业绩、人员、历史标书等资产库在 V1 提供。</p>
<QualificationDetail v-model:open="detailOpen" :item="viewing" @edit="(q) => edit(q)" />
<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>