- 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>
402 lines
15 KiB
Vue
402 lines
15 KiB
Vue
<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>
|