2026-06-14 15:44:25 +08:00
|
|
|
|
// ============================================================
|
2026-06-14 16:31:23 +08:00
|
|
|
|
// SessionSidebar — 会话历史侧边栏(Overlay 抽屉式)
|
|
|
|
|
|
// 职责:展示会话列表、搜索、新建/切换/删除/重命名会话
|
|
|
|
|
|
// 设计:overlay 覆盖在主界面之上,不挤压主界面空间
|
2026-06-14 15:44:25 +08:00
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
2026-06-14 16:31:23 +08:00
|
|
|
|
import { useMemo, useState } from "react";
|
2026-06-14 15:44:25 +08:00
|
|
|
|
import { useI18n } from "../../lib/i18n";
|
|
|
|
|
|
import type { SessionSummary } from "../../types";
|
|
|
|
|
|
|
|
|
|
|
|
interface SessionSidebarProps {
|
|
|
|
|
|
sessions: SessionSummary[];
|
|
|
|
|
|
activeSessionId: string | null;
|
2026-06-14 16:31:23 +08:00
|
|
|
|
open: boolean;
|
|
|
|
|
|
onToggle: () => void;
|
2026-06-14 15:44:25 +08:00
|
|
|
|
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()}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:31:23 +08:00
|
|
|
|
/** 判断两个时间戳是否为同一天 */
|
|
|
|
|
|
function isSameDay(ts: number, ref: Date): boolean {
|
|
|
|
|
|
const d = new Date(ts);
|
|
|
|
|
|
return d.getFullYear() === ref.getFullYear() &&
|
|
|
|
|
|
d.getMonth() === ref.getMonth() &&
|
|
|
|
|
|
d.getDate() === ref.getDate();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TimeGroup = "today" | "yesterday" | "earlier";
|
|
|
|
|
|
|
|
|
|
|
|
function getTimeGroup(ts: number, today: Date, yesterday: Date): TimeGroup {
|
|
|
|
|
|
if (isSameDay(ts, today)) return "today";
|
|
|
|
|
|
if (isSameDay(ts, yesterday)) return "yesterday";
|
|
|
|
|
|
return "earlier";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 15:44:25 +08:00
|
|
|
|
export function SessionSidebar({
|
|
|
|
|
|
sessions,
|
|
|
|
|
|
activeSessionId,
|
2026-06-14 16:31:23 +08:00
|
|
|
|
open,
|
|
|
|
|
|
onToggle,
|
2026-06-14 15:44:25 +08:00
|
|
|
|
onNewSession,
|
|
|
|
|
|
onSelectSession,
|
|
|
|
|
|
onDeleteSession,
|
|
|
|
|
|
onRenameSession,
|
|
|
|
|
|
}: SessionSidebarProps) {
|
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
|
|
const [editTitle, setEditTitle] = useState("");
|
2026-06-14 16:31:23 +08:00
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
2026-06-14 15:44:25 +08:00
|
|
|
|
|
|
|
|
|
|
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("");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-14 16:31:23 +08:00
|
|
|
|
const handleSelect = (id: string) => {
|
|
|
|
|
|
onSelectSession(id);
|
|
|
|
|
|
// 选择后自动收起侧边栏
|
|
|
|
|
|
onToggle();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤 + 分组
|
|
|
|
|
|
const grouped = useMemo(() => {
|
|
|
|
|
|
const today = new Date();
|
|
|
|
|
|
const yesterday = new Date(today);
|
|
|
|
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
|
|
|
|
|
|
|
|
|
|
const filtered = searchQuery.trim()
|
|
|
|
|
|
? sessions.filter((s) =>
|
|
|
|
|
|
s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
|
|
|
|
s.preview.toLowerCase().includes(searchQuery.toLowerCase())
|
|
|
|
|
|
)
|
|
|
|
|
|
: sessions;
|
|
|
|
|
|
|
|
|
|
|
|
const groups: Record<TimeGroup, SessionSummary[]> = {
|
|
|
|
|
|
today: [],
|
|
|
|
|
|
yesterday: [],
|
|
|
|
|
|
earlier: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
for (const session of filtered) {
|
|
|
|
|
|
const group = getTimeGroup(session.lastActiveAt, today, yesterday);
|
|
|
|
|
|
groups[group].push(session);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return groups;
|
|
|
|
|
|
}, [sessions, searchQuery]);
|
|
|
|
|
|
|
|
|
|
|
|
if (!open) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const groupLabels: Record<TimeGroup, string> = {
|
|
|
|
|
|
today: t("sidebar.today"),
|
|
|
|
|
|
yesterday: t("sidebar.yesterday"),
|
|
|
|
|
|
earlier: t("sidebar.earlier"),
|
|
|
|
|
|
};
|
2026-06-14 15:44:25 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-14 16:31:23 +08:00
|
|
|
|
<>
|
|
|
|
|
|
<div className="sidebar-backdrop" onClick={onToggle} />
|
|
|
|
|
|
<div className="sidebar">
|
|
|
|
|
|
{/* 头部:新建 + 收起 */}
|
|
|
|
|
|
<div className="sidebar__header">
|
|
|
|
|
|
<button className="sidebar__new-btn" onClick={onNewSession}>
|
|
|
|
|
|
✚ {t("sidebar.new")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="sidebar__toggle" onClick={onToggle} title={t("sidebar.collapse")}>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-06-14 15:44:25 +08:00
|
|
|
|
|
2026-06-14 16:31:23 +08:00
|
|
|
|
{/* 搜索栏 */}
|
|
|
|
|
|
<div className="sidebar__search">
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="sidebar__search-input"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder={t("sidebar.search")}
|
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 会话列表(按时间分组) */}
|
|
|
|
|
|
<div className="sidebar__list">
|
|
|
|
|
|
{sessions.length === 0 ? (
|
|
|
|
|
|
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
(["today", "yesterday", "earlier"] as TimeGroup[]).map((groupKey) => {
|
|
|
|
|
|
const items = grouped[groupKey];
|
|
|
|
|
|
if (items.length === 0) return null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={groupKey}>
|
|
|
|
|
|
<div className="sidebar__group">{groupLabels[groupKey]}</div>
|
|
|
|
|
|
{items.map((session) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={session.id}
|
|
|
|
|
|
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
|
|
|
|
|
onClick={() => handleSelect(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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* 视频标记图标 */}
|
|
|
|
|
|
{session.messageCount > 5 && (
|
2026-06-21 23:50:27 +08:00
|
|
|
|
<span className="sidebar__item-icon" title={t("sidebar.video")}><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></span>
|
2026-06-14 16:31:23 +08:00
|
|
|
|
)}
|
|
|
|
|
|
<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")}
|
|
|
|
|
|
>
|
2026-06-21 23:50:27 +08:00
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
2026-06-14 16:31:23 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="sidebar__action-btn sidebar__action-btn--danger"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onDeleteSession(session.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
title={t("sidebar.delete")}
|
|
|
|
|
|
>
|
2026-06-21 23:50:27 +08:00
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
2026-06-14 16:31:23 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2026-06-14 15:44:25 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-06-14 16:31:23 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-06-14 15:44:25 +08:00
|
|
|
|
</div>
|
2026-06-14 16:31:23 +08:00
|
|
|
|
</>
|
2026-06-14 15:44:25 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|