游客链接:可建多个带独立 token 的订阅,按节点名勾选白名单 (抗重新抓取),/surge|/clash|/ssr 复用同一路径回落匹配 guest token,三个 generator 接收可选 whitelist 过滤生成。 节点排序:fetched_nodes 新增 sort_order,重抓时按名保留顺序, 选择/节点面板支持原生拖拽排序,输出按此顺序排列。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
255 lines
8.2 KiB
TypeScript
255 lines
8.2 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { subscriptions as subsApi, nodes as nodesApi } from '../api';
|
||
|
||
export default function NodeSelector() {
|
||
const [subs, setSubs] = useState<any[]>([]);
|
||
const [selectedSub, setSelectedSub] = useState<number | null>(null);
|
||
const [nodeList, setNodeList] = useState<any[]>([]);
|
||
const [regexInput, setRegexInput] = useState('');
|
||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
subsApi.list().then(data => {
|
||
setSubs(data);
|
||
if (data.length > 0 && !selectedSub) {
|
||
setSelectedSub(data[0].id);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedSub) {
|
||
subsApi.nodes(selectedSub).then(setNodeList).catch(console.error);
|
||
}
|
||
}, [selectedSub]);
|
||
|
||
const handleToggle = async (id: number, enabled: number) => {
|
||
await nodesApi.fetchedToggle(id, !enabled);
|
||
if (selectedSub) {
|
||
subsApi.nodes(selectedSub).then(setNodeList);
|
||
}
|
||
};
|
||
|
||
const handleSelectAll = async (enabled: boolean) => {
|
||
const ids = nodeList.map(n => n.id);
|
||
await nodesApi.fetchedBatch(ids, enabled);
|
||
if (selectedSub) {
|
||
subsApi.nodes(selectedSub).then(setNodeList);
|
||
}
|
||
};
|
||
|
||
const handleRegexBatch = async (enabled: boolean) => {
|
||
if (!regexInput.trim()) return;
|
||
try {
|
||
const re = new RegExp(regexInput, 'i');
|
||
const matchedIds = nodeList.filter(n => re.test(n.name)).map(n => n.id);
|
||
if (matchedIds.length === 0) return;
|
||
await nodesApi.fetchedBatch(matchedIds, enabled);
|
||
if (selectedSub) {
|
||
subsApi.nodes(selectedSub).then(setNodeList);
|
||
}
|
||
} catch {
|
||
alert('无效的正则表达式');
|
||
}
|
||
};
|
||
|
||
const handleDrop = async (targetIndex: number) => {
|
||
if (dragIndex === null || dragIndex === targetIndex) { setDragIndex(null); return; }
|
||
const reordered = [...nodeList];
|
||
const [moved] = reordered.splice(dragIndex, 1);
|
||
reordered.splice(targetIndex, 0, moved);
|
||
setNodeList(reordered);
|
||
setDragIndex(null);
|
||
try {
|
||
await nodesApi.fetchedReorder(reordered.map(n => n.id));
|
||
} catch (err) {
|
||
console.error(err);
|
||
if (selectedSub) subsApi.nodes(selectedSub).then(setNodeList);
|
||
}
|
||
};
|
||
|
||
const enabledCount = nodeList.filter(n => n.enabled).length;
|
||
|
||
return (
|
||
<div>
|
||
<h2 style={styles.title}>节点选择</h2>
|
||
<p style={styles.subtitle}>选择要包含在最终配置中的节点</p>
|
||
|
||
{/* Subscription tabs */}
|
||
<div style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
|
||
{subs.map(sub => (
|
||
<button
|
||
key={sub.id}
|
||
onClick={() => setSelectedSub(sub.id)}
|
||
style={{
|
||
background: selectedSub === sub.id ? 'var(--bg-active)' : 'transparent',
|
||
color: selectedSub === sub.id ? 'var(--accent)' : 'var(--text-secondary)',
|
||
borderColor: selectedSub === sub.id ? 'var(--accent)' : 'var(--border)',
|
||
}}
|
||
>
|
||
{sub.name}
|
||
<span style={{
|
||
marginLeft: 6,
|
||
fontSize: 10,
|
||
color: 'var(--text-muted)',
|
||
}}>
|
||
({sub.node_count || 0})
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Batch controls */}
|
||
{nodeList.length > 0 && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<button className="small" onClick={() => handleSelectAll(true)}>全选</button>
|
||
<button className="small" onClick={() => handleSelectAll(false)}>全不选</button>
|
||
<span style={{
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: 11,
|
||
color: 'var(--text-secondary)',
|
||
marginLeft: 8,
|
||
}}>
|
||
{enabledCount}/{nodeList.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={() => handleRegexBatch(true)}>匹配启用</button>
|
||
<button className="small" onClick={() => handleRegexBatch(false)}>匹配禁用</button>
|
||
{regexInput && (
|
||
<span style={{
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: 11,
|
||
color: 'var(--text-muted)',
|
||
}}>
|
||
{(() => {
|
||
try {
|
||
const re = new RegExp(regexInput, 'i');
|
||
const matched = nodeList.filter(n => re.test(n.name)).length;
|
||
return `匹配 ${matched} 个`;
|
||
} catch {
|
||
return '无效正则';
|
||
}
|
||
})()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Node list */}
|
||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>
|
||
拖动 ⠿ 可调整节点顺序,输出配置会按此顺序排列
|
||
</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 32 }}></th>
|
||
<th style={{ width: 50 }}>启用</th>
|
||
<th>名称</th>
|
||
<th style={{ width: 80 }}>协议</th>
|
||
<th>服务器</th>
|
||
<th style={{ width: 70 }}>端口</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{nodeList.map((node, idx) => (
|
||
<tr
|
||
key={node.id}
|
||
onDragOver={e => e.preventDefault()}
|
||
onDrop={() => handleDrop(idx)}
|
||
style={{
|
||
opacity: node.enabled ? 1 : 0.5,
|
||
background: dragIndex === idx ? 'var(--bg-active)' : undefined,
|
||
}}
|
||
>
|
||
<td
|
||
draggable
|
||
onDragStart={() => setDragIndex(idx)}
|
||
onDragEnd={() => setDragIndex(null)}
|
||
style={{ cursor: 'grab', textAlign: 'center', color: 'var(--text-muted)' }}
|
||
title="拖动排序"
|
||
>
|
||
⠿
|
||
</td>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
className="toggle"
|
||
checked={!!node.enabled}
|
||
onChange={() => handleToggle(node.id, node.enabled)}
|
||
/>
|
||
</td>
|
||
<td style={{
|
||
fontFamily: 'var(--font-mono)',
|
||
color: node.enabled ? 'var(--accent)' : 'var(--text-secondary)',
|
||
fontSize: 12,
|
||
}}>
|
||
{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>
|
||
<td style={{
|
||
fontFamily: 'var(--font-mono)',
|
||
fontSize: 11,
|
||
}}>
|
||
{node.port}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{nodeList.length === 0 && (
|
||
<tr>
|
||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||
{subs.length === 0
|
||
? '请先添加订阅源'
|
||
: '请先抓取订阅源节点'
|
||
}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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,
|
||
},
|
||
};
|