feat(project): 项目基本信息修改和分包选择(FR-M2-03、04)
- 字段修改:人工值优先、撤销恢复 AI 值、按字段校验(金额可写「286万」,按元存)、 冗余列同步、审计留痕、只读项目 409;关键日期显示改后的投标截止时间 - 分包:增删改、选包(至少一个)、一键「不分包」;删包时条目归为通用,删到一个不剩回到不分包 - 清单列表 lot_scope 默认只列通用 + 所投的包,响应带 hidden_count - 检查记下当时用的项目依据(截止时间、限价、所投的包、法律体系):报告按它写, 之后改了信息检查结果接口给 basis_changed,查标页提示重新检查 - 前端:概览标签页(项目要点、采购包、关键日期)、字段修改对话框、选包页和手工调整; 解析完识别到多个包时进选包页;读标页清单范围和所属采购包;查标页写明查哪几个包 - 决策 PI1–PI7;方案和落地记录 docs/architecture/project-info-plan.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
166
apps/web/src/components/FieldDialog.vue
Normal file
166
apps/web/src/components/FieldDialog.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<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 { 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>
|
||||
<select id="fd-regime" v-model="text" class="input">
|
||||
<option v-for="(label, v) in LEGAL_REGIME" :key="v" :value="v">{{ label }}</option>
|
||||
</select>
|
||||
<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">
|
||||
<input id="fd-day" v-model="day" class="input num" placeholder="例如:2026-11-20" autocomplete="off" @blur="day = parseDay(day) || day" />
|
||||
<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>
|
||||
Reference in New Issue
Block a user