feat(web): 用户端骨架——登录、工作台、新建项目、解析进度、读标清单
- packages/api-client:从 openapi.yaml 生成类型;cookie + CSRF / Bearer 两种鉴权、 分片上传、fetch 读事件流(API2) - apps/web:Vue 3 + Vite + TanStack Query,样式只来自 tokens.css / components.css(AR10); 扣点说明放在「开始解析」旁边(红线 9);接口地址、路由方式运行时可配(架构 §5.2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
262
apps/web/src/pages/ChecklistPage.vue
Normal file
262
apps/web/src/pages/ChecklistPage.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 读标(原型 checklist.html):项目头 + 项目检查清单。
|
||||
* 清单按分类分组,待确认区放在顶部、不和正式清单混排(§7.6);顶部写明标注规则和数量核对提示。
|
||||
* 骨架阶段只读:采纳 / 修改 / 确认清单等写接口还没实现。
|
||||
* TODO(pdf.js):右侧原文查看器(原型的 .cmp 对照布局)。现在每条直接显示「第 N 页」和原句。
|
||||
*/
|
||||
import { FileText, Info, Search } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import ChecklistItemRow from "@/components/ChecklistItemRow.vue";
|
||||
import CitationBlock from "@/components/CitationBlock.vue";
|
||||
import { errorTitle } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { category, CATEGORY_ORDER, deadline, fmtDateTime, legalRegime, MARKER, projectStatus } from "@/lib/labels";
|
||||
import { useChecklist, useChecklistItems, useProject, type ChecklistItem, type MarkingRule } from "@/lib/queries";
|
||||
|
||||
const props = defineProps<{ projectId: string }>();
|
||||
|
||||
const project = useProject(() => props.projectId);
|
||||
const checklist = useChecklist(() => props.projectId);
|
||||
const itemsQ = useChecklistItems(() => props.projectId);
|
||||
|
||||
const p = computed(() => project.data.value);
|
||||
const cl = computed(() => checklist.data.value);
|
||||
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: p.value?.name ?? "项目" }, { label: "读标" }]);
|
||||
|
||||
const meta = computed(() =>
|
||||
p.value ? [p.value.purchaser, p.value.legal_regime !== "unknown" ? legalRegime(p.value.legal_regime) : "", p.value.procurement_method, p.value.code].filter(Boolean).join(" · ") : "",
|
||||
);
|
||||
const due = computed(() => deadline(p.value?.deadline_at));
|
||||
|
||||
/* ---------- 分组与筛选 ---------- */
|
||||
const all = computed(() => (itemsQ.data.value?.items ?? []).filter((i) => !i.deleted_at));
|
||||
const formal = computed(() => all.value.filter((i) => i.zone === "formal"));
|
||||
const pending = computed(() => all.value.filter((i) => i.zone === "pending"));
|
||||
|
||||
type Cat = "all" | "pending" | ChecklistItem["categories"][number];
|
||||
const cat = ref<Cat>("all");
|
||||
const kw = ref("");
|
||||
const starOnly = ref(false);
|
||||
|
||||
const catCount = (c: string) => formal.value.filter((i) => i.categories.includes(c as ChecklistItem["categories"][number])).length;
|
||||
const cats = computed(() => CATEGORY_ORDER.filter((c) => catCount(c) > 0));
|
||||
|
||||
const match = (i: ChecklistItem) => (!starOnly.value || i.importance === "substantive") && (!kw.value || i.title.includes(kw.value.trim()));
|
||||
|
||||
/** 「全部」视图下每条只出现一次,放在它的第一个分类里(一条要求可以属于多个分类) */
|
||||
const groups = computed(() => {
|
||||
const order = cat.value === "all" ? cats.value : cats.value.filter((c) => c === cat.value);
|
||||
const seen = new Set<string>();
|
||||
return order
|
||||
.map((c) => {
|
||||
const rows = formal.value.filter((i) => i.categories.includes(c) && match(i) && (cat.value !== "all" || !seen.has(i.id)));
|
||||
rows.forEach((i) => seen.add(i.id));
|
||||
return { id: c, name: category(c), rows };
|
||||
})
|
||||
.filter((g) => g.rows.length);
|
||||
});
|
||||
const showTop = computed(() => cat.value === "all" && !kw.value && !starOnly.value);
|
||||
const showPending = computed(() => (cat.value === "all" && showTop.value) || cat.value === "pending");
|
||||
|
||||
/* ---------- 标注规则(如「打 ★ 的为实质性要求」)和数量核对 ---------- */
|
||||
function ruleText(r: MarkingRule): string {
|
||||
const mk = r.marker === "keyword" ? `「${r.marker_text ?? ""}」字样` : `「${r.marker_text || MARKER[r.marker]}」`;
|
||||
const head = `标注${mk}的为${r.meaning === "substantive" ? "实质性要求" : "重要要求"}。`;
|
||||
return r.declared_count == null
|
||||
? `${head}文件没写这类条款的总数,已识别 ${r.found_count} 条,请对照原文核对有无遗漏。`
|
||||
: `${head}文件写明共 ${r.declared_count} 条,已识别 ${r.found_count} 条。`;
|
||||
}
|
||||
|
||||
const loading = computed(() => itemsQ.isPending.value || checklist.isPending.value);
|
||||
const loadErr = computed(() => itemsQ.error.value ?? checklist.error.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="ph">
|
||||
<div class="ph-l1">
|
||||
<div>
|
||||
<h1 :title="p?.name">{{ p?.name ?? " " }}</h1>
|
||||
<p class="ph-meta">{{ meta || "招标文件信息待解析" }}</p>
|
||||
</div>
|
||||
<dl v-if="p" class="facts">
|
||||
<div>
|
||||
<dt>投标截止</dt>
|
||||
<dd>
|
||||
<template v-if="due"><span class="fig m">{{ due.sub }}</span><span :class="due.cls || 'muted'">{{ due.main }}</span></template>
|
||||
<span v-else class="muted">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>状态</dt><dd><span class="tag">{{ projectStatus(p.status) }}</span></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<!-- 骨架阶段只有「读标」一页;概览、查标、报告做到时在这里加标签页 -->
|
||||
<nav class="tabs" aria-label="项目"><a role="tab" aria-selected="true" aria-current="page">读标</a></nav>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<div class="cl-bar">
|
||||
<h2>项目检查清单</h2>
|
||||
<div v-if="cl" class="status">
|
||||
<template v-if="cl.status === 'confirmed'">
|
||||
<span class="tag">已确认 v{{ cl.version_no }}</span>
|
||||
<span v-if="cl.confirmed_by" class="sign">
|
||||
<span aria-hidden="true">✓</span><b>{{ cl.confirmed_by.name }}</b><span v-if="cl.confirmed_at" class="fig">{{ fmtDateTime(cl.confirmed_at) }}</span><span>确认</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="tag">草稿</span>
|
||||
<span class="cap muted">{{ cl.version_no ? `上一版 v${cl.version_no} 已确认` : "还没确认过;确认后,查标以这一版清单为依据" }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<span v-if="cl" class="counts cap muted">正式清单 <span class="fig">{{ cl.counts.formal }}</span> 条 · 待确认 <span class="fig">{{ cl.counts.pending }}</span> 条</span>
|
||||
</div>
|
||||
|
||||
<div v-for="w in cl?.warnings ?? []" :key="w.code" class="alert warn warn-row" role="note">
|
||||
<span class="ico w" aria-hidden="true">!</span>
|
||||
<p>{{ w.message }}</p>
|
||||
</div>
|
||||
|
||||
<section class="card flush cmp-list" aria-label="项目检查清单">
|
||||
<nav class="cats" aria-label="清单分类">
|
||||
<button type="button" :aria-current="cat === 'all'" @click="cat = 'all'">全部<span class="fig">{{ formal.length }}</span></button>
|
||||
<button v-for="c in cats" :key="c" type="button" :aria-current="cat === c" @click="cat = c">{{ category(c) }}<span class="fig">{{ catCount(c) }}</span></button>
|
||||
<hr />
|
||||
<button type="button" class="ai" :aria-current="cat === 'pending'" @click="cat = 'pending'">待确认<span class="fig">{{ pending.length }}</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="lst">
|
||||
<div v-if="loading" class="lst-pad"><span class="skel t"></span><span class="skel"></span><span class="skel"></span></div>
|
||||
<div v-else-if="loadErr" class="lst-pad">
|
||||
<div class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(loadErr) }}</p></div>
|
||||
</div>
|
||||
<div v-else-if="!all.length" class="empty">
|
||||
<FileText class="ic xl" :stroke-width="1.25" />
|
||||
<b>还没有清单</b>
|
||||
<p>上传招标文件并解析后,这里会逐条列出要求,每一条都带原文出处。</p>
|
||||
<RouterLink class="btn primary" :to="{ name: 'project-new', query: { project: projectId } }">上传招标文件</RouterLink>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="cat !== 'pending'" class="lst-tb">
|
||||
<label class="search">
|
||||
<Search class="ic" :stroke-width="1.5" />
|
||||
<input v-model="kw" class="input" placeholder="搜索清单" aria-label="搜索清单" />
|
||||
</label>
|
||||
<label class="choice cap"><input v-model="starOnly" type="checkbox" class="ck" />只看 ★</label>
|
||||
</div>
|
||||
|
||||
<template v-if="showTop">
|
||||
<div v-for="r in cl?.marking_rules ?? []" :key="r.id" class="rule">
|
||||
<Info class="ic" :stroke-width="1.5" />
|
||||
<div>
|
||||
<p>{{ ruleText(r) }}</p>
|
||||
<CitationBlock :citation="r.citation" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 待确认(§7.6):清单顶部的分组,不和正式清单混排;确认前不参与检查 -->
|
||||
<section v-if="showPending && pending.length" aria-label="待确认">
|
||||
<div class="grp-h ai"><b>待确认</b><span class="fig">{{ pending.length }}</span><p>没找到可靠出处,或把握不大。确认前不参与检查。</p></div>
|
||||
<ChecklistItemRow v-for="i in pending" :key="i.id" :item="i" pending />
|
||||
</section>
|
||||
<div v-if="cat === 'pending' && !pending.length" class="empty sm"><b>没有待确认的条目</b></div>
|
||||
|
||||
<template v-if="cat !== 'pending'">
|
||||
<section v-for="g in groups" :key="g.id" :aria-label="g.name">
|
||||
<div class="grp-h"><b>{{ g.name }}</b><span class="fig">{{ g.rows.length }}</span></div>
|
||||
<ChecklistItemRow v-for="i in g.rows" :key="i.id" :item="i" />
|
||||
</section>
|
||||
<div v-if="!groups.length" class="empty sm">
|
||||
<b>{{ kw ? `没有找到「${kw}」` : "这个分类下没有符合条件的条目" }}</b>
|
||||
<p v-if="kw">只搜条目标题。</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="end"></div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 清单栏:标题 + 清单状态 + 数量 */
|
||||
.cl-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.cl-bar h2 {
|
||||
margin: 0;
|
||||
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-sans);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cl-bar .status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cl-bar .status .sign {
|
||||
margin: 0;
|
||||
}
|
||||
.cl-bar .counts {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.warn-row {
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
/* 原型里工具条吸顶(整屏布局下列表自己滚动);这里页面整体滚动、卡片 overflow:hidden,先不吸顶,接原文查看器时恢复 */
|
||||
.lst-tb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--card-x);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: var(--line) solid var(--border-subtle);
|
||||
border-top-right-radius: var(--radius-card);
|
||||
}
|
||||
.lst-tb .search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.lst-tb .search .input {
|
||||
height: var(--control-h-sm);
|
||||
}
|
||||
.lst-tb .search > .ic {
|
||||
top: var(--sp-2);
|
||||
}
|
||||
.lst-tb .choice {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
.lst-tb .choice .ck {
|
||||
margin-top: 0;
|
||||
}
|
||||
.lst-pad {
|
||||
padding: var(--card-y) var(--card-x);
|
||||
}
|
||||
.rule {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
align-items: flex-start;
|
||||
padding: var(--sp-3) var(--card-x) 0;
|
||||
}
|
||||
.rule > .ic {
|
||||
margin-top: var(--sp-1);
|
||||
flex: none;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rule p {
|
||||
margin: 0 0 var(--sp-2);
|
||||
}
|
||||
.end {
|
||||
height: var(--sp-8);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user