新增 auth/paths 与登录页;HTTP 与流式聊天在 401 时续签,失败则清理会话并跳转登录。 侧栏用户信息区布局修复;用户菜单项间距与验证码发送冷却、antd 发送按钮。 Made-with: Cursor
This commit is contained in:
@@ -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
157
src/api/auth.ts
Normal 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);
|
||||
}
|
||||
105
src/api/http.ts
105
src/api/http.ts
@@ -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
26
src/api/paths.ts
Normal 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];
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
@@ -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
210
src/pages/login.tsx
Normal 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
6
src/utils/tw.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 拼接 Tailwind / 任意 class 片段。用于把长 className 拆成多行,避免单行过长。
|
||||
*/
|
||||
export function tw(...parts: string[]): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user