后端: - list_channels 从 ChannelManager 动态查询通道列表(合并 websocket + 所有已注册通道) - build_channel_list 移至 ChannelManager,网关层直接依赖领域层 - get_current_topic 自动创建默认话题(修复微信等通道无话题的问题) - is_channel_writable: 仅 websocket 可写,其余通道只读 前端: - 右上角通道选择器 + Session 选择器(Portal 渲染,固定宽度居中) - 只读通道显示刷新按钮替代新建按钮 - 话题列表时间戳修复(秒→毫秒) - 移除冗余的 SessionInfo、AI Ready、所属会话等 UI - 修复 scheduler view 路由无条件拦截消息的 bug
177 lines
6.5 KiB
TypeScript
177 lines
6.5 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { MessageSquare, ChevronDown } from 'lucide-react'
|
|
import type { SessionSummary } from '../../types/protocol'
|
|
|
|
interface SessionSelectorProps {
|
|
sessions: SessionSummary[]
|
|
selectedSessionId: string | null
|
|
onSelectSession: (sessionId: string) => void
|
|
}
|
|
|
|
export function SessionSelector({
|
|
sessions,
|
|
selectedSessionId,
|
|
onSelectSession,
|
|
}: SessionSelectorProps) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
const selected = sessions.find((s) => s.session_id === selectedSessionId)
|
|
|
|
const updatePosition = useCallback(() => {
|
|
if (triggerRef.current) {
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
setDropdownPos({
|
|
top: rect.bottom + 8,
|
|
right: window.innerWidth - rect.right,
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
updatePosition()
|
|
window.addEventListener('resize', updatePosition)
|
|
window.addEventListener('scroll', updatePosition, true)
|
|
return () => {
|
|
window.removeEventListener('resize', updatePosition)
|
|
window.removeEventListener('scroll', updatePosition, true)
|
|
}
|
|
}
|
|
}, [isOpen, updatePosition])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
const handleClick = (e: MouseEvent) => {
|
|
const target = e.target as Node
|
|
if (
|
|
dropdownRef.current && !dropdownRef.current.contains(target) &&
|
|
triggerRef.current && !triggerRef.current.contains(target)
|
|
) {
|
|
setIsOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [isOpen])
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') setIsOpen(false)
|
|
}
|
|
document.addEventListener('keydown', handleKey)
|
|
return () => document.removeEventListener('keydown', handleKey)
|
|
}, [isOpen])
|
|
|
|
if (sessions.length === 0) return null
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={triggerRef}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
disabled={sessions.length <= 1}
|
|
className={`
|
|
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
|
|
transition-all duration-200 ease-out
|
|
${sessions.length <= 1
|
|
? 'border-[var(--border-color)] cursor-default'
|
|
: isOpen
|
|
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
|
|
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
|
|
}
|
|
`}
|
|
>
|
|
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 text-[var(--accent-cyan)]" />
|
|
<span className="flex-1 min-w-0 text-[var(--text-primary)] font-medium text-xs tracking-wide truncate text-center">
|
|
{selected?.title || '无会话'}
|
|
</span>
|
|
{sessions.length > 1 ? (
|
|
<span className="flex items-center gap-1 flex-shrink-0">
|
|
<span className="text-[10px] text-[var(--text-muted)]">{sessions.length}</span>
|
|
<ChevronDown
|
|
className={`h-3.5 w-3.5 text-[var(--text-muted)] transition-transform duration-200 ${
|
|
isOpen ? 'rotate-180' : ''
|
|
}`}
|
|
/>
|
|
</span>
|
|
) : (
|
|
/* 占位保持图标位置一致 */
|
|
<span className="w-4 flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
|
|
{isOpen && sessions.length > 1 && createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
className="fixed z-[9999] w-56 animate-slide-in"
|
|
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
|
>
|
|
<div
|
|
className="
|
|
rounded-xl border border-[var(--border-color)]
|
|
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
|
shadow-2xl shadow-black/40
|
|
overflow-hidden
|
|
"
|
|
>
|
|
<div className="py-1 max-h-60 overflow-y-auto">
|
|
{sessions.map((s, index) => {
|
|
const isActive = s.session_id === selectedSessionId
|
|
return (
|
|
<button
|
|
key={s.session_id}
|
|
onClick={() => {
|
|
onSelectSession(s.session_id)
|
|
setIsOpen(false)
|
|
}}
|
|
className={`
|
|
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
|
|
transition-all duration-150
|
|
hover:bg-[var(--overlay-hover)]
|
|
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
|
|
`}
|
|
style={{
|
|
animationDelay: `${index * 40}ms`,
|
|
animation: 'fade-in 0.2s ease-out both',
|
|
}}
|
|
>
|
|
<div
|
|
className={`
|
|
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
|
|
transition-all duration-200
|
|
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
|
|
`}
|
|
/>
|
|
<MessageSquare
|
|
className={`h-3.5 w-3.5 flex-shrink-0 transition-colors duration-200 ${
|
|
isActive ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] group-hover/item:text-[var(--text-secondary)]'
|
|
}`}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div
|
|
className={`text-sm truncate transition-colors duration-200 ${
|
|
isActive ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
|
|
}`}
|
|
>
|
|
{s.title}
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-[var(--text-muted)] flex-shrink-0">
|
|
{s.message_count}
|
|
</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
)
|
|
}
|