feat(chat): 消息列表回到底部与贴底滚动优化
All checks were successful
CI / build (push) Successful in 2m7s

- 非底部时显示悬浮回到底部按钮,点击平滑滚底并隐藏
- 仅在已处于底部附近时随新消息自动贴底,避免打断上翻阅读
- 按钮使用原生圆钮并相对输入区 max-w-4xl 定位,避免 AntD 按钮宽条问题
- 同步 lucide-react 依赖及流式代码块、全局样式等小改动

Made-with: Cursor
This commit is contained in:
2026-04-24 04:39:24 +08:00
parent 3b0b6eac50
commit 891f09aa0d
5 changed files with 312 additions and 246 deletions

View File

@@ -30,6 +30,7 @@
"axios": "^1.15.0", "axios": "^1.15.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"katex": "^0.16.45", "katex": "^0.16.45",
"lucide-react": "^1.9.0",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { CheckOutlined, CopyOutlined } from "@ant-design/icons";
import { Button, message } from "antd"; import { Button, message } from "antd";
import { Check, Copy } from "lucide-react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
@@ -47,7 +47,7 @@ function MarkdownPreBlock(props: { children: ReactNode }) {
<Button <Button
type="text" type="text"
size="small" size="small"
icon={copied ? <CheckOutlined /> : <CopyOutlined />} icon={copied ? <Check size={16} /> : <Copy size={16} />}
className="text-neutral-600 hover:bg-black/5! hover:text-neutral-800!" className="text-neutral-600 hover:bg-black/5! hover:text-neutral-800!"
onClick={() => { onClick={() => {
void onCopy(); void onCopy();

View File

@@ -83,3 +83,9 @@ pre code.hljs {
.ant-dropdown-menu-item-danger { .ant-dropdown-menu-item-danger {
border-radius: 12px !important; border-radius: 12px !important;
} }
/* 修正 lucide 图标在 AntD Button 中的基线偏移 */
.ant-btn .ant-btn-icon > .lucide {
display: block;
transform: translateY(1px);
}

View File

@@ -1,26 +1,27 @@
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { import {
ArrowUpOutlined, ArrowUp,
CopyOutlined, ChevronDown,
DeleteOutlined, Copy,
EditOutlined, Ellipsis,
LogoutOutlined, LogOut,
MenuFoldOutlined, MessageCirclePlus,
MenuUnfoldOutlined, Paperclip,
MobileOutlined, PanelLeftClose,
MoreOutlined, PanelLeftOpen,
PaperClipOutlined, Pencil,
PlusOutlined, Pin,
QuestionCircleOutlined, RotateCcw,
RedoOutlined, Search,
SearchOutlined, Settings,
ShareAltOutlined, Share2,
SettingOutlined, Smartphone,
ThunderboltOutlined, Trash2,
VerticalAlignTopOutlined, User,
UserOutlined, Zap,
} from "@ant-design/icons"; CircleHelp,
} from "lucide-react";
import { import {
Avatar, Avatar,
Button, Button,
@@ -88,13 +89,7 @@ function subscribeUserProfile(onChange: () => void) {
}; };
} }
const INITIAL_MESSAGES: UiMessage[] = [ const INITIAL_MESSAGES: UiMessage[] = [];
{
id: "init-assistant",
role: "assistant",
content: "你好,我是 ChatOne 助手,有什么可以帮你?",
},
];
const STREAM_TIMEOUT_MS = 90000; const STREAM_TIMEOUT_MS = 90000;
const MAX_STREAM_RETRY = 1; const MAX_STREAM_RETRY = 1;
@@ -155,7 +150,9 @@ export default function HomePage() {
} }
}); });
const messageListRef = useRef<HTMLDivElement | null>(null); const messageListRef = useRef<HTMLDivElement | null>(null);
const shouldStickToBottomRef = useRef(true);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]); const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsLoading, setSessionsLoading] = useState(false);
@@ -200,18 +197,18 @@ export default function HomePage() {
const userMenuDividerStyle: CSSProperties = { margin: "10px 0" }; const userMenuDividerStyle: CSSProperties = { margin: "10px 0" };
const userMenuItems: MenuProps["items"] = [ const userMenuItems: MenuProps["items"] = [
{ key: "app", icon: <MobileOutlined />, label: "下载手机应用", style: userMenuItemStyle }, { key: "app", icon: <Smartphone size={16} />, label: "下载手机应用", style: userMenuItemStyle },
{ key: "settings", icon: <SettingOutlined />, label: "系统设置", style: userMenuItemStyle }, { key: "settings", icon: <Settings size={16} />, label: "系统设置", style: userMenuItemStyle },
{ {
key: "help", key: "help",
icon: <QuestionCircleOutlined />, icon: <CircleHelp size={16} />,
label: "帮助与反馈", label: "帮助与反馈",
style: userMenuItemStyle, style: userMenuItemStyle,
}, },
{ type: "divider", style: userMenuDividerStyle }, { type: "divider", style: userMenuDividerStyle },
{ {
key: "logout", key: "logout",
icon: <LogoutOutlined />, icon: <LogOut size={16} />,
label: "退出登录", label: "退出登录",
danger: true, danger: true,
style: userMenuItemStyle, style: userMenuItemStyle,
@@ -258,11 +255,42 @@ export default function HomePage() {
}; };
}, []); }, []);
const isMessageListAtBottom = useCallback(() => {
const el = messageListRef.current;
if (!el) return true;
// 允许少量像素误差,避免小数像素造成闪烁。
return el.scrollHeight - el.scrollTop - el.clientHeight <= 24;
}, []);
const scrollMessageListToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
const el = messageListRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior });
}, []);
useEffect(() => { useEffect(() => {
const el = messageListRef.current; const el = messageListRef.current;
if (!el) return; if (!el) return;
el.scrollTop = el.scrollHeight; const onScroll = () => {
}, [messages]); const atBottom = isMessageListAtBottom();
shouldStickToBottomRef.current = atBottom;
setShowScrollToBottom(!atBottom);
};
onScroll();
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
};
}, [isMessageListAtBottom]);
useEffect(() => {
if (shouldStickToBottomRef.current) {
scrollMessageListToBottom("auto");
setShowScrollToBottom(false);
} else {
setShowScrollToBottom(true);
}
}, [messages, scrollMessageListToBottom]);
const refreshSessionList = useCallback(async (): Promise<ChatSession[]> => { const refreshSessionList = useCallback(async (): Promise<ChatSession[]> => {
if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return []; if (!window.localStorage.getItem(ACCESS_TOKEN_KEY)) return [];
@@ -567,7 +595,7 @@ export default function HomePage() {
loading={sessionMutationBusy} loading={sessionMutationBusy}
disabled={sessionsLoading} disabled={sessionsLoading}
className="h-10! rounded-xl! border-[var(--ds-border)]! bg-[var(--ds-bg-main)]! text-[13px]! font-medium shadow-none hover:border-sky-300! hover:text-sky-600!" className="h-10! rounded-xl! border-[var(--ds-border)]! bg-[var(--ds-bg-main)]! text-[13px]! font-medium shadow-none hover:border-sky-300! hover:text-sky-600!"
icon={<PlusOutlined />} icon={<MessageCirclePlus size={14} strokeWidth={2} />}
onClick={() => { onClick={() => {
void handleNewSession(); void handleNewSession();
}} }}
@@ -600,9 +628,9 @@ export default function HomePage() {
const active = sid === activeSessionId; const active = sid === activeSessionId;
const itemMenu: MenuProps = { const itemMenu: MenuProps = {
items: [ items: [
{ key: "rename", icon: <EditOutlined />, label: "重命名" }, { key: "rename", icon: <Pencil size={16} />, label: "重命名" },
{ key: "pin", icon: <VerticalAlignTopOutlined />, label: "置顶" }, { key: "pin", icon: <Pin size={16} />, label: "置顶" },
{ key: "delete", icon: <DeleteOutlined />, danger: true, label: "删除" }, { key: "delete", icon: <Trash2 size={16} />, danger: true, label: "删除" },
], ],
onClick: ({ key, domEvent }) => { onClick: ({ key, domEvent }) => {
domEvent.stopPropagation(); domEvent.stopPropagation();
@@ -614,7 +642,7 @@ export default function HomePage() {
return ( return (
<div <div
key={sid} key={sid}
className="mb-1 flex w-full items-center gap-1 rounded-xl pr-1 text-[14px] text-neutral-700 transition-colors hover:bg-black/5" className="group mb-1.5 flex w-full items-center gap-1 rounded-2xl pr-1 text-[14px] text-neutral-700 transition-colors hover:bg-black/5"
style={{ style={{
background: active ? "var(--ds-active-item)" : undefined, background: active ? "var(--ds-active-item)" : undefined,
color: active ? "var(--ds-accent)" : undefined, color: active ? "var(--ds-accent)" : undefined,
@@ -625,23 +653,29 @@ export default function HomePage() {
onClick={() => { onClick={() => {
void openSession(sid); void openSession(sid);
}} }}
className="flex min-h-12 min-w-0 flex-1 items-center rounded-xl px-3 py-3 text-left" className="flex h-11 min-w-0 flex-1 cursor-pointer items-center rounded-2xl px-4 text-left"
> >
{(!collapsed || isMobile) && <span className="truncate">{title}</span>} {(!collapsed || isMobile) && (
<span className="min-w-0 flex-1 truncate text-[14px] leading-none">
{title}
</span>
)}
</button> </button>
{(!collapsed || isMobile) && ( {(!collapsed || isMobile) && (
<div className={`${active ? "flex" : "hidden group-hover:flex"}`}>
<Dropdown menu={itemMenu} trigger={["click"]} placement="bottomRight"> <Dropdown menu={itemMenu} trigger={["click"]} placement="bottomRight">
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<MoreOutlined />} icon={<Ellipsis size={16} />}
loading={sessionActionBusyId === sid} loading={sessionActionBusyId === sid}
className="text-neutral-500 hover:bg-black/5!" className="!flex !h-8 !w-8 !items-center !justify-center rounded-xl text-neutral-500 hover:bg-black/5!"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
}} }}
/> />
</Dropdown> </Dropdown>
</div>
)} )}
</div> </div>
); );
@@ -650,7 +684,7 @@ export default function HomePage() {
)} )}
</div> </div>
<div className="shrink-0 border-t border-[var(--ds-border)] p-3"> <div className="shrink-0 p-3">
<Dropdown <Dropdown
menu={{ items: userMenuItems, onClick: onUserMenuClick }} menu={{ items: userMenuItems, onClick: onUserMenuClick }}
placement="topLeft" placement="topLeft"
@@ -658,14 +692,14 @@ export default function HomePage() {
> >
<button <button
type="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 ${ className={`flex w-full cursor-pointer 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" : "" collapsed && !isMobile ? "justify-center py-2.5" : ""
}`} }`}
> >
<Avatar <Avatar
size={36} size={36}
src={userProfile.avatar} src={userProfile.avatar}
icon={!userProfile.avatar ? <UserOutlined /> : undefined} icon={!userProfile.avatar ? <User size={18} /> : undefined}
className="shrink-0 bg-neutral-300" className="shrink-0 bg-neutral-300"
/> />
{(!collapsed || isMobile) && ( {(!collapsed || isMobile) && (
@@ -673,7 +707,7 @@ export default function HomePage() {
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800"> <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800">
{userProfile.displayName} {userProfile.displayName}
</span> </span>
<MoreOutlined className="shrink-0 text-neutral-500" /> <Ellipsis size={16} className="shrink-0 text-neutral-500" />
</> </>
)} )}
</button> </button>
@@ -696,10 +730,10 @@ export default function HomePage() {
width={260} width={260}
collapsedWidth={72} collapsedWidth={72}
theme="light" theme="light"
className="!min-h-0 !h-full !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)]"
> >
<div className="flex h-full min-h-0 min-w-0 flex-col"> <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"> <div className="flex h-14 shrink-0 items-center gap-2 px-3">
<img <img
src="/logo.png" src="/logo.png"
alt="" alt=""
@@ -724,7 +758,7 @@ export default function HomePage() {
style={{ background: "var(--ds-bg-main)" }} style={{ background: "var(--ds-bg-main)" }}
> >
<Header <Header
className="!flex !h-14 !shrink-0 !items-center !justify-between !border-b !border-[var(--ds-border)] !bg-[var(--ds-bg-main)] !px-5" className="!flex !h-14 !shrink-0 !items-center !justify-between !bg-[var(--ds-bg-main)] !px-5"
style={{ lineHeight: "56px" }} style={{ lineHeight: "56px" }}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -733,11 +767,11 @@ export default function HomePage() {
className="text-neutral-500 hover:bg-black/5!" className="text-neutral-500 hover:bg-black/5!"
icon={ icon={
isMobile ? ( isMobile ? (
<MenuUnfoldOutlined /> <PanelLeftOpen size={16} />
) : collapsed ? ( ) : collapsed ? (
<MenuUnfoldOutlined /> <PanelLeftOpen size={16} />
) : ( ) : (
<MenuFoldOutlined /> <PanelLeftClose size={16} />
) )
} }
onClick={() => { onClick={() => {
@@ -770,13 +804,16 @@ export default function HomePage() {
</Header> </Header>
<Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)]"> <Content className="flex min-h-0 flex-1 flex-col overflow-hidden !bg-[var(--ds-bg-main)]">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> <div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div <div
ref={messageListRef} ref={messageListRef}
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 pt-6 pb-0 md:px-8 md:pt-6 md:pb-0" className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-4 pt-6 pb-0 md:px-8 md:pt-6 md:pb-0"
> >
<div className="mx-auto flex max-w-4xl flex-col gap-5"> <div className="mx-auto flex max-w-4xl flex-col gap-5">
{messages.map((item) => ( {!activeSessionId && messages.length === 0 ? (
<p className="px-4 text-[18px] text-neutral-800"> ChatOne</p>
) : (
messages.map((item) => (
<div <div
key={item.id} key={item.id}
className={`flex ${item.role === "user" ? "justify-end" : "justify-start"}`} className={`flex ${item.role === "user" ? "justify-end" : "justify-start"}`}
@@ -804,7 +841,7 @@ export default function HomePage() {
key: "think", key: "think",
label: ( label: (
<span className="text-[13px] text-neutral-600"> <span className="text-[13px] text-neutral-600">
<ThunderboltOutlined className="mr-1.5" /> <Zap size={14} className="mr-1.5" />
2 2
</span> </span>
), ),
@@ -839,7 +876,7 @@ export default function HomePage() {
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<CopyOutlined />} icon={<Copy size={16} />}
className="!px-1 text-neutral-500 hover:bg-black/5!" className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="复制回复" aria-label="复制回复"
onClick={() => { onClick={() => {
@@ -851,7 +888,7 @@ export default function HomePage() {
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<RedoOutlined />} icon={<RotateCcw size={16} />}
className="!px-1 text-neutral-500 hover:bg-black/5!" className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="重新生成" aria-label="重新生成"
onClick={() => { onClick={() => {
@@ -863,7 +900,7 @@ export default function HomePage() {
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<ShareAltOutlined />} icon={<Share2 size={16} />}
className="!px-1 text-neutral-500 hover:bg-black/5!" className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="分享" aria-label="分享"
onClick={() => { onClick={() => {
@@ -879,7 +916,7 @@ export default function HomePage() {
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<CopyOutlined />} icon={<Copy size={16} />}
className="text-neutral-500 hover:bg-black/5!" className="text-neutral-500 hover:bg-black/5!"
aria-label="复制消息" aria-label="复制消息"
onClick={() => { onClick={() => {
@@ -889,7 +926,7 @@ export default function HomePage() {
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<EditOutlined />} icon={<Pencil size={16} />}
className="text-neutral-500 hover:bg-black/5!" className="text-neutral-500 hover:bg-black/5!"
aria-label="编辑消息" aria-label="编辑消息"
onClick={() => { onClick={() => {
@@ -900,12 +937,28 @@ export default function HomePage() {
)} )}
</div> </div>
</div> </div>
))} ))
)}
</div> </div>
</div> </div>
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pt-0 md:px-8 md:pt-0"> <div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pt-0 md:px-8 md:pt-0">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="relative">
{showScrollToBottom && (
<button
type="button"
aria-label="滚动到底部"
className="absolute -top-14 right-3 z-10 flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-neutral-200/90 bg-white text-neutral-900 shadow-md transition-colors hover:bg-neutral-50"
onClick={() => {
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
scrollMessageListToBottom("smooth");
}}
>
<ChevronDown color="#666" size={18} strokeWidth={2} aria-hidden />
</button>
)}
<div className="rounded-3xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm"> <div className="rounded-3xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
<Input.TextArea <Input.TextArea
value={inputValue} value={inputValue}
@@ -925,38 +978,38 @@ export default function HomePage() {
<button <button
type="button" type="button"
onClick={() => setDeepThink((v) => !v)} onClick={() => setDeepThink((v) => !v)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${ className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
deepThink deepThink
? "border-sky-300 bg-sky-50 text-sky-700" ? "border-sky-300 bg-sky-50 text-sky-700"
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300" : "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
}`} }`}
> >
<ThunderboltOutlined /> <Zap size={14} />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setSmartSearch((v) => !v)} onClick={() => setSmartSearch((v) => !v)}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${ className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors ${
smartSearch smartSearch
? "border-sky-300 bg-sky-50 text-sky-700" ? "border-sky-300 bg-sky-50 text-sky-700"
: "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300" : "border-[var(--ds-border)] bg-[var(--ds-bg-main)] text-neutral-600 hover:border-neutral-300"
}`} }`}
> >
<SearchOutlined /> <Search size={14} />
</button> </button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
type="text" type="text"
icon={<PaperClipOutlined className="text-neutral-400" />} icon={<Paperclip size={16} className="text-neutral-400" />}
className="text-neutral-400" className="text-neutral-400"
/> />
<Button <Button
type="primary" type="primary"
shape="circle" shape="circle"
icon={<ArrowUpOutlined />} icon={<ArrowUp size={16} />}
loading={isSending} loading={isSending}
disabled={isSending || !inputValue.trim()} disabled={isSending || !inputValue.trim()}
onClick={() => { onClick={() => {
@@ -970,6 +1023,7 @@ export default function HomePage() {
</div> </div>
</div> </div>
</div> </div>
</div>
<p className="m-0 py-2 text-center text-[11px] text-neutral-500"> <p className="m-0 py-2 text-center text-[11px] text-neutral-500">
AI AI
</p> </p>

View File

@@ -2932,6 +2932,11 @@ lru-cache@^5.1.1:
dependencies: dependencies:
yallist "^3.0.2" yallist "^3.0.2"
lucide-react@^1.9.0:
version "1.9.0"
resolved "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.9.0.tgz#3c8324e6b574131624c1869cbd38829d9c659627"
integrity sha512-6qVAmbgCjcJz7sAGSPSSJ++RAwjlK2XCbRrZKv63Ciko1KT8jX0//CXxgI3jg2HlJu8tADqdYlNDebmYjeoruA==
magic-string@^0.30.21: magic-string@^0.30.21:
version "0.30.21" version "0.30.21"
resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"