- 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>
263 lines
11 KiB
Vue
263 lines
11 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 新建项目(原型 project-new.html):项目名称(选填)+ 一份招标文件。
|
||
* 选好文件就开始分片上传(格式、加密、页数在上传完成时由服务端拦截,不扣点);
|
||
* 「开始解析」:建项目 → 把文件挂到项目上 → 发起解析(预扣 1 次,带 expected_charge,API4)。
|
||
* 带 ?project=<id> 时给已有的项目补传招标文件(例如上一次上传失败),不再建新项目。
|
||
*/
|
||
import { ApiError, unwrap, uploadFile, type Schemas } from "@bid-doctor/api-client";
|
||
import { Upload, X } from "@lucide/vue";
|
||
import { useQueryClient } from "@tanstack/vue-query";
|
||
import { computed, ref } from "vue";
|
||
import { RouterLink, useRoute, useRouter } from "vue-router";
|
||
import { api, apiOptions, errorTitle, isApiError } from "@/lib/api";
|
||
import { useCrumbs } from "@/lib/crumbs";
|
||
import { qk } from "@/lib/keys";
|
||
import { extOf, fmtSize } from "@/lib/labels";
|
||
import { usePlans, useProject, useQuotaAccount, useMe } from "@/lib/queries";
|
||
import { platform } from "@/platform";
|
||
|
||
const ACCEPT = [".pdf", ".docx", ".doc", ".wps"];
|
||
/** 解析招标文件固定扣 1 次;和服务端算的不一致时服务端返回 billing_changed,不扣点 */
|
||
const EXPECTED_CHARGE = 1 as const;
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const qc = useQueryClient();
|
||
const me = useMe();
|
||
const plans = usePlans();
|
||
const parse = useQuotaAccount("parse");
|
||
|
||
const projectId = ref(typeof route.query.project === "string" ? route.query.project : "");
|
||
const existing = useProject(() => (route.query.project ? projectId.value : undefined));
|
||
useCrumbs(() => [{ label: "工作台", to: { name: "workbench" } }, { label: existing.data.value ? `${existing.data.value.name} · 上传招标文件` : "新建项目" }]);
|
||
|
||
const limits = computed(() => plans.data.value?.items.find((p) => p.code === me.data.value?.plan.code)?.limits);
|
||
const name = ref("");
|
||
|
||
/* ---------- 上传 ---------- */
|
||
type UploadState =
|
||
| { kind: "idle" }
|
||
| { kind: "uploading"; file: File; ratio: number }
|
||
| { kind: "done"; file: File; result: Schemas["UploadCompleted"] }
|
||
| { kind: "error"; file: File; message: string };
|
||
const up = ref<UploadState>({ kind: "idle" });
|
||
let uploadSeq = 0;
|
||
const fileErr = ref("");
|
||
const over = ref(false);
|
||
|
||
async function startUpload(file: File) {
|
||
const seq = ++uploadSeq;
|
||
fileErr.value = "";
|
||
startErr.value = "";
|
||
documentId.value = "";
|
||
up.value = { kind: "uploading", file, ratio: 0 };
|
||
try {
|
||
const result = await uploadFile(api, apiOptions, file, "project_document", (ratio) => {
|
||
if (seq === uploadSeq) up.value = { kind: "uploading", file, ratio };
|
||
});
|
||
if (seq === uploadSeq) up.value = { kind: "done", file, result };
|
||
} catch (e) {
|
||
// 格式不支持、已加密、已损坏、超出页数或大小:都在这里拦下,不建任务、不扣点
|
||
if (seq === uploadSeq) up.value = { kind: "error", file, message: `${errorTitle(e)}${detailOf(e)}。这个文件不会解析,也不扣额度。` };
|
||
}
|
||
}
|
||
const detailOf = (e: unknown) => (e instanceof ApiError && e.problem.detail ? `(${e.problem.detail})` : "");
|
||
|
||
async function pick() {
|
||
if (busy.value) return;
|
||
const f = await platform.pickFile({ accept: ACCEPT });
|
||
if (f) void startUpload(f);
|
||
}
|
||
function onDrop(e: DragEvent) {
|
||
over.value = false;
|
||
const f = e.dataTransfer?.files[0];
|
||
if (f && !busy.value) void startUpload(f);
|
||
}
|
||
function removeFile() {
|
||
uploadSeq++; // 正在上传的分片结果作废;服务端的未完成上传 24 小时后自动过期
|
||
up.value = { kind: "idle" };
|
||
documentId.value = "";
|
||
}
|
||
|
||
const pct = computed(() => (up.value.kind === "uploading" ? Math.round(up.value.ratio * 100) : 100));
|
||
const fileOf = computed(() => (up.value.kind === "idle" ? null : up.value.file));
|
||
|
||
/* ---------- 开始解析 ---------- */
|
||
const documentId = ref("");
|
||
const busy = ref(false);
|
||
const startErr = ref("");
|
||
|
||
async function start() {
|
||
if (up.value.kind !== "done") {
|
||
fileErr.value = up.value.kind === "uploading" ? "文件还在上传,传完再开始解析" : "请先上传招标文件";
|
||
return;
|
||
}
|
||
const uploadId = up.value.result.id;
|
||
busy.value = true;
|
||
startErr.value = "";
|
||
try {
|
||
// 每一步成功后都记下结果,失败重试时不会重复建项目(免费版只能建 1 个)
|
||
if (!projectId.value) {
|
||
const p = await unwrap(api.POST("/projects", { body: name.value.trim() ? { name: name.value.trim() } : {} }));
|
||
projectId.value = p.id;
|
||
void router.replace({ query: { ...route.query, project: p.id } });
|
||
}
|
||
if (!documentId.value) {
|
||
const d = await unwrap(
|
||
api.POST("/projects/{project_id}/documents", {
|
||
params: { path: { project_id: projectId.value } },
|
||
body: { upload_id: uploadId, role: "tender" },
|
||
}),
|
||
);
|
||
documentId.value = d.id;
|
||
}
|
||
const job = await unwrap(
|
||
api.POST("/documents/{document_id}/parse-jobs", {
|
||
params: { path: { document_id: documentId.value } },
|
||
body: { expected_charge: EXPECTED_CHARGE },
|
||
}),
|
||
);
|
||
void qc.invalidateQueries({ queryKey: qk.projects });
|
||
void qc.invalidateQueries({ queryKey: qk.quota });
|
||
await router.push({ name: "parse", params: { projectId: projectId.value, jobId: job.id } });
|
||
} catch (e) {
|
||
const running = isApiError(e, "job_already_running") ? e.problem.job_id : undefined;
|
||
if (typeof running === "string") {
|
||
await router.push({ name: "parse", params: { projectId: projectId.value, jobId: running } });
|
||
return;
|
||
}
|
||
startErr.value = `${errorTitle(e)}${detailOf(e)}。本次没有扣点。`;
|
||
void qc.invalidateQueries({ queryKey: qk.quota });
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="content narrow">
|
||
<div class="page-title">
|
||
<div>
|
||
<h1>{{ existing.data.value ? "上传招标文件" : "新建项目" }}</h1>
|
||
<p>上传招标文件,标博士会逐条提取要求,每一条都带原文出处。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<form class="card flush" novalidate aria-labelledby="h-form" @submit.prevent="start">
|
||
<h2 id="h-form" class="vh">新建项目</h2>
|
||
<div class="card-b form-b">
|
||
<div v-if="!route.query.project" class="field">
|
||
<label for="pn">项目名称 <small>(选填)</small></label>
|
||
<input id="pn" v-model="name" class="input" maxlength="200" placeholder="例如:某市政务服务中心智慧大厅建设项目" autocomplete="off" aria-describedby="pn-msg" :disabled="busy" />
|
||
<span id="pn-msg" class="msg">不填时显示为「未命名项目」,解析后按招标文件自动填写,可以再改</span>
|
||
</div>
|
||
<div v-else-if="existing.data.value" class="field">
|
||
<label>项目</label>
|
||
<span>{{ existing.data.value.name }}</span>
|
||
</div>
|
||
|
||
<div class="field" :class="{ err: fileErr }">
|
||
<label id="l-file">招标文件</label>
|
||
<div
|
||
v-if="!fileOf"
|
||
class="drop"
|
||
:class="{ 'is-over': over }"
|
||
tabindex="0"
|
||
role="button"
|
||
aria-labelledby="l-file"
|
||
aria-describedby="drop-sub"
|
||
@click="pick"
|
||
@keydown.enter.prevent="pick"
|
||
@keydown.space.prevent="pick"
|
||
@dragenter.prevent="over = true"
|
||
@dragover.prevent="over = true"
|
||
@dragleave.prevent="over = false"
|
||
@drop.prevent="onDrop"
|
||
>
|
||
<Upload class="ic lg" :stroke-width="1.5" />
|
||
<b>拖入或点击上传</b>
|
||
<span id="drop-sub">
|
||
PDF、Word(DOC / DOCX)、WPS,一次一份<template v-if="limits"><br />单个文件不超过 <span class="fig xs">{{ limits.max_pages }}</span> 页、<span class="fig xs">{{ Math.round((limits.max_file_bytes ?? 0) / 1048576) }}</span> MB</template>
|
||
</span>
|
||
</div>
|
||
|
||
<div v-else class="file-row" :class="{ off: up.kind === 'error' }">
|
||
<span class="ext">{{ up.kind === "done" ? up.result.detected_format.toUpperCase() : extOf(fileOf.name) }}</span>
|
||
<div>
|
||
<b :title="fileOf.name">{{ fileOf.name }}</b>
|
||
<span class="meta">
|
||
<template v-if="up.kind === 'done' && up.result.page_count"><span class="fig xs">{{ up.result.page_count }}</span> 页 · </template>{{ fmtSize(fileOf.size) }}
|
||
<template v-if="up.kind === 'done'"> · 已上传</template>
|
||
</span>
|
||
<div v-if="up.kind === 'uploading'" class="progress up">
|
||
<div class="bar" role="progressbar" :aria-valuenow="pct" aria-valuemin="0" aria-valuemax="100" aria-label="上传进度"><i :style="{ width: `${pct}%` }"></i></div>
|
||
<span class="fig xs">{{ pct }}%</span>
|
||
</div>
|
||
<p v-if="up.kind === 'done' && up.result.extension_mismatch" class="hint-warn">
|
||
<span class="ico w" aria-hidden="true">!</span>
|
||
<span>扩展名和实际格式不一致,会按实际格式({{ up.result.detected_format.toUpperCase() }})解析</span>
|
||
</p>
|
||
<p v-if="up.kind === 'error'" class="hint-warn" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ up.message }}</span></p>
|
||
</div>
|
||
<span class="acts">
|
||
<button type="button" class="icon-btn sm tip-left" :aria-label="`移除 ${fileOf.name}`" data-tip="移除" :disabled="busy" @click="removeFile"><X class="ic" :stroke-width="1.5" /></button>
|
||
</span>
|
||
</div>
|
||
<span v-if="fileErr" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ fileErr }}</span>
|
||
</div>
|
||
|
||
<div v-if="startErr" class="alert warn start-err" role="alert">
|
||
<span class="ico w" aria-hidden="true">!</span>
|
||
<p>{{ startErr }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-f">
|
||
<!-- 红线 9:扣点规则写在按钮旁边 -->
|
||
<span class="cost">将扣 <span class="fig">1</span> 次解析;解析失败、超时或取消不扣点<template v-if="parse"> · 剩 <span class="fig">{{ parse.balance }}</span> 次</template></span>
|
||
<RouterLink class="btn" :to="{ name: 'workbench' }">取消</RouterLink>
|
||
<button type="submit" class="btn primary swap" :class="{ 'is-loading': busy }" :aria-busy="busy" :disabled="busy || up.kind !== 'done'">
|
||
<span class="idle">开始解析</span><span class="busy">正在提交…</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.card-b.form-b {
|
||
padding-top: var(--sp-5);
|
||
padding-bottom: var(--sp-6);
|
||
}
|
||
.form-b > .field {
|
||
max-width: none;
|
||
}
|
||
.form-b > .field + .field {
|
||
margin-top: var(--sp-5);
|
||
}
|
||
.field.err .drop {
|
||
border-color: var(--warn);
|
||
}
|
||
.card-f .cost {
|
||
margin-right: auto;
|
||
white-space: normal;
|
||
}
|
||
.file-row .meta {
|
||
display: block;
|
||
font-size: var(--fs-caption);
|
||
line-height: var(--lh-caption);
|
||
color: var(--text-secondary);
|
||
}
|
||
.file-row.off b {
|
||
color: var(--text-secondary);
|
||
font-weight: var(--fw-regular);
|
||
}
|
||
.progress.up {
|
||
margin-top: var(--sp-1);
|
||
}
|
||
.start-err {
|
||
margin-top: var(--sp-5);
|
||
}
|
||
</style>
|