feat(ui): 查标第 6 步前端补齐
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled

- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 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:
YANG JIANKUAN
2026-11-05 13:39:19 +08:00
parent 808259da87
commit 54cbb556d0
30 changed files with 956 additions and 143 deletions

View File

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