// OneBot per-UIN configuration page. // // Layout: collapsible left sidebar for account selection + tabbed right // pane (通用 / 4 network kinds). Each network tab is a list view of // summary cards with inline enable/disable; create + edit both go // through `NodeEditDialog`. All changes auto-save: network mutations // persist immediately; general-settings edits are debounced (500 ms). import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { motion } from 'motion/react'; import { Check, Loader2, MousePointerClick, Plus, Save } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { ScrollableTabList } from '@/components/ui/tabs'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { SkeletonSwap } from '@/components/interior/skeleton-swap'; import { cn } from '@/lib/utils'; import type { AdapterStatus, NetworkKind, OneBotConfig, OneBotNetworks } from '@/types'; import { useOneBotInstanceConfig } from '@/hooks/use-onebot-instance-config'; import { useAppState } from '@/contexts/AppStateContext'; import { useLayout } from '@/contexts/LayoutContext'; import { AccountSidebar } from '@/components/config/account-sidebar'; import { GeneralSettingsTab } from '@/components/config/general-settings-tab'; import { NodeSummaryCard } from '@/components/config/node-summary-card'; import { NodeEditDialog } from '@/components/config/node-edit-dialog'; import { ALL_TABS, NETWORK_TABS, nextUniqueSuffix, type TabKey, } from '@/components/config/defaults'; type DialogState = | { open: false } | { open: true; kind: NetworkKind; index: number | null; seed: OneBotNetworks[NetworkKind][number]; }; // index: null → create with `seed`, otherwise edit the item at that position. export function ConfigPage() { const { qqList, connections, selectedUin, setSelectedUin, resources, refreshQqList } = useAppState(); const { config, loading, loadError, reload, setConfig, dirty, requestSwitchUin, pendingSwitchUin, confirmSwitch, cancelSwitch, save, saveStatus, saveStatusTone, } = useOneBotInstanceConfig(qqList, { selectedUin, onSelectedUinChange: setSelectedUin, }); const { pages, setPages } = useLayout(); const [activeTab, setActiveTabState] = useState(() => { const t = pages.configTab; return t === 'general' || (!!t && t in NETWORK_TABS) ? (t as TabKey) : 'general'; }); // Persist the last-used tab as the default for next time. const setActiveTab = useCallback( (t: TabKey) => { setActiveTabState(t); setPages({ configTab: t }); }, [setPages], ); // Default the account strip to its 56px avatar-only form on narrow screens // (≤lg) so the editor pane isn't squeezed; the user can still expand it. const [sidebarCollapsed, setSidebarCollapsed] = useState( () => typeof window !== 'undefined' && window.matchMedia('(max-width: 1024px)').matches, ); // The edit dialog is modal and blocks every other click in the page, // so `selectedUin` cannot change while it's open — no defensive close // wiring needed beyond the dialog's own open/close. const [dialog, setDialog] = useState({ open: false }); const pendingSwitchAccount = useMemo( () => (pendingSwitchUin ? (qqList.find((q) => q.uin === pendingSwitchUin) ?? null) : null), [pendingSwitchUin, qqList], ); // Live adapter status for the selected account, keyed by adapter name so // each summary card can light up its real connection state. const liveStatusByName = useMemo(() => { const acc = connections.find((c) => c.uin === selectedUin); const map = new Map(); for (const a of acc?.adapters ?? []) { map.set(a.name, a); } return map; }, [connections, selectedUin]); // Auto-save general settings with debounce. Network mutations // (create / edit / delete / enable-toggle) persist immediately in commitKind. const debounceRef = useRef(null); useEffect(() => { if (debounceRef.current != null) { window.clearTimeout(debounceRef.current); } if (dirty) { debounceRef.current = window.setTimeout(() => { void save(); debounceRef.current = null; }, 500); } return () => { if (debounceRef.current != null) { window.clearTimeout(debounceRef.current); } }; }, [dirty, config, save]); /** Immediate save: clear any pending auto-save debounce and persist now. */ const immediateSave = useCallback(() => { if (debounceRef.current != null) { window.clearTimeout(debounceRef.current); debounceRef.current = null; } void save(); }, [save]); function commitKind(kind: K, nextList: OneBotNetworks[K]): void { if (!config) { return; } const next = { ...config, networks: { ...config.networks, [kind]: nextList } }; setConfig(next); void save(next); } function handleCreate(kind: K, item: OneBotNetworks[K][number]): void { if (!config) { return; } const list = config.networks[kind] as OneBotNetworks[K]; commitKind(kind, [...list, item] as OneBotNetworks[K]); } function handleEdit( kind: K, index: number, item: OneBotNetworks[K][number], ): void { if (!config) { return; } const list = config.networks[kind] as OneBotNetworks[K]; commitKind(kind, list.map((it, i) => (i === index ? item : it)) as OneBotNetworks[K]); } function handleDelete(kind: K, index: number): void { if (!config) { return; } const list = config.networks[kind] as OneBotNetworks[K]; commitKind(kind, list.filter((_, i) => i !== index) as OneBotNetworks[K]); } function handleToggleEnabled( kind: K, index: number, enabled: boolean, ): void { if (!config) { return; } const list = config.networks[kind] as OneBotNetworks[K]; commitKind( kind, list.map((it, i) => i === index ? ({ ...it, enabled: enabled ? undefined : false } as OneBotNetworks[K][number]) : it, ) as OneBotNetworks[K], ); } const openCreate = (kind: NetworkKind) => { if (!config) { return; } const tab = NETWORK_TABS[kind]; const list = config.networks[kind]; const suffix = nextUniqueSuffix(list, tab.defaultEntry); setDialog({ open: true, kind, index: null, seed: tab.defaultEntry(suffix) }); }; const openEdit = (kind: NetworkKind, index: number) => { if (!config) { return; } const item = config.networks[kind][index]; if (!item) { return; } setDialog({ open: true, kind, index, seed: item }); }; return ( {resources.qqList.ready ? ( resources.qqList.error && qqList.length === 0 ? ( ) : (
{resources.qqList.error && (
{resources.qqList.error};当前显示上次成功读取的账号列表。
)}
setSidebarCollapsed((v) => !v)} />
{!selectedUin ? ( ) : ( {!loading ? ( loadError || !config ? ( ) : (
openCreate( activeTab as NetworkKind, ) : undefined } />
{activeTab === 'general' ? ( ) : ( openCreate(activeTab) } onEdit={(idx) => openEdit(activeTab, idx) } onDelete={(idx) => handleDelete(activeTab, idx) } onToggleEnabled={(idx, v) => handleToggleEnabled( activeTab, idx, v, ) } /> )}
) ) : null}
)}
{/* Single dialog covers create + edit across all 4 kinds. */} {dialog.open && config && selectedUin && ( !open && setDialog({ open: false })} kind={dialog.kind} initial={dialog.seed} isEdit={dialog.index != null} otherNames={otherNames( config.networks, dialog.kind, dialog.index, )} uin={selectedUin} onSubmit={(item) => { if (dialog.index == null) { handleCreate(dialog.kind, item); } else { handleEdit(dialog.kind, dialog.index, item); } }} /> )} !open && cancelSwitch()} title="放弃未保存的修改?" description={ <>

当前会话{' '} {selectedUin}{' '} 还有未保存的修改。

切换到{' '} {pendingSwitchAccount?.uin ?? pendingSwitchUin} {pendingSwitchAccount?.nickname ? `(${pendingSwitchAccount.nickname})` : ''}{' '} 会丢弃这些修改。

} confirmText="放弃并切换" destructive onConfirm={confirmSwitch} />
) ) : null}
); } // ─────────────── header ─────────────── interface HeaderBarProps { selectedUin: string; dirty: boolean; saveStatus: string; saveStatusTone: 'idle' | 'saving' | 'success' | 'warning' | 'error'; onSave: () => void; activeTab: TabKey; onCreate?: () => void; } function HeaderBar({ selectedUin, dirty, saveStatus, saveStatusTone, onSave, activeTab, onCreate, }: HeaderBarProps) { return (

