Files
bid-doctor/apps/web/src/pages/WorkbenchPage.vue
YANG JIANKUAN 54cbb556d0
Some checks failed
ci / server (push) Has been cancelled
ci / web (push) Has been cancelled
ci / contract (push) Has been cancelled
feat(ui): 查标第 6 步前端补齐
- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
  「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
  (新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:39:19 +08:00

273 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
/**
* 工作台(原型 workbench.html,ui-demo page-workbench.html):页头 + 统计 + 项目列表 + 待处理的致命问题;
* 右栏是正在进行的任务和本期额度。统计可以点,按它筛选项目列表(再点一下取消)。
* 「最近处理」要等操作记录的接口,还没做。
*/
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 { 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, type ProjectListItem } from "@/lib/queries";
useCrumbs(() => [{ label: "工作台" }]);
const me = useMe();
const projects = useProjects();
const quota = useQuota();
const parse = useQuotaAccount("parse");
const today = new Date();
const hello = computed(() => {
const name = me.data.value?.user.display_name ?? "";
return `你好,${name}。今天是 ${today.getMonth() + 1}月${today.getDate()}日,周${"日一二三四五六"[today.getDay()]}。`;
});
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) ?? "");
</script>
<template>
<div class="content">
<div class="page-title">
<div>
<h1>工作台</h1>
<p>{{ hello }}</p>
</div>
<div class="actions">
<span v-if="parse" class="cost">解析 1 份招标文件扣 <span class="fig">1</span> 次 · 剩 <span class="fig">{{ parse.balance }}</span> 次</span>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</div>
</div>
<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>
<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>
</div>
<div v-else-if="projects.isError.value" class="card-b">
<div class="alert warn" role="alert">
<span class="ico w" aria-hidden="true">!</span>
<p>{{ errorTitle(projects.error.value) }}</p>
<button class="btn sm" type="button" @click="projects.refetch()">重试</button>
</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>
<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>
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
</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>
<div class="card-b">
<p v-if="!jobs.length" class="cap muted none">没有正在进行的任务</p>
<RouterLink
v-for="p in jobs"
:key="p.id"
class="task"
:to="p.active_job!.kind === 'parse_job' ? { name: 'parse', params: { projectId: p.id, jobId: p.active_job!.id } } : { name: 'checklist', params: { projectId: p.id } }"
>
<b>{{ p.active_job!.label || "任务进行中" }}</b>
<span class="cap muted">{{ p.name }}</span>
<div class="progress">
<div class="bar" role="progressbar" :aria-valuenow="p.active_job!.progress ?? 0" aria-valuemin="0" aria-valuemax="100" :aria-label="`${p.name}的进度`">
<i :style="{ width: `${p.active_job!.progress ?? 0}%` }"></i>
</div>
<span class="fig">{{ p.active_job!.progress ?? 0 }}%</span>
</div>
</RouterLink>
</div>
</section>
<section class="card flush quota" aria-labelledby="h-quota">
<div class="card-h">
<h3 id="h-quota">本期额度</h3>
<span v-if="planEnds" class="cap muted ends">至 {{ planEnds }}</span>
</div>
<div class="card-b">
<div v-for="a in accounts" :key="a.unit" class="acct">
<small class="muted">{{ quotaUnit(a.unit) }}剩余</small>
<div>
<span class="big" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }">{{ a.balance }}</span><span class="unit">/ {{ a.period_granted }}</span>
</div>
<div class="bar" :class="{ warn: (a.used_ratio ?? 0) >= 0.8 }" aria-hidden="true">
<i :style="{ width: `${Math.round((a.used_ratio ?? 0) * 100)}%` }"></i>
</div>
<p v-if="a.held" class="cap muted">含正在预扣的 <span class="fig">{{ a.held }}</span> 次,解析失败、超时或取消会退回</p>
</div>
</div>
</section>
</aside>
</div>
</div>
</template>
<style scoped>
.stats {
margin-bottom: var(--gap-card);
}
/* 主列 + 右侧信息栏(--aside-w),全页一个间距 --gap-card */
.wb {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--aside-w);
gap: var(--gap-card);
align-items: start;
}
.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;
text-decoration: none;
}
.task + .task {
margin-top: var(--sp-3);
padding-top: var(--sp-3);
border-top: var(--line) solid var(--border-subtle);
}
.task b,
.task .cap {
display: block;
}
.task .progress {
margin-top: var(--sp-2);
}
.ends {
margin-left: auto;
}
.acct + .acct {
margin-top: var(--sp-4);
}
.quota .big.warn {
color: var(--warn-text);
}
.acct p {
margin: 0;
}
</style>