事件流先报完成、结果随后由重新拉取带回,中间会闪一下「提取要求 0 条」。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
13 KiB
Vue
293 lines
13 KiB
Vue
<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>
|