feat: 重构用户端手机号验证码登录页

This commit is contained in:
2026-07-10 17:08:31 +08:00
parent 808ba74fc5
commit 2e274dea51
9 changed files with 991 additions and 209 deletions

View File

@@ -8,6 +8,7 @@
"name": "ai-kb-user-client", "name": "ai-kb-user-client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@lucide/vue": "^1.24.0",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"markdown-it": "^14.3.0", "markdown-it": "^14.3.0",
@@ -487,6 +488,15 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lucide/vue": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.24.0.tgz",
"integrity": "sha512-5bNPX0G2YEWdUlBYk7pE8SgDg/f1mkIFpJ9vtE44pW/cwRz7Ioc0tOTESoVJAPvxIELSmYekX+XXIJMjsswNIg==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview --host 127.0.0.1" "preview": "vite preview --host 127.0.0.1"
}, },
"dependencies": { "dependencies": {
"@lucide/vue": "^1.24.0",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"markdown-it": "^14.3.0", "markdown-it": "^14.3.0",

View File

@@ -178,7 +178,7 @@ async function scrollToBottom() {
<template> <template>
<main class="app-shell"> <main class="app-shell">
<section class="phone-frame"> <section class="phone-frame" :class="{ 'login-mode': !user && !booting }">
<div v-if="booting" class="booting">正在启动...</div> <div v-if="booting" class="booting">正在启动...</div>
<LoginPanel v-else-if="!user" @logged-in="onLoggedIn" /> <LoginPanel v-else-if="!user" @logged-in="onLoggedIn" />
<template v-else> <template v-else>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { X } from "@lucide/vue";
defineProps<{
title: string;
labelledBy: string;
}>();
defineEmits<{ close: [] }>();
</script>
<template>
<div class="modal-backdrop" role="presentation" @click.self="$emit('close')">
<section class="app-dialog" role="dialog" aria-modal="true" :aria-labelledby="labelledBy">
<header class="dialog-header">
<h2 :id="labelledBy">{{ title }}</h2>
<button type="button" class="dialog-close" aria-label="关闭" @click="$emit('close')">
<X :size="20" aria-hidden="true" />
</button>
</header>
<div class="dialog-content"><slot /></div>
<footer v-if="$slots.footer" class="dialog-footer"><slot name="footer" /></footer>
</section>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { CircleCheck } from "@lucide/vue";
import AppDialog from "./AppDialog.vue";
defineProps<{
mode: "sms" | "login";
contact?: string;
}>();
defineEmits<{ close: [] }>();
</script>
<template>
<AppDialog
:title="mode === 'sms' ? '收不到验证码?' : '登录遇到问题'"
:labelled-by="mode === 'sms' ? 'sms-help-title' : 'login-help-title'"
@close="$emit('close')"
>
<ul class="help-list">
<li><CircleCheck :size="18" aria-hidden="true" /><span>确认输入的是后台登记的本人手机号</span></li>
<li v-if="mode === 'sms'"><CircleCheck :size="18" aria-hidden="true" /><span>检查短信拦截骚扰信息和手机信号</span></li>
<li><CircleCheck :size="18" aria-hidden="true" /><span>等待倒计时结束后再重新发送避免频繁操作</span></li>
<li><CircleCheck :size="18" aria-hidden="true" /><span>网络异常时切换稳定网络后重试</span></li>
</ul>
<p v-if="contact" class="support-contact">仍未解决请联系工作人员{{ contact }}</p>
<p v-else class="support-contact">仍未解决请联系大本营工作人员核对学员信息</p>
<template #footer>
<button type="button" class="dialog-confirm" @click="$emit('close')">知道了</button>
</template>
</AppDialog>
</template>

View File

@@ -1,145 +1,355 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue"; import {
import { api, saveToken } from "../services/api"; CircleHelp,
GraduationCap,
RefreshCw,
ShieldCheck,
Smartphone,
X,
} from "@lucide/vue";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { api, ApiError, saveToken } from "../services/api";
import type { UserProfile } from "../types/api"; import type { UserProfile } from "../types/api";
import AppDialog from "./AppDialog.vue";
import HelpDialog from "./HelpDialog.vue";
import PolicyDialog from "./PolicyDialog.vue";
const emit = defineEmits<{ const emit = defineEmits<{
loggedIn: [user: UserProfile]; loggedIn: [user: UserProfile];
}>(); }>();
const COUNTDOWN_SECONDS = 60;
const COUNTDOWN_PREFIX = "ai-kb-sms-countdown:";
const PHONE_PATTERN = /^1[3-9]\d{9}$/;
const phone = ref(""); const phone = ref("");
const code = ref(""); const code = ref("");
const phoneTouched = ref(false);
const sending = ref(false);
const loggingIn = ref(false);
const countdown = ref(0);
const agreed = ref(false);
const agreementAttention = ref(false);
const activePolicy = ref<"agreement" | "privacy" | null>(null);
const helpMode = ref<"sms" | "login" | null>(null);
const toast = ref("");
const captchaOpen = ref(false);
const captchaId = ref(""); const captchaId = ref("");
const captchaCode = ref(""); const captchaCode = ref("");
const captchaImage = ref(""); const captchaImage = ref("");
const loading = ref(false);
const captchaLoading = ref(false); const captchaLoading = ref(false);
const message = ref("请先完成图形验证码,再获取短信验证码。"); const captchaSubmitting = ref(false);
const errorDialog = ref(""); const captchaError = ref("");
const countdown = ref(0);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
function startCountdown(seconds = 60) { const supportContact = import.meta.env.VITE_SUPPORT_CONTACT?.trim() ?? "";
countdown.value = seconds; const phoneValid = computed(() => PHONE_PATTERN.test(phone.value));
if (countdownTimer) clearInterval(countdownTimer); const phoneError = computed(() => phoneTouched.value && phone.value.length > 0 && !phoneValid.value);
countdownTimer = setInterval(() => { const loginFieldsValid = computed(() => phoneValid.value && code.value.length >= 4);
countdown.value--; const sendButtonText = computed(() => {
if (countdown.value <= 0 && countdownTimer) { if (sending.value) return "发送中...";
clearInterval(countdownTimer); if (countdown.value > 0) return `${countdown.value}s 后重发`;
countdownTimer = null; return "发送验证码";
} });
}, 1000);
let countdownTimer: ReturnType<typeof setInterval> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(() => restoreCountdown());
onUnmounted(() => {
stopCountdownTimer();
if (toastTimer) clearTimeout(toastTimer);
});
watch(phone, () => {
code.value = sanitizeDigits(code.value, 6);
restoreCountdown();
});
function onPhoneInput(event: Event) {
phone.value = sanitizeDigits((event.target as HTMLInputElement).value, 11);
} }
onUnmounted(() => { function onCodeInput(event: Event) {
if (countdownTimer) clearInterval(countdownTimer); code.value = sanitizeDigits((event.target as HTMLInputElement).value, 6);
}); }
onMounted(() => { function sanitizeDigits(value: string, maxLength: number) {
void loadCaptcha(); return value.replace(/\D/g, "").slice(0, maxLength);
}); }
function clearPhone() {
phone.value = "";
phoneTouched.value = false;
}
async function sendCode() {
if (sending.value || countdown.value > 0) return;
phoneTouched.value = true;
if (!phoneValid.value) {
showToast("请输入正确的手机号码");
return;
}
sending.value = true;
try {
await api.sendSms(phone.value);
completeSmsSend();
} catch (error) {
if (error instanceof ApiError && error.status === 428) {
await openCaptcha();
} else {
showToast(errorMessage(error, "验证码发送失败,请稍后重试"));
}
} finally {
sending.value = false;
}
}
async function openCaptcha() {
captchaOpen.value = true;
captchaError.value = "";
await loadCaptcha();
}
async function loadCaptcha() { async function loadCaptcha() {
if (captchaLoading.value) return;
captchaLoading.value = true; captchaLoading.value = true;
captchaError.value = "";
try { try {
const result = await api.captcha(); const result = await api.captcha();
captchaId.value = result.captchaId; captchaId.value = result.captchaId;
captchaImage.value = result.imageBase64; captchaImage.value = result.imageBase64;
captchaCode.value = ""; captchaCode.value = "";
} catch (error) { } catch (error) {
message.value = error instanceof Error ? error.message : "图形验证码加载失败"; captchaError.value = errorMessage(error, "图形验证码加载失败,请重试");
} finally { } finally {
captchaLoading.value = false; captchaLoading.value = false;
} }
} }
async function sendCode() { async function submitCaptcha() {
if (!captchaId.value || captchaCode.value.length < 4) { if (captchaSubmitting.value) return;
showError("请先输入图形验证码"); const normalizedCode = captchaCode.value.trim();
if (normalizedCode.length < 4) {
captchaError.value = "请输入图形验证码";
return; return;
} }
loading.value = true;
message.value = ""; captchaSubmitting.value = true;
captchaError.value = "";
try { try {
await api.sendSms(phone.value, captchaId.value, captchaCode.value); await api.sendSms(phone.value, captchaId.value, normalizedCode);
message.value = "短信验证码已发送,请查收。"; captchaOpen.value = false;
startCountdown(60); completeSmsSend();
await loadCaptcha();
} catch (error) { } catch (error) {
showError(error instanceof Error ? error.message : "发送失败"); captchaError.value = errorMessage(error, "图形验证失败,请重试");
await loadCaptcha(); if (/图形验证码|过期/.test(captchaError.value)) await loadCaptcha();
} finally { } finally {
loading.value = false; captchaSubmitting.value = false;
} }
} }
function completeSmsSend() {
persistCountdown(phone.value, Date.now() + COUNTDOWN_SECONDS * 1000);
restoreCountdown();
showToast("验证码已发送,请注意查收");
}
async function login() { async function login() {
loading.value = true; if (loggingIn.value || !loginFieldsValid.value) return;
message.value = ""; if (!agreed.value) {
agreementAttention.value = true;
showToast("请先阅读并同意用户协议和隐私政策");
window.setTimeout(() => (agreementAttention.value = false), 1800);
return;
}
loggingIn.value = true;
try { try {
const result = await api.login(phone.value, code.value); const result = await api.login(phone.value, code.value);
saveToken(result.token); saveToken(result.token);
showToast("登录成功");
emit("loggedIn", result.user); emit("loggedIn", result.user);
} catch (error) { } catch (error) {
message.value = error instanceof Error ? error.message : "登录失败"; showToast(errorMessage(error, "登录失败,请稍后重试"));
} finally { } finally {
loading.value = false; loggingIn.value = false;
} }
} }
function showError(text: string) { function countdownKey(value = phone.value) {
message.value = text; return `${COUNTDOWN_PREFIX}${value}`;
errorDialog.value = text; }
function persistCountdown(value: string, expiresAt: number) {
window.localStorage.setItem(countdownKey(value), String(expiresAt));
}
function restoreCountdown() {
stopCountdownTimer();
if (!phoneValid.value) {
countdown.value = 0;
return;
}
updateCountdown();
if (countdown.value > 0) countdownTimer = setInterval(updateCountdown, 1000);
}
function updateCountdown() {
const stored = Number(window.localStorage.getItem(countdownKey()) ?? 0);
const remaining = Math.max(0, Math.ceil((stored - Date.now()) / 1000));
countdown.value = remaining;
if (remaining <= 0) {
window.localStorage.removeItem(countdownKey());
stopCountdownTimer();
}
}
function stopCountdownTimer() {
if (countdownTimer) clearInterval(countdownTimer);
countdownTimer = null;
}
function showToast(text: string) {
toast.value = text;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast.value = ""), 3000);
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
async function keepFieldVisible(event: FocusEvent) {
await nextTick();
(event.target as HTMLElement).scrollIntoView({ block: "center", behavior: "smooth" });
} }
</script> </script>
<template> <template>
<section class="login-panel"> <section class="login-page">
<div class="login-content">
<header class="login-brand-area">
<div class="brand-heading">
<span class="brand-icon" aria-hidden="true"><GraduationCap :size="30" stroke-width="2.2" /></span>
<h1>大本营答疑助手</h1> <h1>大本营答疑助手</h1>
<p>有问题随时问帮你快速找到答案</p> </div>
<p>登录后即可开始提问快速找到你需要的答案</p>
<ShieldCheck class="brand-watermark" :size="150" stroke-width="1.2" aria-hidden="true" />
</header>
<label> <form class="login-card" novalidate @submit.prevent="login">
手机号 <div class="form-field">
<input v-model="phone" inputmode="numeric" maxlength="11" placeholder="请输入手机号" /> <label for="login-phone">手机号</label>
</label> <div class="input-shell" :class="{ invalid: phoneError }">
<Smartphone :size="20" aria-hidden="true" />
<label> <input
图形验证码 id="login-phone"
<div class="captcha-row"> :value="phone"
<input v-model="captchaCode" inputmode="text" maxlength="8" placeholder="请输入图形验证码" /> type="tel"
<button type="button" class="captcha-image" :disabled="captchaLoading" @click="loadCaptcha"> inputmode="numeric"
<img v-if="captchaImage" :src="captchaImage" alt="图形验证码" /> autocomplete="tel"
<span v-else>{{ captchaLoading ? "加载中" : "刷新" }}</span> maxlength="11"
placeholder="请输入手机号"
@input="onPhoneInput"
@blur="phoneTouched = true"
@focus="keepFieldVisible"
/>
<button v-if="phone" type="button" class="input-clear" aria-label="清空手机号" @click="clearPhone">
<X :size="17" aria-hidden="true" />
</button> </button>
</div> </div>
</label> <p v-if="phoneError" class="field-error">请输入正确的手机号码</p>
</div>
<label> <div class="form-field">
短信验证码 <label for="login-code">短信验证码</label>
<div class="code-row"> <div class="input-shell code-input-shell">
<input v-model="code" inputmode="numeric" maxlength="8" placeholder="请输入短信验证码" /> <ShieldCheck :size="20" aria-hidden="true" />
<input
id="login-code"
:value="code"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="请输入短信验证码"
@input="onCodeInput"
@focus="keepFieldVisible"
/>
<span class="input-divider" aria-hidden="true"></span>
<button <button
type="button" type="button"
class="secondary" class="send-code-button"
:disabled="loading || countdown > 0 || phone.length < 11 || captchaCode.length < 4" :disabled="!phoneValid || sending || countdown > 0"
@click="sendCode" @click="sendCode"
> >
{{ countdown > 0 ? `${countdown} 秒后重发` : "发送" }} {{ sendButtonText }}
</button> </button>
</div> </div>
<p class="security-hint"><ShieldCheck :size="16" aria-hidden="true" />为保障账号安全必要时将进行图形验证</p>
</div>
<div class="agreement-row" :class="{ attention: agreementAttention }">
<label class="agreement-toggle">
<input v-model="agreed" type="checkbox" />
<span>我已阅读并同意</span>
</label> </label>
<button type="button" class="policy-link" @click="activePolicy = 'agreement'">用户协议</button>
<span aria-hidden="true"></span>
<button type="button" class="policy-link" @click="activePolicy = 'privacy'">隐私政策</button>
</div>
<button type="button" class="primary" :disabled="loading || phone.length < 11 || code.length < 4" @click="login"> <button type="submit" class="login-submit" :disabled="!loginFieldsValid || loggingIn">
{{ loading ? "处理中..." : "立即进入" }} {{ loggingIn ? "正在进入..." : "立即进入" }}
</button> </button>
<div class="form-tip">{{ message }}</div>
<div v-if="errorDialog" class="dialog-mask" role="alertdialog" aria-modal="true" aria-labelledby="login-error-title"> <div class="login-help-links">
<div class="dialog-box"> <button type="button" @click="helpMode = 'sms'">收不到验证码</button>
<strong id="login-error-title">无法发送验证码</strong> <span aria-hidden="true"></span>
<p>{{ errorDialog }}</p> <button type="button" @click="helpMode = 'login'">登录遇到问题</button>
<button type="button" class="primary" @click="errorDialog = ''">知道了</button>
</div> </div>
</form>
<footer class="login-security-footer">
<span></span><ShieldCheck :size="20" aria-hidden="true" /><span></span>
<p>仅用于身份验证与账号登录不会泄露你的信息</p>
</footer>
</div> </div>
<div v-if="toast" class="login-toast" role="status" aria-live="polite">{{ toast }}</div>
<AppDialog v-if="captchaOpen" title="安全验证" labelled-by="captcha-dialog-title" @close="captchaOpen = false">
<div class="captcha-dialog-copy">
<p>请完成图形验证后继续发送短信验证码</p>
<div class="captcha-entry">
<input
v-model.trim="captchaCode"
type="text"
inputmode="text"
autocomplete="off"
maxlength="8"
placeholder="请输入图形验证码"
@keyup.enter="submitCaptcha"
/>
<button type="button" :disabled="captchaLoading" aria-label="刷新图形验证码" @click="loadCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="图形验证码,点击刷新" />
<RefreshCw v-else :size="20" :class="{ spinning: captchaLoading }" aria-hidden="true" />
</button>
</div>
<p class="captcha-refresh-tip"><CircleHelp :size="15" aria-hidden="true" />看不清点击图片刷新</p>
<p v-if="captchaError" class="field-error">{{ captchaError }}</p>
</div>
<template #footer>
<button type="button" class="dialog-confirm" :disabled="captchaSubmitting" @click="submitCaptcha">
{{ captchaSubmitting ? "验证中..." : "确认并发送" }}
</button>
</template>
</AppDialog>
<PolicyDialog v-if="activePolicy" :policy="activePolicy" @close="activePolicy = null" />
<HelpDialog v-if="helpMode" :mode="helpMode" :contact="supportContact" @close="helpMode = null" />
</section> </section>
</template> </template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import AppDialog from "./AppDialog.vue";
defineProps<{ policy: "agreement" | "privacy" }>();
defineEmits<{ close: [] }>();
</script>
<template>
<AppDialog
:title="policy === 'agreement' ? '用户协议' : '隐私政策'"
:labelled-by="policy === 'agreement' ? 'user-agreement-title' : 'privacy-policy-title'"
@close="$emit('close')"
>
<div v-if="policy === 'agreement'" class="policy-copy">
<p>欢迎使用大本营答疑助手登录并使用本服务前请阅读并理解本协议</p>
<h3>服务说明</h3>
<p>本服务面向已登记学员提供知识问答回答内容用于学习和信息参考不替代专业意见</p>
<h3>账号使用</h3>
<p>请使用本人登记手机号登录不得转让账号冒用他人身份或利用本服务进行违法违规活动</p>
<h3>使用规范</h3>
<p>请勿提交违法有害或侵犯他人权益的内容为保障服务安全系统可能记录必要的登录和问答日志</p>
<h3>服务变更</h3>
<p>因维护升级或不可抗力可能暂时中断服务我们会在合理范围内尽快恢复</p>
</div>
<div v-else class="policy-copy">
<p>我们重视你的个人信息安全并按照最小必要原则处理登录和服务数据</p>
<h3>收集的信息</h3>
<p>登录时处理手机号和验证码使用过程中记录必要的会话问答和安全审计信息</p>
<h3>使用目的</h3>
<p>上述信息仅用于身份验证账号登录提供答疑服务保障系统安全和排查服务问题</p>
<h3>信息保护</h3>
<p>我们采取访问控制传输保护日志审计等措施保护信息不会将验证码或登录凭证用于无关用途</p>
<h3>你的权利</h3>
<p>如需查询更正或处理账号信息请通过工作人员提供的正式渠道联系我们</p>
</div>
<template #footer>
<button type="button" class="dialog-confirm" @click="$emit('close')">我知道了</button>
</template>
</AppDialog>
</template>

View File

@@ -2,6 +2,18 @@ import type { ApiResponse, CaptchaResult, ChatMessage, ChatSession, LoginResult,
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
const TOKEN_KEY = "ai-kb-user-token"; const TOKEN_KEY = "ai-kb-user-token";
const REQUEST_TIMEOUT_MS = 15_000;
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly requestId = "",
) {
super(message);
this.name = "ApiError";
}
}
export function getToken() { export function getToken() {
return window.localStorage.getItem(TOKEN_KEY); return window.localStorage.getItem(TOKEN_KEY);
@@ -22,18 +34,38 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
if (token) { if (token) {
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
} }
const response = await fetch(`${API_BASE}${path}`, { ...init, headers }); const controller = new AbortController();
const body = (await response.json()) as ApiResponse<T>; const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, { ...init, headers, signal: controller.signal });
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new ApiError("请求超时,请检查网络后重试", 408);
}
throw new ApiError("网络连接失败,请稍后重试", 0);
} finally {
window.clearTimeout(timeout);
}
const body = (await response.json().catch(() => ({ code: response.status, message: "服务响应异常", data: null }))) as ApiResponse<T>;
if (!response.ok || body.code !== 0) { if (!response.ok || body.code !== 0) {
throw new Error(body.message || `${response.status} ${response.statusText}`); throw new ApiError(
body.message || `${response.status} ${response.statusText}`,
response.status,
response.headers.get("X-Request-ID") ?? "",
);
} }
return body.data; return body.data;
} }
export const api = { export const api = {
captcha: () => request<CaptchaResult>("/auth/captcha"), captcha: () => request<CaptchaResult>("/auth/captcha"),
sendSms: (phone: string, captchaId: string, captchaCode: string) => sendSms: (phone: string, captchaId?: string, captchaCode?: string) =>
request<null>("/auth/sms/send", { method: "POST", body: JSON.stringify({ phone, captchaId, captchaCode }) }), request<null>("/auth/sms/send", {
method: "POST",
body: JSON.stringify({ phone, ...(captchaId && captchaCode ? { captchaId, captchaCode } : {}) }),
}),
login: (phone: string, code: string) => login: (phone: string, code: string) =>
request<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ phone, code }) }), request<LoginResult>("/auth/login", { method: "POST", body: JSON.stringify({ phone, code }) }),
logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }), logout: () => request<null>("/auth/logout", { method: "POST", body: JSON.stringify({}) }),

