Files
SnowLuma/packages/webui/src/components/pages/config-page.tsx
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

707 lines
29 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.
// 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);
}