SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,706 @@
|
||||
// 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<TabKey>(() => {
|
||||
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<DialogState>({ 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<string, AdapterStatus>();
|
||||
|
||||
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<number | null>(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<K extends NetworkKind>(kind: K, nextList: OneBotNetworks[K]): void {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = { ...config, networks: { ...config.networks, [kind]: nextList } };
|
||||
setConfig(next);
|
||||
void save(next);
|
||||
}
|
||||
|
||||
function handleCreate<K extends NetworkKind>(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<K extends NetworkKind>(
|
||||
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<K extends NetworkKind>(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<K extends NetworkKind>(
|
||||
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 (
|
||||
<SkeletonSwap
|
||||
ready={resources.qqList.ready}
|
||||
lines={7}
|
||||
lineHeight={28}
|
||||
barHeight={11}
|
||||
reserve={196}
|
||||
label="账号列表"
|
||||
className={resources.qqList.ready ? 'skeleton-swap-fluid min-h-[196px]' : ''}
|
||||
>
|
||||
{resources.qqList.ready ? (
|
||||
resources.qqList.error && qqList.length === 0 ? (
|
||||
<ConfigLoadError message={resources.qqList.error} onRetry={refreshQqList} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{resources.qqList.error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
>
|
||||
{resources.qqList.error};当前显示上次成功读取的账号列表。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<AccountSidebar
|
||||
accounts={qqList}
|
||||
selectedUin={selectedUin}
|
||||
onSelect={requestSwitchUin}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapsed={() => setSidebarCollapsed((v) => !v)}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{!selectedUin ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<SkeletonSwap
|
||||
ready={!loading}
|
||||
lines={7}
|
||||
lineHeight={28}
|
||||
barHeight={11}
|
||||
reserve={196}
|
||||
label="账号配置"
|
||||
className={
|
||||
!loading
|
||||
? 'skeleton-swap-fluid min-h-[196px]'
|
||||
: 'w-full'
|
||||
}
|
||||
>
|
||||
{!loading ? (
|
||||
loadError || !config ? (
|
||||
<ConfigLoadError
|
||||
message={loadError ?? '账号配置不可用'}
|
||||
onRetry={reload}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<HeaderBar
|
||||
selectedUin={selectedUin}
|
||||
dirty={dirty}
|
||||
saveStatus={saveStatus}
|
||||
saveStatusTone={saveStatusTone}
|
||||
onSave={immediateSave}
|
||||
activeTab={activeTab}
|
||||
onCreate={
|
||||
activeTab !== 'general'
|
||||
? () =>
|
||||
openCreate(
|
||||
activeTab as NetworkKind,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<TabStrip
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
counts={countMap(config.networks)}
|
||||
/>
|
||||
|
||||
<div
|
||||
id={`config-panel-${activeTab}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`config-tab-${activeTab}`}
|
||||
>
|
||||
{activeTab === 'general' ? (
|
||||
<GeneralSettingsTab
|
||||
config={config}
|
||||
onChange={setConfig}
|
||||
/>
|
||||
) : (
|
||||
<NetworkTabView
|
||||
kind={activeTab}
|
||||
config={config}
|
||||
statusByName={liveStatusByName}
|
||||
onCreateClick={() =>
|
||||
openCreate(activeTab)
|
||||
}
|
||||
onEdit={(idx) =>
|
||||
openEdit(activeTab, idx)
|
||||
}
|
||||
onDelete={(idx) =>
|
||||
handleDelete(activeTab, idx)
|
||||
}
|
||||
onToggleEnabled={(idx, v) =>
|
||||
handleToggleEnabled(
|
||||
activeTab,
|
||||
idx,
|
||||
v,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Single dialog covers create + edit across all 4 kinds. */}
|
||||
{dialog.open && config && selectedUin && (
|
||||
<NodeEditDialog
|
||||
open={dialog.open}
|
||||
onOpenChange={(open) => !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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingSwitchUin != null}
|
||||
onOpenChange={(open) => !open && cancelSwitch()}
|
||||
title="放弃未保存的修改?"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
当前会话{' '}
|
||||
<code className="font-mono">{selectedUin}</code>{' '}
|
||||
还有未保存的修改。
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
切换到{' '}
|
||||
<code className="font-mono">
|
||||
{pendingSwitchAccount?.uin ?? pendingSwitchUin}
|
||||
</code>
|
||||
{pendingSwitchAccount?.nickname
|
||||
? `(${pendingSwitchAccount.nickname})`
|
||||
: ''}{' '}
|
||||
会丢弃这些修改。
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
confirmText="放弃并切换"
|
||||
destructive
|
||||
onConfirm={confirmSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</SkeletonSwap>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── 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 (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold tracking-tight">OneBot 协议端点</h2>
|
||||
<code className="mt-0.5 block font-mono text-xs text-muted-foreground tabular-nums">
|
||||
UIN {selectedUin}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{(saveStatus || dirty) && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
{saveStatusTone === 'saving' ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">保存中</span>
|
||||
</>
|
||||
) : saveStatusTone === 'success' ? (
|
||||
<>
|
||||
<Check className="size-3.5 text-success" />
|
||||
<span className="text-success">{saveStatus}</span>
|
||||
</>
|
||||
) : saveStatusTone === 'warning' ? (
|
||||
<>
|
||||
<span className="size-1.5 rounded-full bg-warning" />
|
||||
<span className="text-warning">{saveStatus}</span>
|
||||
</>
|
||||
) : saveStatusTone === 'error' ? (
|
||||
<>
|
||||
<span className="size-1.5 rounded-full bg-destructive" />
|
||||
<span className="text-destructive">{saveStatus}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="size-1.5 rounded-full bg-warning" />
|
||||
<span className="text-warning">未保存</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{onCreate && (
|
||||
<Button size="sm" variant="outline" onClick={onCreate}>
|
||||
<Plus className="size-3.5" />
|
||||
新建
|
||||
{activeTab === 'general'
|
||||
? ''
|
||||
: NETWORK_TABS[activeTab as NetworkKind].title}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSave} size="sm">
|
||||
<Save className="size-3.5" /> 保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── tab strip ───────────────
|
||||
|
||||
interface TabStripProps {
|
||||
activeTab: TabKey;
|
||||
onChange: (key: TabKey) => void;
|
||||
counts: Record<NetworkKind, number>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<ScrollableTabList
|
||||
activeValue={activeTab}
|
||||
role="tablist"
|
||||
aria-label="节点配置分类"
|
||||
className="gap-1 border-b"
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={key}
|
||||
ref={(element) => {
|
||||
tabRefs.current[index] = element;
|
||||
}}
|
||||
id={`config-tab-${key}`}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={active}
|
||||
aria-controls={`config-panel-${key}`}
|
||||
data-tab-active={active ? 'true' : undefined}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(key)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
moveTab(index, 1);
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
moveTab(index, -1);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
moveTab(index, 'home');
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
moveTab(index, 'end');
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group relative inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap px-3 py-2 text-sm outline-none transition-colors cursor-pointer md:min-h-10',
|
||||
'focus-visible:ring-[3px] focus-visible:ring-ring/40',
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{count != null && (
|
||||
<Badge
|
||||
variant={active ? 'default' : 'secondary'}
|
||||
className="h-4 px-1.5 font-mono text-micro tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="config-tab-underline"
|
||||
className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-primary"
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollableTabList>
|
||||
);
|
||||
}
|
||||
|
||||
function countMap(networks: OneBotNetworks): Record<NetworkKind, number> {
|
||||
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<string, AdapterStatus>;
|
||||
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<string, number>();
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed py-16 text-muted-foreground">
|
||||
<p className="text-sm">暂无 {tab.title} 节点</p>
|
||||
<Button variant="outline" size="sm" onClick={onCreateClick}>
|
||||
<Plus className="size-3.5" /> 创建第一个
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted-foreground">{tab.description}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{list.map((item, idx) => {
|
||||
const duplicate = !!item.name && (nameCounts.get(item.name) ?? 0) > 1;
|
||||
return (
|
||||
<NodeSummaryCard
|
||||
key={`${item.name}-${idx}`}
|
||||
item={item}
|
||||
summary={summarize(item)}
|
||||
duplicateName={duplicate}
|
||||
liveStatus={statusByName.get(item.name)}
|
||||
onToggleEnabled={(v) => onToggleEnabled(idx, v)}
|
||||
onEdit={() => onEdit(idx)}
|
||||
onDelete={() => onDelete(idx)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── empty + loading ───────────────
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 rounded-xl border border-dashed text-muted-foreground">
|
||||
<MousePointerClick className="size-8 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">请在左栏选择会话以配置通信节点</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed text-muted-foreground">
|
||||
<p className="max-w-md text-center text-sm text-destructive">{message}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────── helpers ───────────────
|
||||
|
||||
/** Names of every adapter except the one currently being edited. */
|
||||
function otherNames<K extends NetworkKind>(
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user