走查(注册 → 读标 → 查标 → 导出报告)发现的问题: - 下拉:原生 select 换成 SelectBox(Reka UI,规范 §7.21):右侧 chevron 有边距、 sm 高 32 和搜索框一致、浮层和触发框同宽、选中项加粗带 ✓;响应方式栏的签名不再被挤成竖排 - 读标:「依据 · 第 N 页」改成行内出处链接;证明材料标签不再被挤压 - 概览:关键日期同一种、同一时刻只列一次 - 报告页:「还有 N 项致命」读项目实时值(检查的 counts 是快照) - 读标抽取:★ 只表示「重要」的文件,清单项也挂到 ★ 规则上(「已识别 N 条」) - 检查范围文案和后端覆盖范围一致;报告出处不重复写文件名(模板 2026.11-v3) - 带加载态的按钮图标和文字间距;单选、半选、禁用复选框样式 查标结果页按原型 results.html 补齐: - 本次处理的问题留在原位(署名行可撤销),修了处理后仍显示处理按钮的问题 - 键盘 J / K / O / 1 / 2 / 3 / X / U / Esc;「提示」批量确认无误;「判断有误?」反馈 - 覆盖范围默认收起成一行;顶部加导出报告入口和致命提示;已处理计数 - 检查进度卡和解析进度卡同一种样式 - 项目头「就绪 / 未就绪」,致命数挂在查标标签上 - 上传投标文件页:已有分册时新增表单收起到「添加分册」 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
13 KiB
Vue
332 lines
13 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 资质的添加 / 编辑对话框(宽 640,原型 qualifications.html、ui-demo forms.html「资质录入」)。
|
||
* - 名称、证书编号、有效期至必填(长期有效打开开关);证书编号界面必填、接口可空(qualification-plan §2.3);
|
||
* - 有效期至早于进行中项目的投标截止日:出赭色提醒小字,只提醒、不拦截保存;
|
||
* - 附件可以稍后补录。新增时选的文件在保存资质后再上传挂上。
|
||
*/
|
||
import { unwrap, uploadFile } from "@bid-doctor/api-client";
|
||
import { X } from "@lucide/vue";
|
||
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "reka-ui";
|
||
import { computed, ref, watch } from "vue";
|
||
import 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";
|
||
|
||
export interface ActiveDeadline {
|
||
name: string;
|
||
/** YYYY-MM-DD(北京时间) */
|
||
day: string;
|
||
}
|
||
|
||
const props = defineProps<{ item: Qualification | null; deadlines: ActiveDeadline[]; focusExpiry?: boolean }>();
|
||
const open = defineModel<boolean>("open", { required: true });
|
||
/** saved:created / updated 是点了「保存资质」,files 是在对话框里加删附件 */
|
||
const emit = defineEmits<{ saved: [q: Qualification, how: "created" | "updated" | "files"] }>();
|
||
|
||
type Kind = Qualification["kind"];
|
||
const 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 from = ref("");
|
||
const to = ref("");
|
||
const longTerm = ref(false);
|
||
const files = ref<Qualification["files"]>([]);
|
||
const pending = ref<File | null>(null);
|
||
const errs = ref<Record<string, string>>({});
|
||
const err = ref("");
|
||
const busy = ref(false);
|
||
const fileBusy = ref(false);
|
||
const fileInput = ref<HTMLInputElement | null>(null);
|
||
const toInput = ref<HTMLInputElement | null>(null);
|
||
|
||
watch(open, (v) => {
|
||
if (!v) return;
|
||
const q = props.item;
|
||
name.value = q?.name ?? "";
|
||
kind.value = q?.kind ?? "certificate";
|
||
level.value = q?.level ?? "";
|
||
certNo.value = q?.cert_no ?? "";
|
||
issuer.value = q?.issuer ?? "";
|
||
from.value = q?.valid_from ?? "";
|
||
to.value = q?.valid_to ?? "";
|
||
longTerm.value = !!q && !q.valid_to;
|
||
files.value = q?.files ?? [];
|
||
pending.value = null;
|
||
errs.value = {};
|
||
err.value = "";
|
||
if (props.focusExpiry) setTimeout(() => toInput.value?.select());
|
||
});
|
||
|
||
/** 有效期至早于哪些进行中项目的投标截止日(截止日当天到期仍算有效) */
|
||
const late = computed(() => {
|
||
const day = longTerm.value ? "" : parseDay(to.value);
|
||
return day ? props.deadlines.filter((d) => day < d.day) : [];
|
||
});
|
||
|
||
function tidy(field: "from" | "to") {
|
||
const r = field === "from" ? from : to;
|
||
const day = parseDay(r.value);
|
||
if (day) r.value = day;
|
||
}
|
||
|
||
function validate(): boolean {
|
||
const e: Record<string, string> = {};
|
||
if (!name.value.trim()) e.name = "请填写资质名称,和证书上的写法一致";
|
||
if (!certNo.value.trim()) e.certNo = "请填写证书编号,查标时用它核对";
|
||
const f = parseDay(from.value);
|
||
if (f === null) e.from = "日期写法不对,例如:2023-11-16";
|
||
if (!longTerm.value) {
|
||
const t = parseDay(to.value);
|
||
if (t === null) e.to = "日期写法不对,例如:2026-11-20";
|
||
else if (!t) e.to = "请填写有效期至;长期有效请打开下方开关";
|
||
else if (f && t <= f) e.to = "有效期至要晚于有效期起,请核对";
|
||
}
|
||
errs.value = e;
|
||
return !Object.keys(e).length;
|
||
}
|
||
|
||
async function attach(qid: string, file: File): Promise<Qualification> {
|
||
const done = await uploadFile(api, apiOptions, file, "qualification_file");
|
||
return unwrap(
|
||
api.POST("/qualifications/{qualification_id}/files", { params: { path: { qualification_id: qid } }, body: { upload_id: done.id } }),
|
||
);
|
||
}
|
||
|
||
async function submit() {
|
||
if (!validate() || busy.value) return;
|
||
busy.value = true;
|
||
err.value = "";
|
||
const body = {
|
||
name: name.value.trim(),
|
||
kind: kind.value,
|
||
level: level.value.trim() || null,
|
||
cert_no: certNo.value.trim() || null,
|
||
issuer: issuer.value.trim() || null,
|
||
valid_from: parseDay(from.value) || null,
|
||
valid_to: longTerm.value ? null : parseDay(to.value) || null,
|
||
};
|
||
const how = props.item ? "updated" : "created";
|
||
try {
|
||
let q = props.item
|
||
? await unwrap(api.PUT("/qualifications/{qualification_id}", { params: { path: { qualification_id: props.item.id } }, body }))
|
||
: await unwrap(api.POST("/qualifications", { body }));
|
||
if (pending.value) {
|
||
try {
|
||
q = await attach(q.id, pending.value);
|
||
} catch (e) {
|
||
// 资质已经存好了(再点保存是修改,不会重复添加);附件没挂上时关掉对话框会让人以为都好了,所以留在对话框里
|
||
emit("saved", q, how);
|
||
err.value = `资质已保存,附件没有上传成功:${errorTitle(e)}。可以稍后在列表里补录。`;
|
||
return;
|
||
}
|
||
}
|
||
emit("saved", q, how);
|
||
open.value = false;
|
||
} catch (e) {
|
||
err.value = errorTitle(e);
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function pick() {
|
||
fileInput.value?.click();
|
||
}
|
||
|
||
async function onFile(ev: Event) {
|
||
const input = ev.target as HTMLInputElement;
|
||
const f = input.files?.[0];
|
||
input.value = "";
|
||
if (!f) return;
|
||
if (!props.item) {
|
||
pending.value = f;
|
||
return;
|
||
}
|
||
fileBusy.value = true;
|
||
err.value = "";
|
||
try {
|
||
const q = await attach(props.item.id, f);
|
||
files.value = q.files;
|
||
emit("saved", q, "files");
|
||
} catch (e) {
|
||
err.value = errorTitle(e);
|
||
} finally {
|
||
fileBusy.value = false;
|
||
}
|
||
}
|
||
|
||
async function view(fileId: string) {
|
||
if (!props.item) return;
|
||
try {
|
||
const link = await unwrap(
|
||
api.GET("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
|
||
);
|
||
window.open(link.url, "_blank", "noopener");
|
||
} catch (e) {
|
||
err.value = errorTitle(e);
|
||
}
|
||
}
|
||
|
||
async function removeFile(fileId: string) {
|
||
if (!props.item) return;
|
||
fileBusy.value = true;
|
||
try {
|
||
await unwrap(
|
||
api.DELETE("/qualifications/{qualification_id}/files/{file_id}", { params: { path: { qualification_id: props.item.id, file_id: fileId } } }),
|
||
);
|
||
files.value = files.value.filter((f) => f.id !== fileId);
|
||
emit("saved", { ...props.item, files: files.value }, "files");
|
||
} catch (e) {
|
||
err.value = errorTitle(e);
|
||
} finally {
|
||
fileBusy.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<DialogRoot v-model:open="open">
|
||
<DialogPortal>
|
||
<DialogOverlay class="scrim">
|
||
<DialogContent class="dialog wide" :aria-describedby="undefined">
|
||
<DialogTitle as="h2">{{ item ? "编辑资质" : "添加资质" }}</DialogTitle>
|
||
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
|
||
<form class="body" novalidate @submit.prevent="submit">
|
||
<div class="form">
|
||
<div class="field span2" :class="{ err: errs.name }">
|
||
<label for="qd-name">名称 <small>(必填)</small></label>
|
||
<input id="qd-name" v-model="name" class="input" maxlength="200" placeholder="和证书上的写法一致" autocomplete="off" />
|
||
<span v-if="errs.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.name }}</span>
|
||
</div>
|
||
<div class="field">
|
||
<label for="qd-kind">类型 <small>(必填)</small></label>
|
||
<SelectBox
|
||
id="qd-kind"
|
||
:model-value="kind"
|
||
:options="KIND_OPTIONS"
|
||
@update:model-value="(v) => (kind = v as Kind)"
|
||
/>
|
||
</div>
|
||
<div class="field">
|
||
<label for="qd-level">等级 <small>(选填)</small></label>
|
||
<input id="qd-level" v-model="level" class="input" maxlength="40" placeholder="例如:CS3、二级" autocomplete="off" />
|
||
</div>
|
||
<div class="field" :class="{ err: errs.certNo }">
|
||
<label for="qd-no">证书编号 <small>(必填)</small></label>
|
||
<input id="qd-no" v-model="certNo" class="input num" maxlength="80" autocomplete="off" />
|
||
<span v-if="errs.certNo" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.certNo }}</span>
|
||
</div>
|
||
<div class="field">
|
||
<label for="qd-issuer">发证机构</label>
|
||
<input id="qd-issuer" v-model="issuer" class="input" maxlength="120" autocomplete="off" />
|
||
</div>
|
||
<div class="field" :class="{ err: errs.from }">
|
||
<label for="qd-from">有效期起</label>
|
||
<input id="qd-from" v-model="from" class="input num" placeholder="例如:2023-11-16" autocomplete="off" @blur="tidy('from')" />
|
||
<span v-if="errs.from" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.from }}</span>
|
||
</div>
|
||
<div class="field" :class="{ err: errs.to }">
|
||
<label for="qd-to">有效期至 <small v-if="!longTerm">(必填)</small></label>
|
||
<input
|
||
id="qd-to"
|
||
ref="toInput"
|
||
v-model="to"
|
||
class="input num"
|
||
:placeholder="longTerm ? '长期有效' : '例如:2026-11-20'"
|
||
:disabled="longTerm"
|
||
autocomplete="off"
|
||
@blur="tidy('to')"
|
||
/>
|
||
<span v-if="errs.to" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ errs.to }}</span>
|
||
<span v-else-if="late.length" class="hint-warn">
|
||
<span class="ico w" aria-hidden="true">!</span>
|
||
<span>早于「{{ late[0]!.name }}」的投标截止 {{ fmtMonthDay(late[0]!.day) }}<template v-if="late.length > 1"> 等 <span class="fig">{{ late.length }}</span> 个项目</template></span>
|
||
</span>
|
||
</div>
|
||
<div class="field span2">
|
||
<button
|
||
type="button"
|
||
class="switch"
|
||
role="switch"
|
||
:aria-checked="longTerm"
|
||
@click="
|
||
longTerm = !longTerm;
|
||
if (longTerm) to = '';
|
||
"
|
||
>
|
||
<span class="track" aria-hidden="true"></span>长期有效
|
||
</button>
|
||
</div>
|
||
<div class="field span2">
|
||
<label>证书扫描件 <small>(可以稍后补录)</small></label>
|
||
<div v-for="f in files" :key="f.id" class="file-row">
|
||
<span class="ext">{{ extOf(f.filename) }}</span>
|
||
<div><b>{{ f.filename }}</b><span class="cap muted">{{ fmtSize(f.size_bytes) }}</span></div>
|
||
<span class="acts">
|
||
<button type="button" class="btn sm" @click="view(f.id)">查看</button>
|
||
<button type="button" class="btn sm" :disabled="fileBusy" @click="removeFile(f.id)">删除</button>
|
||
</span>
|
||
</div>
|
||
<div v-if="pending" class="file-row">
|
||
<span class="ext">{{ extOf(pending.name) }}</span>
|
||
<div><b>{{ pending.name }}</b><span class="cap muted">{{ fmtSize(pending.size) }} · 保存资质后上传</span></div>
|
||
<span class="acts"><button type="button" class="btn sm" @click="pending = null">移除</button></span>
|
||
</div>
|
||
<button v-if="!pending" type="button" class="drop" :aria-disabled="fileBusy" @click="pick">
|
||
<span>{{ fileBusy ? "正在上传…" : files.length ? "再上传一个附件(PDF / JPG / PNG)" : "点击上传 PDF / JPG / PNG,单个不超过 20MB" }}</span>
|
||
</button>
|
||
<input ref="fileInput" type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" hidden @change="onFile" />
|
||
</div>
|
||
</div>
|
||
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
|
||
<div class="foot">
|
||
<span class="cap muted left">不扣额度</span>
|
||
<DialogClose class="btn" type="button" :disabled="busy">取消</DialogClose>
|
||
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :disabled="busy">
|
||
<span class="idle">保存资质</span><span class="busy">正在保存…</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</DialogContent>
|
||
</DialogOverlay>
|
||
</DialogPortal>
|
||
</DialogRoot>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 编号、日期用数字字体(同原型 .dt > .input) */
|
||
.input.num {
|
||
font-family: var(--font-figures);
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: var(--fs-figure-sm);
|
||
}
|
||
.input.num::placeholder {
|
||
font-family: var(--font-sans);
|
||
font-size: var(--fs-body);
|
||
}
|
||
.file-row + .file-row,
|
||
.file-row + .drop {
|
||
margin-top: var(--sp-2);
|
||
}
|
||
.drop {
|
||
width: 100%;
|
||
padding: var(--sp-4);
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.file-row .cap {
|
||
display: block;
|
||
}
|
||
.hint-warn {
|
||
margin: var(--sp-4) 0 0;
|
||
}
|
||
.field .hint-warn {
|
||
margin: var(--sp-1) 0 0;
|
||
}
|
||
</style>
|