feat: 新增会话历史侧边栏,支持新建/切换/删除/重命名会话
类似 ChatGPT 的侧边栏布局,消息历史持久化到 localStorage。 - 新增 SessionSummary 类型和 localStorage 读写函数 - 新增 useSessionList Hook 管理会话列表 CRUD - 新增 SessionSidebar 组件(展开/折叠、行内重命名) - App.tsx 协调 useSessionList 和 useVisionSession - 消息变化时自动持久化,切换/新建会话时保存并加载 - 新增 i18n 翻译 key(中/英/日)
This commit is contained in:
160
frontend/src/components/SessionSidebar/index.tsx
Normal file
160
frontend/src/components/SessionSidebar/index.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
// ============================================================
|
||||
// SessionSidebar — 会话历史侧边栏
|
||||
// 职责:展示会话列表、新建/切换/删除/重命名会话
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { SessionSummary } from "../../types";
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId: string | null;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onNewSession: () => void;
|
||||
onSelectSession: (id: string) => void;
|
||||
onDeleteSession: (id: string) => void;
|
||||
onRenameSession: (id: string, title: string) => void;
|
||||
}
|
||||
|
||||
/** 格式化相对时间 */
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - ts;
|
||||
const min = 60 * 1000;
|
||||
const hour = 60 * min;
|
||||
const day = 24 * hour;
|
||||
|
||||
if (diff < min) return "刚刚";
|
||||
if (diff < hour) return `${Math.floor(diff / min)}分钟前`;
|
||||
if (diff < day) return `${Math.floor(diff / hour)}小时前`;
|
||||
if (diff < 2 * day) return "昨天";
|
||||
if (diff < 7 * day) return `${Math.floor(diff / day)}天前`;
|
||||
const d = new Date(ts);
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
export function SessionSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
onNewSession,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
onRenameSession,
|
||||
}: SessionSidebarProps) {
|
||||
const { t } = useI18n();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
|
||||
const handleStartRename = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
setEditTitle(currentTitle);
|
||||
};
|
||||
|
||||
const handleConfirmRename = () => {
|
||||
if (editingId && editTitle.trim()) {
|
||||
onRenameSession(editingId, editTitle.trim());
|
||||
}
|
||||
setEditingId(null);
|
||||
setEditTitle("");
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setEditingId(null);
|
||||
setEditTitle("");
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="sidebar sidebar--collapsed">
|
||||
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.expand")}>
|
||||
☰
|
||||
</button>
|
||||
<button className="sidebar__new-btn" onClick={onNewSession} title={t("sidebar.new")}>
|
||||
✚
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar__header">
|
||||
<button className="sidebar__new-btn sidebar__new-btn--full" onClick={onNewSession}>
|
||||
✚ {t("sidebar.new")}
|
||||
</button>
|
||||
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.collapse")}>
|
||||
◀
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar__list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
{editingId === session.id ? (
|
||||
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className="sidebar__edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleConfirmRename();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="sidebar__edit-btn" onClick={handleConfirmRename}>✓</button>
|
||||
<button className="sidebar__edit-btn" onClick={handleCancelRename}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="sidebar__item-content">
|
||||
<div className="sidebar__item-title">{session.title}</div>
|
||||
<div className="sidebar__item-meta">
|
||||
{session.messageCount > 0 && (
|
||||
<span>{session.messageCount}{t("sidebar.messages")}</span>
|
||||
)}
|
||||
<span>{formatRelativeTime(session.lastActiveAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar__item-actions">
|
||||
<button
|
||||
className="sidebar__action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartRename(session.id, session.title);
|
||||
}}
|
||||
title={t("sidebar.rename")}
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
className="sidebar__action-btn sidebar__action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
title={t("sidebar.delete")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user