feat: 短信登录、会话续签与侧栏体验优化
All checks were successful
CI / build (push) Successful in 2m17s

新增 auth/paths 与登录页;HTTP 与流式聊天在 401 时续签,失败则清理会话并跳转登录。
侧栏用户信息区布局修复;用户菜单项间距与验证码发送冷却、antd 发送按钮。

Made-with: Cursor
This commit is contained in:
2026-04-21 06:30:43 +08:00
parent 6579578a62
commit d4a91f11cb
9 changed files with 773 additions and 83 deletions

View File

@@ -2,7 +2,10 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="viewport"
content="initial-scale=1.0,maximum-scale=1,minimum-scale=1.0,user-scalable=no,width=device-width,viewport-fit=cover"
/>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<title>Chat One Web</title>

157
src/api/auth.ts Normal file
View File

@@ -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<string, unknown>;
/** 发送短信验证码时 `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<T extends { accessToken?: string; refreshToken?: string }>(
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<void> {
const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY);
if (refreshToken) {
try {
await postJson<unknown>(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<SmsSendResponse & { data?: SmsSendResponse }>(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<SmsLoginResponse> {
const raw = await postJson<SmsLoginResponse & { data?: SmsLoginResponse }>(ApiPath.authSmsLogin, {
phone,
code,
});
return pickAuthPayload(raw);
}
/**
* 使用 refreshToken 换取新的访问令牌与刷新令牌。
* 该请求在 `http` 层不会附带旧的 `Authorization`。
*/
export async function refreshTokens(refreshToken: string): Promise<RefreshTokenResponse> {
const raw = await postJson<RefreshTokenResponse & { data?: RefreshTokenResponse }>(
ApiPath.authRefresh,
{ refreshToken },
);
return pickAuthPayload(raw);
}

View File

@@ -1,6 +1,20 @@
import axios, { AxiosError } from "axios";
/**
* HTTP 客户端封装axios
*
* - `baseURL`:开发环境通常为空,走 Vite 同源代理;生产可配 `VITE_API_BASE_URL`。
* - 请求拦截:除白名单接口外,自动附加 `Authorization: Bearer <accessToken>`。
* - 错误:`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<string, unknown>;
@@ -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<void> | 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<unknown>;
@@ -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<TResponse>(
path: string,
body: JsonBody,
@@ -40,12 +134,15 @@ export async function postJson<TResponse>(
}
}
/**
* POST JSON返回原生 `Response`(用于需要直接读 body stream 的场景)。
* 注意:不走 axios 拦截器以外的逻辑;若需鉴权请自行在 headers 中传入。
*/
export async function postStream(
path: string,
body: JsonBody,
signal?: AbortSignal,
): Promise<Response> {
// 流式响应仍使用原生 fetch便于直接读取 ReadableStream。
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "POST",
headers: {

26
src/api/paths.ts Normal file
View File

@@ -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];

View File

@@ -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<string, string> {
const headers: Record<string, string> = {
"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, unknown>): string {
const directDelta = payload.delta;
if (typeof directDelta === "string") return directDelta;
@@ -43,9 +85,7 @@ function pickTokenFromJson(payload: Record<string, unknown>): 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 | undefined>): AbortSignal
return controller.signal;
}
export async function streamQwenChat(options: StreamOptions): Promise<void> {
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<string, unknown>;
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<void> {
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<string, unknown>;
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();
}
}
}

View File

@@ -4,6 +4,8 @@ html,
body {
margin: 0;
height: 100%;
font-size: 14px;
overflow: hidden;
}
#root {

View File

