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>