Files
bid-doctor/apps/web/src/pages/ProjectNewPage.vue
YANG JIANKUAN 6aaf8e768b 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>
2026-11-03 18:48:10 +08:00

263 lines
11 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">
/**
* 新建项目(原型 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>