347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Check, Eraser, MessageSquareText, Pencil, Plus, Trash2, X } from 'lucide-react';
|
|
|
|
export interface SixStepConversationMeta {
|
|
id: string;
|
|
title: string;
|
|
updatedAt: number;
|
|
}
|
|
|
|
interface Props {
|
|
isOpen: boolean;
|
|
isDark: boolean;
|
|
conversations: SixStepConversationMeta[];
|
|
activeId: string | null;
|
|
onClose: () => void;
|
|
onOpen: () => void;
|
|
onSelect: (id: string) => void;
|
|
onNew: () => void;
|
|
onDelete: (id: string) => void;
|
|
onRename: (id: string, title: string) => void;
|
|
onClearAll: () => void;
|
|
}
|
|
|
|
const UNTITLED = '未命名对话';
|
|
|
|
const SixStepChatHistory: React.FC<Props> = ({
|
|
isOpen,
|
|
isDark,
|
|
conversations,
|
|
activeId,
|
|
onClose,
|
|
onOpen,
|
|
onSelect,
|
|
onNew,
|
|
onDelete,
|
|
onRename,
|
|
onClearAll,
|
|
}) => {
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [draftTitle, setDraftTitle] = useState('');
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (editingId && inputRef.current) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
}
|
|
}, [editingId]);
|
|
|
|
const startEdit = (conversation: SixStepConversationMeta) => {
|
|
setEditingId(conversation.id);
|
|
setDraftTitle(conversation.title);
|
|
};
|
|
|
|
const commitEdit = () => {
|
|
if (!editingId) return;
|
|
onRename(editingId, draftTitle.trim() || UNTITLED);
|
|
setEditingId(null);
|
|
setDraftTitle('');
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingId(null);
|
|
setDraftTitle('');
|
|
};
|
|
|
|
const handleDelete = (conversation: SixStepConversationMeta) => {
|
|
const ok = window.confirm(`确认删除对话“${conversation.title || UNTITLED}”吗?`);
|
|
if (!ok) return;
|
|
onDelete(conversation.id);
|
|
};
|
|
|
|
const handleClearAll = () => {
|
|
if (conversations.length === 0) return;
|
|
const ok = window.confirm('确认清空当前对话列表中的全部历史记录吗?此操作无法撤销。');
|
|
if (!ok) return;
|
|
onClearAll();
|
|
};
|
|
|
|
const railButtonClass = (active = false) =>
|
|
`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border transition ${
|
|
active
|
|
? isDark
|
|
? 'border-cyan-400/40 bg-cyan-500/10 text-cyan-200 shadow-sm'
|
|
: 'border-red-200 bg-red-50/70 text-red-500 shadow-sm'
|
|
: isDark
|
|
? 'border-slate-700 bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-100'
|
|
: 'border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900'
|
|
}`;
|
|
|
|
const handleCollapsedSelect = (id: string) => {
|
|
onOpen();
|
|
window.setTimeout(() => {
|
|
onSelect(id);
|
|
}, 120);
|
|
};
|
|
|
|
const handleCollapsedNew = () => {
|
|
onOpen();
|
|
window.setTimeout(() => {
|
|
onNew();
|
|
}, 120);
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity] duration-300 ease-out ${
|
|
isOpen ? 'pointer-events-auto w-[300px] opacity-100' : 'pointer-events-auto w-[72px] opacity-100'
|
|
} ${
|
|
isDark
|
|
? 'border-slate-700/50 bg-slate-900/60 text-slate-100'
|
|
: 'border-red-100 bg-gray-50/70 text-gray-900'
|
|
}`}
|
|
>
|
|
{isOpen ? (
|
|
<>
|
|
<div
|
|
className={`border-b px-4 py-4 transition-all duration-300 ease-out ${isDark ? 'border-slate-700/50' : 'border-red-100'} ${
|
|
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-semibold">聊天记录</h3>
|
|
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
|
查看和管理当前页面的历史对话
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
title="收起聊天记录"
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
|
|
isDark
|
|
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
|
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onNew}
|
|
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition ${
|
|
isDark
|
|
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
|
: 'border-red-200 bg-white text-red-600 hover:bg-red-50'
|
|
}`}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
新建对话
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleClearAll}
|
|
disabled={conversations.length === 0}
|
|
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
isDark
|
|
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
|
|
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<Eraser className="h-4 w-4" />
|
|
清空记录
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={`min-h-0 flex-1 space-y-1 overflow-y-auto px-3 py-3 transition-all duration-300 ease-out ${
|
|
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
|
|
}`}
|
|
>
|
|
{conversations.length === 0 ? (
|
|
<div
|
|
className={`rounded-xl border border-dashed px-3 py-8 text-center text-xs ${
|
|
isDark ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
|
|
}`}
|
|
>
|
|
还没有任何对话记录
|
|
</div>
|
|
) : (
|
|
conversations.map((conversation) => {
|
|
const isActive = activeId === conversation.id;
|
|
const isEditing = editingId === conversation.id;
|
|
|
|
return (
|
|
<div
|
|
key={conversation.id}
|
|
role={isEditing ? undefined : 'button'}
|
|
tabIndex={isEditing ? -1 : 0}
|
|
onClick={() => !isEditing && onSelect(conversation.id)}
|
|
onKeyDown={(event) => {
|
|
if (!isEditing && (event.key === 'Enter' || event.key === ' ')) {
|
|
event.preventDefault();
|
|
onSelect(conversation.id);
|
|
}
|
|
}}
|
|
className={`group flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
|
|
isActive
|
|
? isDark
|
|
? 'border-cyan-400/40 bg-cyan-500/10 text-slate-50 shadow-sm'
|
|
: 'border-red-200 bg-red-50/70 text-gray-900 shadow-sm'
|
|
: isDark
|
|
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
|
|
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-white hover:text-gray-900'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
|
isActive
|
|
? isDark
|
|
? 'bg-cyan-500/20 text-cyan-200'
|
|
: 'bg-white text-red-500 ring-1 ring-red-100'
|
|
: isDark
|
|
? 'bg-slate-800 text-slate-400'
|
|
: 'bg-gray-100 text-gray-500'
|
|
}`}
|
|
>
|
|
<MessageSquareText className="h-4 w-4" />
|
|
</span>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
{isEditing ? (
|
|
<input
|
|
ref={inputRef}
|
|
value={draftTitle}
|
|
onChange={(event) => setDraftTitle(event.target.value)}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
commitEdit();
|
|
} else if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
cancelEdit();
|
|
}
|
|
}}
|
|
onBlur={commitEdit}
|
|
className={`w-full border-b bg-transparent text-sm font-medium outline-none ${
|
|
isDark ? 'border-cyan-400/60 text-white' : 'border-red-300 text-gray-900'
|
|
}`}
|
|
/>
|
|
) : (
|
|
<div className="truncate text-sm font-medium">{conversation.title || UNTITLED}</div>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
commitEdit();
|
|
}}
|
|
title="保存"
|
|
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
|
isDark
|
|
? 'text-emerald-300 hover:bg-emerald-500/15'
|
|
: 'text-emerald-600 hover:bg-emerald-50'
|
|
}`}
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
startEdit(conversation);
|
|
}}
|
|
title="重命名"
|
|
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
|
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
} ${
|
|
isDark
|
|
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
|
|
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
|
|
}`}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handleDelete(conversation);
|
|
}}
|
|
title="删除对话"
|
|
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
|
|
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
} ${
|
|
isDark
|
|
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
|
|
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
|
|
}`}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex min-h-0 flex-1 flex-col items-center gap-3 px-2 py-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleCollapsedNew}
|
|
title="新建对话"
|
|
className={`${railButtonClass(false)} sixstep-fade-up`}
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
</button>
|
|
|
|
<div className="min-h-0 w-full flex-1 overflow-y-auto">
|
|
<div className="flex flex-col items-center gap-2">
|
|
{conversations.map((conversation, index) => (
|
|
<button
|
|
key={conversation.id}
|
|
type="button"
|
|
onClick={() => handleCollapsedSelect(conversation.id)}
|
|
title={conversation.title || UNTITLED}
|
|
className={`${railButtonClass(activeId === conversation.id)} transition-all duration-300 ease-out`}
|
|
style={{ transitionDelay: `${Math.min(index, 6) * 25}ms` }}
|
|
>
|
|
<MessageSquareText className="h-4 w-4" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</aside>
|
|
);
|
|
};
|
|
|
|
export default SixStepChatHistory;
|