feat: init proj

This commit is contained in:
2026-03-31 13:11:54 +08:00
commit 8f75ea24d6
38 changed files with 6826 additions and 0 deletions

View 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,
},
};