Files
sub-router/web/src/components/NodeSelector.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

255 lines
8.2 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 } 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,
},
};