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

@@ -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>
);
}