Files
memind/src/components/HistorySidebar.tsx
T
2026-07-01 18:13:24 +08:00

231 lines
7.7 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.
import { useCallback, useEffect, useRef, useState } from 'react';
import { AvatarPicker } from './AvatarPicker';
import type { SessionSummary } from '../types';
import { getSessionListLabel, groupSessionsByDate } from '../utils/sessions';
function formatSessionTime(iso?: string): string {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (isToday) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
type HistorySidebarProps = {
open: boolean;
sessions: SessionSummary[];
activeSessionId?: string;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
searchQuery: string;
onClose: () => void;
onSelect: (sessionId: string) => void;
onNew: () => void;
onDelete: (sessionId: string) => void;
onLoadMore: () => void;
onSearchChange: (query: string) => void;
};
export function HistorySidebar({
open,
sessions,
activeSessionId,
loading,
loadingMore,
hasMore,
searchQuery,
onClose,
onSelect,
onNew,
onDelete,
onLoadMore,
onSearchChange,
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [pendingSelectId, setPendingSelectId] = useState<string | null>(null);
useEffect(() => {
if (open) {
setPendingDeleteId(null);
setPendingSelectId(null);
}
}, [open, sessions.length]);
const handleScroll = useCallback(() => {
const el = bodyRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom && hasMore && !loadingMore && !loading) {
onLoadMore();
}
}, [hasMore, loading, loadingMore, onLoadMore]);
const handleDeleteClick = useCallback((sessionId: string) => {
setPendingSelectId(null);
setPendingDeleteId(sessionId);
}, []);
const handleConfirmDelete = useCallback(
(sessionId: string) => {
setPendingDeleteId(null);
onDelete(sessionId);
},
[onDelete],
);
const handleCancelDelete = useCallback(() => {
setPendingDeleteId(null);
}, []);
const handleSelectClick = useCallback(
(sessionId: string) => {
if (pendingDeleteId === sessionId) return;
if (activeSessionId === sessionId) {
setPendingSelectId(null);
onSelect(sessionId);
return;
}
if (pendingSelectId === sessionId) {
setPendingSelectId(null);
onSelect(sessionId);
return;
}
setPendingSelectId(sessionId);
},
[activeSessionId, onSelect, pendingDeleteId, pendingSelectId],
);
if (!open) return null;
const sessionGroups = groupSessionsByDate(sessions);
return (
<>
<button type="button" className="sidebar-backdrop" aria-label="关闭历史" onClick={onClose} />
<aside className="sidebar sidebar-open">
<div className="sidebar-header">
<span className="sidebar-title"></span>
<button type="button" className="sidebar-close" aria-label="收起历史" onClick={onClose}>
</button>
</div>
<div className="sidebar-body" ref={bodyRef} onScroll={handleScroll}>
<button type="button" className="sidebar-new" onClick={onNew}>
+
</button>
<label className="sidebar-search">
<input
type="search"
className="sidebar-search-input"
value={searchQuery}
placeholder="搜索历史标题"
aria-label="搜索历史对话"
onChange={(event) => onSearchChange(event.target.value)}
/>
</label>
{loading && sessions.length === 0 ? (
<div className="sidebar-empty"></div>
) : sessions.length === 0 ? (
<div className="sidebar-empty">{searchQuery ? '没有找到相关历史标题' : '暂无历史对话'}</div>
) : (
<>
{sessionGroups.map((group) => (
<section key={group.dateKey || group.label} className="sidebar-date-group">
<div className="sidebar-date-divider">
<span>{group.label}</span>
</div>
<ul className="sidebar-list">
{group.sessions.map((item) => {
const label = getSessionListLabel(item);
const isPending = pendingDeleteId === item.id;
const isPendingSelect = pendingSelectId === item.id;
return (
<li key={item.id} className={`sidebar-item-wrap${isPending ? ' sidebar-item-confirming' : ''}`}>
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}${isPendingSelect ? ' sidebar-item-pending' : ''}`}
onClick={() => handleSelectClick(item.id)}
>
<span className="sidebar-item-title">{label}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
{isPendingSelect && item.id !== activeSessionId ? (
<span className="sidebar-item-hint"></span>
) : null}
</button>
{isPending ? (
<div className="sidebar-item-confirm">
<button
type="button"
className="sidebar-confirm-yes"
onClick={() => handleConfirmDelete(item.id)}
>
</button>
<button
type="button"
className="sidebar-confirm-no"
onClick={handleCancelDelete}
>
</button>
</div>
) : (
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDeleteClick(item.id)}
>
<CloseIcon />
</button>
)}
</li>
);
})}
</ul>
</section>
))}
{loadingMore && <div className="sidebar-more"></div>}
{!loadingMore && hasMore && <div className="sidebar-more"></div>}
</>
)}
</div>
<div className="sidebar-footer">
<AvatarPicker />
</div>
</aside>
</>
);
}