- 读标页:每条清单项写最近一次检查的结论,点投标侧出处看投标原文;
「已响应」也能反馈判断有误(漏报);证书要求写资质库里的情况
(新接口 GET /projects/{id}/qualification-matches,资质库空时不写)
- 概览:检查记录卡(历次检查、各级问题数、报告下载)、文件卡
- 工作台:「致命」列、统计可点筛选、待处理的致命问题卡(?f= 定位到问题)
- 日期输入加日历弹层(DateInput,Reka UI Calendar)
- 查标页:? 键显示快捷键说明;反馈对话框抽成 FeedbackDialog 共用
- 扫描件提示去掉多余空格
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
11 KiB
Vue
273 lines
11 KiB
Vue
<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>
|