View File

@@ -1,18 +1,36 @@
:root { :root {
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
color: #17211d; color: #14221d;
background: #e9efed; background: #f6faf8;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
--login-bg: #f6faf8;
--login-surface: #ffffff;
--login-text: #14221d;
--login-muted: #66756f;
--login-weak: #98a39f;
--login-brand: #42a982;
--login-brand-dark: #2f8f6d;
--login-border: #dce6e2;
--login-error: #d85b5b;
--login-disabled: #e8f0ed;
--login-radius-card: 22px;
--login-radius-control: 15px;
--login-shadow: 0 18px 54px rgba(35, 79, 63, 0.09);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
background: var(--login-bg);
}
body { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
overflow-x: hidden;
} }
button, button,
@@ -25,16 +43,23 @@ button {
cursor: pointer; cursor: pointer;
} }
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid rgba(66, 169, 130, 0.2);
outline-offset: 2px;
}
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 24px;
} }
.phone-frame { .phone-frame {
position: relative; position: relative;
width: min(100%, 430px); width: min(calc(100% - 48px), 430px);
height: min(860px, calc(100vh - 48px)); height: min(860px, calc(100vh - 48px));
min-height: 640px; min-height: 640px;
overflow: hidden; overflow: hidden;
@@ -44,6 +69,19 @@ button {
box-shadow: 0 28px 80px rgba(15, 31, 26, 0.18); box-shadow: 0 28px 80px rgba(15, 31, 26, 0.18);
} }
.phone-frame.login-mode {
width: 100%;
max-width: none;
height: auto;
min-height: 100vh;
min-height: 100dvh;
overflow: visible;
border: 0;
border-radius: 0;
background: var(--login-bg);
box-shadow: none;
}
.booting { .booting {
height: 100%; height: 100%;
display: grid; display: grid;
@@ -51,167 +89,562 @@ button {
color: #557066; color: #557066;
} }
.login-panel { .login-page {
height: 100%; min-height: 100vh;
padding: 72px 24px 24px; min-height: 100dvh;
display: flex; padding:
flex-direction: column; max(30px, env(safe-area-inset-top))
gap: 18px; 22px
background: linear-gradient(180deg, #ffffff 0%, #f3faf7 100%); max(24px, env(safe-area-inset-bottom));
background: var(--login-bg);
} }
.brand-mark { .login-content {
width: 56px; width: min(100%, 460px);
height: 56px; min-height: calc(100vh - 54px);
min-height: calc(100dvh - 54px);
margin: 0 auto;
display: flex;
flex-direction: column;
}
.login-brand-area {
position: relative;
overflow: hidden;
padding: 28px 16px 26px;
}
.brand-heading {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 14px;
}
.brand-icon {
flex: 0 0 auto;
width: 54px;
height: 54px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 16px; border-radius: 16px;
background: #0f8b6f; background: var(--login-brand);
color: #ffffff; color: #ffffff;
font-weight: 800; box-shadow: 0 10px 24px rgba(47, 143, 109, 0.2);
}
.brand-heading h1 {
margin: 0;
color: var(--login-text);
font-size: 29px;
line-height: 1.25;
font-weight: 750;
letter-spacing: 0; letter-spacing: 0;
} }
.login-panel h1 { .login-brand-area > p {
margin: 10px 0 0; position: relative;
font-size: 28px; z-index: 1;
line-height: 1.2; margin: 20px 0 0;
color: var(--login-muted);
font-size: 15px;
line-height: 1.7;
} }
.login-panel p { .brand-watermark {
margin: 0 0 18px; position: absolute;
color: #5f746c; top: -28px;
right: -18px;
color: var(--login-brand);
opacity: 0.07;
pointer-events: none;
} }
.login-panel label { .login-card {
display: grid; display: grid;
gap: 22px;
padding: 26px 24px 24px;
border: 1px solid rgba(220, 230, 226, 0.9);
border-radius: var(--login-radius-card);
background: rgba(255, 255, 255, 0.97);
box-shadow: var(--login-shadow);
}
.form-field {
display: grid;
gap: 10px;
}
.form-field > label {
color: var(--login-text);
font-size: 16px;
line-height: 1.4;
font-weight: 650;
}
.input-shell {
min-height: 54px;
display: flex;
align-items: center;
gap: 11px;
padding: 0 15px;
border: 1px solid var(--login-border);
border-radius: var(--login-radius-control);
background: var(--login-surface);
color: #a5b0ac;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.input-shell:focus-within {
border-color: var(--login-brand);
box-shadow: 0 0 0 3px rgba(66, 169, 130, 0.11);
color: var(--login-brand-dark);
}
.input-shell.invalid {
border-color: var(--login-error);
}
.input-shell input {
min-width: 0;
flex: 1;
height: 52px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--login-text);
font-size: 16px;
letter-spacing: 0;
}
.input-shell input::placeholder {
color: var(--login-weak);
}
.input-clear {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-right: -12px;
border: 0;
background: transparent;
color: var(--login-weak);
}
.code-input-shell {
gap: 10px;
padding-right: 7px;
}
.input-divider {
width: 1px;
height: 26px;
background: var(--login-border);
}
.send-code-button {
min-width: 108px;
min-height: 44px;
padding: 0 8px;
border: 0;
background: transparent;
color: var(--login-brand-dark);
font-size: 15px;
font-weight: 650;
white-space: nowrap;
}
.send-code-button:disabled {
cursor: not-allowed;
color: var(--login-weak);
}
.security-hint {
min-height: 22px;
display: flex;
align-items: center;
gap: 7px;
margin: 0;
color: var(--login-weak);
font-size: 13px;
line-height: 1.6;
}
.field-error {
margin: -3px 0 0;
color: var(--login-error);
font-size: 13px;
line-height: 1.5;
}
.agreement-row {
min-height: 44px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
margin-top: 1px;
padding: 5px 4px;
border-radius: 10px;
color: var(--login-muted);
font-size: 13px;
line-height: 1.7;
transition: background 0.18s ease, color 0.18s ease;
}
.agreement-row.attention {
background: rgba(216, 91, 91, 0.08);
color: var(--login-error);
}
.agreement-toggle {
min-height: 34px;
display: inline-flex;
align-items: center;
gap: 8px; gap: 8px;
color: #40524b; cursor: pointer;
}
.agreement-toggle input {
width: 20px;
height: 20px;
margin: 0;
accent-color: var(--login-brand-dark);
cursor: pointer;
}
.policy-link {
min-height: 34px;
padding: 0 2px;
border: 0;
background: transparent;
color: var(--login-brand-dark);
font-weight: 600;
}
.login-submit {
width: 100%;
height: 54px;
border: 0;
border-radius: 15px;
background: var(--login-brand-dark);
color: #ffffff;
font-size: 17px;
font-weight: 700;
letter-spacing: 0;
box-shadow: 0 11px 24px rgba(47, 143, 109, 0.2);
transition: transform 0.15s ease, background 0.15s ease;
}
.login-submit:not(:disabled):active {
transform: translateY(1px);
background: #287d60;
}
.login-submit:disabled,
.dialog-confirm:disabled {
cursor: not-allowed;
background: var(--login-disabled);
color: var(--login-weak);
box-shadow: none;
}
.login-help-links {
min-height: 44px;
display: grid;
grid-template-columns: 1fr 1px 1fr;
align-items: center;
gap: 12px;
}
.login-help-links span {
width: 1px;
height: 18px;
background: var(--login-border);
}
.login-help-links button {
min-height: 44px;
padding: 0;
border: 0;
background: transparent;
color: var(--login-muted);
font-size: 14px; font-size: 14px;
} }
.login-panel input, .login-security-footer {
.composer textarea { margin-top: auto;
width: 100%; padding: 34px 10px 6px;
border: 1px solid #cad8d2; color: #a3afaa;
border-radius: 14px; text-align: center;
background: #ffffff;
color: #17211d;
outline: none;
} }
.login-panel input { .login-security-footer > span {
height: 48px; display: inline-block;
padding: 0 14px; width: calc(50% - 28px);
height: 1px;
margin: 0 8px 5px;
background: var(--login-border);
} }
.login-panel input:focus, .login-security-footer p {
.composer textarea:focus { margin: 10px 0 0;
border-color: #0f8b6f; font-size: 12px;
box-shadow: 0 0 0 3px rgba(15, 139, 111, 0.12); line-height: 1.6;
} }
.code-row { .login-toast {
position: fixed;
z-index: 80;
left: 50%;
bottom: max(28px, calc(env(safe-area-inset-bottom) + 18px));
max-width: calc(100vw - 40px);
padding: 11px 16px;
border-radius: 10px;
background: rgba(20, 34, 29, 0.92);
color: #ffffff;
font-size: 14px;
line-height: 1.5;
text-align: center;
transform: translateX(-50%);
box-shadow: 0 10px 30px rgba(20, 34, 29, 0.18);
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 70;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 92px; place-items: center;
gap: 10px; padding: 22px;
background: rgba(20, 34, 29, 0.38);
} }
.captcha-row { .app-dialog {
display: grid; width: min(100%, 390px);
grid-template-columns: minmax(0, 1fr) 128px; max-height: min(78vh, 650px);
gap: 10px; display: flex;
} flex-direction: column;
.captcha-image {
height: 48px;
padding: 0;
overflow: hidden; overflow: hidden;
border: 1px solid #cad8d2; border: 1px solid rgba(220, 230, 226, 0.95);
border-radius: 14px; border-radius: 20px;
background: #ffffff; background: #ffffff;
box-shadow: 0 24px 70px rgba(20, 34, 29, 0.2);
} }
.captcha-image img { .dialog-header {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 12px 22px;
border-bottom: 1px solid #edf2f0;
}
.dialog-header h2 {
margin: 0;
color: var(--login-text);
font-size: 19px;
line-height: 1.4;
}
.dialog-close {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--login-muted);
}
.dialog-content {
overflow-y: auto;
padding: 20px 22px;
overscroll-behavior: contain;
}
.dialog-footer {
padding: 12px 22px max(18px, env(safe-area-inset-bottom));
border-top: 1px solid #edf2f0;
}
.dialog-confirm {
width: 100%;
min-height: 48px;
border: 0;
border-radius: 13px;
background: var(--login-brand-dark);
color: #ffffff;
font-size: 16px;
font-weight: 650;
}
.policy-copy p {
margin: 0 0 16px;
color: var(--login-muted);
font-size: 14px;
line-height: 1.8;
}
.policy-copy h3 {
margin: 20px 0 8px;
color: var(--login-text);
font-size: 15px;
}
.help-list {
display: grid;
gap: 15px;
margin: 0;
padding: 0;
list-style: none;
}
.help-list li {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 9px;
color: var(--login-muted);
font-size: 14px;
line-height: 1.7;
}
.help-list svg {
margin-top: 3px;
color: var(--login-brand-dark);
}
.support-contact {
margin: 20px 0 0;
padding: 12px 14px;
border-radius: 10px;
background: #f3f8f6;
color: #50645c;
font-size: 13px;
line-height: 1.7;
}
.captcha-dialog-copy > p:first-child {
margin: 0 0 15px;
color: var(--login-muted);
font-size: 14px;
line-height: 1.7;
}
.captcha-entry {
display: grid;
grid-template-columns: minmax(0, 1fr) 132px;
gap: 10px;
}
.captcha-entry input {
min-width: 0;
height: 50px;
padding: 0 13px;
border: 1px solid var(--login-border);
border-radius: 13px;
outline: 0;
color: var(--login-text);
font-size: 16px;
}
.captcha-entry input:focus {
border-color: var(--login-brand);
box-shadow: 0 0 0 3px rgba(66, 169, 130, 0.11);
}
.captcha-entry button {
height: 50px;
display: grid;
place-items: center;
overflow: hidden;
padding: 0;
border: 1px solid var(--login-border);
border-radius: 13px;
background: #f8fbfa;
color: var(--login-brand-dark);
}
.captcha-entry img {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
object-fit: cover; object-fit: cover;
} }
.captcha-image span { .captcha-refresh-tip {
color: #5f746c; display: flex;
font-size: 13px; align-items: center;
gap: 6px;
margin: 9px 0 0;
color: var(--login-weak);
font-size: 12px;
} }
.primary, .spinning {
.secondary, animation: spin 0.8s linear infinite;
.send-btn,
.stop-btn,
.new-session-btn {
border: 0;
border-radius: 14px;
font-weight: 700;
} }
.primary { @keyframes spin {
height: 50px; to { transform: rotate(360deg); }
margin-top: 8px;
background: #0f8b6f;
color: #ffffff;
} }
.secondary { @media (max-width: 380px) {
background: #e5f3ef; .login-page {
color: #0f735d; padding-right: 18px;
} padding-left: 18px;
}
button:disabled { .login-brand-area {
cursor: not-allowed; padding-right: 8px;
opacity: 0.52; padding-left: 8px;
} }
.form-tip { .brand-heading h1 {
min-height: 20px; font-size: 26px;
color: #657970; }
font-size: 13px;
}
.dialog-mask { .login-card {
position: absolute; padding-right: 18px;
inset: 0; padding-left: 18px;
z-index: 20; }
display: grid;
place-items: center;
padding: 24px;
background: rgba(12, 24, 20, 0.42);
}
.dialog-box { .send-code-button {
width: min(100%, 320px); min-width: 96px;
padding: 22px;
border-radius: 18px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(15, 31, 26, 0.22);
}
.dialog-box strong {
display: block;
color: #17211d;
font-size: 18px;
line-height: 1.35;
}
.dialog-box p {
margin: 10px 0 18px;
color: #51655d;
font-size: 14px; font-size: 14px;
line-height: 1.7; }
} }
.dialog-box .primary { @media (max-height: 700px) {
width: 100%; .login-page {
margin-top: 0; padding-top: max(16px, env(safe-area-inset-top));
}
.login-brand-area {
padding-top: 12px;
padding-bottom: 18px;
}
.login-card {
gap: 17px;
}
.login-security-footer {
padding-top: 22px;
}
}
@media (min-width: 700px) {
.login-page {
padding-top: 58px;
padding-bottom: 40px;
}
} }
.topbar { .topbar {