Files
bid-doctor/apps/web/src/pages/ParsePage.vue
YANG JIANKUAN f5edc585f1 fix(ui): 解析完成时结果没到前不显示「0 条」
事件流先报完成、结果随后由重新拉取带回,中间会闪一下「提取要求 0 条」。

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

293 lines
13 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">
/**
* 解析进度(原型 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);
/** 补遗 / 澄清(FR-M2-10):结果按补遗写,下一步去读标页核对这份补遗的条目 */
const isAddendum = computed(() => !!doc.data.value && doc.data.value.role !== "tender");
</script>
<template>
<div class="content narrow">
<div class="page-title">
<div>
<h1>{{ isAddendum ? `解析${doc.data.value?.label}` : "解析招标文件" }}</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>
<!-- 事件流先报完成、结果随后由重新拉取带回:没到之前不显示「0 条」 -->
<template v-else-if="status === 'succeeded' && !result">
<span class="skel t"></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="isAddendum">
<dt>日期</dt>
<dd v-if="result?.dates_superseded"><span class="fig">{{ result.dates_superseded }}</span> 处日期有新值,旧条目标「已被{{ doc.data.value?.label }} 更新」,等你确认</dd>
<dd v-else>没有改动已有的日期</dd>
<template v-if="result?.fields_pending"><dt>项目信息</dt><dd><span class="fig">{{ result.fields_pending }}</span> 个字段有新值,在概览里采用或不采用</dd></template>
<dt>清单</dt><dd>回到草稿。标博士不判断哪些旧条目被补遗改了或作废了,核对后重新确认</dd>
</template>
<template v-else-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'">
<!-- 识别到 2 个及以上的包时先选包(FR-M2-04);只有 1 个包按不分包处理(PI4) -->
<RouterLink v-if="isAddendum" class="btn primary" :to="{ name: 'checklist', params: { projectId }, query: { source: doc.data.value?.id } }">核对补遗</RouterLink>
<RouterLink v-else-if="(result?.lots_found ?? 0) > 1" class="btn primary" :to="{ name: 'lots', params: { projectId } }">选择要投的包</RouterLink>
<RouterLink v-else 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>