Files
bid-doctor/apps/web/src/components/FieldDialog.vue
YANG JIANKUAN 54cbb556d0
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(ui): 查标第 6 步前端补齐
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:39:19 +08:00

172 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>