feat(ui): 查标第 6 步前端补齐
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
(新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,23 @@
|
||||
import { computed } from "vue";
|
||||
import CitationBlock from "./CitationBlock.vue";
|
||||
import { groupCitations, type CitationGroup } from "@/lib/citations";
|
||||
import { category, failureText, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
|
||||
import type { ChecklistItem } from "@/lib/queries";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { category, CHECK_VERDICT, failureText, fmtDay, IMPORTANCE_MARK, IMPORTANCE_NAME, pendingReason, specFacts } from "@/lib/labels";
|
||||
import type { CheckItemResult, ChecklistItem, Citation, QualificationMatch } from "@/lib/queries";
|
||||
|
||||
const props = defineProps<{ item: ChecklistItem; pending?: boolean; selected?: boolean; activeCitation?: string | null }>();
|
||||
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup] }>();
|
||||
const props = defineProps<{
|
||||
item: ChecklistItem;
|
||||
pending?: boolean;
|
||||
selected?: boolean;
|
||||
activeCitation?: string | null;
|
||||
/** 最近一次检查对这一条的结论(C9);没参与检查的不传 */
|
||||
check?: CheckItemResult | null;
|
||||
/** 资质库里的情况(资格条件、评分项里认出证书类别的条目);资质库是用户自己录的,墨色,不用蓝铅笔色 */
|
||||
library?: QualificationMatch[] | null;
|
||||
/** library 按哪天判断有效期:「投标截止日」或「今天」 */
|
||||
libraryAsOf?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ select: []; openCitation: [g: CitationGroup]; openBid: [c: Citation]; feedback: []; findings: [] }>();
|
||||
|
||||
/** 一条结论最多默认展开 2 处出处,其余写「另有 N 处」(§7.3);跨页的一句话算一处 */
|
||||
const groups = computed(() => groupCitations(props.item.citations));
|
||||
@@ -22,6 +34,9 @@ const mark = computed(() => IMPORTANCE_MARK[props.item.importance] ?? "");
|
||||
const summary = computed(() => (props.item.summary && props.item.summary !== props.item.title ? props.item.summary : ""));
|
||||
/** 结构化取值:分值、参数要求、日期等,都来自原文(取值校验通过的才进正式清单) */
|
||||
const facts = computed(() => specFacts(props.item.spec));
|
||||
/** 检查结论:已响应、默认接受是好的;其余在查标页有问题可处理 */
|
||||
const checkOk = computed(() => props.check?.verdict === "responded" || props.check?.verdict === "implicit_accepted");
|
||||
const bidCites = computed(() => groupCitations(props.check?.bid_citations ?? []).slice(0, 2));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,6 +69,34 @@ const facts = computed(() => specFacts(props.item.spec));
|
||||
<div v-else class="cites">
|
||||
<div class="cite missing"><p>{{ item.no_citation_ack || item.origin === "human" ? "没有出处" : "没有出处,确认前不参与检查" }}</p></div>
|
||||
</div>
|
||||
<p v-for="m in library ?? []" :key="m.family" class="lib cap" @click.stop>
|
||||
<span class="muted">资质库</span>
|
||||
<template v-if="m.record">
|
||||
<span>《{{ m.record.name }}》</span>
|
||||
<span v-if="m.state === 'expired'" class="warn-text">有效期至 <span class="fig">{{ fmtDay(m.record.valid_to!) }}</span>,早于{{ libraryAsOf }}</span>
|
||||
<span v-else>{{ m.record.valid_to ? `有效期至 ${fmtDay(m.record.valid_to)}` : "长期有效" }}</span>
|
||||
<span v-if="m.count > 1" class="muted">(这一类共 <span class="fig">{{ m.count }}</span> 条,这里是有效期最晚的)</span>
|
||||
</template>
|
||||
<span v-else class="muted">没有录入「{{ m.family }}」</span>
|
||||
<RouterLink v-if="m.state !== 'valid'" class="lnk" :to="{ name: 'qualifications' }">去资质库</RouterLink>
|
||||
</p>
|
||||
<p v-if="check" class="chk cap" :class="{ bad: !checkOk }" @click.stop>
|
||||
<span class="muted">查标</span>
|
||||
<b>{{ CHECK_VERDICT[check.verdict] }}</b>
|
||||
<button
|
||||
v-for="g in bidCites"
|
||||
:key="g.head.id"
|
||||
type="button"
|
||||
class="cite-src inline"
|
||||
:class="{ on: activeCitation === g.head.id }"
|
||||
@click="emit('select'); emit('openBid', g.head)"
|
||||
>
|
||||
{{ g.head.document_label }} 第 <span class="fig">{{ g.head.page_no }}</span> 页
|
||||
</button>
|
||||
<span v-if="check.reused" class="muted">沿用上次结论</span>
|
||||
<button v-if="!checkOk" type="button" class="lnk" @click="emit('findings')">去查标页处理</button>
|
||||
<button v-else-if="check.verdict === 'responded' && selected" type="button" class="lnk feedback" @click="emit('feedback')">判断有误?</button>
|
||||
</p>
|
||||
<div v-if="$slots.actions && (pending || selected)" class="f-actions" @click.stop>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -115,4 +158,35 @@ const facts = computed(() => specFacts(props.item.spec));
|
||||
.cites .cap {
|
||||
margin: var(--sp-1) 0 0;
|
||||
}
|
||||
/* 检查结论:有问题的加深,不用红(级别和处理都在查标页);投标侧出处同样是蓝铅笔色 */
|
||||
.chk {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1) var(--sp-2);
|
||||
align-items: baseline;
|
||||
margin: var(--sp-2) 0 0;
|
||||
}
|
||||
.chk b {
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.chk:not(.bad) b {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.chk .feedback {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lib {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1) var(--sp-2);
|
||||
align-items: baseline;
|
||||
margin: var(--sp-2) 0 0;
|
||||
}
|
||||
.lib + .lib,
|
||||
.lib + .chk {
|
||||
margin-top: var(--sp-1);
|
||||
}
|
||||
.warn-text {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
112
apps/web/src/components/DateInput.vue
Normal file
112
apps/web/src/components/DateInput.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 日期输入(设计规范 §7.22、§9.3):可以直接键入(接受 2026/11/20、2026年11月20日 等,离开时整理成 2026-11-20),
|
||||
* 右侧按钮打开日期选择器。选择器周一开始;今天 1px 边框,选中墨底白字;非本月的格子留空;不可选的日期只变灰。
|
||||
* 交互基础用 Reka UI 的 Popover + Calendar(键盘方向键选日期、Esc 关闭、焦点回到按钮),外观来自 app.css 的 .dt / .cal。
|
||||
*/
|
||||
import { getLocalTimeZone, parseDate, today, type DateValue } from "@internationalized/date";
|
||||
import { Calendar, ChevronLeft, ChevronRight } from "@lucide/vue";
|
||||
import {
|
||||
CalendarCell,
|
||||
CalendarCellTrigger,
|
||||
CalendarGrid,
|
||||
CalendarGridBody,
|
||||
CalendarGridHead,
|
||||
CalendarGridRow,
|
||||
CalendarHeadCell,
|
||||
CalendarHeader,
|
||||
CalendarHeading,
|
||||
CalendarNext,
|
||||
CalendarPrev,
|
||||
CalendarRoot,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
} from "reka-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import { parseDay } from "@/lib/labels";
|
||||
|
||||
const props = defineProps<{ id?: string; placeholder?: string; disabled?: boolean; label?: string }>();
|
||||
const text = defineModel<string>({ required: true });
|
||||
const emit = defineEmits<{ blur: [] }>();
|
||||
|
||||
const open = ref(false);
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
defineExpose({ select: () => input.value?.select(), focus: () => input.value?.focus() });
|
||||
|
||||
/** 输入框里能认出的日期,选择器打开时停在这个月 */
|
||||
const value = computed<DateValue | undefined>(() => {
|
||||
const d = parseDay(text.value);
|
||||
return d ? parseDate(d) : undefined;
|
||||
});
|
||||
function pick(v: DateValue | undefined) {
|
||||
if (!v) return;
|
||||
text.value = v.toString();
|
||||
open.value = false;
|
||||
}
|
||||
function onBlur() {
|
||||
const d = parseDay(text.value);
|
||||
if (d) text.value = d;
|
||||
emit("blur");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-model:open="open">
|
||||
<PopoverAnchor as-child>
|
||||
<div class="dt">
|
||||
<input
|
||||
:id="id"
|
||||
ref="input"
|
||||
v-model="text"
|
||||
class="input"
|
||||
:placeholder="placeholder ?? '例如:2026-11-20'"
|
||||
:disabled="disabled"
|
||||
autocomplete="off"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<PopoverTrigger as-child>
|
||||
<button type="button" class="icon-btn sm" :aria-label="`打开日期选择器${label ? `:${label}` : ''}`" :disabled="disabled">
|
||||
<Calendar class="ic" :stroke-width="1.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverPortal>
|
||||
<PopoverContent class="cal" align="start" :side-offset="4" :aria-label="label ?? '选择日期'">
|
||||
<CalendarRoot
|
||||
v-slot="{ grid, weekDays }"
|
||||
:model-value="value"
|
||||
:default-placeholder="value ?? today(getLocalTimeZone())"
|
||||
locale="zh-CN"
|
||||
:week-starts-on="1"
|
||||
weekday-format="narrow"
|
||||
initial-focus
|
||||
@update:model-value="pick"
|
||||
>
|
||||
<CalendarHeader class="cal-h">
|
||||
<CalendarPrev class="icon-btn sm" aria-label="上个月"><ChevronLeft class="ic" :stroke-width="1.5" /></CalendarPrev>
|
||||
<CalendarHeading v-slot="{ headingValue }"><b>{{ headingValue }}</b></CalendarHeading>
|
||||
<CalendarNext class="icon-btn sm" aria-label="下个月"><ChevronRight class="ic" :stroke-width="1.5" /></CalendarNext>
|
||||
</CalendarHeader>
|
||||
<CalendarGrid v-for="month in grid" :key="month.value.toString()" class="cal-grid">
|
||||
<CalendarGridHead>
|
||||
<CalendarGridRow>
|
||||
<CalendarHeadCell v-for="d in weekDays" :key="d" class="wd">{{ d }}</CalendarHeadCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridHead>
|
||||
<CalendarGridBody>
|
||||
<CalendarGridRow v-for="(week, i) in month.rows" :key="i">
|
||||
<CalendarCell v-for="d in week" :key="d.toString()" :date="d">
|
||||
<CalendarCellTrigger :day="d" :month="month.value" class="day" />
|
||||
</CalendarCell>
|
||||
</CalendarGridRow>
|
||||
</CalendarGridBody>
|
||||
</CalendarGrid>
|
||||
</CalendarRoot>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
98
apps/web/src/components/FeedbackDialog.vue
Normal file
98
apps/web/src/components/FeedbackDialog.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 反馈:判断有误(J5)。查标页的问题(误报)和读标页的「已响应」结论(漏报)共用。
|
||||
* 反馈只用于改进检查规则,不改变处理状态;理由和补充说明合成一句存进 note。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { ref, watch } from "vue";
|
||||
import ConfirmDialog from "./ConfirmDialog.vue";
|
||||
import { api, errorTitle } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export type FeedbackTarget = {
|
||||
objectType: "finding" | "checklist_item";
|
||||
objectId: string;
|
||||
title: string;
|
||||
/** false_positive:报了不该报的;false_negative:说已响应,其实没有 */
|
||||
kind: "false_positive" | "false_negative";
|
||||
reasons: string[];
|
||||
/** 对话框底部那句说明:反馈不改变什么 */
|
||||
note: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{ projectId: string; target: FeedbackTarget | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const reason = ref("");
|
||||
const text = ref("");
|
||||
const busy = ref(false);
|
||||
watch(
|
||||
() => props.target,
|
||||
(t) => {
|
||||
reason.value = t?.reasons[0] ?? "";
|
||||
text.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
async function send() {
|
||||
const t = props.target;
|
||||
if (!t) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await unwrap(
|
||||
api.POST("/feedback", {
|
||||
body: {
|
||||
project_id: props.projectId,
|
||||
object_type: t.objectType,
|
||||
object_id: t.objectId,
|
||||
kind: t.kind,
|
||||
note: [reason.value, text.value.trim()].filter(Boolean).join(":").slice(0, 1000),
|
||||
},
|
||||
}),
|
||||
);
|
||||
emit("close");
|
||||
toast("已收到反馈");
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog
|
||||
:open="!!target"
|
||||
title="反馈:判断有误"
|
||||
ok-text="提交反馈"
|
||||
busy-text="正在提交…"
|
||||
:busy="busy"
|
||||
@update:open="(v) => { if (!v) emit('close'); }"
|
||||
@ok="send"
|
||||
>
|
||||
<p class="muted fb-title">「{{ target?.title }}」</p>
|
||||
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
|
||||
<label v-for="r in target?.reasons ?? []" :key="r" class="choice"><input v-model="reason" type="radio" class="ck" name="fb" :value="r" />{{ r }}</label>
|
||||
</div>
|
||||
<div class="field fill fb-note">
|
||||
<label for="fb-note">补充说明 <small>(选填)</small></label>
|
||||
<textarea id="fb-note" v-model="text" class="input" maxlength="200"></textarea>
|
||||
<span class="msg">{{ target?.note }}</span>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fb-title {
|
||||
margin: 0;
|
||||
}
|
||||
.fb-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.fb-note {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -124,7 +125,7 @@ async function save(restore = false) {
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工作台项目行(设计规范 §7.9,原型 app.css B20):整行可点,「下一步」单独可点。
|
||||
* 列:项目 · 投标截止 · 状态(含就绪)· 待确认 · 下一步。
|
||||
* 列:项目 · 投标截止 · 状态(含就绪)· 致命(待处理的致命问题数)· 下一步。
|
||||
*/
|
||||
import { ChevronRight } from "@lucide/vue";
|
||||
import { computed } from "vue";
|
||||
@@ -54,7 +54,7 @@ const next = computed<RouteLocationRaw>(() =>
|
||||
</template>
|
||||
</span>
|
||||
<span class="c">
|
||||
<span v-if="p.pending_item_count" class="fig" :aria-label="`待确认 ${p.pending_item_count} 条`">{{ p.pending_item_count }}</span>
|
||||
<span v-if="p.readiness.open_fatal_count" class="sev fatal">致命<span class="fig">{{ p.readiness.open_fatal_count }}</span></span>
|
||||
<span v-else class="dash" aria-label="无">—</span>
|
||||
</span>
|
||||
<span>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 DateInput from "@/components/DateInput.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";
|
||||
@@ -42,7 +43,7 @@ const err = ref("");
|
||||
const busy = ref(false);
|
||||
const fileBusy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const toInput = ref<HTMLInputElement | null>(null);
|
||||
const toInput = ref<InstanceType<typeof DateInput> | null>(null);
|
||||
|
||||
watch(open, (v) => {
|
||||
if (!v) return;
|
||||
@@ -68,12 +69,6 @@ const late = computed(() => {
|
||||
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 = "请填写资质名称,和证书上的写法一致";
|
||||
@@ -227,21 +222,12 @@ async function removeFile(fileId: string) {
|
||||
</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')" />
|
||||
<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>
|
||||
</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')"
|
||||
/>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user