feat: init proj
This commit is contained in:
219
web/src/components/NodeSelector.tsx
Normal file
219
web/src/components/NodeSelector.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
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('');
|
||||
|
||||
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 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 */}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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 => (
|
||||
<tr key={node.id} style={{
|
||||
opacity: node.enabled ? 1 : 0.5,
|
||||
}}>
|
||||
<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={5} 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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user