PicoBot/web/src/components/Sidebar/TopicList.tsx
oudecheng eaee29841d feat(web): 对齐 deepseek-harness 设计语言,重构前端视觉与三栏布局
- 设计 token 重映射为 DeepSeek 调色板(深浅双主题),正文改无衬线字体栈,全面去霓虹化
- 移除顶部 Header,控件迁入左栏(Logo/连接/通道/会话/tabs/底部操作区),左右栏支持拖拽调宽并持久化,折叠为图标 rail
- 对话区对齐 DeepSeek 聊天风:hero/docked 状态机、736px 居中列、用户右对齐柔和气泡、助手无气泡平铺、composer 卡片与反差发送键;子智能体点击导航完整保留
- 全功能区同步换肤:侧栏列表、右栏面板、ConfigPage、弹窗、选择器等
- fix: 修复 virtual-core 3.17.x 陈旧行高导致消息行重叠(宽度变化/滚动停止后强制 resizeItem 重测)
- fix: 修复 ConfigPage/Modal 引用不存在的 fadeIn/scaleIn keyframes 致入场动画失效
2026-08-14 19:15:45 +08:00

361 lines
15 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 { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import {
Plus,
MessageSquare,
Layers,
Hash,
Clock,
RefreshCw,
Trash2,
Check,
X,
ChevronLeft,
ChevronRight,
Edit2,
} from 'lucide-react';
import type { Topic } from '../../types/protocol';
interface TopicListProps {
sessionId: string | null;
topics: Topic[];
currentTopicId: string | null;
isReadOnly: boolean;
onCreateTopic: () => void;
onRefresh: () => void;
onSwitchTopic: (topicId: string) => void;
onDeleteTopic: (topicId: string) => void;
onRenameTopic: (topicId: string, title: string) => void;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) {
return '昨天';
} else if (diffDays < 7) {
return `${diffDays}天前`;
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
}
export function TopicList({
sessionId,
topics,
currentTopicId,
isReadOnly,
onCreateTopic,
onRefresh,
onSwitchTopic,
onDeleteTopic,
onRenameTopic,
}: TopicListProps) {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const editInputRef = useRef<HTMLInputElement>(null);
// 进入编辑模式时自动聚焦 input
useEffect(() => {
if (editingTopicId && editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingTopicId]);
const startEdit = useCallback((topic: Topic) => {
setConfirmDeleteId(null);
setEditingTopicId(topic.id);
// 预填当前显示值description 优先,与列表显示逻辑一致)
setEditingTitle(topic.description || topic.title);
}, []);
const cancelEdit = useCallback(() => {
setEditingTopicId(null);
setEditingTitle('');
}, []);
const commitEdit = useCallback(() => {
const trimmed = editingTitle.trim();
if (!trimmed || !editingTopicId) {
cancelEdit();
return;
}
onRenameTopic(editingTopicId, trimmed);
setEditingTopicId(null);
setEditingTitle('');
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]);
// Pagination — dynamically sized to fill one screen without scrolling
const ESTIMATED_ITEM_HEIGHT = 64; // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px)
const LIST_PADDING = 24; // p-3 top + bottom
const [pageSize, setPageSize] = useState(8); // fallback before measurement
const [currentPage, setCurrentPage] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const measurePageSize = useCallback(() => {
const el = listRef.current;
if (!el) return;
const available = el.clientHeight - LIST_PADDING;
setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1));
}, []);
useEffect(() => {
measurePageSize();
const el = listRef.current;
if (!el) return;
const observer = new ResizeObserver(() => measurePageSize());
observer.observe(el);
return () => observer.disconnect();
}, [measurePageSize]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(topics.length / pageSize)),
[topics.length, pageSize],
);
const pagedTopics = useMemo(
() => topics.slice(currentPage * pageSize, (currentPage + 1) * pageSize),
[topics, currentPage, pageSize],
);
// Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page)
useEffect(() => {
if (currentPage >= totalPages) {
setCurrentPage(Math.max(0, totalPages - 1));
}
}, [currentPage, totalPages]);
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--border-color)] px-4 py-3">
<h2 className="font-semibold text-[var(--text-primary)] flex items-center gap-2 text-sm">
<Layers className="h-4 w-4 text-[var(--accent-cyan)]" />
{topics.length > 0 && (
<span className="text-xs text-[var(--text-muted)]">({topics.length})</span>
)}
</h2>
{isReadOnly ? (
<button
onClick={onRefresh}
disabled={!sessionId}
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition-all ${
!sessionId
? 'text-[var(--text-muted)] cursor-not-allowed'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)]'
}`}
title="刷新话题列表"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={onCreateTopic}
disabled={!sessionId}
className={`flex items-center gap-1 rounded-lg px-3 py-1.5 text-sm transition-colors ${
!sessionId
? 'bg-[var(--overlay-subtle)] text-[var(--text-muted)] cursor-not-allowed'
: 'bg-[var(--accent-cyan)] text-white hover:opacity-90'
}`}
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
{/* Topics 列表 */}
<div ref={listRef} className="flex-1 overflow-y-auto p-3">
{!sessionId ? (
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>...</p>
</div>
) : topics.length === 0 ? (
<div className="p-4 text-center text-sm text-[var(--text-muted)]">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p></p>
<p className="text-xs mt-1">"新建"</p>
</div>
) : (
<div className="space-y-1">
{pagedTopics.map((topic, index) => (
<div key={topic.id} className="group relative">
{editingTopicId === topic.id ? (
// 编辑模式:内联输入框 + 提交/取消按钮
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
<form
onSubmit={(e) => {
e.preventDefault();
commitEdit();
}}
className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40"
>
<input
ref={editInputRef}
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
}}
onBlur={cancelEdit}
className="flex-1 min-w-0 bg-transparent text-sm text-[var(--text-primary)] outline-none border-none focus:ring-0 placeholder:text-[var(--text-muted)]"
placeholder="话题标题"
maxLength={120}
/>
<button
type="submit"
onMouseDown={(e) => e.preventDefault()}
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors shrink-0"
title="确认 (Enter)"
>
<Check className="h-3.5 w-3.5" />
</button>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={cancelEdit}
className="flex items-center justify-center h-6 w-6 rounded-md bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors shrink-0"
title="取消 (Esc)"
>
<X className="h-3.5 w-3.5" />
</button>
</form>
) : (
<>
<button
onClick={() => onSwitchTopic(topic.id)}
className={`w-full rounded-xl pl-3 pr-8 py-3 text-left text-sm transition-colors ${
topic.id === currentTopicId
? 'bg-[var(--overlay-subtle)] border border-[var(--border-color)]'
: 'hover:bg-[var(--overlay-hover)] border border-transparent'
}`}
>
<div className="flex items-start gap-3">
<span className="mt-0.5 text-xs text-[var(--text-muted)] w-4">
{currentPage * pageSize + index + 1}
</span>
<div className="min-w-0 flex-1">
<div
className={`truncate font-medium ${
topic.id === currentTopicId
? 'text-[var(--text-primary)]'
: 'text-[var(--text-secondary)]'
}`}
>
{topic.description || topic.title}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5">
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Hash className="h-3 w-3" />
{topic.message_count}
</span>
<span className="text-xs text-[var(--text-muted)] flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(topic.updated_at)}
</span>
</div>
</div>
{topic.id === currentTopicId && (
<span className="inline-block h-2 w-2 rounded-full bg-[var(--accent-cyan)] mt-1.5" />
)}
</div>
</button>
{/* 编辑/删除按钮 — 悬停可见 */}
<div className="absolute top-2.5 right-2.5">
{confirmDeleteId === topic.id ? (
<span className="flex items-center gap-1.5 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] px-2 py-1 shadow-lg animate-scale-in">
<span className="text-xs text-[rgb(242,90,90)] whitespace-nowrap">
?
</span>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteTopic(topic.id);
setConfirmDeleteId(null);
}}
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--accent-green)]/15 text-[var(--accent-green)] hover:bg-[var(--accent-green)]/25 transition-colors"
title="确认"
>
<Check className="h-3 w-3" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(null);
}}
className="flex items-center justify-center h-5 w-5 rounded bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:bg-[var(--overlay-medium)] transition-colors"
title="取消"
>
<X className="h-3 w-3" />
</button>
</span>
) : (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
startEdit(topic);
}}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
title="重命名话题"
>
<Edit2 className="h-3.5 w-3.5" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(topic.id);
}}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[rgb(242,90,90)] hover:bg-[rgb(242,90,90)]/10 transition-colors"
title="删除话题"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</>
)}
</div>
))}
</div>
)}
</div>
{/* Pagination bar */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2">
<button
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs text-[var(--text-muted)] min-w-[3rem] text-center select-none">
{currentPage + 1} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage >= totalPages - 1}
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
)}
</div>
);
}