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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user