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:
188
apps/web/src/pages/WorkbenchPage.vue
Normal file
188
apps/web/src/pages/WorkbenchPage.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 工作台(原型 workbench.html):页头 + 统计 + 项目列表;右栏是正在进行的任务和本期额度。
|
||||
* 原型里的「待处理的致命问题」「最近处理」要等查标和操作记录的接口,骨架阶段不做。
|
||||
*/
|
||||
import { Plus } from "@lucide/vue";
|
||||
import { computed } from "vue";
|
||||
import { RouterLink } from "vue-router";
|
||||
import ProjectRow from "@/components/ProjectRow.vue";
|
||||
import { errorTitle } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { quotaUnit } from "@/lib/labels";
|
||||
import { useMe, useProjects, useQuota, useQuotaAccount } 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 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 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="项目统计">
|
||||
<div v-for="s in stats" :key="s.label" class="stat">
|
||||
<small>{{ s.label }}</small><span class="big" :class="s.value ? s.cls : ''">{{ s.value }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="wb">
|
||||
<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>
|
||||
</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 class="empty">
|
||||
<b>还没有项目</b>
|
||||
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
|
||||
<RouterLink class="btn primary" :to="{ name: 'project-new' }"><Plus class="ic" :stroke-width="1.5" />新建项目</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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;
|
||||
}
|
||||
.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>
|
||||
Reference in New Issue
Block a user