231 lines
7.7 KiB
TypeScript
231 lines
7.7 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|