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:
@@ -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;
|
||||
|
||||
298
apps/web/src/components/ImageViewer.vue
Normal file
298
apps/web/src/components/ImageViewer.vue
Normal 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>
|
||||
190
apps/web/src/components/QualificationDetail.vue
Normal file
190
apps/web/src/components/QualificationDetail.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user