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:
YANG JIANKUAN
2026-11-03 18:48:10 +08:00
parent ad7fba6021
commit 6aaf8e768b
36 changed files with 8684 additions and 0 deletions

View File

@@ -0,0 +1,401 @@
<script setup lang="ts">
/**
* 登录 / 注册(原型 login.html):手机号 + 短信验证码。登录和注册共用一个验证码:
* 登录返回 404 phone_not_registered 时切到注册,验证码不用重新获取;
* 已有其他设备在线时返回 409 concurrent_session,用户确认后带 force: true 重发(API3)。
*/
import type { Schemas } from "@bid-doctor/api-client";
import { unwrap } from "@bid-doctor/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import { computed, nextTick, onBeforeUnmount, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import logoUrl from "@design/logo/svg/logo-horizontal-textured.svg?url";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { api, errorTitle, isApiError } from "@/lib/api";
import { qk } from "@/lib/keys";
import { fmtDateTime } from "@/lib/labels";
import { usePlans, type Me, type SessionInfo } from "@/lib/queries";
/** TODO:服务协议版本应由服务端下发(目前没有接口),暂时写死;协议文本定稿后同步。 */
const TERMS_VERSION = "2026-11-draft";
const PHONE_RE = /^1[3-9][0-9]{9}$/;
const CODE_RE = /^[0-9]{6}$/;
const isDev = import.meta.env.DEV;
const route = useRoute();
const router = useRouter();
const qc = useQueryClient();
const plans = usePlans();
type Mode = "login" | "reg";
const mode = ref<Mode>("login");
const form = reactive({ phone: "", code: "", tenant: "", name: "", agreed: false });
const err = reactive<Partial<Record<"phone" | "code" | "tenant" | "name" | "agree" | "form", string>>>({});
const REASONS: Record<string, string> = {
session_revoked: "你的账号在其他设备登录,请重新登录。",
session_expired: "登录已过期,请重新登录。",
};
const notice = ref(REASONS[String(route.query.reason ?? "")] ?? "");
const free = computed(() => plans.data.value?.items.find((p) => p.code === "free"));
/* ---------- 发送验证码:60 秒内不能重发(以服务端返回为准) ---------- */
const countdown = ref(0);
const sending = ref(false);
let timer: number | undefined;
function startCountdown(s: number) {
countdown.value = s;
window.clearInterval(timer);
timer = window.setInterval(() => {
if (--countdown.value <= 0) window.clearInterval(timer);
}, 1000);
}
onBeforeUnmount(() => window.clearInterval(timer));
function checkPhone(): boolean {
err.phone = PHONE_RE.test(form.phone) ? "" : "手机号应为 11 位数字,以 1 开头";
return !err.phone;
}
async function sendCode() {
if (!checkPhone()) return;
sending.value = true;
err.code = "";
try {
const r = await unwrap(api.POST("/auth/sms-codes", { body: { phone: form.phone, purpose: mode.value === "reg" ? "register" : "login" } }));
startCountdown(r.resend_after_s);
await nextTick();
document.getElementById("cd")?.focus();
} catch (e) {
err.code = errorTitle(e);
const after = isApiError(e, "rate_limited") ? Number(e.problem.retry_after_s) : NaN;
if (Number.isFinite(after) && after > 0) startCountdown(after);
} finally {
sending.value = false;
}
}
/* ---------- 登录 / 注册 ---------- */
const submitting = ref(false);
const concurrent = reactive({ open: false, sessions: [] as SessionInfo[] });
function switchMode(m: Mode) {
mode.value = m;
err.form = err.tenant = err.name = err.agree = "";
}
function validate(): boolean {
checkPhone();
err.code = CODE_RE.test(form.code) ? "" : "验证码是 6 位数字";
if (mode.value === "reg") {
const t = form.tenant.trim();
err.tenant = t.length >= 2 && t.length <= 100 ? "" : "企业名称 2~100 个字";
const n = form.name.trim();
err.name = n.length >= 1 && n.length <= 40 ? "" : "请填写你的姓名,最多 40 个字";
err.agree = form.agreed ? "" : "请先阅读并同意服务协议";
}
return !err.phone && !err.code && !err.tenant && !err.name && !err.agree;
}
/** 接口校验错误按字段放回表单 */
function applyError(e: unknown) {
if (isApiError(e, "sms_code_invalid")) err.code = e.problem.title;
else if (isApiError(e, "validation_failed") && e.problem.errors?.length) {
const map: Record<string, keyof typeof err> = { phone: "phone", sms_code: "code", tenant_name: "tenant", display_name: "name" };
for (const fe of e.problem.errors) err[map[fe.field.replace(/^body\./, "")] ?? "form"] = fe.message;
} else err.form = errorTitle(e);
}
async function finish(me: Me, registered: boolean) {
qc.setQueryData(qk.me, me);
void qc.invalidateQueries({ queryKey: qk.quota });
void qc.invalidateQueries({ queryKey: qk.projects });
const redirect = typeof route.query.redirect === "string" && route.query.redirect.startsWith("/") ? route.query.redirect : "";
await router.replace(redirect || (registered ? { name: "project-new" } : { name: "workbench" }));
}
async function login(force: boolean) {
submitting.value = true;
err.form = "";
try {
const r = await unwrap(api.POST("/auth/login", { body: { phone: form.phone, sms_code: form.code, client_kind: "web", force } }));
concurrent.open = false;
await finish(r.me, false);
} catch (e) {
if (isApiError(e, "phone_not_registered")) {
switchMode("reg");
notice.value = "这个手机号还没有注册。填写企业名称和你的姓名完成注册,验证码不用重新获取。";
await nextTick();
document.getElementById("co")?.focus();
} else if (isApiError(e, "concurrent_session")) {
concurrent.sessions = (e.problem.sessions as Schemas["SessionInfo"][] | undefined) ?? [];
concurrent.open = true;
} else {
concurrent.open = false;
applyError(e);
}
} finally {
submitting.value = false;
}
}
async function register() {
submitting.value = true;
err.form = "";
try {
const r = await unwrap(
api.POST("/auth/register", {
body: {
phone: form.phone,
sms_code: form.code,
tenant_name: form.tenant.trim(),
display_name: form.name.trim(),
terms_version: TERMS_VERSION,
client_kind: "web",
},
}),
);
await finish(r.me, true);
} catch (e) {
if (isApiError(e, "phone_already_registered")) {
switchMode("login");
notice.value = "这个手机号已经注册,直接登录即可。";
} else applyError(e);
} finally {
submitting.value = false;
}
}
function submit() {
if (!validate()) return;
if (mode.value === "reg") void register();
else void login(false);
}
</script>
<template>
<div class="login-page">
<div class="wrap">
<section class="brand" aria-label="标博士">
<img :src="logoUrl" alt="标博士 BIAOBOSHI" />
<p class="slogan">标博士,你的AI投标专家</p>
<ol class="points">
<li><div><b>读标:每一条要求都有出处</b><span>资格、无效条款、评分、日期和 ★ 参数逐条列出,每条都能回到原文那一页。</span></div></li>
<li><div><b>查标:递交前逐条对一遍</b><span>找出没响应、疑似不满足、漏盖章的地方,由你逐条确认。</span></div></li>
<li><div><b>报告:谁在什么时候确认了什么</b><span>导出带处理记录的检查报告,写明查了什么、没查什么。</span></div></li>
</ol>
</section>
<form class="card login" novalidate aria-label="登录或注册" @submit.prevent="submit">
<div class="tabs" role="tablist" aria-label="登录方式">
<button type="button" role="tab" :aria-selected="mode === 'login'" @click="switchMode('login')">登录</button>
<button type="button" role="tab" :aria-selected="mode === 'reg'" @click="switchMode('reg')">注册</button>
</div>
<div v-if="notice" class="alert" role="status">
<span class="ico i" aria-hidden="true">i</span>
<p>{{ notice }}</p>
</div>
<div class="field" :class="{ err: err.phone }">
<label for="ph">手机号</label>
<input id="ph" v-model.trim="form.phone" class="input" inputmode="numeric" autocomplete="tel" maxlength="11" placeholder="11 位手机号" @blur="form.phone && checkPhone()" />
<span v-if="err.phone" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.phone }}</span>
</div>
<div class="field" :class="{ err: err.code }">
<label for="cd">验证码</label>
<div class="code">
<input id="cd" v-model.trim="form.code" class="input" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="6 位数字" />
<button class="btn" type="button" :disabled="sending || countdown > 0" @click="sendCode">
{{ countdown > 0 ? `${countdown} 秒后重发` : sending ? "正在发送…" : "发送验证码" }}
</button>
</div>
<span v-if="err.code" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.code }}</span>
<span v-else-if="isDev" class="msg">开发环境:验证码固定为 000000</span>
</div>
<template v-if="mode === 'reg'">
<div class="field" :class="{ err: err.tenant }">
<label for="co">企业名称</label>
<input id="co" v-model="form.tenant" class="input" placeholder="与开票信息一致" maxlength="100" />
<span class="msg"><span v-if="err.tenant" class="ico w" aria-hidden="true">!</span>{{ err.tenant || "额度和数据归属企业主体,一个手机号只能属于一个企业。" }}</span>
</div>
<div class="field" :class="{ err: err.name }">
<label for="nm">你的姓名</label>
<input id="nm" v-model="form.name" class="input" placeholder="确认记录里显示这个名字" maxlength="40" autocomplete="name" />
<span v-if="err.name" class="msg"><span class="ico w" aria-hidden="true">!</span>{{ err.name }}</span>
</div>
<div class="agree">
<label class="choice">
<input v-model="form.agreed" type="checkbox" class="ck" @change="form.agreed && (err.agree = '')" />
<span>我已阅读并同意《服务协议》《隐私政策》。本产品只用于投标人对自己投标文件的投前自查,不得用于串通投标、帮助他人规避雷同等违法用途。</span>
</label>
</div>
<p v-if="err.agree" class="hint-warn agree-err"><span class="ico w" aria-hidden="true">!</span>{{ err.agree }}</p>
</template>
<p v-if="err.form" class="hint-warn form-err" role="alert"><span class="ico w" aria-hidden="true">!</span><span>{{ err.form }}</span></p>
<button type="submit" class="btn primary lg go swap" :class="{ 'is-loading': submitting && !concurrent.open }" :aria-busy="submitting" :disabled="submitting">
<span class="idle">{{ mode === "reg" ? "注册并开通免费版" : "登录" }}</span>
<span class="busy">{{ mode === "reg" ? "正在注册…" : "正在登录…" }}</span>
</button>
<p v-if="mode === 'reg' && free" class="free">
注册即开通免费版:解析 <span class="fig">{{ free.quotas.parse ?? 0 }}</span> 次招标文件<template v-if="free.limits.max_projects">,限
<span class="fig">{{ free.limits.max_projects }}</span> 个项目</template>。不需要上传营业执照。
</p>
</form>
</div>
<p class="legal">辅助检查工具,不替代人工审核;检查结果仅供投标前自查参考,是否递交由投标人自行决定。</p>
<ConfirmDialog v-model:open="concurrent.open" title="账号已在其他设备登录" ok-text="继续登录" busy-text="正在登录…" :busy="submitting" @ok="login(true)">
<p>继续登录后,下面的设备会退出登录。</p>
<ul class="sessions">
<li v-for="s in concurrent.sessions" :key="s.id">
<b>{{ s.device || (s.client_kind === "desktop" ? "桌面客户端" : "网页") }}</b>
<span class="cap muted">{{ s.ip_region ? `${s.ip_region} · ` : "" }}最近活跃 <span class="fig">{{ fmtDateTime(s.last_seen_at) }}</span></span>
</li>
</ul>
</ConfirmDialog>
</div>
</template>
<style scoped>
/* 登录页:不用侧栏框架。浅灰页面上一张白卡片放表单;左侧品牌与三句话;按钮、输入框用 40 高(§7.1) */
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.wrap {
flex: 1;
display: grid;
/* 表单卡片宽 440(11 × 40),与原型一致 */
grid-template-columns: minmax(0, 1fr) calc(var(--sp-10) * 11);
gap: var(--sp-16);
align-items: center;
width: 100%;
max-width: var(--content-max);
margin: 0 auto;
padding: var(--sp-12) var(--pad-page);
}
.brand img {
width: calc(var(--sp-10) * 5);
display: block;
}
.brand .slogan {
font: var(--fw-bold) var(--fs-figure) / 1.4 var(--font-serif);
margin: var(--sp-10) 0 var(--sp-8);
}
.points {
list-style: none;
margin: 0;
padding: 0;
max-width: 30em;
counter-reset: p;
}
.points li {
display: grid;
grid-template-columns: var(--sp-8) minmax(0, 1fr);
padding: var(--sp-4) 0;
border-top: var(--line) solid var(--border-subtle);
counter-increment: p;
}
.points li:first-child {
border-top: 0;
padding-top: 0;
}
.points li::before {
content: counter(p);
font: var(--fw-bold) var(--fs-title) / var(--lh-title) var(--font-figures);
color: var(--text-secondary);
}
.points b {
display: block;
font-size: var(--fs-title);
line-height: var(--lh-title);
}
.points span {
color: var(--text-secondary);
}
.login {
padding: var(--sp-8);
}
.login .tabs {
margin-bottom: var(--sp-6);
}
.login .tabs [role="tab"] {
font-size: var(--fs-title);
}
.login .alert {
margin-bottom: var(--sp-5);
}
.login .field {
max-width: none;
}
.login .field + .field {
margin-top: var(--sp-5);
}
.login .input,
.login .btn {
height: var(--control-h-lg);
}
.code {
display: flex;
gap: var(--sp-2);
}
.code .input {
flex: 1;
min-width: 0;
}
.code .btn {
min-width: calc(var(--sp-16) * 2);
}
.agree {
display: flex;
margin-top: var(--sp-5);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.agree-err {
margin-top: var(--sp-2);
}
.form-err {
margin-top: var(--sp-4);
}
.go {
width: 100%;
margin-top: var(--sp-6);
}
.free {
margin: var(--sp-5) 0 0;
padding-top: var(--sp-4);
border-top: var(--line) solid var(--border-subtle);
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.free .fig {
color: var(--text-primary);
}
.legal {
padding: 0 var(--pad-page) var(--sp-6);
text-align: center;
font-size: var(--fs-caption);
line-height: var(--lh-caption);
color: var(--text-secondary);
}
.sessions {
list-style: none;
margin: 0;
padding: 0;
}
.sessions li {
display: flex;
flex-direction: column;
padding: var(--sp-2) 0;
border-top: var(--line) solid var(--border-subtle);
}
</style>