Files
CamTalk/frontend/src/components/SessionSidebar/index.tsx
hhs 2180751a9b fix: 修复前端侧边栏双重展开动画问题
移除条件渲染和 CSS animation 的冲突,改用纯 CSS transition 控制显示/隐藏。

**问题根源:**
- 组件使用 `if (!open) return null;` 条件渲染,导致挂载时立即出现
- CSS 同时使用 `animation: slideInLeft`,触发从 -100% 的二次滑入
- 两个独立显示机制叠加,造成双重动画闪烁

**修复方案:**
- SessionSidebar: 移除条件渲染,始终保持 DOM 存在,通过动态 className 控制状态
- App.css: 移除 `animation` 和 `@keyframes`,改用 `transition: transform`
- 新增 `.sidebar--collapsed` / `.sidebar--open` 类控制 `translateX`
- 新增 `.sidebar-backdrop--visible` 类控制背景遮罩淡入淡出
- 添加 `pointer-events: none` 确保隐藏状态不响应交互

影响范围:
- frontend/src/components/SessionSidebar/index.tsx
- frontend/src/App.css
2026-06-22 12:05:54 +08:00

237 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// SessionSidebar — 会话历史侧边栏Overlay 抽屉式)
// 职责:展示会话列表、搜索、新建/切换/删除/重命名会话
// 设计overlay 覆盖在主界面之上,不挤压主界面空间
// ============================================================
import { useMemo, useState } from "react";
import { useI18n } from "../../lib/i18n";
import type { SessionSummary } from "../../types";
interface SessionSidebarProps {
sessions: SessionSummary[];
activeSessionId: string | null;
open: boolean;
onClose: () => 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()}`;
}
/** 判断两个时间戳是否为同一天 */
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";
}
export function SessionSidebar({
sessions,
activeSessionId,
open,
onClose,
onNewSession,
onSelectSession,
onDeleteSession,
onRenameSession,
}: SessionSidebarProps) {
const { t } = useI18n();
const [editingId, setEditingId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState("");
const [searchQuery, setSearchQuery] = 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("");
};
const handleSelect = (id: string) => {
onSelectSession(id);
// 选择后自动收起侧边栏
onClose();
};
// 过滤 + 分组
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]);
const groupLabels: Record<TimeGroup, string> = {
today: t("sidebar.today"),
yesterday: t("sidebar.yesterday"),
earlier: t("sidebar.earlier"),
};
return (
<>
<div
className={`sidebar-backdrop ${open ? 'sidebar-backdrop--visible' : ''}`}
onClick={onClose}
/>
<div className={`sidebar ${open ? 'sidebar--open' : 'sidebar--collapsed'}`}>
{/* 头部:新建 + 收起 */}
<div className="sidebar__header">
<button className="sidebar__new-btn" onClick={onNewSession}>
{t("sidebar.new")}
</button>
<button className="sidebar__toggle" onClick={onClose} title={t("sidebar.collapse")}>
</button>
</div>
{/* 搜索栏 */}
<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 && (
<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>
)}
<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")}
>
<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>
</button>
<button
className="sidebar__action-btn sidebar__action-btn--danger"
onClick={(e) => {
e.stopPropagation();
onDeleteSession(session.id);
}}
title={t("sidebar.delete")}
>
<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>
</button>
</div>
</>
)}
</div>
))}
</div>
);
})
)}
</div>
</div>
</>
);
}