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:
276
apps/web/src/pages/ParsePage.vue
Normal file
276
apps/web/src/pages/ParsePage.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 解析进度(原型 parsing.html,设计规范 §7.11):分阶段列表 + 进度条。
|
||||
* 进度来自事件流(AppShell 订阅,写进 parse-job 缓存),GET /parse-jobs/{id} 每 5 秒轮询兜底。
|
||||
* 失败写明原因和额度是否已退回(error.refunded);取消、重试都在本页。
|
||||
*/
|
||||
import { unwrap } from "@bid-doctor/api-client";
|
||||
import { RefreshCw, CircleAlert, Upload } from "@lucide/vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
import { RouterLink, useRouter } from "vue-router";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { api, errorTitle, isApiError } from "@/lib/api";
|
||||
import { useCrumbs } from "@/lib/crumbs";
|
||||
import { qk } from "@/lib/keys";
|
||||
import { fmtDateTime, fmtDuration, PARSE_STEPS, parseStepIndex } from "@/lib/labels";
|
||||
import { useDocument, useParseJob, useProject, useQuotaAccount } from "@/lib/queries";
|
||||
|
||||
const props = defineProps<{ projectId: string; jobId: string }>();
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const job = useParseJob(() => props.jobId);
|
||||
const j = computed(() => job.data.value);
|
||||
const doc = useDocument(() => j.value?.document_id);
|
||||
const project = useProject(() => props.projectId);
|
||||
const parse = useQuotaAccount("parse");
|
||||
|
||||
useCrumbs(() => [
|
||||
{ label: "工作台", to: { name: "workbench" } },
|
||||
{ label: project.data.value?.name ?? "项目", to: { name: "checklist", params: { projectId: props.projectId } } },
|
||||
{ label: "解析" },
|
||||
]);
|
||||
|
||||
const status = computed(() => j.value?.status);
|
||||
const running = computed(() => status.value === "queued" || status.value === "running");
|
||||
const heading = computed(
|
||||
() =>
|
||||
({ queued: "排队中", running: "正在解析", succeeded: "解析完成", failed: "解析失败", cancelled: "已取消解析" })[status.value ?? "queued"],
|
||||
);
|
||||
|
||||
/** 当前在第几步;成功时全部完成 */
|
||||
const cur = computed(() => (status.value === "succeeded" ? PARSE_STEPS.length : parseStepIndex(j.value?.stage)));
|
||||
function stepClass(k: number) {
|
||||
if (status.value === "failed" && k === Math.max(0, cur.value)) return "fail";
|
||||
if (k < cur.value) return "done";
|
||||
if (k === cur.value && running.value) return "now";
|
||||
return "todo";
|
||||
}
|
||||
|
||||
/* 已用时:从任务创建算起,进行中每秒刷新 */
|
||||
const now = ref(Date.now());
|
||||
const tick = window.setInterval(() => (now.value = Date.now()), 1000);
|
||||
onBeforeUnmount(() => window.clearInterval(tick));
|
||||
const elapsed = computed(() => {
|
||||
if (!j.value) return null;
|
||||
const end = j.value.finished_at ? new Date(j.value.finished_at).getTime() : now.value;
|
||||
return fmtDuration(end - new Date(j.value.created_at).getTime());
|
||||
});
|
||||
|
||||
/* 结束后刷新额度和项目列表(事件流断线时也能拿到最新结果) */
|
||||
watch(running, (r, was) => {
|
||||
if (was && !r) {
|
||||
void qc.invalidateQueries({ queryKey: qk.quota });
|
||||
void qc.invalidateQueries({ queryKey: qk.projects });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 取消 ---------- */
|
||||
const cancelOpen = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const actionErr = ref("");
|
||||
async function cancel() {
|
||||
cancelling.value = true;
|
||||
actionErr.value = "";
|
||||
try {
|
||||
const r = await unwrap(api.POST("/parse-jobs/{job_id}/cancel", { params: { path: { job_id: props.jobId } } }));
|
||||
qc.setQueryData(qk.parseJob(props.jobId), r);
|
||||
} catch (e) {
|
||||
if (!isApiError(e, "job_finished")) actionErr.value = errorTitle(e);
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
cancelOpen.value = false;
|
||||
void qc.invalidateQueries({ queryKey: qk.parseJob(props.jobId) });
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 重试:新建一条任务并重新预扣 1 次,上一条已经退回(FR-M2-02) ---------- */
|
||||
const retrying = ref(false);
|
||||
async function retry() {
|
||||
if (!j.value) return;
|
||||
retrying.value = true;
|
||||
actionErr.value = "";
|
||||
try {
|
||||
const r = await unwrap(
|
||||
api.POST("/documents/{document_id}/parse-jobs", {
|
||||
params: { path: { document_id: j.value.document_id } },
|
||||
body: { expected_charge: 1 },
|
||||
}),
|
||||
);
|
||||
void qc.invalidateQueries({ queryKey: qk.quota });
|
||||
await router.replace({ name: "parse", params: { projectId: props.projectId, jobId: r.id } });
|
||||
} catch (e) {
|
||||
const other = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
|
||||
if (typeof other === "string") await router.replace({ name: "parse", params: { projectId: props.projectId, jobId: other } });
|
||||
else actionErr.value = `${errorTitle(e)}。本次没有扣点。`;
|
||||
} finally {
|
||||
retrying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const result = computed(() => j.value?.result ?? null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content narrow">
|
||||
<div class="page-title">
|
||||
<div>
|
||||
<h1>解析招标文件</h1>
|
||||
<p v-if="doc.data.value">
|
||||
{{ doc.data.value.original_filename }}<template v-if="doc.data.value.page_count"> · <span class="fig">{{ doc.data.value.page_count }}</span> 页</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card flush" aria-labelledby="h-task" aria-live="polite">
|
||||
<div class="card-h">
|
||||
<h2 id="h-task">{{ j ? heading : "正在读取进度" }}</h2>
|
||||
<span v-if="elapsed" class="aside">
|
||||
{{ running ? "已用时" : "用时" }} <span class="fig">{{ elapsed.min }}</span> 分 <span class="fig">{{ elapsed.sec }}</span> 秒
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-b">
|
||||
<template v-if="job.isError.value && !j">
|
||||
<div class="alert warn" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ errorTitle(job.error.value) }}</p></div>
|
||||
</template>
|
||||
<template v-else-if="!j">
|
||||
<span class="skel t"></span><span class="skel"></span><span class="skel"></span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="status === 'succeeded'">
|
||||
<dl class="kv result">
|
||||
<dt>提取要求</dt>
|
||||
<dd>
|
||||
<span class="fig">{{ result?.items_added ?? 0 }}</span> 条<template v-if="result?.items_pending">
|
||||
· 其中 <span class="fig">{{ result.items_pending }}</span> 条没找到可靠出处或把握不大,放在「待确认」里</template>
|
||||
</dd>
|
||||
<template v-if="result?.items_updated"><dt>更新</dt><dd><span class="fig">{{ result.items_updated }}</span> 条</dd></template>
|
||||
<template v-if="result?.lots_found"><dt>采购包</dt><dd>识别到 <span class="fig">{{ result.lots_found }}</span> 个</dd></template>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<template v-else-if="status === 'cancelled'">
|
||||
<p class="plain">已取消解析。预扣的 <span class="fig">1</span> 次解析额度已退回。</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<ol class="steps">
|
||||
<li v-for="(s, k) in PARSE_STEPS" :key="s.label" :class="stepClass(k)">
|
||||
<span class="dot" aria-hidden="true">{{ stepClass(k) === "done" ? "✓" : stepClass(k) === "fail" ? "!" : "" }}</span>
|
||||
<span>{{ s.label }}<span v-if="stepClass(k) === 'now'" class="vh">(进行中)</span></span>
|
||||
</li>
|
||||
</ol>
|
||||
<div v-if="running" class="progress">
|
||||
<div class="bar" role="progressbar" :aria-valuenow="j.progress" aria-valuemin="0" aria-valuemax="100" aria-label="解析进度"><i :style="{ width: `${j.progress}%` }"></i></div>
|
||||
<span class="fig">{{ j.progress }}%</span>
|
||||
</div>
|
||||
<p v-if="running" class="note">可以离开本页,完成或失败都会发站内通知。</p>
|
||||
|
||||
<!-- 失败说明(§7.11):赭色图标 + 赭色关键字,不用红 -->
|
||||
<div v-if="status === 'failed'" class="fail-why" role="note">
|
||||
<CircleAlert class="ic" :stroke-width="1.5" />
|
||||
<p>
|
||||
<b>{{ j.error?.message ?? "解析没有完成。" }}</b>
|
||||
<template v-if="j.error?.refunded === true">已退回 <span class="fig">1</span> 次解析额度。</template>
|
||||
<template v-else-if="j.error?.refunded === false">额度还没退回,请联系客服并提供下方错误编号。</template>
|
||||
<span class="sub">可以重试,或者换一份文件。</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="actionErr" class="alert warn act-err" role="alert"><span class="ico w" aria-hidden="true">!</span><p>{{ actionErr }}</p></div>
|
||||
</div>
|
||||
|
||||
<div v-if="j" class="card-f">
|
||||
<template v-if="running">
|
||||
<button class="btn sm" type="button" :disabled="j.cancel_requested" @click="cancelOpen = true">{{ j.cancel_requested ? "正在取消…" : "取消解析" }}</button>
|
||||
<span class="cost">已预扣 <span class="fig">1</span> 次解析 · 失败、超时、取消都退回</span>
|
||||
<span class="r"><RouterLink class="btn sm" :to="{ name: 'workbench' }">回工作台,完成后通知我</RouterLink></span>
|
||||
</template>
|
||||
<template v-else-if="status === 'succeeded'">
|
||||
<RouterLink class="btn primary" :to="{ name: 'checklist', params: { projectId } }">查看检查清单</RouterLink>
|
||||
<span class="r"><span class="cost">已结算 <span class="fig">1</span> 次解析 · {{ j.finished_at ? fmtDateTime(j.finished_at) : "" }}</span></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="btn primary swap" type="button" :class="{ 'is-loading': retrying }" :aria-busy="retrying" :disabled="retrying" @click="retry">
|
||||
<span class="idle"><RefreshCw class="ic" :stroke-width="1.5" /> 重新解析</span><span class="busy">正在提交…</span>
|
||||
</button>
|
||||
<RouterLink class="btn" :to="{ name: 'project-new', query: { project: projectId } }"><Upload class="ic" :stroke-width="1.5" />换一个文件</RouterLink>
|
||||
<span class="cost">重新解析预扣 <span class="fig">1</span> 次<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
|
||||
<span v-if="j.error" class="r"><span class="errno">{{ j.error.code }} · {{ j.id.slice(0, 8) }}</span></span>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog v-model:open="cancelOpen" title="取消解析?" ok-text="取消解析" cancel-text="继续解析" busy-text="正在取消…" :busy="cancelling" @ok="cancel">
|
||||
<p>已完成的阶段不会保留。取消后退回 1 次解析额度,可以稍后重新解析。</p>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-h .aside {
|
||||
margin-left: auto;
|
||||
font-size: var(--fs-caption);
|
||||
line-height: var(--lh-caption);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-h .aside .fig {
|
||||
font-size: var(--fs-figure-xs);
|
||||
}
|
||||
.card-b {
|
||||
padding-top: var(--sp-2);
|
||||
}
|
||||
.card-b .progress {
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
.card-f .r {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
}
|
||||
.btn.swap .idle {
|
||||
display: inline-flex;
|
||||
gap: var(--sp-2);
|
||||
align-items: center;
|
||||
}
|
||||
.plain {
|
||||
margin: 0;
|
||||
}
|
||||
.result {
|
||||
grid-template-columns: calc(var(--sp-8) + var(--sp-12)) minmax(0, 1fr);
|
||||
}
|
||||
/* 失败说明(§7.11):赭色图标 + 赭色关键字,不用红 */
|
||||
.fail-why {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
margin-top: var(--sp-3);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: var(--line) solid var(--border-subtle);
|
||||
}
|
||||
.fail-why > .ic {
|
||||
flex: none;
|
||||
margin-top: var(--sp-1);
|
||||
color: var(--warn);
|
||||
}
|
||||
.fail-why b {
|
||||
color: var(--warn-text);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
.fail-why p {
|
||||
margin: 0;
|
||||
}
|
||||
.fail-why .sub {
|
||||
display: block;
|
||||
font-size: var(--fs-caption);
|
||||
line-height: var(--lh-caption);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.act-err {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user