feat: 完善会话交互并统一聊天页视觉样式
All checks were successful
CI / build (push) Successful in 10m45s

补齐会话重命名/删除与会话菜单能力,新增聊天参数透传与本地开关持久化,并统一输入区、消息区、代码块和弹框等关键交互样式。

Made-with: Cursor
This commit is contained in:
2026-04-23 23:09:23 +08:00
parent b3fdb0ad4b
commit 3b0b6eac50
8 changed files with 559 additions and 93 deletions

View File

@@ -2,18 +2,23 @@ import type { CSSProperties } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import {
ArrowUpOutlined,
CopyOutlined,
DeleteOutlined,
EditOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MessageOutlined,
MobileOutlined,
MoreOutlined,
PaperClipOutlined,
PlusOutlined,
QuestionCircleOutlined,
RedoOutlined,
SearchOutlined,
ShareAltOutlined,
SettingOutlined,
ThunderboltOutlined,
VerticalAlignTopOutlined,
UserOutlined,
} from "@ant-design/icons";
import {
@@ -24,7 +29,9 @@ import {
Dropdown,
Input,
Layout,
Modal,
Select,
Tooltip,
Typography,
message,
} from "antd";
@@ -33,11 +40,13 @@ import { useNavigate, useParams } from "react-router-dom";
import { ACCESS_TOKEN_KEY, SessionExpiredError, USER_KEY, logout } from "../api/auth";
import {
createChatSession,
deleteChatSession,
listChatSessionMessages,
listChatSessions,
normalizeSessionMessages,
sessionDisplayTitle,
sessionStableId,
updateChatSessionTitle,
type ChatSession,
type ChatTurnForUi,
} from "../api/chatSessions";
@@ -49,22 +58,18 @@ const MOBILE_WIDTH = 750;
type UiMessage = ChatTurnForUi;
type StoredUserProfile = {
nickname: string;
avatarUrl: string;
};
function parseStoredUserProfileJson(raw: string | null): { displayName: string; avatar?: string } {
try {
if (!raw) return { displayName: "用户" };
const u = JSON.parse(raw) as Record<string, unknown>;
const nickname = typeof u.nickname === "string" && u.nickname.trim() ? u.nickname.trim() : "";
const displayName =
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 };
const user = JSON.parse(raw) as StoredUserProfile;
const displayName = user.nickname.trim() || "用户";
const avatar = user.avatarUrl || undefined;
return { displayName, avatar };
} catch {
return { displayName: "用户" };
}
@@ -94,35 +99,70 @@ const INITIAL_MESSAGES: UiMessage[] = [
const STREAM_TIMEOUT_MS = 90000;
const MAX_STREAM_RETRY = 1;
const CONTEXT_WINDOW_SIZE = 8;
const DEEP_THINK_STORAGE_KEY = "chatone-deep-think-enabled";
const SMART_SEARCH_STORAGE_KEY = "chatone-smart-search-enabled";
const SELECTED_MODEL_STORAGE_KEY = "chatone-selected-model";
/** 展示名与请求 model 字段;需与后端实际支持的模型 id 一致 */
const DEFAULT_QWEN_MODEL = "qwen3.5-flash";
const DEFAULT_QWEN_MODEL = "qwen3.6-plus";
const QWEN_MODEL_OPTIONS: { value: string; label: string }[] = [
{ value: "qwen3-max", label: "Qwen3-Max" },
{ value: "qwen3.6-plus", label: "Qwen3.6-Plus" },
{ value: DEFAULT_QWEN_MODEL, label: "Qwen3.5-Flash" },
{ value: DEFAULT_QWEN_MODEL, label: "Qwen3.6-Plus" },
{ value: "qwen3.5-flash", label: "Qwen3.5-Flash" },
{ value: "qwen-plus", label: "Qwen-Plus" },
{ value: "deepseek-r1", label: "DeepSeek-R1" },
{ value: "kimi-k2.6", label: "Kimi-K2.6" },
];
function replaceSessionUrl(sessionId: string) {
const base = import.meta.env.BASE_URL ?? "/";
const prefix = base === "/" ? "" : base.replace(/\/$/, "");
const nextPath = `${prefix}/s/${encodeURIComponent(sessionId)}`;
window.history.replaceState(window.history.state, "", nextPath);
}
export default function HomePage() {
const [modal, modalContextHolder] = Modal.useModal();
const navigate = useNavigate();
const { sessionId: routeSessionId } = useParams<{ sessionId?: string }>();
const [collapsed, setCollapsed] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [viewportWidth, setViewportWidth] = useState(0);
const [isMobile, setIsMobile] = useState(false);
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<UiMessage[]>(INITIAL_MESSAGES);
const [isSending, setIsSending] = useState(false);
const [deepThink, setDeepThink] = useState(false);
const [smartSearch, setSmartSearch] = useState(false);
const [selectedModel, setSelectedModel] = useState(DEFAULT_QWEN_MODEL);
const [deepThink, setDeepThink] = useState(() => {
try {
return window.localStorage.getItem(DEEP_THINK_STORAGE_KEY) === "1";
} catch {
return false;
}
});
const [smartSearch, setSmartSearch] = useState(() => {
try {
return window.localStorage.getItem(SMART_SEARCH_STORAGE_KEY) === "1";
} catch {
return false;
}
});
const [selectedModel, setSelectedModel] = useState(() => {
try {
const raw = window.localStorage.getItem(SELECTED_MODEL_STORAGE_KEY);
const supported = QWEN_MODEL_OPTIONS.some((item) => item.value === raw);
return supported && raw ? raw : DEFAULT_QWEN_MODEL;
} catch {
return DEFAULT_QWEN_MODEL;
}
});
const messageListRef = useRef<HTMLDivElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionMutationBusy, setSessionMutationBusy] = useState(false);
const [sessionActionBusyId, setSessionActionBusyId] = useState<string | null>(null);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const activeSessionIdRef = useRef<string | null>(null);
const userJsonSnapshot = useSyncExternalStore(
subscribeUserProfile,
@@ -134,6 +174,22 @@ export default function HomePage() {
[userJsonSnapshot],
);
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
useEffect(() => {
window.localStorage.setItem(DEEP_THINK_STORAGE_KEY, deepThink ? "1" : "0");
}, [deepThink]);
useEffect(() => {
window.localStorage.setItem(SMART_SEARCH_STORAGE_KEY, smartSearch ? "1" : "0");
}, [smartSearch]);
useEffect(() => {
window.localStorage.setItem(SELECTED_MODEL_STORAGE_KEY, selectedModel);
}, [selectedModel]);
/** 用户信息下拉:略增大行高与项间距 */
const userMenuItemStyle: CSSProperties = {
height: "auto",
@@ -189,7 +245,6 @@ export default function HomePage() {
window.innerWidth,
document.documentElement.clientWidth || window.innerWidth,
);
setViewportWidth(w);
const mobile = w < MOBILE_WIDTH;
setIsMobile(mobile);
if (!mobile) setMobileSidebarOpen(false);
@@ -229,6 +284,10 @@ export default function HomePage() {
void refreshSessionList();
}, [refreshSessionList]);
/**
* 打开指定会话并加载历史消息。
* 默认会把当前会话 id 同步到路由;当由路由参数驱动时可关闭 URL 更新,避免重复跳转。
*/
const openSession = useCallback(
async (sessionId: string, options?: { updateUrl?: boolean }) => {
if (options?.updateUrl ?? true) {
@@ -253,32 +312,140 @@ export default function HomePage() {
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
return;
}
// 路由已与当前会话一致时,不重复加载,避免覆盖流式中的本地消息状态。
if (routeSessionId === activeSessionIdRef.current) return;
void openSession(routeSessionId, { updateUrl: false });
}, [openSession, routeSessionId]);
const handleNewSession = async () => {
if (sessionMutationBusy) return;
setSessionMutationBusy(true);
try {
const row = await createChatSession();
await refreshSessionList();
setActiveSessionId(row.id);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
navigate(`/s/${encodeURIComponent(row.id)}`);
} catch (e) {
const text = e instanceof Error ? e.message : "新建会话失败";
message.error(text);
} finally {
setSessionMutationBusy(false);
}
setActiveSessionId(null);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
navigate("/");
};
const handleRenameSession = useCallback(
(session: ChatSession) => {
const sid = sessionStableId(session);
if (!sid || sessionActionBusyId) return;
let nextTitle = session.title;
modal.confirm({
centered: true,
title: "重命名会话",
content: (
<Input
autoFocus
maxLength={200}
defaultValue={session.title}
placeholder="请输入会话标题"
onChange={(event) => {
nextTitle = event.target.value;
}}
onPressEnter={(event) => {
event.preventDefault();
}}
/>
),
okText: "保存",
cancelText: "取消",
async onOk() {
if (nextTitle === session.title) return;
setSessionActionBusyId(sid);
try {
await updateChatSessionTitle(sid, { title: nextTitle });
await refreshSessionList();
message.success("会话已重命名");
} catch (error) {
const text = error instanceof Error ? error.message : "重命名会话失败";
message.error(text);
throw error;
} finally {
setSessionActionBusyId(null);
}
},
});
},
[modal, refreshSessionList, sessionActionBusyId],
);
const handleDeleteSession = useCallback(
(session: ChatSession) => {
const sid = sessionStableId(session);
if (!sid || sessionActionBusyId) return;
modal.confirm({
centered: true,
title: "删除会话",
content: "确认删除该会话?删除后不可恢复。",
okText: "删除",
okType: "danger",
cancelText: "取消",
async onOk() {
setSessionActionBusyId(sid);
try {
await deleteChatSession(sid);
if (activeSessionId === sid) {
abortRef.current?.abort();
setIsSending(false);
setActiveSessionId(null);
setMessages(INITIAL_MESSAGES.map((m) => ({ ...m })));
navigate("/");
}
await refreshSessionList();
message.success("会话已删除");
} catch (error) {
const text = error instanceof Error ? error.message : "删除会话失败";
message.error(text);
throw error;
} finally {
setSessionActionBusyId(null);
}
},
});
},
[activeSessionId, modal, navigate, refreshSessionList, sessionActionBusyId],
);
const handleCopyUserMessage = useCallback(async (content: string) => {
try {
await navigator.clipboard.writeText(content);
message.success("已复制");
} catch {
message.error("复制失败,请检查浏览器权限");
}
}, []);
const handleCopyAssistantMessage = useCallback(async (content: string) => {
try {
await navigator.clipboard.writeText(content);
message.success("已复制");
} catch {
message.error("复制失败,请检查浏览器权限");
}
}, []);
const sendMessage = async () => {
const trimmed = inputValue.trim();
if (!trimmed || isSending) return;
if (!activeSessionId) {
message.warning("请先新建会话");
return;
let targetSessionId = activeSessionId;
if (!targetSessionId) {
if (sessionMutationBusy) return;
setSessionMutationBusy(true);
try {
const row = await createChatSession({
// 首条提问作为会话初始标题(后端限制 200
title: trimmed.slice(0, 200),
});
targetSessionId = row.id;
setActiveSessionId(row.id);
replaceSessionUrl(row.id);
// 侧栏列表后台刷新,不阻塞当前消息发送。
void refreshSessionList();
} catch (e) {
const text = e instanceof Error ? e.message : "新建会话失败";
message.error(text);
return;
} finally {
setSessionMutationBusy(false);
}
}
setInputValue("");
@@ -330,7 +497,9 @@ export default function HomePage() {
await streamQwenChat({
messages: retryMessages,
model: selectedModel,
sessionId: activeSessionId ?? undefined,
sessionId: targetSessionId ?? undefined,
enableWebSearch: smartSearch,
enableThinking: deepThink,
signal: controller.signal,
timeoutMs: STREAM_TIMEOUT_MS,
onToken: (token) => {
@@ -429,21 +598,52 @@ export default function HomePage() {
if (!sid) return null;
const title = sessionDisplayTitle(s);
const active = sid === activeSessionId;
const itemMenu: MenuProps = {
items: [
{ key: "rename", icon: <EditOutlined />, label: "重命名" },
{ key: "pin", icon: <VerticalAlignTopOutlined />, label: "置顶" },
{ key: "delete", icon: <DeleteOutlined />, danger: true, label: "删除" },
],
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
if (key === "rename") void handleRenameSession(s);
if (key === "pin") message.info("置顶功能开发中");
if (key === "delete") void handleDeleteSession(s);
},
};
return (
<button
<div
key={sid}
type="button"
onClick={() => {
void openSession(sid);
}}
className="mb-1 flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left text-[13px] text-neutral-700 transition-colors hover:bg-black/5"
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"
style={{
background: active ? "var(--ds-active-item)" : undefined,
color: active ? "var(--ds-accent)" : undefined,
}}
>
<MessageOutlined className="shrink-0 text-neutral-400" />
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
</button>
<button
type="button"
onClick={() => {
void openSession(sid);
}}
className="flex min-h-12 min-w-0 flex-1 items-center rounded-xl px-3 py-3 text-left"
>
{(!collapsed || isMobile) && <span className="truncate">{title}</span>}
</button>
{(!collapsed || isMobile) && (
<Dropdown menu={itemMenu} trigger={["click"]} placement="bottomRight">
<Button
type="text"
size="small"
icon={<MoreOutlined />}
loading={sessionActionBusyId === sid}
className="text-neutral-500 hover:bg-black/5!"
onClick={(event) => {
event.stopPropagation();
}}
/>
</Dropdown>
)}
</div>
);
})}
</div>
@@ -487,6 +687,7 @@ export default function HomePage() {
className="chat-one-shell flex h-dvh max-h-dvh overflow-hidden"
style={{ background: "var(--ds-bg-main)" }}
>
{modalContextHolder}
{!isMobile && (
<Sider
trigger={null}
@@ -574,7 +775,7 @@ export default function HomePage() {
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"
>
<div className="mx-auto flex max-w-3xl flex-col gap-5">
<div className="mx-auto flex max-w-4xl flex-col gap-5">
{messages.map((item) => (
<div
key={item.id}
@@ -582,24 +783,22 @@ export default function HomePage() {
>
<div
className={
item.role === "user"
? "max-w-[85%] rounded-2xl border px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800"
: "max-w-[85%] space-y-2"
}
style={
item.role === "user"
? {
background: "var(--ds-user-bubble)",
borderColor: "var(--ds-user-border)",
}
: undefined
item.role === "user" ? "group max-w-full px-4 " : "group w-full space-y-2"
}
>
{item.role === "user" && (
<div
className="rounded-2xl px-4 py-2.5 text-[15px] leading-relaxed text-neutral-800"
style={{ background: "var(--ds-user-bubble)" }}
>
{item.content}
</div>
)}
{item.role === "assistant" && item.thinking && (
<Collapse
bordered={false}
size="small"
className="!mb-2 !rounded-xl !bg-neutral-50 !border !border-[var(--ds-border)]"
className="!mb-2 !rounded-xl !bg-transparent"
items={[
{
key: "think",
@@ -622,27 +821,92 @@ export default function HomePage() {
(item.content ? (
<StreamMessage content={item.content} />
) : (
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/80 px-4 py-3 text-[14px] text-neutral-500">
<div className="flex items-center gap-2">
<span></span>
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400" />
</span>
</div>
<div className="rounded-2xl px-4 py-3 text-[14px] text-neutral-500">
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-neutral-400" />
</span>
</div>
))}
{item.role === "user" && item.content}
{item.role === "assistant" && item.content && (
<>
<p className="mt-3 px-4 text-[12px] text-neutral-500 italic">
AI
</p>
<div className="mt-2 flex h-8 items-center gap-1 px-4 text-neutral-500 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto">
<Tooltip title="复制">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="复制回复"
onClick={() => {
void handleCopyAssistantMessage(item.content);
}}
/>
</Tooltip>
<Tooltip title="重新生成">
<Button
type="text"
size="small"
icon={<RedoOutlined />}
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="重新生成"
onClick={() => {
message.info("重新生成功能开发中");
}}
/>
</Tooltip>
<Tooltip title="分享">
<Button
type="text"
size="small"
icon={<ShareAltOutlined />}
className="!px-1 text-neutral-500 hover:bg-black/5!"
aria-label="分享"
onClick={() => {
message.info("分享功能开发中");
}}
/>
</Tooltip>
</div>
</>
)}
{item.role === "user" && (
<div className="mt-2 flex h-7 items-center justify-end gap-1 text-neutral-500 opacity-0 pointer-events-none transition-opacity group-hover:opacity-100 group-hover:pointer-events-auto">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
className="text-neutral-500 hover:bg-black/5!"
aria-label="复制消息"
onClick={() => {
void handleCopyUserMessage(item.content);
}}
/>
<Button
type="text"
size="small"
icon={<EditOutlined />}
className="text-neutral-500 hover:bg-black/5!"
aria-label="编辑消息"
onClick={() => {
message.info("编辑功能开发中");
}}
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="shrink-0 bg-[var(--ds-bg-main)] px-4 pb-4 pt-0 md:px-8 md:pb-4 md:pt-0">
<div className="mx-auto max-w-3xl">
<div className="rounded-2xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
<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="rounded-3xl border border-[var(--ds-border)] bg-neutral-50/50 p-3 shadow-sm">
<Input.TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
@@ -653,7 +917,7 @@ export default function HomePage() {
}}
placeholder="给 ChatOne 发送消息"
variant="borderless"
autoSize={{ minRows: 1, maxRows: 6 }}
autoSize={{ minRows: 2, maxRows: 8 }}
className="!px-1 !text-[15px] placeholder:text-neutral-400"
/>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
@@ -706,11 +970,9 @@ export default function HomePage() {
</div>
</div>
</div>
{import.meta.env.DEV && (
<Typography.Text type="secondary" className="mt-2 block text-center text-[11px]">
W:{viewportWidth}px · {isMobile ? "mobile" : "desktop"}
</Typography.Text>
)}
<p className="m-0 py-2 text-center text-[11px] text-neutral-500">
AI
</p>
</div>
</div>
</div>