feat: 新增游客订阅链接与节点拖动排序
游客链接:可建多个带独立 token 的订阅,按节点名勾选白名单 (抗重新抓取),/surge|/clash|/ssr 复用同一路径回落匹配 guest token,三个 generator 接收可选 whitelist 过滤生成。 节点排序:fetched_nodes 新增 sort_order,重抓时按名保留顺序, 选择/节点面板支持原生拖拽排序,输出按此顺序排列。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import StaticNodes from './components/StaticNodes';
|
||||
import NodeSelector from './components/NodeSelector';
|
||||
import Rules from './components/Rules';
|
||||
import Output from './components/Output';
|
||||
import Guests from './components/Guests';
|
||||
import { auth, setToken } from './api';
|
||||
|
||||
function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
@@ -147,6 +148,7 @@ export default function App() {
|
||||
'node-selector': <NodeSelector />,
|
||||
rules: <Rules />,
|
||||
output: <Output />,
|
||||
guests: <Guests />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -89,6 +89,14 @@ export const nodes = {
|
||||
staticDelete: (id: number) => request<{ ok: boolean }>(`/nodes/static/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
staticReorder: (ids: number[]) => request<{ ok: boolean }>('/nodes/static/reorder', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
fetchedReorder: (ids: number[]) => request<{ ok: boolean }>('/nodes/fetched/reorder', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Rules
|
||||
@@ -118,6 +126,24 @@ export const config = {
|
||||
regenerateSurgeToken: () => request<{ token: string }>('/config/surge-token', { method: 'POST' }),
|
||||
};
|
||||
|
||||
// Guest links
|
||||
export const guests = {
|
||||
list: () => request<any[]>('/guests'),
|
||||
availableNodes: () => request<{ static: any[]; subscriptions: any[] }>('/guests/available-nodes'),
|
||||
create: (name: string) => request<{ id: number; token: string }>('/guests', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
get: (id: number) => request<any>(`/guests/${id}`),
|
||||
update: (id: number, data: any) => request<{ ok: boolean }>(`/guests/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (id: number) => request<{ ok: boolean }>(`/guests/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Stats
|
||||
export const stats = {
|
||||
get: () => request<any>('/stats'),
|
||||
|
||||
376
web/src/components/Guests.tsx
Normal file
376
web/src/components/Guests.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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',
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { stats as statsApi } from '../api';
|
||||
|
||||
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output';
|
||||
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output' | 'guests';
|
||||
|
||||
const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
|
||||
{ key: 'subscriptions', label: '订阅', icon: '⟐' },
|
||||
@@ -9,6 +9,7 @@ const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
|
||||
{ key: 'node-selector', label: '选择', icon: '☰' },
|
||||
{ key: 'rules', label: '规则', icon: '⧖' },
|
||||
{ key: 'output', label: '输出', icon: '▸' },
|
||||
{ key: 'guests', label: '游客', icon: '◐' },
|
||||
];
|
||||
|
||||
interface LayoutProps {
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function NodeSelector() {
|
||||
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 => {
|
||||
@@ -52,6 +53,21 @@ export default function NodeSelector() {
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
@@ -129,9 +145,13 @@ export default function NodeSelector() {
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
@@ -140,10 +160,25 @@ export default function NodeSelector() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodeList.map(node => (
|
||||
<tr key={node.id} style={{
|
||||
opacity: node.enabled ? 1 : 0.5,
|
||||
}}>
|
||||
{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"
|
||||
@@ -189,7 +224,7 @@ export default function NodeSelector() {
|
||||
))}
|
||||
{nodeList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
{subs.length === 0
|
||||
? '请先添加订阅源'
|
||||
: '请先抓取订阅源节点'
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function StaticNodes() {
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
|
||||
const load = () => api.staticList().then(setNodeList).catch(console.error);
|
||||
useEffect(() => { load(); }, []);
|
||||
@@ -47,10 +48,27 @@ export default function StaticNodes() {
|
||||
}
|
||||
};
|
||||
|
||||
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 api.staticReorder(reordered.map(n => n.id));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={styles.title}>静态节点</h2>
|
||||
<p style={styles.subtitle}>粘贴 ss:// / vmess:// / trojan:// / vless:// URI 自动解析</p>
|
||||
<p style={styles.subtitle}>
|
||||
粘贴 ss:// / vmess:// / trojan:// / vless:// / hysteria2:// / tuic:// / anytls:// / socks5:// / socks5-tls:// URI 自动解析
|
||||
</p>
|
||||
|
||||
<div style={styles.form}>
|
||||
<input
|
||||
@@ -60,7 +78,7 @@ export default function StaticNodes() {
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="粘贴节点 URI(ss:// / vmess:// / trojan:// / vless://)"
|
||||
placeholder="粘贴节点 URI(ss / vmess / trojan / vless / hysteria2 / tuic / anytls / socks5 / socks5-tls)"
|
||||
value={uri}
|
||||
onChange={e => setUri(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -72,6 +90,7 @@ export default function StaticNodes() {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 32 }}></th>
|
||||
<th style={{ width: 50 }}>状态</th>
|
||||
<th>名称</th>
|
||||
<th style={{ width: 80 }}>协议</th>
|
||||
@@ -80,8 +99,22 @@ export default function StaticNodes() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodeList.map(node => (
|
||||
<tr key={node.id}>
|
||||
{nodeList.map((node, idx) => (
|
||||
<tr
|
||||
key={node.id}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => handleDrop(idx)}
|
||||
style={{ 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"
|
||||
@@ -145,7 +178,7 @@ export default function StaticNodes() {
|
||||
))}
|
||||
{nodeList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
暂无静态节点
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user