@@ -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<string, unknown>;
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: <MobileOutlined />, label: "下载手机应用", style: userMenuItemStyle },
{ key: "settings", icon: <SettingOutlined />, label: "系统设置", style: userMenuItemStyle },
{
key: "help",
icon: <QuestionCircleOutlined />,
label: "帮助与反馈",
style: userMenuItemStyle,
},
{ type: "divider", style: userMenuDividerStyle },
{
key: "logout",
icon: <LogoutOutlined />,
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 = (
<div className="flex h-full flex-col bg-[var(--ds-bg-sider)]">
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col bg-[var(--ds-bg-sider)]">
<div className="shrink-0 px-3 pt-3 pb-2">
<Button
block
@@ -241,21 +337,33 @@ export default function HomePage() {
</div>
<div className="shrink-0 border-t border-[var(--ds-border)] p-3">
<Button
type="text"
className="h-10 w-full justify-start! rounded-xl! px-2 text-neutral-600 hover:bg-black/5!"
icon={<SettingOutlined />}
<Dropdown
menu={{ items: userMenuItems, onClick: onUserMenuClick }}
placement="topLeft"
trigger={["click"]}
>
{(!collapsed || isMobile) && "设置"}
</Button>
<div className="mt-2 flex items-center gap-2 rounded-xl px-2 py-1.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-neutral-500">
<UserOutlined />
</div>
{(!collapsed || isMobile) && (
<Typography.Text className="truncate text-[13px]"></Typography.Text>
)}
</div>
<button
type="button"
className={`flex w-full items-center gap-2 rounded-xl bg-neutral-100 px-2 py-2 text-left transition-colors hover:bg-neutral-200/90 ${
collapsed && !isMobile ? "justify-center py-2.5" : ""
}`}
>
<Avatar
size={36}
src={userProfile.avatar}
icon={!userProfile.avatar ? <UserOutlined /> : undefined}
className="shrink-0 bg-neutral-300"
/>
{(!collapsed || isMobile) && (
<>
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800">
{userProfile.displayName}
</span>
<MoreOutlined className="shrink-0 text-neutral-500" />
</>
)}
</button>
</Dropdown>
</div>
</div>
);
@@ -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)]"
>
<div className="flex h-14 items-center gap-2 border-b border-[var(--ds-border)] px-3">
<img
src="/logo.png"
alt=""
width={32}
height={32}
className="h-8 w-8 shrink-0 object-contain"
decoding="async"
/>
{!collapsed && (
<Typography.Title level={5} className="!m-0 !truncate !text-[15px] !font-semibold">
ChatOne
</Typography.Title>
)}
<div className="flex h-full min-h-0 min-w-0 flex-col">
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-[var(--ds-border)] px-3">
<img
src="/logo.png"
alt=""
width={32}
height={32}
className="h-8 w-8 shrink-0 object-contain"
decoding="async"
/>
{!collapsed && (
<Typography.Title level={5} className="!m-0 !truncate !text-[15px] !font-semibold">
ChatOne
</Typography.Title>
)}
</div>
{sidebarContent}
</div>
{sidebarContent}
</Sider>
)}
@@ -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}
</Drawer>

210
src/pages/login.tsx Normal file
View File

@@ -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 (
<main
className={tw(
"flex h-dvh w-full items-center justify-center",
"overflow-hidden bg-white px-4",
)}
>
{contextHolder}
<section className="w-[325px] -translate-y-6">
<div className="mb-7 flex items-center justify-center gap-2">
<img
src="/logo.png"
alt="ChatOne Logo"
width={28}
height={28}
className="h-7 w-7 object-contain"
decoding="async"
/>
<Typography.Title level={3} className="mb-0! leading-none text-[#355ad9]">
ChatOne
</Typography.Title>
</div>
<div className="space-y-5">
<Input
size="large"
placeholder="请输入手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={loginFieldClass}
prefix={<span className="mr-2 text-[14px] text-[#222]">+86</span>}
/>
<Input
size="large"
placeholder="请输入验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
className={loginFieldClass}
suffix={
<span className="flex items-center gap-2">
<span className="h-5 w-px bg-gray-200" />
<Button
type="link"
size="small"
loading={sendingSms}
disabled={smsCooldown > 0}
onClick={() => {
void onSendSmsCode();
}}
className={tw(
"m-0 h-auto min-w-0 shrink-0 p-0 text-[14px] font-medium",
"text-[#3b5ff7] hover:text-[#2d4dcc]!",
"disabled:text-neutral-400! disabled:opacity-100",
)}
>
{smsCooldown > 0 ? `${smsCooldown}s 后重发` : "发送验证码"}
</Button>
</span>
}
/>
<p className="block text-[12px] leading-6 text-[#81858c] py-2">
{" "}
<a href="#" className="text-[#333] underline">
</a>{" "}
{" "}
<a href="#" className="text-[#333] underline">
</a>
</p>
<Button
type="primary"
shape="round"
size="large"
block
loading={loggingIn}
onClick={() => {
void onLogin();
}}
className="mt-1 h-[42px] rounded-full border-0 bg-[#3b5ff7]"
>
</Button>
</div>
</section>
</main>
);
}

6
src/utils/tw.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* 拼接 Tailwind / 任意 class 片段。用于把长 className 拆成多行,避免单行过长。
*/
export function tw(...parts: string[]): string {
return parts.filter(Boolean).join(" ");
}