import { useState, useEffect, useMemo } from 'react'; import { guests as api } from '../api'; interface NodeItem { name: string; type: string; server?: string } interface NodeGroup { label: string; nodes: NodeItem[] } export default function Guests() { const [links, setLinks] = useState([]); const [selectedId, setSelectedId] = useState(null); const [detail, setDetail] = useState(null); const [available, setAvailable] = useState<{ static: any[]; subscriptions: any[] }>({ static: [], subscriptions: [] }); const [selectedNames, setSelectedNames] = useState>(new Set()); const [editName, setEditName] = useState(''); const [regexInput, setRegexInput] = useState(''); const [newName, setNewName] = useState(''); const [saving, setSaving] = useState(false); const [copied, setCopied] = useState(''); const loadLinks = () => api.list().then(setLinks).catch(console.error); useEffect(() => { loadLinks(); api.availableNodes().then(setAvailable).catch(console.error); }, []); useEffect(() => { if (selectedId == null) { setDetail(null); return; } api.get(selectedId).then(d => { setDetail(d); setEditName(d.name); setSelectedNames(new Set(d.node_names || [])); }).catch(console.error); }, [selectedId]); // Flatten available nodes into render groups const groups: NodeGroup[] = useMemo(() => { const g: NodeGroup[] = []; if (available.static.length > 0) { g.push({ label: '自定义节点', nodes: available.static.map((n: any) => ({ name: n.name, type: n.type })) }); } for (const sub of available.subscriptions) { if (sub.nodes.length > 0) { g.push({ label: sub.name, nodes: sub.nodes.map((n: any) => ({ name: n.name, type: n.type, server: n.server })) }); } } return g; }, [available]); const allNames = useMemo(() => groups.flatMap(g => g.nodes.map(n => n.name)), [groups]); const toggleName = (name: string) => { setSelectedNames(prev => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); }; const selectAll = (enabled: boolean) => { setSelectedNames(prev => { const next = new Set(prev); for (const n of allNames) { if (enabled) next.add(n); else next.delete(n); } return next; }); }; const regexBatch = (enabled: boolean) => { if (!regexInput.trim()) return; let re: RegExp; try { re = new RegExp(regexInput, 'i'); } catch { alert('无效的正则表达式'); return; } setSelectedNames(prev => { const next = new Set(prev); for (const n of allNames) { if (re.test(n)) { if (enabled) next.add(n); else next.delete(n); } } return next; }); }; const regexMatchCount = useMemo(() => { if (!regexInput) return null; try { const re = new RegExp(regexInput, 'i'); return allNames.filter(n => re.test(n)).length; } catch { return -1; } }, [regexInput, allNames]); const handleCreate = async () => { if (!newName.trim()) return; const { id } = await api.create(newName.trim()); setNewName(''); await loadLinks(); setSelectedId(id); }; const handleSave = async () => { if (selectedId == null) return; setSaving(true); try { await api.update(selectedId, { name: editName.trim() || detail.name, node_names: [...selectedNames], }); await loadLinks(); const d = await api.get(selectedId); setDetail(d); } finally { setSaving(false); } }; const handleToggleEnabled = async (link: any) => { await api.update(link.id, { enabled: link.enabled ? 0 : 1 }); loadLinks(); }; const handleDelete = async (link: any) => { if (!confirm(`删除游客链接「${link.name}」?该链接将立即失效。`)) return; await api.delete(link.id); if (selectedId === link.id) setSelectedId(null); loadLinks(); }; const copy = (key: string, url: string) => { navigator.clipboard.writeText(url); setCopied(key); setTimeout(() => setCopied(''), 2000); }; const origin = window.location.origin; const selectedCount = allNames.filter(n => selectedNames.has(n)).length; return (

游客链接

为他人生成独立订阅链接,仅返回勾选的白名单节点。可随时编辑,链接保持不变。

{/* Left: link list */}
setNewName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }} style={{ flex: 1, fontSize: 12 }} />
{links.map(link => (
setSelectedId(link.id)} style={{ border: '1px solid', borderColor: selectedId === link.id ? 'var(--accent)' : 'var(--border)', background: selectedId === link.id ? 'var(--bg-active)' : 'var(--bg-input)', borderRadius: 'var(--radius)', padding: '10px 12px', cursor: 'pointer', opacity: link.enabled ? 1 : 0.5, }} >
{link.name} e.stopPropagation()} onChange={() => handleToggleEnabled(link)} title="启用/禁用此链接" />
{link.node_count} 个节点
))} {links.length === 0 && (
暂无游客链接
)}
{/* Right: editor */}
{!detail ? (
选择左侧链接进行编辑,或新建一个
) : ( <> {/* Name + delete */}
setEditName(e.target.value)} style={{ width: 220, fontSize: 13 }} />
{/* URLs */} {(['surge', 'clash', 'ssr'] as const).map(kind => { const url = `${origin}/${kind}/${detail.token}`; const label = kind === 'surge' ? 'SURGE' : kind === 'clash' ? 'CLASH / STASH' : 'SSR / QX / 小火箭'; return (
{label} {url}
); })} {/* Batch controls (same as 节点选择) */}
{selectedCount}/{allNames.length} 已选
setRegexInput(e.target.value)} style={{ width: 260, fontSize: 12 }} /> {regexInput && ( {regexMatchCount === -1 ? '无效正则' : `匹配 ${regexMatchCount} 个`} )}
{/* Node list grouped */} {groups.map(group => ( ))} {allNames.length === 0 && ( )}
白名单 名称 协议 服务器
暂无可选节点,请先添加静态节点或抓取订阅
)}
); } function FragmentGroup({ group, selectedNames, onToggle }: { group: NodeGroup; selectedNames: Set; onToggle: (name: string) => void; }) { return ( <> {group.label} · {group.nodes.length} {group.nodes.map((node, i) => { const checked = selectedNames.has(node.name); return ( onToggle(node.name)} /> {node.name} {node.type} {node.server || '—'} ); })} ); } const styles = { title: { fontFamily: 'var(--font-mono)' as const, fontSize: 16, fontWeight: 600 as const, color: 'var(--text-primary)', marginBottom: 4, }, subtitle: { fontSize: 12, color: 'var(--text-secondary)', marginBottom: 20, }, urlRow: { display: 'flex' as const, gap: 8, alignItems: 'center' as const, marginBottom: 6, }, urlLabel: { fontFamily: 'var(--font-mono)' as const, fontSize: 10, color: 'var(--text-muted)', width: 120, flexShrink: 0, }, urlCode: { flex: 1, fontFamily: 'var(--font-mono)' as const, fontSize: 12, color: 'var(--accent)', userSelect: 'all' as const, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const, background: 'var(--bg-input)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '6px 10px', }, };