From d4a91f11cbb1f7467d3625e26e740fc24a110601 Mon Sep 17 00:00:00 2001 From: alboped Date: Tue, 21 Apr 2026 06:30:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9F=AD=E4=BF=A1=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E3=80=81=E4=BC=9A=E8=AF=9D=E7=BB=AD=E7=AD=BE=E4=B8=8E=E4=BE=A7?= =?UTF-8?q?=E6=A0=8F=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 auth/paths 与登录页;HTTP 与流式聊天在 401 时续签,失败则清理会话并跳转登录。 侧栏用户信息区布局修复;用户菜单项间距与验证码发送冷却、antd 发送按钮。 Made-with: Cursor --- index.html | 5 +- src/api/auth.ts | 157 +++++++++++++++++++++++++++++++++ src/api/http.ts | 105 +++++++++++++++++++++- src/api/paths.ts | 26 ++++++ src/api/qwenChat.ts | 160 +++++++++++++++++++++++---------- src/index.css | 2 + src/pages/index.tsx | 185 +++++++++++++++++++++++++++++++------- src/pages/login.tsx | 210 ++++++++++++++++++++++++++++++++++++++++++++ src/utils/tw.ts | 6 ++ 9 files changed, 773 insertions(+), 83 deletions(-) create mode 100644 src/api/auth.ts create mode 100644 src/api/paths.ts create mode 100644 src/pages/login.tsx create mode 100644 src/utils/tw.ts diff --git a/index.html b/index.html index f9a13f9..ca27987 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,10 @@ - + Chat One Web diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..5afc556 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,157 @@ +/** + * 鉴权相关:登录、刷新令牌、本地会话读写。 + * + * 会话默认存 `localStorage`,键名见下方常量(与 `http.ts` 里读取的 `accessToken` 一致)。 + */ +import { postJson } from "./http"; +import { ApiPath } from "./paths"; + +/** localStorage:访问令牌 */ +export const ACCESS_TOKEN_KEY = "accessToken"; +/** localStorage:刷新令牌 */ +export const REFRESH_TOKEN_KEY = "refreshToken"; +/** localStorage:用户信息 JSON 字符串 */ +export const USER_KEY = "user"; + +export type AuthUser = Record; + +/** 发送短信验证码时 `scene` 取值(与后端约定一致) */ +export const AUTH_SMS_SCENE_LOGIN = "login"; + +/** `POST .../auth/sms/send` 响应(含开发环境可能返回的 `testCode`) */ +export type SmsSendResponse = { + testCode?: string; + data?: { + testCode?: string; + }; +}; + +function pickSmsSendPayload(body: SmsSendResponse & { data?: SmsSendResponse }): SmsSendResponse { + if (body.data && typeof body.data === "object") { + return { ...body, testCode: body.data.testCode ?? body.testCode }; + } + return body; +} + +/** `POST .../auth/sms/login` 成功后的典型响应(若后端包一层 `data`,见 `pickAuthPayload`) */ +export type SmsLoginResponse = { + accessToken: string; + refreshToken: string; + user?: AuthUser; +}; + +/** `POST .../auth/refresh` 成功后的典型响应 */ +export type RefreshTokenResponse = { + accessToken: string; + refreshToken: string; +}; + +function pickAuthPayload( + body: T & { data?: T }, +): T { + if (body.data && typeof body.data.accessToken === "string") { + return body.data; + } + return body; +} + +/** 写入一对令牌,并清理历史 mock 键 `token` */ +export function persistTokens(accessToken: string, refreshToken: string): void { + window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + window.localStorage.removeItem("token"); +} + +/** 写入用户信息(可选) */ +export function persistUser(user: AuthUser | undefined): void { + if (!user) return; + window.localStorage.setItem(USER_KEY, JSON.stringify(user)); +} + +/** 清除本地会话(含兼容旧键) */ +export function clearSession(): void { + window.localStorage.removeItem(ACCESS_TOKEN_KEY); + window.localStorage.removeItem(REFRESH_TOKEN_KEY); + window.localStorage.removeItem(USER_KEY); + window.localStorage.removeItem("token"); +} + +/** + * 续签失败、双 token 均失效等场景:清会话并回到登录页(整页跳转,避免残留状态)。 + */ +export class SessionExpiredError extends Error { + override readonly name = "SessionExpiredError"; + constructor(message = "登录已过期,请重新登录") { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export function invalidateSessionAndGoLogin(): void { + clearSession(); + const base = import.meta.env.BASE_URL || "/"; + const loginPath = base === "/" ? "/login" : `${String(base).replace(/\/$/, "")}/login`; + window.location.replace(loginPath); +} + +/** + * 退出登录:尽量通知后端吊销 refresh(失败则忽略),并清除本地会话。 + */ +export async function logout(): Promise { + const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY); + if (refreshToken) { + try { + await postJson(ApiPath.authLogout, { refreshToken }); + } catch { + // 无网或接口未实现时仍允许用户本地退出 + } + } + clearSession(); +} + +/** + * 发送短信验证码(如登录前获取验证码)。 + * @param phone 国际格式,如 `+86153xxxxxxxx` + * @param scene 业务场景,默认 `AUTH_SMS_SCENE_LOGIN` + * @returns `testCode` 仅部分环境返回,用于联调自动填码 + */ +export async function sendAuthSmsCode( + phone: string, + scene: string = AUTH_SMS_SCENE_LOGIN, +): Promise<{ testCode?: string }> { + const raw = await postJson(ApiPath.authSmsSend, { + phone, + scene, + }); + const merged = pickSmsSendPayload(raw); + const testCode = merged.testCode; + if (typeof testCode === "string" && testCode.trim()) { + return { testCode: testCode.trim() }; + } + return {}; +} + +/** + * 短信验证码登录。 + * @param phone 国际格式,如 `+86153xxxxxxxx` + * @param code 短信验证码 + */ +export async function smsLogin(phone: string, code: string): Promise { + const raw = await postJson(ApiPath.authSmsLogin, { + phone, + code, + }); + return pickAuthPayload(raw); +} + +/** + * 使用 refreshToken 换取新的访问令牌与刷新令牌。 + * 该请求在 `http` 层不会附带旧的 `Authorization`。 + */ +export async function refreshTokens(refreshToken: string): Promise { + const raw = await postJson( + ApiPath.authRefresh, + { refreshToken }, + ); + return pickAuthPayload(raw); +} diff --git a/src/api/http.ts b/src/api/http.ts index 8d231ce..67e04a3 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,6 +1,20 @@ -import axios, { AxiosError } from "axios"; +/** + * HTTP 客户端封装(axios)。 + * + * - `baseURL`:开发环境通常为空,走 Vite 同源代理;生产可配 `VITE_API_BASE_URL`。 + * - 请求拦截:除白名单接口外,自动附加 `Authorization: Bearer `。 + * - 错误:`postJson` 将 axios 错误统一转为 `Error`,便于页面层 `try/catch`。 + */ +import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { + REFRESH_TOKEN_KEY, + SessionExpiredError, + invalidateSessionAndGoLogin, + persistTokens, + refreshTokens, +} from "./auth"; +import { ApiPath, pathsWithoutBearerAuth } from "./paths"; -// Dev 默认走 Vite 同源代理,避免浏览器跨域。 const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""; type JsonBody = Record; @@ -10,7 +24,83 @@ export const httpClient = axios.create({ timeout: 30000, }); -// 统一把 axios 错误转为可读 Error,避免上层感知库细节。 +httpClient.interceptors.request.use((config) => { + const path = config.url ?? ""; + const skipBearer = pathsWithoutBearerAuth.some((p) => path === p || path.endsWith(p)); + if (skipBearer) { + config.headers.delete("Authorization"); + return config; + } + const accessToken = window.localStorage.getItem("accessToken"); + if (accessToken) { + config.headers.set("Authorization", `Bearer ${accessToken}`); + } + return config; +}); + +type SessionRetryConfig = InternalAxiosRequestConfig & { _sessionRetried?: boolean }; + +/** 同一时刻共用一个 refresh,避免并发 401 打爆续签接口 */ +let refreshOnce: Promise | null = null; + +function isAuthRefreshUrl(url: string): boolean { + return url === ApiPath.authRefresh || url.endsWith(ApiPath.authRefresh); +} + +httpClient.interceptors.response.use( + (response) => response, + async (error) => { + if (!axios.isAxiosError(error) || !error.config) { + return Promise.reject(error); + } + const status = error.response?.status; + const config = error.config as SessionRetryConfig; + if (status !== 401) { + return Promise.reject(error); + } + + const path = config.url ?? ""; + if (isAuthRefreshUrl(path)) { + invalidateSessionAndGoLogin(); + return Promise.reject(new SessionExpiredError()); + } + if (pathsWithoutBearerAuth.some((p) => path === p || path.endsWith(p))) { + return Promise.reject(error); + } + if (config._sessionRetried) { + invalidateSessionAndGoLogin(); + return Promise.reject(new SessionExpiredError()); + } + config._sessionRetried = true; + + if (!refreshOnce) { + refreshOnce = (async () => { + const rt = window.localStorage.getItem(REFRESH_TOKEN_KEY); + if (!rt) { + invalidateSessionAndGoLogin(); + throw new SessionExpiredError(); + } + try { + const data = await refreshTokens(rt); + persistTokens(data.accessToken, data.refreshToken); + } catch { + invalidateSessionAndGoLogin(); + throw new SessionExpiredError(); + } + })().finally(() => { + refreshOnce = null; + }); + } + + try { + await refreshOnce; + } catch (e) { + return Promise.reject(e); + } + return httpClient.request(config); + }, +); + function toRequestError(error: unknown, fallback: string): Error { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; @@ -27,6 +117,10 @@ function toRequestError(error: unknown, fallback: string): Error { return error instanceof Error ? error : new Error(fallback); } +/** + * POST JSON,返回解析后的响应体 `data`。 + * @param path 以 `/` 开头的相对路径 + */ export async function postJson( path: string, body: JsonBody, @@ -40,12 +134,15 @@ export async function postJson( } } +/** + * POST JSON,返回原生 `Response`(用于需要直接读 body stream 的场景)。 + * 注意:不走 axios 拦截器以外的逻辑;若需鉴权请自行在 headers 中传入。 + */ export async function postStream( path: string, body: JsonBody, signal?: AbortSignal, ): Promise { - // 流式响应仍使用原生 fetch,便于直接读取 ReadableStream。 const response = await fetch(`${API_BASE_URL}${path}`, { method: "POST", headers: { diff --git a/src/api/paths.ts b/src/api/paths.ts new file mode 100644 index 0000000..bd69321 --- /dev/null +++ b/src/api/paths.ts @@ -0,0 +1,26 @@ +/** + * 客户端 API 路径(均以 `/` 开头)。 + * 与 `VITE_API_BASE_URL`、Vite 代理拼接后请求后端。 + */ + +export const ApiPath = { + /** 发送短信验证码。POST body: `{ phone, scene }` */ + authSmsSend: "/api/client/v1/auth/sms/send", + /** 短信验证码登录。POST body: `{ phone, code }` */ + authSmsLogin: "/api/client/v1/auth/sms/login", + /** + * 刷新访问令牌。POST body: `{ refreshToken }` + * 注意:此接口不应携带 `Authorization: Bearer`(见 `http.ts` 拦截器白名单)。 + */ + authRefresh: "/api/client/v1/auth/refresh", + /** + * 登出(可选,若后端未实现会失败但本地仍会清理会话)。 + * POST body: `{ refreshToken }`;与 refresh 一样不携带 `Authorization`(见 `http.ts` 白名单)。 + */ + authLogout: "/api/client/v1/auth/logout", + /** 聊天补全(SSE 流式)。POST body: `{ messages, model? }` */ + chatCompletionsStream: "/api/client/v1/chat/completions/stream", +} as const; + +/** 请求拦截器跳过附加 Bearer 的路径(与 `ApiPath` 保持一致) */ +export const pathsWithoutBearerAuth: readonly string[] = [ApiPath.authRefresh, ApiPath.authLogout]; diff --git a/src/api/qwenChat.ts b/src/api/qwenChat.ts index b13c413..39dc6a6 100644 --- a/src/api/qwenChat.ts +++ b/src/api/qwenChat.ts @@ -1,4 +1,29 @@ +/** + * 千问 / 聊天补全流式接口(SSE)。 + * + * 使用 `@microsoft/fetch-event-source`,不走 axios,因此需在此自行组装请求头 + *(含 `Authorization`,与登录后 `localStorage` 中的 `accessToken` 一致)。 + */ import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, + invalidateSessionAndGoLogin, + persistTokens, + refreshTokens, + SessionExpiredError, +} from "./auth"; +import { ApiPath } from "./paths"; + +/** 携带 HTTP 状态码,便于流式 `onopen` 与外层续签逻辑区分 */ +class StreamHttpError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = "StreamHttpError"; + this.status = status; + } +} export type ChatRole = "user" | "assistant" | "system"; @@ -7,16 +32,33 @@ export interface ChatMessagePayload { content: string; } -interface StreamOptions { +export interface StreamOptions { messages: ChatMessagePayload[]; - /** 与后端 OpenAI 兼容字段一致,不传则由服务端默认模型处理 */ + /** OpenAI 兼容字段;不传则由服务端默认模型 */ model?: string; onToken: (token: string) => void; signal?: AbortSignal; + /** 超时后中止请求(与业务 `signal` 合并) */ timeoutMs?: number; } -// 兼容不同后端返回结构,提取可渲染的增量文本。 +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""; + +/** 流式 POST 的请求头:JSON + 可选 Bearer */ +function streamRequestHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY); + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}`; + } + return headers; +} + +/** + * 从 SSE 单条 JSON 中解析增量文本(兼容多种后端字段)。 + */ function pickTokenFromJson(payload: Record): string { const directDelta = payload.delta; if (typeof directDelta === "string") return directDelta; @@ -43,9 +85,7 @@ function pickTokenFromJson(payload: Record): string { return ""; } -// Dev 默认走 Vite 同源代理,避免浏览器跨域。 -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ""; - +/** 判断是否为用户/超时类中止,便于重试逻辑区分 */ export function isAbortLikeError(error: unknown): boolean { if (!error) return false; if (error instanceof DOMException && error.name === "AbortError") return true; @@ -82,43 +122,73 @@ function mergeAbortSignals(signals: Array): AbortSignal return controller.signal; } -export async function streamQwenChat(options: StreamOptions): Promise { - const timeoutSignal = createTimeoutAbortSignal(options.timeoutMs ?? 90000); - const mergedSignal = mergeAbortSignals([options.signal, timeoutSignal]); - - await fetchEventSource(`${API_BASE_URL}/api/client/v1/chat/completions/stream`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - messages: options.messages, - ...(options.model ? { model: options.model } : {}), - }), - signal: mergedSignal, - openWhenHidden: true, - async onopen(response) { - if (!response.ok) { - const errorText = await response.text(); - throw new Error(errorText || `Stream request failed with status ${response.status}`); - } - }, - onmessage(event) { - // 标准 SSE 结束标记,直接忽略。 - const raw = event.data?.trim(); - if (!raw || raw === "[DONE]") return; - - try { - const parsed = JSON.parse(raw) as Record; - const token = pickTokenFromJson(parsed); - if (token) options.onToken(token); - } catch { - // 非 JSON 片段兜底按纯文本追加。 - options.onToken(raw); - } - }, - onerror(error) { - throw error; - }, - }); +function isStreamUnauthorized(error: unknown): boolean { + return error instanceof StreamHttpError && error.status === 401; +} + +/** + * 建立 SSE 流式聊天连接,在 `onToken` 中持续收到增量文本。 + * + * **401 续签**:首次 `onopen` 为 401 时,用 `refreshToken` 调刷新接口,写入新令牌后**自动重试一次**; + * 若仍失败或无 `refreshToken`,则抛出原错误。 + */ +export async function streamQwenChat(options: StreamOptions): Promise { + const connectOnce = async () => { + const timeoutSignal = createTimeoutAbortSignal(options.timeoutMs ?? 90000); + const mergedSignal = mergeAbortSignals([options.signal, timeoutSignal]); + + await fetchEventSource(`${API_BASE_URL}${ApiPath.chatCompletionsStream}`, { + method: "POST", + headers: streamRequestHeaders(), + body: JSON.stringify({ + messages: options.messages, + ...(options.model ? { model: options.model } : {}), + }), + signal: mergedSignal, + openWhenHidden: true, + async onopen(response) { + if (!response.ok) { + const errorText = await response.text(); + throw new StreamHttpError( + response.status, + errorText || `Stream request failed with status ${response.status}`, + ); + } + }, + onmessage(event) { + const raw = event.data?.trim(); + if (!raw || raw === "[DONE]") return; + + try { + const parsed = JSON.parse(raw) as Record; + const token = pickTokenFromJson(parsed); + if (token) options.onToken(token); + } catch { + options.onToken(raw); + } + }, + onerror(error) { + throw error; + }, + }); + }; + + try { + await connectOnce(); + } catch (error) { + if (!isStreamUnauthorized(error)) throw error; + const storedRefresh = window.localStorage.getItem(REFRESH_TOKEN_KEY); + if (!storedRefresh) { + invalidateSessionAndGoLogin(); + throw new SessionExpiredError(); + } + try { + const refreshed = await refreshTokens(storedRefresh); + persistTokens(refreshed.accessToken, refreshed.refreshToken); + await connectOnce(); + } catch { + invalidateSessionAndGoLogin(); + throw new SessionExpiredError(); + } + } } diff --git a/src/index.css b/src/index.css index e1b095e..ab28670 100644 --- a/src/index.css +++ b/src/index.css @@ -4,6 +4,8 @@ html, body { margin: 0; height: 100%; + font-size: 14px; + overflow: hidden; } #root { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 8fe5531..9cb462e 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,17 +1,36 @@ +import type { CSSProperties } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowUpOutlined, + LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, MessageOutlined, + MobileOutlined, + MoreOutlined, PaperClipOutlined, PlusOutlined, + QuestionCircleOutlined, SearchOutlined, SettingOutlined, ThunderboltOutlined, UserOutlined, } from "@ant-design/icons"; -import { Button, Collapse, Drawer, Input, Layout, Select, Typography } from "antd"; +import { + Avatar, + Button, + Collapse, + Drawer, + Dropdown, + Input, + Layout, + Select, + Typography, + message, +} from "antd"; +import type { MenuProps } from "antd"; +import { useNavigate } from "react-router-dom"; +import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth"; import { isAbortLikeError, streamQwenChat, type ChatMessagePayload } from "../api/qwenChat"; import StreamMessage from "../components/StreamMessage"; @@ -25,6 +44,27 @@ type UiMessage = { thinking?: string; }; +function readStoredUserProfile(): { displayName: string; avatar?: string } { + try { + const raw = window.localStorage.getItem(USER_KEY); + if (!raw) return { displayName: "用户" }; + const u = JSON.parse(raw) as Record; + const displayName = + (typeof u.nickname === "string" && u.nickname) || + (typeof u.name === "string" && u.name) || + (typeof u.username === "string" && u.username) || + (typeof u.phone === "string" && u.phone) || + "用户"; + const avatar = + (typeof u.avatar === "string" && u.avatar) || + (typeof u.avatarUrl === "string" && u.avatarUrl) || + undefined; + return { displayName: String(displayName), avatar }; + } catch { + return { displayName: "用户" }; + } +} + const INITIAL_MESSAGES: UiMessage[] = [ { id: "init-assistant", @@ -46,6 +86,7 @@ const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [ ]; export default function HomePage() { + const navigate = useNavigate(); const [collapsed, setCollapsed] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [viewportWidth, setViewportWidth] = useState(0); @@ -67,6 +108,57 @@ export default function HomePage() { [], ); + const userProfile = useMemo(() => readStoredUserProfile(), []); + + /** 用户信息下拉:略增大行高与项间距 */ + const userMenuItemStyle: CSSProperties = { + height: "auto", + lineHeight: 1.45, + paddingBlock: 10, + marginBlock: 3, + }; + const userMenuDividerStyle: CSSProperties = { margin: "10px 0" }; + + const userMenuItems: MenuProps["items"] = [ + { key: "app", icon: , label: "下载手机应用", style: userMenuItemStyle }, + { key: "settings", icon: , label: "系统设置", style: userMenuItemStyle }, + { + key: "help", + icon: , + label: "帮助与反馈", + style: userMenuItemStyle, + }, + { type: "divider", style: userMenuDividerStyle }, + { + key: "logout", + icon: , + label: "退出登录", + danger: true, + style: userMenuItemStyle, + }, + ]; + + const onUserMenuClick: MenuProps["onClick"] = async ({ key }) => { + if (key === "logout") { + abortRef.current?.abort(); + setIsSending(false); + await logout(); + message.success("已退出登录"); + navigate("/login", { replace: true }); + return; + } + if (key === "app" || key === "settings" || key === "help") { + message.info("功能开发中"); + } + }; + + useEffect(() => { + const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY); + if (!accessToken) { + navigate("/login", { replace: true }); + } + }, [navigate]); + useEffect(() => { const updateIsMobile = () => { const w = Math.min( @@ -163,6 +255,10 @@ export default function HomePage() { lastError = null; break; } catch (error) { + if (error instanceof SessionExpiredError) { + lastError = null; + break; + } lastError = error; const isAbortLike = isAbortLikeError(error); const canRetry = attempt < MAX_STREAM_RETRY; @@ -202,7 +298,7 @@ export default function HomePage() { }; const sidebarContent = ( -
+
-
-
- -
- {(!collapsed || isMobile) && ( - 用户 - )} -
+ +
); @@ -273,24 +381,26 @@ export default function HomePage() { width={260} collapsedWidth={72} theme="light" - className="!min-h-0 !overflow-hidden !bg-[var(--ds-bg-sider)] !border-r !border-[var(--ds-border)]" + className="!min-h-0 !h-full !overflow-hidden !bg-[var(--ds-bg-sider)] !border-r !border-[var(--ds-border)]" > -
- - {!collapsed && ( - - ChatOne - - )} +
+
+ + {!collapsed && ( + + ChatOne + + )} +
+ {sidebarContent}
- {sidebarContent} )} @@ -500,7 +610,16 @@ export default function HomePage() { onClose={() => setMobileSidebarOpen(false)} open={isMobile && mobileSidebarOpen} width={280} - styles={{ body: { padding: 0, background: "var(--ds-bg-sider)" } }} + styles={{ + body: { + padding: 0, + background: "var(--ds-bg-sider)", + display: "flex", + flexDirection: "column", + height: "100%", + minHeight: 0, + }, + }} > {sidebarContent} diff --git a/src/pages/login.tsx b/src/pages/login.tsx new file mode 100644 index 0000000..4790c9d --- /dev/null +++ b/src/pages/login.tsx @@ -0,0 +1,210 @@ +import { Button, Input, Typography, message } from "antd"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + ACCESS_TOKEN_KEY, + persistTokens, + persistUser, + sendAuthSmsCode, + smsLogin, +} from "../api/auth"; +import { tw } from "../utils/tw"; + +/** 发送验证码成功后,按钮冷却秒数 */ +const SMS_RESEND_COOLDOWN_SEC = 60; + +/** 短信验证码登录页:校验本地 token、发送验证码、提交登录 */ +export default function LoginPage() { + const navigate = useNavigate(); + const [phone, setPhone] = useState(""); + const [code, setCode] = useState(""); + const [sendingSms, setSendingSms] = useState(false); + const [smsCooldown, setSmsCooldown] = useState(0); + const [loggingIn, setLoggingIn] = useState(false); + const [msgApi, contextHolder] = message.useMessage(); + + /** 已登录则直接进入首页,避免重复停留在登录页 */ + useEffect(() => { + const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY); + if (accessToken) { + navigate("/", { replace: true }); + } + }, [navigate]); + + const smsCountdownActive = smsCooldown > 0; + /** 验证码发送冷却:每秒递减,到 0 后允许再次发送 */ + useEffect(() => { + if (!smsCountdownActive) return undefined; + const id = window.setInterval(() => { + setSmsCooldown((s) => (s <= 1 ? 0 : s - 1)); + }, 1000); + return () => window.clearInterval(id); + }, [smsCountdownActive]); + + /** + * 提交手机号与验证码,调用登录接口;成功则持久化 token 并跳转首页。 + */ + const onLogin = async () => { + const formattedPhone = formatPhoneForApi(phone); + if (!formattedPhone) { + msgApi.warning("请输入正确的手机号"); + return; + } + const trimmedCode = code.trim(); + if (!trimmedCode) { + msgApi.warning("请输入验证码"); + return; + } + if (loggingIn) return; + setLoggingIn(true); + try { + const data = await smsLogin(formattedPhone, trimmedCode); + persistTokens(data.accessToken, data.refreshToken); + persistUser(data.user); + msgApi.success("登录成功"); + navigate("/", { replace: true }); + } catch (error) { + const text = error instanceof Error ? error.message : "登录失败"; + msgApi.error(text || "登录失败"); + } finally { + setLoggingIn(false); + } + }; + + /** + * 将用户输入规范为后端要求的国际格式(如 `+8613xxxxxxxx`)。 + * @returns 无法识别时返回 `null` + */ + function formatPhoneForApi(input: string): string | null { + const cleaned = input.trim().replace(/\s+/g, ""); + if (!cleaned) return null; + if (cleaned.startsWith("+")) return cleaned; + if (/^86\d{11}$/.test(cleaned)) return `+${cleaned}`; + if (/^1\d{10}$/.test(cleaned)) return `+86${cleaned}`; + return null; + } + + /** + * 请求发送短信验证码;成功后启动冷却,若接口返回 `testCode` 则自动填入(联调环境)。 + */ + const onSendSmsCode = async () => { + const formattedPhone = formatPhoneForApi(phone); + if (!formattedPhone) { + msgApi.warning("请输入正确的手机号"); + return; + } + if (sendingSms || smsCooldown > 0) return; + setSendingSms(true); + try { + const { testCode } = await sendAuthSmsCode(formattedPhone); + if (testCode) { + setCode(testCode); + } + setSmsCooldown(SMS_RESEND_COOLDOWN_SEC); + msgApi.success("验证码已发送"); + } catch (error) { + const text = error instanceof Error ? error.message : "验证码发送失败"; + msgApi.error(text || "验证码发送失败"); + } finally { + setSendingSms(false); + } + }; + + /** 手机号、验证码两个输入框共用的外观 class */ + const loginFieldClass = tw( + "h-[48px] !rounded-[999px] border-gray-200", + "!bg-gray-50 !text-[14px]", + ); + + return ( +
+ {contextHolder} +
+
+ ChatOne Logo + + ChatOne + +
+ +
+ setPhone(e.target.value)} + className={loginFieldClass} + prefix={+86} + /> + + setCode(e.target.value)} + className={loginFieldClass} + suffix={ + + + + + } + /> + +

+ 注册登录即代表已阅读并同意我们的{" "} + + 用户协议 + {" "} + 与{" "} + + 隐私政策 + + ,未注册的手机号将自动注册 +

+ + +
+
+
+ ); +} diff --git a/src/utils/tw.ts b/src/utils/tw.ts new file mode 100644 index 0000000..f8f0093 --- /dev/null +++ b/src/utils/tw.ts @@ -0,0 +1,6 @@ +/** + * 拼接 Tailwind / 任意 class 片段。用于把长 className 拆成多行,避免单行过长。 + */ +export function tw(...parts: string[]): string { + return parts.filter(Boolean).join(" "); +}