Files
sub-router/web/src/components/Guests.tsx
YANG JIANKUAN fb7a30489e feat: 新增游客订阅链接与节点拖动排序
游客链接:可建多个带独立 token 的订阅,按节点名勾选白名单
(抗重新抓取),/surge|/clash|/ssr 复用同一路径回落匹配 guest
token,三个 generator 接收可选 whitelist 过滤生成。

节点排序:fetched_nodes 新增 sort_order,重抓时按名保留顺序,
选择/节点面板支持原生拖拽排序,输出按此顺序排列。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 23:43:28 +08:00

377 lines
14 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.

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<any[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<any>(null);
const [available, setAvailable] = useState<{ static: any[]; subscriptions: any[] }>({ static: [], subscriptions: [] });
const [selectedNames, setSelectedNames] = useState<Set<string>>(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<string>(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 (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}></p>
<div style={{ display: 'flex', gap: 20, alignItems: 'flex-start' }}>
{/* Left: link list */}
<div style={{ width: 260, flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
<input
placeholder="新链接名称"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
style={{ flex: 1, fontSize: 12 }}
/>
<button className="primary small" onClick={handleCreate}></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map(link => (
<div
key={link.id}
onClick={() => 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,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--accent)' }}>{link.name}</span>
<input
type="checkbox"
className="toggle"
checked={!!link.enabled}
onClick={e => e.stopPropagation()}
onChange={() => handleToggleEnabled(link)}
title="启用/禁用此链接"
/>
</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
{link.node_count}
</div>
</div>
))}
{links.length === 0 && (
<div style={{ color: 'var(--text-muted)', fontSize: 12, padding: 12, textAlign: 'center' }}>
</div>
)}
</div>
</div>
{/* Right: editor */}
<div style={{ flex: 1, minWidth: 0 }}>
{!detail ? (
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: 40, textAlign: 'center' }}>
</div>
) : (
<>
{/* Name + delete */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
style={{ width: 220, fontSize: 13 }}
/>
<button className="primary" onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存'}
</button>
<button className="small danger" onClick={() => handleDelete(detail)}></button>
</div>
{/* 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 (
<div key={kind} style={styles.urlRow}>
<span style={styles.urlLabel}>{label}</span>
<code style={styles.urlCode}>{url}</code>
<button className="small" onClick={() => copy(kind, url)}>
{copied === kind ? '已复制' : '复制'}
</button>
</div>
);
})}
{/* Batch controls (same as 节点选择) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '16px 0' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="small" onClick={() => selectAll(true)}></button>
<button className="small" onClick={() => selectAll(false)}></button>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-secondary)', marginLeft: 8 }}>
{selectedCount}/{allNames.length}
</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
placeholder="正则匹配节点名(如 香港|HK"
value={regexInput}
onChange={e => setRegexInput(e.target.value)}
style={{ width: 260, fontSize: 12 }}
/>
<button className="small" onClick={() => regexBatch(true)}></button>
<button className="small" onClick={() => regexBatch(false)}></button>
{regexInput && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{regexMatchCount === -1 ? '无效正则' : `匹配 ${regexMatchCount}`}
</span>
)}
</div>
</div>
{/* Node list grouped */}
<table>
<thead>
<tr>
<th style={{ width: 50 }}></th>
<th></th>
<th style={{ width: 80 }}></th>
<th></th>
</tr>
</thead>
<tbody>
{groups.map(group => (
<FragmentGroup
key={group.label}
group={group}
selectedNames={selectedNames}
onToggle={toggleName}
/>
))}
{allNames.length === 0 && (
<tr>
<td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
</td>
</tr>
)}
</tbody>
</table>
</>
)}
</div>
</div>
</div>
);
}
function FragmentGroup({ group, selectedNames, onToggle }: {
group: NodeGroup;
selectedNames: Set<string>;
onToggle: (name: string) => void;
}) {
return (
<>
<tr>
<td colSpan={4} style={{
background: 'var(--bg-active)',
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
{group.label} · {group.nodes.length}
</td>
</tr>
{group.nodes.map((node, i) => {
const checked = selectedNames.has(node.name);
return (
<tr key={`${group.label}-${i}-${node.name}`} style={{ opacity: checked ? 1 : 0.55 }}>
<td>
<input
type="checkbox"
className="toggle"
checked={checked}
onChange={() => onToggle(node.name)}
/>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: checked ? 'var(--accent)' : 'var(--text-secondary)' }}>
{node.name}
</td>
<td>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 6px',
borderRadius: 'var(--radius)', background: 'var(--bg-active)',
color: 'var(--text-secondary)', textTransform: 'uppercase',
}}>
{node.type}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-secondary)' }}>
{node.server || '—'}
</td>
</tr>
);
})}
</>
);
}
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',
},
};