OneBot 协议端点

UIN {selectedUin}
{(saveStatus || dirty) && ( {saveStatusTone === 'saving' ? ( <> 保存中 ) : saveStatusTone === 'success' ? ( <> {saveStatus} ) : saveStatusTone === 'warning' ? ( <> {saveStatus} ) : saveStatusTone === 'error' ? ( <> {saveStatus} ) : ( <> 未保存 )} )} {onCreate && ( )}
); } // ─────────────── tab strip ─────────────── interface TabStripProps { activeTab: TabKey; onChange: (key: TabKey) => void; counts: Record; } function TabStrip({ activeTab, onChange, counts }: TabStripProps) { const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const moveTab = (from: number, direction: 1 | -1 | 'home' | 'end') => { const length = ALL_TABS.length; const nextIndex = direction === 'home' ? 0 : direction === 'end' ? length - 1 : (from + direction + length) % length; const next = ALL_TABS[nextIndex]; if (!next) { return; } onChange(next); tabRefs.current[nextIndex]?.focus(); }; return ( {ALL_TABS.map((key, index) => { const label = key === 'general' ? '通用设置' : NETWORK_TABS[key].title; const count = key === 'general' ? null : counts[key]; const active = activeTab === key; return ( ); })} ); } function countMap(networks: OneBotNetworks): Record { return { httpServers: networks.httpServers.length, httpClients: networks.httpClients.length, wsServers: networks.wsServers.length, wsClients: networks.wsClients.length, }; } // ─────────────── network tab body ─────────────── interface NetworkTabViewProps { kind: NetworkKind; config: OneBotConfig; statusByName: Map; onCreateClick: () => void; onEdit: (index: number) => void; onDelete: (index: number) => void; onToggleEnabled: (index: number, enabled: boolean) => void; } function NetworkTabView({ kind, config, statusByName, onCreateClick, onEdit, onDelete, onToggleEnabled, }: NetworkTabViewProps) { const tab = NETWORK_TABS[kind]; const list = config.networks[kind]; // `summarize` is typed per K in NETWORK_TABS; the union here forces a // widening cast at the call site rather than ladder-of-ifs per tab. const summarize = tab.summarize as (it: (typeof list)[number]) => string; // Tally name occurrences once instead of doing `list.filter(...).map(...).includes(...)` // inside `.map()` (O(n²) per render). With this lookup, the duplicate // check is O(1) per row — matters once the list grows past a few entries // or when typing renames a node and the tab re-renders on every keystroke. const nameCounts = useMemo(() => { const counts = new Map(); for (const item of list) { const name = item.name; if (!name) { continue; } counts.set(name, (counts.get(name) ?? 0) + 1); } return counts; }, [list]); if (list.length === 0) { return (

暂无 {tab.title} 节点

); } return (

{tab.description}

{list.map((item, idx) => { const duplicate = !!item.name && (nameCounts.get(item.name) ?? 0) > 1; return ( onToggleEnabled(idx, v)} onEdit={() => onEdit(idx)} onDelete={() => onDelete(idx)} /> ); })}
); } // ─────────────── empty + loading ─────────────── function EmptyState() { return (

请在左栏选择会话以配置通信节点

); } function ConfigLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { return (

{message}

); } // ─────────────── helpers ─────────────── /** Names of every adapter except the one currently being edited. */ function otherNames( networks: OneBotNetworks, kind: K, excludeIndex: number | null, ): string[] { const list = networks[kind]; return list .filter((_, i) => i !== excludeIndex) .map((n) => n.name) .filter((s): s is string => typeof s === 'string' && s.length > 0); }