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:
@@ -13,6 +13,7 @@
|
||||
"@bid-doctor/api-client": "workspace:*",
|
||||
"@fontsource/barlow-semi-condensed": "5.3.0",
|
||||
"@fontsource/noto-serif-sc": "5.3.0",
|
||||
"@internationalized/date": "3.12.4",
|
||||
"@lucide/vue": "1.50.0",
|
||||
"@tanstack/vue-query": "5.104.1",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,5 +18,7 @@ export const qk = {
|
||||
reports: (projectId: string) => ["reports", projectId] as const,
|
||||
itemResults: (runId: string) => ["item-results", runId] as const,
|
||||
qualifications: ["qualifications"] as const,
|
||||
/** 前缀和 qualifications 一样:资质库一改,读标页的资质库情况跟着失效 */
|
||||
qualificationMatches: (projectId: string) => ["qualifications", "matches", projectId] as const,
|
||||
lots: (projectId: string) => ["lots", projectId] as const,
|
||||
};
|
||||
|
||||
@@ -185,6 +185,19 @@ export const FINDING_ACTION: Dict<Schemas["FindingStatus"]> = {
|
||||
ignored: "已忽略",
|
||||
};
|
||||
|
||||
/** 每条清单项的检查结论(C9),读标页条目下面那一行 */
|
||||
export const CHECK_VERDICT: Dict<Schemas["CheckItemResult"]["verdict"]> = {
|
||||
responded: "已响应",
|
||||
not_found: "未找到响应",
|
||||
negative_deviation: "负偏离未说明",
|
||||
suspected_unmet: "疑似不满足",
|
||||
no_value: "未给出具体值",
|
||||
undetermined: "未判定",
|
||||
scanned: "文字部分没找到,请人工核对",
|
||||
implicit_accepted: "按招标文件默认接受",
|
||||
implicit_review: "默认接受,请人工核对",
|
||||
};
|
||||
|
||||
export const READY_TIP = "只表示致命问题都已处理,不代表投标文件没有问题。";
|
||||
|
||||
export const QUALIFICATION_KIND_ORDER: readonly Schemas["QualificationKind"][] = ["certificate", "system_cert", "software_copyright", "other"];
|
||||
|
||||
@@ -148,6 +148,37 @@ export function useFindings(runId: MaybeRefOrGetter<string>, enabled: MaybeRefOr
|
||||
});
|
||||
}
|
||||
|
||||
/** 每条清单项的检查结论(C9):「已响应」也带投标侧出处,读标页显示在条目下面 */
|
||||
export function useItemResults(runId: MaybeRefOrGetter<string>) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => qk.itemResults(toValue(runId))),
|
||||
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/item-results", { params: { path: { run_id: toValue(runId) } } })),
|
||||
enabled: computed(() => !!toValue(runId)),
|
||||
});
|
||||
}
|
||||
|
||||
/** 项目的历次报告(新的在前) */
|
||||
export function useReports(projectId: MaybeRefOrGetter<string>) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => qk.reports(toValue(projectId))),
|
||||
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: toValue(projectId) } } })),
|
||||
});
|
||||
}
|
||||
|
||||
/** 报告文件的短时签名地址(预览、下载都现取,不缓存) */
|
||||
export async function reportFileUrl(id: string): Promise<string> {
|
||||
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
|
||||
}
|
||||
|
||||
/** 读标清单上每条资质要求在资质库里的情况(资质库空时没有条目) */
|
||||
export type QualificationMatch = Schemas["QualificationMatch"];
|
||||
export function useQualificationMatches(projectId: MaybeRefOrGetter<string>) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => qk.qualificationMatches(toValue(projectId))),
|
||||
queryFn: () => unwrap(api.GET("/projects/{project_id}/qualification-matches", { params: { path: { project_id: toValue(projectId) } } })),
|
||||
});
|
||||
}
|
||||
|
||||
/** 资质库:一次取全部(最小资质库条目不多),类型筛选和搜索在前端做 */
|
||||
export function useQualifications() {
|
||||
return useQuery({ queryKey: qk.qualifications, queryFn: () => unwrap(api.GET("/qualifications")) });
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
* - 待确认:采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃;正式清单:修改、补出处、删除(可撤销)、修改记录;
|
||||
* - 补出处:查看器进入选句模式,点选同一页上的句子,建一条人工出处再挂到条目上;
|
||||
* - 确认清单:生成不可变版本,查标以这一版为依据;之后再改会回到草稿,历史保留(FR-M2-08)。
|
||||
* - 检查过的项目:每条下面写最近一次检查的结论(C9),点投标侧出处在右侧看投标原文;「已响应」也能反馈判断有误。
|
||||
* - 资格条件、评分项里的证书要求:写资质库里有没有、在投标截止日是否有效(资质库空的不写)。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { FileText, Info, Plus, Search } from "@lucide/vue";
|
||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RouterLink, useRoute } from "vue-router";
|
||||
import { RouterLink, useRoute, useRouter } from "vue-router";
|
||||
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
|
||||
import CitationBlock from "@/components/CitationBlock.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
|
||||
import HistoryDialog from "@/components/HistoryDialog.vue";
|
||||
import ItemDialog from "@/components/ItemDialog.vue";
|
||||
import PdfViewer, { type ViewerTarget } from "@/components/PdfViewer.vue";
|
||||
@@ -26,7 +29,17 @@ import { groupCitations, type CitationGroup } from "@/lib/citations";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { category, CATEGORY_ORDER, fmtDateTime, MARKER, projectStatus } from "@/lib/labels";
|
||||
import { useChecklist, useChecklistItems, useLots, useProject, type ChecklistItem, type Citation, type MarkingRule } from "@/lib/queries";
|
||||
import {
|
||||
useChecklist,
|
||||
useChecklistItems,
|
||||
useItemResults,
|
||||
useLots,
|
||||
useProject,
|
||||
useQualificationMatches,
|
||||
type ChecklistItem,
|
||||
type Citation,
|
||||
type MarkingRule,
|
||||
} from "@/lib/queries";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const props = defineProps<{ projectId: string }>();
|
||||
@@ -50,6 +63,30 @@ const readOnly = computed(() => p.value?.status === "submitted" || p.value?.stat
|
||||
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "读标" }]);
|
||||
|
||||
|
||||
/* ---------- 最近一次检查的结论(C9):只看已完成的那次 ---------- */
|
||||
const router = useRouter();
|
||||
const runId = computed(() => p.value?.latest_check_run_id ?? "");
|
||||
const resultsQ = useItemResults(runId);
|
||||
const checkOf = computed(() => new Map((resultsQ.data.value?.items ?? []).map((r) => [r.checklist_item_id, r])));
|
||||
function goFindings() {
|
||||
if (runId.value) void router.push({ name: "results", params: { projectId: props.projectId, runId: runId.value } });
|
||||
}
|
||||
/* 资质库的情况:资质库是空的时没有条目,什么都不显示(QL3) */
|
||||
const libQ = useQualificationMatches(() => props.projectId);
|
||||
const libOf = computed(() => new Map((libQ.data.value?.items ?? []).map((x) => [x.checklist_item_id, x.families])));
|
||||
const libAsOf = computed(() => (libQ.data.value?.by_deadline === false ? "今天" : "投标截止日"));
|
||||
const fbFor = ref<FeedbackTarget | null>(null);
|
||||
function askFeedback(i: ChecklistItem) {
|
||||
fbFor.value = {
|
||||
objectType: "checklist_item",
|
||||
objectId: i.id,
|
||||
title: i.title,
|
||||
kind: "false_negative",
|
||||
reasons: ["投标文件里其实没有响应这条", "引的那句不是这条的响应", "响应了,但不满足要求", "其他"],
|
||||
note: "反馈只用于改进检查规则,不改变这次检查的结论。",
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- 分组与筛选 ---------- */
|
||||
const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
|
||||
const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
|
||||
@@ -470,8 +507,14 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
:item="i"
|
||||
:selected="selId === i.id"
|
||||
:active-citation="target?.id"
|
||||
:check="checkOf.get(i.id)"
|
||||
:library="libOf.get(i.id)"
|
||||
:library-as-of="libAsOf"
|
||||
@select="select(i)"
|
||||
@open-citation="(g) => { selId = i.id; openGroup(g); }"
|
||||
@open-bid="(c) => openCitation(c)"
|
||||
@findings="goFindings"
|
||||
@feedback="askFeedback(i)"
|
||||
>
|
||||
<template #actions>
|
||||
<template v-if="!readOnly && supPending(i)">
|
||||
@@ -537,6 +580,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
|
||||
<ItemDialog v-model:open="dlg.open" :mode="dlg.mode" :item="dlg.item" :lots="realLots" :default-category="cat !== 'all' && cat !== 'pending' && cat !== 'deleted' ? cat : undefined" @submit="onSubmit" @pick="onDialogPick" />
|
||||
<HistoryDialog v-model:open="hist.open" :item="hist.item" />
|
||||
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
|
||||
<ConfirmDialog v-model:open="confirmOpen" :title="`确认清单 v${nextVersion}?`" ok-text="确认清单" busy-text="正在确认…" :busy="confirming" @ok="doConfirm">
|
||||
<p>确认后,查标以这一版清单为依据。之后再改清单会回到草稿,历史版本保留。</p>
|
||||
<div v-if="pending.length" class="alert warn">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 项目概览(原型 project.html,PI1):这一步只做三张卡——项目要点(基本信息字段,带出处,可改、可恢复 AI 值)、
|
||||
* 采购包(所投的包和限价)、关键日期(时间线和倒计时,FR-M2-09)。检查记录、文件、AI 摘要卡留到第 6 步。
|
||||
* 项目概览(原型 project.html,PI1):关键日期(时间线和倒计时,FR-M2-09)和采购包、项目要点(基本信息字段,
|
||||
* 带出处,可改、可恢复 AI 值)、检查记录(历次检查的结果和报告)、文件(招标方的文件和投标文件的最新版本)。
|
||||
* 查标按这里的值核对截止时间、限价、法律体系;改了以后重新检查才生效(PI5)。项目已递交、已归档时只读。
|
||||
* 补遗(FR-M2-10):招标文件解析后可以追加;补遗带来的新值在这里「采用 / 不采用」(AD3),没确认前查标用旧值(AD4)。
|
||||
*/
|
||||
import { unwrap, type Schemas } from "@bid-doctor/api-client";
|
||||
import { Pencil, Plus } from "@lucide/vue";
|
||||
import { Download, Pencil, Plus } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, ref, watchEffect } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
@@ -17,8 +17,18 @@ import ProjectHead from "@/components/ProjectHead.vue";
|
||||
import { api, errorTitle } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTimeFull, fmtYuan } from "@/lib/labels";
|
||||
import { useLots, useProject, useProjectDocuments, type Citation, type ProjectField } from "@/lib/queries";
|
||||
import { deadline, FIELD_LABEL, FIELD_ORDER, fieldText, fmtDateTime, fmtDateTimeFull, fmtYuan, SEVERITY, SEVERITY_CLASS } from "@/lib/labels";
|
||||
import {
|
||||
reportFileUrl,
|
||||
useCheckRuns,
|
||||
useLots,
|
||||
useProject,
|
||||
useProjectDocuments,
|
||||
useReports,
|
||||
type Citation,
|
||||
type DocumentInfo,
|
||||
type ProjectField,
|
||||
} from "@/lib/queries";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const props = defineProps<{ projectId: string }>();
|
||||
@@ -80,6 +90,36 @@ async function resolvePending(key: Schemas["ProjectFieldKey"], accept: boolean)
|
||||
}
|
||||
const canAddendum = computed(() => !!p.value && p.value.status !== "tracking" && !readOnly.value);
|
||||
|
||||
/* ---------- 检查记录:历次检查和对应的报告 ---------- */
|
||||
const runsQ = useCheckRuns(() => props.projectId);
|
||||
const runs = computed(() => runsQ.data.value?.items ?? []);
|
||||
const reportsQ = useReports(() => props.projectId);
|
||||
const reportsByRun = computed(() => {
|
||||
const m = new Map<string, NonNullable<typeof reportsQ.data.value>["items"]>();
|
||||
for (const r of reportsQ.data.value?.items ?? []) m.set(r.check_run_id, [...(m.get(r.check_run_id) ?? []), r]);
|
||||
return m;
|
||||
});
|
||||
const RUN_STATE: Record<string, string> = { queued: "排队中", running: "检查中", failed: "检查失败", cancelled: "已取消" };
|
||||
async function openReport(id: string) {
|
||||
try {
|
||||
window.open(await reportFileUrl(id), "_blank", "noopener");
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 文件:招标方的文件全列;投标文件每个分册只列最新版本 ---------- */
|
||||
const tenderDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role !== "bid_volume"));
|
||||
const bidDocs = computed(() => (docsQ.data.value?.items ?? []).filter((d) => d.role === "bid_volume" && d.is_latest_version !== false));
|
||||
function parseState(d: DocumentInfo): string {
|
||||
const s = d.latest_parse_job?.status;
|
||||
if (s === "succeeded") return "解析成功";
|
||||
if (s === "queued" || s === "running") return "解析中";
|
||||
if (s === "failed") return "解析失败";
|
||||
if (s === "cancelled") return "已取消解析";
|
||||
return d.status === "failed" ? "读取失败" : "未解析";
|
||||
}
|
||||
|
||||
/* 右侧原文(左右布局,同读标页):默认摆招标文件,点出处翻到那一页并高亮;补遗的出处换成那份补遗 */
|
||||
const docsQ = useProjectDocuments(() => props.projectId);
|
||||
const shown = ref<{ doc: { id: string; label: string } | null; target: (ViewerTarget & { id: string }) | null }>({ doc: null, target: null });
|
||||
@@ -142,7 +182,6 @@ function openCitation(c: Citation) {
|
||||
<div class="card-h">
|
||||
<h2 id="h-facts">项目要点</h2>
|
||||
<span class="cap muted r">查标按这里的值核对截止时间、限价和法律体系</span>
|
||||
<RouterLink v-if="canAddendum" class="btn sm" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
|
||||
</div>
|
||||
<dl class="facts-list">
|
||||
<div v-for="{ key, f, value } in rows" :key="key" class="frow">
|
||||
@@ -170,6 +209,75 @@ function openCitation(c: Citation) {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card flush" aria-labelledby="h-runs">
|
||||
<div class="card-h">
|
||||
<h2 id="h-runs">检查记录</h2>
|
||||
<RouterLink v-if="!readOnly && p && p.status !== 'tracking'" class="btn sm r" :to="{ name: 'bid-check', params: { projectId } }">
|
||||
{{ runs.length ? "上传新版本并检查" : "上传投标文件并检查" }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p v-if="!runs.length" class="cap muted none pad">还没有检查过。上传投标文件后,每次检查的结果和导出的报告都列在这里。</p>
|
||||
<table v-else class="data runs">
|
||||
<thead>
|
||||
<tr><th>批次</th><th>时间</th><th>投标文件</th><th>结果</th><th>报告</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in runs" :key="r.id">
|
||||
<td>第 <span class="fig">{{ r.run_no }}</span> 次</td>
|
||||
<td class="fig nowrap">{{ fmtDateTime(r.created_at) }}</td>
|
||||
<td>{{ (r.documents ?? []).map((d) => d.label).join("、") || "—" }}</td>
|
||||
<td>
|
||||
<template v-if="r.status === 'succeeded'">
|
||||
<!-- 只列有问题的级别:「致命 0」不该是红色 -->
|
||||
<span class="sevs">
|
||||
<template v-for="s in (['fatal', 'important', 'hint'] as const)" :key="s">
|
||||
<span v-if="r.counts?.[s]" class="sev" :class="SEVERITY_CLASS[s]">{{ SEVERITY[s] }}<span class="fig">{{ r.counts[s] }}</span></span>
|
||||
</template>
|
||||
<span v-if="!r.counts?.fatal && !r.counts?.important && !r.counts?.hint" class="cap muted">没有发现问题</span>
|
||||
</span>
|
||||
<RouterLink class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">查看结果</RouterLink>
|
||||
</template>
|
||||
<RouterLink v-else class="lnk cap" :to="{ name: 'results', params: { projectId, runId: r.id } }">{{ RUN_STATE[r.status] ?? r.status }}</RouterLink>
|
||||
</td>
|
||||
<td>
|
||||
<button v-for="rep in reportsByRun.get(r.id) ?? []" :key="rep.id" type="button" class="lnk cap rep" @click="openReport(rep.id)">
|
||||
<Download class="ic" :stroke-width="1.5" />{{ fmtDateTime(rep.created_at) }}
|
||||
</button>
|
||||
<span v-if="!reportsByRun.get(r.id)?.length" class="dash" aria-label="没有导出">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card flush" aria-labelledby="h-files">
|
||||
<div class="card-h">
|
||||
<h2 id="h-files">文件</h2>
|
||||
<RouterLink v-if="canAddendum" class="btn sm r" :to="{ name: 'project-new', query: { project: projectId, addendum: '1' } }"><Plus class="ic" :stroke-width="1.5" />追加补遗 / 澄清</RouterLink>
|
||||
</div>
|
||||
<div class="files">
|
||||
<h3 class="cap muted">招标方</h3>
|
||||
<p v-if="!tenderDocs.length" class="cap muted none">还没有上传招标文件</p>
|
||||
<div v-for="d in tenderDocs" :key="d.id" class="file-row">
|
||||
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
|
||||
<div>
|
||||
<b>{{ d.label }}</b>
|
||||
<span class="cap muted">{{ d.original_filename }}<template v-if="d.page_count"> · <span class="fig">{{ d.page_count }}</span> 页</template> · {{ parseState(d) }}</span>
|
||||
</div>
|
||||
<span class="acts"><RouterLink class="btn sm text" :to="{ name: 'checklist', params: { projectId } }">读标</RouterLink></span>
|
||||
</div>
|
||||
<h3 class="cap muted">投标文件(加密前的 Word / PDF 稿)</h3>
|
||||
<p v-if="!bidDocs.length" class="cap muted none">还没有上传投标文件</p>
|
||||
<div v-for="d in bidDocs" :key="d.id" class="file-row">
|
||||
<span class="ext">{{ d.detected_format.toUpperCase() }}</span>
|
||||
<div>
|
||||
<b>{{ d.label }}</b>
|
||||
<span class="cap muted"><template v-if="d.page_count"><span class="fig">{{ d.page_count }}</span> 页 · </template>{{ fmtDateTime(d.created_at) }} 上传</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<aside class="split-doc" aria-label="原文">
|
||||
<DocStage v-model:active="side" :slots="slots" />
|
||||
@@ -217,6 +325,47 @@ function openCitation(c: Citation) {
|
||||
.card-h .r {
|
||||
margin-left: auto;
|
||||
}
|
||||
.pad {
|
||||
padding: 0 var(--card-x) var(--sp-4);
|
||||
}
|
||||
.runs {
|
||||
margin: 0;
|
||||
}
|
||||
.runs td {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.runs .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sevs {
|
||||
display: inline-flex;
|
||||
gap: var(--sp-1);
|
||||
margin-right: var(--sp-2);
|
||||
}
|
||||
.rep {
|
||||
display: inline-flex;
|
||||
gap: var(--sp-1);
|
||||
align-items: center;
|
||||
}
|
||||
.rep + .rep {
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
padding: 0 var(--card-x) var(--sp-4);
|
||||
}
|
||||
.files h3 {
|
||||
margin: var(--sp-2) 0 0;
|
||||
font-weight: var(--fw-regular);
|
||||
}
|
||||
.files .file-row b {
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
.files .file-row .cap {
|
||||
display: block;
|
||||
}
|
||||
.facts-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { AlertCircle, Download, Eye } from "@lucide/vue";
|
||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import ProjectHead from "@/components/ProjectHead.vue";
|
||||
@@ -15,7 +15,7 @@ import { api, errorTitle } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { fmtDateTime } from "@/lib/labels";
|
||||
import { useCheckRun, useProject } from "@/lib/queries";
|
||||
import { reportFileUrl, useCheckRun, useProject, useReports } from "@/lib/queries";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const props = defineProps<{ projectId: string }>();
|
||||
@@ -27,10 +27,7 @@ const runQ = useCheckRun(runId);
|
||||
const run = computed(() => (runId.value ? runQ.data.value : undefined));
|
||||
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "报告" }]);
|
||||
|
||||
const reportsQ = useQuery({
|
||||
queryKey: computed(() => qk.reports(props.projectId)),
|
||||
queryFn: () => unwrap(api.GET("/projects/{project_id}/reports", { params: { path: { project_id: props.projectId } } })),
|
||||
});
|
||||
const reportsQ = useReports(() => props.projectId);
|
||||
const reports = computed(() => reportsQ.data.value?.items ?? []);
|
||||
|
||||
const citations = ref(true);
|
||||
@@ -69,9 +66,7 @@ async function exportReport() {
|
||||
/* ---------- 预览和下载:短时签名地址 ---------- */
|
||||
const previewId = ref<string | null>(null);
|
||||
const previewUrl = ref<string | null>(null);
|
||||
async function fileUrl(id: string) {
|
||||
return (await unwrap(api.GET("/reports/{report_id}/file", { params: { path: { report_id: id } } }))).url;
|
||||
}
|
||||
const fileUrl = reportFileUrl;
|
||||
async function preview(id: string) {
|
||||
try {
|
||||
previewUrl.value = await fileUrl(id);
|
||||
|
||||
@@ -4,20 +4,21 @@
|
||||
* - 检查进行中显示进度和取消;失败时写明原因和是否退回额度;
|
||||
* - 顶部是检查覆盖范围:查了什么、没查什么(m3-plan §2.5,红线 3);
|
||||
* - 问题按级别筛选,逐条处理:确认无误 / 已修改 / 忽略并注明原因;致命问题确认无误也要写说明(U4),
|
||||
* 说明框在行内展开;处理后显示署名,可以撤销(U 键在第 6 步);
|
||||
* 说明框在行内展开;处理后显示署名,可以撤销;
|
||||
* - 每个问题带招标原文和投标原文两侧出处,「未找到」写明找过哪些分册;点出处在右侧抽屉里看原文;
|
||||
* - 只有最近一次检查的问题可以处理,历史批次只读。
|
||||
* - 需人工核对(FR-M3-04、P2):份数、密封、递交、CA 加密等文件里看不出来的要求,按项目勾选,写勾选人和时间;
|
||||
* TODO(第 6 步):键盘操作、批量处理「提示」、判断有误反馈、导出报告。
|
||||
* - 键盘(规范 §8.2):J / K 上下条、O / Enter 看原文、1 / 2 / 3 处理、X 勾选「提示」、U 撤销、? 快捷键说明;
|
||||
* 「提示」可以批量确认无误;每个问题可以反馈判断有误;从工作台带 ?f=问题编号 进来时选中那一条。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { ChevronDown, ChevronRight, Download } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { RouterLink, useRoute } from "vue-router";
|
||||
import CitationBlock from "@/components/CitationBlock.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import DocStage, { type StageSlot } from "@/components/DocStage.vue";
|
||||
import FeedbackDialog, { type FeedbackTarget } from "@/components/FeedbackDialog.vue";
|
||||
import type { ViewerTarget } from "@/components/PdfViewer.vue";
|
||||
import ProjectHead from "@/components/ProjectHead.vue";
|
||||
import { api, errorTitle, isApiError } from "@/lib/api";
|
||||
@@ -261,11 +262,15 @@ function setCur(f: Finding, scroll = false) {
|
||||
if (changed) follow(f);
|
||||
if (scroll) requestAnimationFrame(() => document.querySelector(`[data-fid="${f.id}"]`)?.scrollIntoView({ block: "nearest" }));
|
||||
}
|
||||
// 第一次加载完:原文翻到第一条问题的出处
|
||||
// 从工作台「去处理」进来(?f=问题编号):选中那一条;否则第一次加载完,原文翻到第一条问题的出处
|
||||
const route = useRoute();
|
||||
watch(
|
||||
() => cur.value?.id,
|
||||
(id, old) => {
|
||||
if (id && !old && cur.value && !curId.value) follow(cur.value);
|
||||
if (!id || old || curId.value) return;
|
||||
const wanted = typeof route.query.f === "string" ? todo.value.find((x) => x.id === route.query.f) : undefined;
|
||||
if (wanted) setCur(wanted, true);
|
||||
else if (cur.value) follow(cur.value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -279,6 +284,11 @@ function next(f: Finding) {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || document.querySelector(".scrim")) return;
|
||||
if ((e.target as HTMLElement | null)?.closest("input, textarea, select, [role=listbox], [contenteditable]")) return;
|
||||
if (e.key === "?") {
|
||||
e.preventDefault();
|
||||
document.querySelector<HTMLElement>(".kb-tip")?.focus(); // 快捷键说明是提示气泡,聚焦就显示
|
||||
return;
|
||||
}
|
||||
const f = cur.value;
|
||||
if (!f) return;
|
||||
const k = e.key.toLowerCase();
|
||||
@@ -309,40 +319,16 @@ onMounted(() => window.addEventListener("keydown", onKey));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKey));
|
||||
|
||||
/* ---------- 判断有误?(J5):反馈只用于改进检查规则,不改变处理状态 ---------- */
|
||||
const FEEDBACK_REASONS = ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"];
|
||||
const fbFor = ref<Finding | null>(null);
|
||||
const fbReason = ref(FEEDBACK_REASONS[0]!);
|
||||
const fbNote = ref("");
|
||||
const fbBusy = ref(false);
|
||||
const fbOpen = computed({ get: () => !!fbFor.value, set: (v) => { if (!v) fbFor.value = null; } });
|
||||
const fbFor = ref<FeedbackTarget | null>(null);
|
||||
function askFeedback(f: Finding) {
|
||||
fbFor.value = f;
|
||||
fbReason.value = FEEDBACK_REASONS[0]!;
|
||||
fbNote.value = "";
|
||||
}
|
||||
async function sendFeedback() {
|
||||
const f = fbFor.value;
|
||||
if (!f) return;
|
||||
fbBusy.value = true;
|
||||
try {
|
||||
await unwrap(
|
||||
api.POST("/feedback", {
|
||||
body: {
|
||||
project_id: props.projectId,
|
||||
object_type: "finding",
|
||||
object_id: f.id,
|
||||
kind: "false_positive",
|
||||
note: [fbReason.value, fbNote.value.trim()].filter(Boolean).join(":").slice(0, 1000),
|
||||
},
|
||||
}),
|
||||
);
|
||||
fbFor.value = null;
|
||||
toast("已收到反馈");
|
||||
} catch (e) {
|
||||
toast(errorTitle(e), { ok: false });
|
||||
} finally {
|
||||
fbBusy.value = false;
|
||||
}
|
||||
fbFor.value = {
|
||||
objectType: "finding",
|
||||
objectId: f.id,
|
||||
title: f.title,
|
||||
kind: "false_positive",
|
||||
reasons: ["这不是问题(误报)", "级别不对", "出处定位错了", "其他"],
|
||||
note: "反馈只用于改进检查规则,不改变这条问题的处理状态。",
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- 检查覆盖范围:默认收起成一行,展开看全部(报告首页照样全文列出) ---------- */
|
||||
@@ -456,7 +442,7 @@ const errorText = computed(() => {
|
||||
<input type="checkbox" class="ck" :checked="allPicked" :indeterminate="!allPicked && pickedList.length > 0" :disabled="!hints.length" @change="pickAll(($event.target as HTMLInputElement).checked)" />全选「提示」
|
||||
</label>
|
||||
<span class="cap muted">已处理 <span class="fig">{{ doneCount }}</span> / <span class="fig">{{ live.length }}</span></span>
|
||||
<span class="cap muted hint-u tip-below tip-left" tabindex="0" data-tip="J / K 上下条(右边原文跟着翻) · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销">快捷键</span>
|
||||
<span class="cap muted hint-u tip-below tip-left kb-tip" tabindex="0" data-tip="J / K 上下条(右边原文跟着翻) · O 看原文 · 1 确认无误 · 2 已修改 · 3 忽略 · X 勾选「提示」 · U 撤销 · ? 显示这条说明">快捷键</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -579,17 +565,7 @@ const errorText = computed(() => {
|
||||
<div v-else-if="runQ.error.value" class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(runQ.error.value) }}</p></div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog v-model:open="fbOpen" title="反馈:判断有误" ok-text="提交反馈" busy-text="正在提交…" :busy="fbBusy" @ok="sendFeedback">
|
||||
<p class="muted fb-title">「{{ fbFor?.title }}」</p>
|
||||
<div class="fb-choices" role="radiogroup" aria-label="哪里不对">
|
||||
<label v-for="r in FEEDBACK_REASONS" :key="r" class="choice"><input v-model="fbReason" 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="fbNote" class="input" maxlength="200"></textarea>
|
||||
<span class="msg">反馈只用于改进检查规则,不改变这条问题的处理状态。</span>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<FeedbackDialog :project-id="projectId" :target="fbFor" @close="fbFor = null" />
|
||||
|
||||
</template>
|
||||
|
||||
@@ -715,18 +691,6 @@ const errorText = computed(() => {
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.seg {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工作台(原型 workbench.html):页头 + 统计 + 项目列表;右栏是正在进行的任务和本期额度。
|
||||
* 原型里的「待处理的致命问题」「最近处理」要等查标和操作记录的接口,骨架阶段不做。
|
||||
* 工作台(原型 workbench.html,ui-demo page-workbench.html):页头 + 统计 + 项目列表 + 待处理的致命问题;
|
||||
* 右栏是正在进行的任务和本期额度。统计可以点,按它筛选项目列表(再点一下取消)。
|
||||
* 「最近处理」要等操作记录的接口,还没做。
|
||||
*/
|
||||
import { Plus } from "@lucide/vue";
|
||||
import { computed } from "vue";
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { Filter, Plus, X } from "@lucide/vue";
|
||||
import { useQueries } from "@tanstack/vue-query";
|
||||
import { computed, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import ProjectRow from "@/components/ProjectRow.vue";
|
||||
import { errorTitle } from "@/lib/api";
|
||||
import { api, errorTitle } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { quotaUnit } from "@/lib/labels";
|
||||
import { useMe, useProjects, useQuota, useQuotaAccount } from "@/lib/queries";
|
||||
import { useMe, useProjects, useQuota, useQuotaAccount, type ProjectListItem } from "@/lib/queries";
|
||||
|
||||
useCrumbs(() => [{ label: "工作台" }]);
|
||||
|
||||
@@ -25,24 +29,50 @@ const hello = computed(() => {
|
||||
return `你好,${name}。今天是 ${today.getMonth() + 1}月${today.getDate()}日,周${"日一二三四五六"[today.getDay()]}。`;
|
||||
});
|
||||
|
||||
const list = computed(() => projects.data.value?.items ?? []);
|
||||
const active = computed(() => list.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
|
||||
const stats = computed(() => {
|
||||
const now = Date.now();
|
||||
const due7 = active.value.filter((p) => {
|
||||
if (!p.deadline_at) return false;
|
||||
const ms = new Date(p.deadline_at).getTime() - now;
|
||||
return ms >= 0 && ms <= 7 * 86_400_000;
|
||||
}).length;
|
||||
const sum = (f: (p: (typeof list.value)[number]) => number) => active.value.reduce((a, p) => a + f(p), 0);
|
||||
return [
|
||||
{ label: "进行中的项目", value: active.value.length, cls: "" },
|
||||
{ label: "7 天内截止", value: due7, cls: due7 ? "due" : "" },
|
||||
// 致命红只用于致命问题(§3.2)
|
||||
{ label: "待处理的致命问题", value: sum((p) => p.readiness.open_fatal_count), cls: "fatal" },
|
||||
{ label: "待确认的清单条目", value: sum((p) => p.pending_item_count ?? 0), cls: "" },
|
||||
];
|
||||
const all = computed(() => projects.data.value?.items ?? []);
|
||||
const active = computed(() => all.value.filter((p) => p.status !== "submitted" && p.status !== "archived"));
|
||||
const due7 = (p: ProjectListItem) => {
|
||||
if (!p.deadline_at) return false;
|
||||
const ms = new Date(p.deadline_at).getTime() - Date.now();
|
||||
return ms >= 0 && ms <= 7 * 86_400_000;
|
||||
};
|
||||
type StatKey = "active" | "due7" | "fatal" | "pending";
|
||||
const STAT: Record<StatKey, { label: string; fn: (p: ProjectListItem) => boolean; count: (p: ProjectListItem) => number; cls: string }> = {
|
||||
active: { label: "进行中的项目", fn: () => true, count: () => 1, cls: "" },
|
||||
due7: { label: "7 天内截止", fn: due7, count: (p) => (due7(p) ? 1 : 0), cls: "due" },
|
||||
// 致命红只用于致命问题(§3.2)
|
||||
fatal: { label: "待处理的致命问题", fn: (p) => p.readiness.open_fatal_count > 0, count: (p) => p.readiness.open_fatal_count, cls: "fatal" },
|
||||
pending: { label: "待确认的清单条目", fn: (p) => (p.pending_item_count ?? 0) > 0, count: (p) => p.pending_item_count ?? 0, cls: "" },
|
||||
};
|
||||
const stats = computed(() =>
|
||||
(Object.keys(STAT) as StatKey[]).map((key) => ({ key, ...STAT[key], value: active.value.reduce((a, p) => a + STAT[key].count(p), 0) })),
|
||||
);
|
||||
/** 点了哪个统计:按它筛选进行中的项目;没点时列全部项目(含已递交、已归档) */
|
||||
const filter = ref<StatKey | null>(null);
|
||||
function toggle(k: StatKey) {
|
||||
filter.value = filter.value === k ? null : k;
|
||||
}
|
||||
const list = computed(() => (filter.value ? active.value.filter(STAT[filter.value].fn) : all.value));
|
||||
|
||||
/* ---------- 待处理的致命问题:有致命问题的项目,取最近一次检查里还没处理的致命问题 ---------- */
|
||||
const withFatal = computed(() => active.value.filter((p) => p.readiness.open_fatal_count > 0 && p.latest_check_run_id));
|
||||
const fatalQs = useQueries({
|
||||
queries: computed(() =>
|
||||
withFatal.value.map((p) => ({
|
||||
queryKey: qk.findings(p.latest_check_run_id!),
|
||||
queryFn: () => unwrap(api.GET("/check-runs/{run_id}/findings", { params: { path: { run_id: p.latest_check_run_id! } } })),
|
||||
})),
|
||||
),
|
||||
});
|
||||
const TODO_MAX = 5;
|
||||
const todos = computed(() =>
|
||||
withFatal.value.flatMap((p, i) =>
|
||||
(fatalQs.value[i]?.data?.items ?? [])
|
||||
.filter((f) => f.severity === "fatal" && f.status === "pending" && f.presence !== "resolved")
|
||||
.map((f) => ({ p, f, cite: f.bid_citations[0] ?? f.tender_citations[0] ?? null })),
|
||||
),
|
||||
);
|
||||
const fatalTotal = computed(() => withFatal.value.reduce((a, p) => a + p.readiness.open_fatal_count, 0));
|
||||
const jobs = computed(() => list.value.filter((p) => p.active_job));
|
||||
const accounts = computed(() => quota.data.value?.accounts.filter((a) => a.period_granted > 0) ?? []);
|
||||
const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ?? "");
|
||||
@@ -61,17 +91,22 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="stats" aria-label="项目统计">
|
||||
<div v-for="s in stats" :key="s.label" class="stat">
|
||||
<section class="stats" aria-label="项目统计,点击可筛选项目列表">
|
||||
<button v-for="s in stats" :key="s.key" type="button" class="stat" :aria-pressed="filter === s.key" @click="toggle(s.key)">
|
||||
<small>{{ s.label }}</small><span class="big" :class="s.value ? s.cls : ''">{{ s.value }}</span>
|
||||
</div>
|
||||
<Filter class="ic" :stroke-width="1.5" />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="wb">
|
||||
<div class="stack">
|
||||
<section class="card flush" aria-labelledby="h-proj">
|
||||
<div class="card-h">
|
||||
<h2 id="h-proj">项目</h2>
|
||||
<span v-if="list.length" class="fig muted">{{ list.length }}</span>
|
||||
<button v-if="filter" type="button" class="btn text sm chip-clear" @click="filter = null">
|
||||
{{ STAT[filter].label }}<X class="ic" :stroke-width="1.5" /><span class="vh">,清除筛选</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="projects.isPending.value" class="card-b">
|
||||
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
|
||||
@@ -84,11 +119,15 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="list.length">
|
||||
<div class="prow-head" aria-hidden="true"><span>项目</span><span>投标截止</span><span>状态</span><span class="c">待确认</span><span>下一步</span><span></span></div>
|
||||
<div class="prow-head" aria-hidden="true"><span>项目</span><span>投标截止</span><span>状态</span><span class="c">致命</span><span>下一步</span><span></span></div>
|
||||
<ul class="prows" aria-label="项目列表">
|
||||
<ProjectRow v-for="p in list" :key="p.id" :project="p" />
|
||||
</ul>
|
||||
</template>
|
||||
<div v-else-if="filter" class="empty sm">
|
||||
<b>没有符合条件的项目</b>
|
||||
<p>清除筛选,看全部项目。</p>
|
||||
</div>
|
||||
<div v-else class="empty">
|
||||
<b>还没有项目</b>
|
||||
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
|
||||
@@ -96,6 +135,26 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="fatalTotal" class="card flush" aria-labelledby="h-todo">
|
||||
<div class="card-h">
|
||||
<h2 id="h-todo">待处理的致命问题</h2>
|
||||
<span class="sev fatal">致命<span class="fig">{{ fatalTotal }}</span></span>
|
||||
<button v-if="todos.length > TODO_MAX" type="button" class="lnk cap more" @click="filter = 'fatal'">只看有致命问题的项目</button>
|
||||
</div>
|
||||
<ul class="todos">
|
||||
<li v-for="{ p, f, cite } in todos.slice(0, TODO_MAX)" :key="f.id" class="todo">
|
||||
<span class="sev fatal">致命</span>
|
||||
<div>
|
||||
<b>{{ f.title }}</b>
|
||||
<span class="psub">{{ p.name }}<template v-if="cite"> · {{ cite.document_label }} 第 <span class="fig">{{ cite.page_no }}</span> 页</template></span>
|
||||
</div>
|
||||
<RouterLink class="btn sm" :to="{ name: 'results', params: { projectId: p.id, runId: p.latest_check_run_id! }, query: { f: f.id } }">去处理</RouterLink>
|
||||
</li>
|
||||
<li v-if="!todos.length" class="todo"><span class="cap muted">正在读取…</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="stack" aria-label="任务与额度">
|
||||
<section class="card flush" aria-labelledby="h-task">
|
||||
<div class="card-h"><h3 id="h-task">正在进行的任务</h3></div>
|
||||
@@ -156,6 +215,31 @@ const planEnds = computed(() => quota.data.value?.plan.ends_at?.slice(0, 10) ??
|
||||
.none {
|
||||
margin: 0;
|
||||
}
|
||||
.chip-clear,
|
||||
.more {
|
||||
margin-left: auto;
|
||||
}
|
||||
/* 待处理的致命问题:不加左侧红竖线,靠实心「致命」标签扫读(ui-demo d5) */
|
||||
.todos {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.todo {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
column-gap: var(--sp-4);
|
||||
align-items: center;
|
||||
padding: var(--sp-3) var(--card-x);
|
||||
border-top: var(--line) solid var(--border-subtle);
|
||||
}
|
||||
.todo b {
|
||||
display: block;
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
.todo .psub {
|
||||
display: block;
|
||||
}
|
||||
.task {
|
||||
display: block;
|
||||
color: inherit;
|
||||
|
||||
@@ -200,6 +200,16 @@
|
||||
.stat .big{font:var(--fw-bold) var(--fs-figure)/var(--lh-figure) var(--font-figures);font-variant-numeric:tabular-nums}
|
||||
.stat .big.fatal{color:var(--sev-fatal)}
|
||||
.stat .big.due{color:var(--warn-text)}
|
||||
/* 可点的统计(ui-demo 工作台):点一下按它筛选项目列表,再点一下取消 */
|
||||
button.stat{cursor:pointer;transition:background-color var(--dur-fast) var(--ease)}
|
||||
button.stat:hover{background:var(--c-n50)}
|
||||
button.stat:focus-visible{outline-offset:-2px;border-radius:0}
|
||||
.stat::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--c-ink);transform:scaleY(0);transform-origin:bottom;transition:transform var(--dur-fast) var(--ease)}
|
||||
.stat[aria-pressed="true"]::after{transform:scaleY(1)}
|
||||
.stat[aria-pressed="true"] small{color:var(--text-primary);font-weight:var(--fw-bold)}
|
||||
.stat > .ic{position:absolute;top:18px;right:var(--card-x);color:var(--text-secondary);opacity:0;transition:opacity var(--dur-fast) var(--ease)}
|
||||
.stat:hover > .ic,.stat:focus-visible > .ic,.stat[aria-pressed="true"] > .ic{opacity:1}
|
||||
.stat[aria-pressed="true"] > .ic{color:var(--text-primary)}
|
||||
|
||||
/* B19 列表卡片:左侧分类导航 + 列表;列表里的分组标题(.ai 是清单顶部的「待确认」分组) */
|
||||
.cmp-list{display:grid;grid-template-columns:176px minmax(0,1fr);min-height:0;overflow:hidden}
|
||||
@@ -339,3 +349,26 @@ textarea.input{height:auto;min-height:80px;padding:var(--sp-2) var(--sp-3);resiz
|
||||
.switch .st{min-width:2em;font-size:var(--fs-caption);color:var(--text-secondary)}
|
||||
.switch[aria-checked="true"] .st{color:var(--text-primary)}
|
||||
.switch[disabled]{color:var(--text-disabled);cursor:not-allowed}
|
||||
|
||||
/* B6 日期输入与日期选择(§7.22、§9.3,ui-demo demo.css 的 .dt / .cal):DateInput.vue。
|
||||
* 选择器是 Reka UI 的 Calendar(表格结构),格子状态看 data-today / data-selected / data-disabled / data-outside-view */
|
||||
.dt{position:relative;display:block}
|
||||
.dt > .input{width:100%;padding-right:40px;font-family:var(--font-figures);font-variant-numeric:tabular-nums;font-size:var(--fs-figure-sm)}
|
||||
.dt > .input::placeholder{font-family:var(--font-sans);font-size:var(--fs-body)}
|
||||
.dt > .icon-btn{position:absolute;top:2px;right:2px;color:var(--text-secondary)}
|
||||
.dt > .icon-btn:hover,.dt > .icon-btn[aria-expanded="true"]{color:var(--text-primary)}
|
||||
.cal{z-index:56;width:288px;padding:var(--sp-3);background:var(--bg-surface);border:var(--line) solid var(--border-subtle);border-radius:var(--radius-card);box-shadow:var(--shadow-overlay)}
|
||||
.cal-h{display:flex;align-items:center;gap:var(--sp-1);margin-bottom:var(--sp-2)}
|
||||
.cal-h > div,.cal-h > h2,.cal-h b{flex:1;text-align:center;font-weight:var(--fw-bold)}
|
||||
.cal-grid{width:100%;border-collapse:separate;border-spacing:2px;table-layout:fixed}
|
||||
.cal-grid .wd{font-size:var(--fs-caption);line-height:28px;font-weight:var(--fw-regular);color:var(--text-secondary)}
|
||||
.cal-grid td{padding:0}
|
||||
.cal-grid .day{display:grid;place-items:center;height:34px;border-radius:var(--radius-ctl);font:var(--fw-medium) var(--fs-figure-sm)/1 var(--font-figures);font-variant-numeric:tabular-nums;color:var(--text-primary);cursor:pointer;outline:none;transition:background-color var(--dur-fast) var(--ease)}
|
||||
.cal-grid .day:hover{background:var(--c-n100)}
|
||||
.cal-grid .day:focus-visible{box-shadow:0 0 0 2px var(--focus-ring)}
|
||||
.cal-grid .day[data-today]{box-shadow:inset 0 0 0 1px var(--border-control)}
|
||||
.cal-grid .day[data-selected]{background:var(--c-ink);color:var(--text-on-dark);box-shadow:none}
|
||||
.cal-grid .day[data-selected]:hover{background:var(--primary-hover)}
|
||||
.cal-grid .day[data-disabled]{color:var(--text-disabled);cursor:not-allowed;background:none;font-weight:var(--fw-regular)}
|
||||
/* 非本月的格子留空(不显示日期),和不可选日期不混淆(v0.2) */
|
||||
.cal-grid .day[data-outside-view]{visibility:hidden}
|
||||
|
||||
Reference in New Issue
Block a user