- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
(新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
8.0 KiB
Vue
172 lines
8.0 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 修改项目基本信息的一个字段(FR-M2-03,project-info-plan §2)。按字段类型给输入控件;人工值优先,可以恢复 AI 值。
|
||
* 金额可写「286万」「2,860,000」,服务端换成元(PI7);投标截止时间按北京时间录入。修改留痕(审计)。
|
||
*/
|
||
import { unwrap, type Schemas } 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 DateInput from "@/components/DateInput.vue";
|
||
import SelectBox from "@/components/SelectBox.vue";
|
||
import { api, errorTitle, isApiError } from "@/lib/api";
|
||
import { BOOL_FIELDS, FIELD_LABEL, fieldText, LEGAL_REGIME, MONEY_FIELDS, parseDay } from "@/lib/labels";
|
||
import type { ProjectField } from "@/lib/queries";
|
||
|
||
type Key = Schemas["ProjectFieldKey"];
|
||
const props = defineProps<{ projectId: string; fieldKey: Key | null; field: ProjectField | null }>();
|
||
const open = defineModel<boolean>("open", { required: true });
|
||
const emit = defineEmits<{ saved: [f: ProjectField, restored: boolean] }>();
|
||
|
||
const text = ref("");
|
||
const flag = ref<boolean | null>(null);
|
||
const day = ref("");
|
||
const time = ref("");
|
||
const err = ref("");
|
||
const busy = ref(false);
|
||
|
||
const key = computed(() => props.fieldKey);
|
||
const kind = computed(() =>
|
||
!key.value ? "text" : MONEY_FIELDS.has(key.value) ? "money" : BOOL_FIELDS.has(key.value) ? "bool" : key.value === "legal_regime" ? "regime" : key.value === "deadline_at" ? "time" : "text",
|
||
);
|
||
const canRestore = computed(() => props.field?.source === "human" && props.field.ai_value != null);
|
||
|
||
/** 时间按北京时间拆成日期和时分 */
|
||
function splitTime(iso: string): [string, string] {
|
||
const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
|
||
const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((x) => [x.type, x.value]));
|
||
return [`${parts.year}-${parts.month}-${parts.day}`, `${parts.hour === "24" ? "00" : parts.hour}:${parts.minute}`];
|
||
}
|
||
|
||
watch(open, (v) => {
|
||
if (!v) return;
|
||
const val = props.field?.value;
|
||
err.value = "";
|
||
text.value = kind.value === "money" && val != null ? String(val) : typeof val === "string" ? val : kind.value === "regime" ? "unknown" : "";
|
||
flag.value = typeof val === "boolean" ? val : null;
|
||
[day.value, time.value] = kind.value === "time" && typeof val === "string" ? splitTime(val) : ["", "09:30"];
|
||
});
|
||
|
||
function value(): string | boolean | null | undefined {
|
||
if (kind.value === "bool") return flag.value;
|
||
if (kind.value === "time") {
|
||
const d = parseDay(day.value);
|
||
const m = /^(\d{1,2})[::](\d{2})$/.exec(time.value.trim());
|
||
if (!d || !m || Number(m[1]) > 23 || Number(m[2]) > 59) {
|
||
err.value = "请填日期和时间,例如:2026-11-20 和 09:30";
|
||
return undefined;
|
||
}
|
||
return `${d}T${m[1]!.padStart(2, "0")}:${m[2]}:00+08:00`;
|
||
}
|
||
const t = text.value.trim();
|
||
if (key.value === "name" && !t) {
|
||
err.value = "项目名称不能为空";
|
||
return undefined;
|
||
}
|
||
return t || null;
|
||
}
|
||
|
||
async function save(restore = false) {
|
||
if (!key.value || busy.value) return;
|
||
const v = restore ? null : value();
|
||
if (v === undefined) return;
|
||
busy.value = true;
|
||
err.value = "";
|
||
try {
|
||
const f = await unwrap(
|
||
api.PUT("/projects/{project_id}/fields/{field_key}", { params: { path: { project_id: props.projectId, field_key: key.value } }, body: { value: v } }),
|
||
);
|
||
emit("saved", f, restore);
|
||
open.value = false;
|
||
} catch (e) {
|
||
const detail = isApiError(e, "validation_failed") ? (e.problem as { errors?: { message: string }[] }).errors?.[0]?.message : undefined;
|
||
err.value = detail ?? errorTitle(e);
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<DialogRoot v-model:open="open">
|
||
<DialogPortal>
|
||
<DialogOverlay class="scrim">
|
||
<DialogContent class="dialog" :aria-describedby="undefined">
|
||
<DialogTitle as="h2">修改{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</DialogTitle>
|
||
<DialogClose class="icon-btn sm x" aria-label="关闭"><X class="ic" :stroke-width="1.5" /></DialogClose>
|
||
<form class="body" novalidate @submit.prevent="save()">
|
||
<div v-if="kind === 'text'" class="field fill">
|
||
<label for="fd-text">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
|
||
<input id="fd-text" v-model="text" class="input" maxlength="500" autocomplete="off" />
|
||
</div>
|
||
<div v-else-if="kind === 'money'" class="field fill">
|
||
<label for="fd-money">{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
|
||
<input id="fd-money" v-model="text" class="input num" placeholder="例如:2,860,000 或 286万" autocomplete="off" />
|
||
<span class="msg">按招标文件原文填写,存成元;留空表示没写明</span>
|
||
</div>
|
||
<div v-else-if="kind === 'regime'" class="field fill">
|
||
<label for="fd-regime">法律体系</label>
|
||
<SelectBox
|
||
id="fd-regime"
|
||
:model-value="text"
|
||
:options="Object.entries(LEGAL_REGIME).map(([value, label]) => ({ value, label }))"
|
||
@update:model-value="(v) => (text = v ?? '')"
|
||
/>
|
||
<span class="msg">决定查标用「投标无效」还是「否决投标」的口径</span>
|
||
</div>
|
||
<div v-else-if="kind === 'bool'" class="field fill">
|
||
<label>{{ fieldKey ? FIELD_LABEL[fieldKey] : "" }}</label>
|
||
<div class="seg" role="group" :aria-label="fieldKey ? FIELD_LABEL[fieldKey] : ''">
|
||
<button type="button" :aria-pressed="flag === true" @click="flag = true">是</button>
|
||
<button type="button" :aria-pressed="flag === false" @click="flag = false">否</button>
|
||
<button type="button" :aria-pressed="flag === null" @click="flag = null">没写明</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="field fill">
|
||
<label for="fd-day">投标截止时间(北京时间)</label>
|
||
<div class="dt-row">
|
||
<DateInput id="fd-day" v-model="day" label="投标截止日期" />
|
||
<input v-model="time" class="input num" aria-label="时分" placeholder="09:30" autocomplete="off" />
|
||
</div>
|
||
<span class="msg">查标按它核对证书有效期和落款日期,改了以后重新检查才生效</span>
|
||
</div>
|
||
<p v-if="field && field.ai_value != null" class="cap muted ai">
|
||
AI 提取:{{ fieldText(field.key, field.ai_value) }}<template v-if="field.citation">({{ field.citation.document_label }} 第 <span class="fig">{{ field.citation.page_no }}</span> 页)</template>
|
||
</p>
|
||
<p v-if="err" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span>{{ err }}</p>
|
||
<div class="foot">
|
||
<button v-if="canRestore" type="button" class="btn text left" :disabled="busy" @click="save(true)">恢复 AI 值</button>
|
||
<span v-else 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-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) 96px;
|
||
gap: var(--sp-2);
|
||
}
|
||
.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);
|
||
}
|
||
.ai,
|
||
.hint-warn {
|
||
margin: var(--sp-4) 0 0;
|
||
}
|
||
</style>
|