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:
2026-06-23 23:43:28 +08:00
parent c3d8e1a961
commit fb7a30489e
14 changed files with 737 additions and 44 deletions

View File

@@ -63,8 +63,29 @@ for (const sql of [
"ALTER TABLE subscriptions ADD COLUMN url_ssr TEXT",
"ALTER TABLE subscriptions ADD COLUMN raw_config_clash TEXT",
"ALTER TABLE subscriptions ADD COLUMN raw_config_ssr TEXT",
// Migration: fetched nodes are drag-sortable within their subscription
"ALTER TABLE fetched_nodes ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0",
]) {
try { db.exec(sql); } catch { /* column already exists */ }
}
// Guest links: each link has its own token and a whitelist of node names.
// The whitelist references nodes by name (not id) so it survives re-fetch,
// which deletes and re-inserts fetched_nodes rows.
db.exec(`
CREATE TABLE IF NOT EXISTS guest_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS guest_link_nodes (
guest_id INTEGER NOT NULL,
node_name TEXT NOT NULL,
FOREIGN KEY (guest_id) REFERENCES guest_links(id) ON DELETE CASCADE
);
`);
export default db;

View File

@@ -6,6 +6,7 @@ import subscriptionsRouter from './routes/subscriptions.js';
import nodesRouter from './routes/nodes.js';
import rulesRouter from './routes/rules.js';
import surgeRouter from './routes/surge.js';
import guestsRouter from './routes/guests.js';
import db from './db.js';
import { generateSurgeConfig } from './services/generator.js';
import { generateClashConfig } from './services/clashGenerator.js';
@@ -26,18 +27,36 @@ const PORT = parseInt(process.env.PORT || '3456', 10);
app.use(express.json());
function verifySurgeToken(token: string): boolean {
type TokenResolution =
| { kind: 'main' }
| { kind: 'guest'; whitelist: Set<string> }
| null;
/**
* Resolve a subscription token to either the admin token (full config) or an
* enabled guest link (config restricted to its whitelisted node names).
*/
function resolveToken(token: string): TokenResolution {
const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any;
return !!row?.value && token === row.value;
if (row?.value && token === row.value) return { kind: 'main' };
const guest = db.prepare('SELECT id FROM guest_links WHERE token = ? AND enabled = 1').get(token) as any;
if (guest) {
const names = db.prepare('SELECT node_name FROM guest_link_nodes WHERE guest_id = ?').all(guest.id) as any[];
return { kind: 'guest', whitelist: new Set(names.map(n => n.node_name)) };
}
return null;
}
// Surge endpoint (no auth, token-protected path)
// Surge endpoint (no auth, token-protected path; admin token or guest link)
app.get('/surge/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const resolved = resolveToken(req.params.token);
if (!resolved) return res.status(404).send('Not Found');
const host = req.headers.host || 'localhost:3456';
const protocol = req.secure ? 'https' : 'http';
const hostUrl = `${protocol}://${host}/surge/${req.params.token}`;
const config = generateSurgeConfig(hostUrl);
const whitelist = resolved.kind === 'guest' ? resolved.whitelist : undefined;
const config = generateSurgeConfig(hostUrl, whitelist);
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=IPLC.MAX.conf',
@@ -45,10 +64,12 @@ app.get('/surge/:token', (req, res) => {
res.send(config);
});
// Clash endpoint (no auth, token-protected path)
// Clash endpoint (no auth, token-protected path; admin token or guest link)
app.get('/clash/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateClashConfig();
const resolved = resolveToken(req.params.token);
if (!resolved) return res.status(404).send('Not Found');
const whitelist = resolved.kind === 'guest' ? resolved.whitelist : undefined;
const config = generateClashConfig(whitelist);
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=IPLC.MAX.yaml',
@@ -56,10 +77,12 @@ app.get('/clash/:token', (req, res) => {
res.send(config);
});
// SSR endpoint (no auth, token-protected path)
// SSR endpoint (no auth, token-protected path; admin token or guest link)
app.get('/ssr/:token', (req, res) => {
if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found');
const config = generateSSRConfig();
const resolved = resolveToken(req.params.token);
if (!resolved) return res.status(404).send('Not Found');
const whitelist = resolved.kind === 'guest' ? resolved.whitelist : undefined;
const config = generateSSRConfig(whitelist);
res.set({
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': 'attachment; filename=IPLC.MAX.txt',
@@ -106,6 +129,7 @@ app.use('/api/subscriptions', subscriptionsRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/rules', rulesRouter);
app.use('/api/config', surgeRouter);
app.use('/api/guests', guestsRouter);
// Stats endpoint
app.get('/api/stats', (_req, res) => {

View File

@@ -0,0 +1,95 @@
import { Router } from 'express';
import crypto from 'crypto';
import db from '../db.js';
const router = Router();
/** Load the whitelisted node names for a guest link. */
function getNodeNames(guestId: number): string[] {
const rows = db.prepare('SELECT node_name FROM guest_link_nodes WHERE guest_id = ?').all(guestId) as any[];
return rows.map(r => r.node_name);
}
/** Replace a guest link's whitelist with the given node names. */
function setNodeNames(guestId: number, names: string[]) {
const del = db.prepare('DELETE FROM guest_link_nodes WHERE guest_id = ?');
const ins = db.prepare('INSERT INTO guest_link_nodes (guest_id, node_name) VALUES (?, ?)');
const tx = db.transaction(() => {
del.run(guestId);
for (const name of names) {
if (typeof name === 'string' && name.trim()) ins.run(guestId, name);
}
});
tx();
}
// GET /api/guests — list links with whitelist count
router.get('/', (_req, res) => {
const links = db.prepare('SELECT * FROM guest_links ORDER BY id').all() as any[];
const counts = db.prepare(
'SELECT guest_id, COUNT(*) as c FROM guest_link_nodes GROUP BY guest_id'
).all() as any[];
const countMap = new Map(counts.map(r => [r.guest_id, r.c]));
res.json(links.map(l => ({ ...l, node_count: countMap.get(l.id) || 0 })));
});
// GET /api/guests/available-nodes — all selectable nodes (static + per subscription).
// MUST be before /:id.
router.get('/available-nodes', (_req, res) => {
const staticNodes = db.prepare(
'SELECT id, name, type FROM static_nodes ORDER BY sort_order, id'
).all();
const subs = db.prepare('SELECT id, name FROM subscriptions ORDER BY id').all() as any[];
const subscriptions = subs.map(sub => ({
id: sub.id,
name: sub.name,
nodes: db.prepare(
'SELECT id, name, type, server, port FROM fetched_nodes WHERE subscription_id = ? ORDER BY sort_order, id'
).all(sub.id),
}));
res.json({ static: staticNodes, subscriptions });
});
// POST /api/guests — create a new guest link
router.post('/', (req, res) => {
const { name } = req.body;
if (!name || !String(name).trim()) return res.status(400).json({ error: 'name is required' });
const token = crypto.randomUUID();
const result = db.prepare(
'INSERT INTO guest_links (name, token, created_at) VALUES (?, ?, ?)'
).run(String(name).trim(), token, new Date().toISOString());
res.json({ id: result.lastInsertRowid, token });
});
// GET /api/guests/:id — full detail including whitelist
router.get('/:id', (req, res) => {
const { id } = req.params;
const link = db.prepare('SELECT * FROM guest_links WHERE id = ?').get(id) as any;
if (!link) return res.status(404).json({ error: 'not found' });
res.json({ ...link, node_names: getNodeNames(link.id) });
});
// PUT /api/guests/:id — update name / enabled / whitelist
router.put('/:id', (req, res) => {
const { id } = req.params;
const link = db.prepare('SELECT * FROM guest_links WHERE id = ?').get(id) as any;
if (!link) return res.status(404).json({ error: 'not found' });
const { name, enabled, node_names } = req.body;
db.prepare('UPDATE guest_links SET name = ?, enabled = ? WHERE id = ?').run(
name ?? link.name,
enabled ?? link.enabled,
id
);
if (Array.isArray(node_names)) setNodeNames(link.id, node_names);
res.json({ ok: true });
});
// DELETE /api/guests/:id
router.delete('/:id', (req, res) => {
const { id } = req.params;
db.prepare('DELETE FROM guest_links WHERE id = ?').run(id);
res.json({ ok: true });
});
export default router;

View File

@@ -14,6 +14,19 @@ function renameSurgeLine(surgeLine: string, oldName: string, newName: string): s
// --- Fetched nodes ---
// PUT /api/nodes/fetched/reorder — MUST be before /fetched/:id.
// ids are the fetched_node ids of a single subscription, in the desired order.
router.put('/fetched/reorder', (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be array' });
const stmt = db.prepare('UPDATE fetched_nodes SET sort_order = ? WHERE id = ?');
const reorder = db.transaction(() => {
ids.forEach((id: number, index: number) => stmt.run(index, id));
});
reorder();
res.json({ ok: true });
});
// PUT /api/nodes/fetched/batch — MUST be before /fetched/:id
router.put('/fetched/batch', (req, res) => {
const { ids, enabled } = req.body;
@@ -44,6 +57,18 @@ router.get('/static', (_req, res) => {
res.json(rows);
});
// PUT /api/nodes/static/reorder — MUST be before /static/:id
router.put('/static/reorder', (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be array' });
const stmt = db.prepare('UPDATE static_nodes SET sort_order = ? WHERE id = ?');
const reorder = db.transaction(() => {
ids.forEach((id: number, index: number) => stmt.run(index, id));
});
reorder();
res.json({ ok: true });
});
// POST /api/nodes/static
router.post('/static', (req, res) => {
const { uri, name: customName } = req.body;

View File

@@ -74,9 +74,11 @@ router.post('/:id/fetch', async (req, res) => {
fetchOptional(sub.url_ssr),
]);
// Save existing enabled states by node name
const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
// Save existing enabled states and sort order by node name (survives re-fetch)
const existingNodes = db.prepare('SELECT name, enabled, sort_order FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled]));
const orderMap = new Map(existingNodes.map((n: any) => [n.name, n.sort_order]));
const maxOrder = existingNodes.reduce((m: number, n: any) => Math.max(m, n.sort_order ?? 0), -1);
// Parse nodes from primary Surge config
const nodes = parseSubscriptionContent(rawConfig);
@@ -85,11 +87,14 @@ router.post('/:id/fetch', async (req, res) => {
const replace = db.transaction(() => {
db.prepare('DELETE FROM fetched_nodes WHERE subscription_id = ?').run(id);
const insert = db.prepare(
'INSERT INTO fetched_nodes (subscription_id, name, type, server, port, surge_line, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO fetched_nodes (subscription_id, name, type, server, port, surge_line, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
// New nodes (no prior order) are appended after existing ones, in parse order
let nextOrder = maxOrder + 1;
for (const node of nodes) {
const enabled = enabledMap.get(node.name) ?? 1;
insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled);
const sortOrder = orderMap.has(node.name) ? orderMap.get(node.name) : nextOrder++;
insert.run(id, node.name, node.type, node.server, node.port, node.surgeLine, enabled, sortOrder);
}
db.prepare(
'UPDATE subscriptions SET raw_config = ?, raw_config_clash = ?, raw_config_ssr = ?, last_fetch = ?, node_count = ? WHERE id = ?'
@@ -106,7 +111,7 @@ router.post('/:id/fetch', async (req, res) => {
// GET /api/subscriptions/:id/nodes
router.get('/:id/nodes', (req, res) => {
const { id } = req.params;
const nodes = db.prepare('SELECT * FROM fetched_nodes WHERE subscription_id = ? ORDER BY id').all(id);
const nodes = db.prepare('SELECT * FROM fetched_nodes WHERE subscription_id = ? ORDER BY sort_order, id').all(id);
res.json(nodes);
});

View File

@@ -2,7 +2,11 @@ import YAML from 'yaml';
import db from '../db.js';
import { uriToClashProxy, type ClashProxy } from '../parsers/toClash.js';
export function generateClashConfig(): string {
/**
* @param whitelist When provided (guest links), only nodes whose name is in the
* set are included, independent of their global enabled state.
*/
export function generateClashConfig(whitelist?: Set<string>): string {
const sub = db.prepare(
'SELECT raw_config_clash FROM subscriptions WHERE enabled = 1 AND raw_config_clash IS NOT NULL ORDER BY id LIMIT 1'
).get() as any;
@@ -25,7 +29,9 @@ export function generateClashConfig(): string {
const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`));
const staticRows = db.prepare(
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
whitelist
? 'SELECT uri, name FROM static_nodes ORDER BY sort_order, id'
: 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as { uri: string; name: string }[];
const userRules = db.prepare(
@@ -37,6 +43,12 @@ export function generateClashConfig(): string {
const removedNames = new Set<string>();
const filteredProxies = proxies.filter(p => {
if (!p || typeof p !== 'object') return true;
// Guest links: keep only whitelisted node names
if (whitelist) {
if (typeof p.name === 'string' && whitelist.has(p.name)) return true;
if (typeof p.name === 'string') removedNames.add(p.name);
return false;
}
const key = `${p.server}:${p.port}`;
if (disabledSet.has(key)) {
if (typeof p.name === 'string') removedNames.add(p.name);
@@ -48,6 +60,7 @@ export function generateClashConfig(): string {
const staticProxies: ClashProxy[] = [];
const staticNames: string[] = [];
for (const row of staticRows) {
if (whitelist && !whitelist.has(row.name)) continue;
const proxy = uriToClashProxy(row.uri);
if (!proxy) continue;
proxy.name = row.name;

View File

@@ -1,6 +1,11 @@
import db from '../db.js';
export function generateSurgeConfig(hostUrl: string): string {
/**
* @param whitelist When provided (guest links), only nodes whose name is in the
* set are included, independent of their global enabled state. When omitted,
* the normal enabled-only behavior applies.
*/
export function generateSurgeConfig(hostUrl: string, whitelist?: Set<string>): string {
// Get first enabled subscription's raw_config as base template
const sub = db.prepare(
'SELECT raw_config FROM subscriptions WHERE enabled = 1 AND raw_config IS NOT NULL ORDER BY id LIMIT 1'
@@ -10,14 +15,18 @@ export function generateSurgeConfig(hostUrl: string): string {
return '# No subscription config available. Add and fetch a subscription first.';
}
// Collect enabled fetched nodes (exclude vless — Surge doesn't support it)
// Collect fetched nodes (exclude vless — Surge doesn't support it; tuic uses `tuic-v5`)
const fetchedNodes = db.prepare(
'SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND type != \'vless\' ORDER BY subscription_id, id'
whitelist
? "SELECT surge_line FROM fetched_nodes WHERE type != 'vless' ORDER BY subscription_id, sort_order, id"
: "SELECT surge_line FROM fetched_nodes WHERE enabled = 1 AND type != 'vless' ORDER BY subscription_id, sort_order, id"
).all() as any[];
// Collect enabled static nodes (these go FIRST, exclude vless)
// Collect static nodes (these go FIRST, exclude vless)
const staticNodes = db.prepare(
'SELECT surge_line FROM static_nodes WHERE enabled = 1 AND type != \'vless\' ORDER BY sort_order, id'
whitelist
? "SELECT surge_line FROM static_nodes WHERE type != 'vless' ORDER BY sort_order, id"
: "SELECT surge_line FROM static_nodes WHERE enabled = 1 AND type != 'vless' ORDER BY sort_order, id"
).all() as any[];
// Collect enabled rules
@@ -26,8 +35,10 @@ export function generateSurgeConfig(hostUrl: string): string {
).all() as any[];
// Static nodes first, then fetched nodes
const staticLines = staticNodes.map((n: any) => n.surge_line);
const fetchedLines = fetchedNodes.map((n: any) => n.surge_line);
const lineName = (l: string) => l.split(' = ')[0].trim();
const keep = (l: string) => !whitelist || whitelist.has(lineName(l));
const staticLines = staticNodes.map((n: any) => n.surge_line).filter(keep);
const fetchedLines = fetchedNodes.map((n: any) => n.surge_line).filter(keep);
const allNodeLines = [...staticLines, ...fetchedLines];
const allNodeNames = allNodeLines.map((l: string) => l.split(' = ')[0].trim());

View File

@@ -1,6 +1,10 @@
import db from '../db.js';
export function generateSSRConfig(): string {
/**
* @param whitelist When provided (guest links), only nodes whose name is in the
* set are included, independent of their global enabled state.
*/
export function generateSSRConfig(whitelist?: Set<string>): string {
const sub = db.prepare(
'SELECT raw_config_ssr FROM subscriptions WHERE enabled = 1 AND raw_config_ssr IS NOT NULL ORDER BY id LIMIT 1'
).get() as any;
@@ -16,30 +20,52 @@ export function generateSSRConfig(): string {
const disabledSet = new Set(disabledNodes.map(n => `${n.server}:${n.port}`));
const staticNodes = db.prepare(
'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
whitelist
? 'SELECT uri, name FROM static_nodes ORDER BY sort_order, id'
: 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
).all() as { uri: string; name: string }[];
// Decode base64 content → URI list
const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString();
const rawLines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean);
// Filter out disabled fetched nodes by server:port matching
const filteredLines = disabledSet.size > 0
// Filter fetched nodes: by whitelist (guest) name, else by disabled server:port
const filteredLines = whitelist
? rawLines.filter((line: string) => {
const sp = extractServerPort(line);
return !sp || !disabledSet.has(`${sp.server}:${sp.port}`);
const name = extractName(line);
return name !== null && whitelist.has(name);
})
: rawLines;
: disabledSet.size > 0
? rawLines.filter((line: string) => {
const sp = extractServerPort(line);
return !sp || !disabledSet.has(`${sp.server}:${sp.port}`);
})
: rawLines;
// Prepend static node URIs with custom names (static nodes go first)
const staticLines = staticNodes
.filter(n => n.uri)
.filter(n => n.uri && (!whitelist || whitelist.has(n.name)))
.map(n => setUriName(n.uri, n.name));
filteredLines.unshift(...staticLines);
return Buffer.from(filteredLines.join('\n')).toString('base64');
}
/** Extract the display name (fragment / ps) from a proxy URI. Returns null if unparseable. */
function extractName(uri: string): string | null {
try {
if (uri.startsWith('vmess://')) {
const json = JSON.parse(Buffer.from(uri.replace('vmess://', ''), 'base64').toString());
return typeof json.ps === 'string' ? json.ps : null;
}
const hashIdx = uri.indexOf('#');
if (hashIdx < 0) return null;
return decodeURIComponent(uri.slice(hashIdx + 1));
} catch {
return null;
}
}
/** Extract server and port from a proxy URI. Returns null if unparseable. */
function extractServerPort(uri: string): { server: string; port: number } | null {
try {

View File

@@ -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 (

View File

@@ -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'),

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

View File

@@ -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 {

View File

@@ -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
? '请先添加订阅源'
: '请先抓取订阅源节点'

View File

@@ -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="粘贴节点 URIss:// / vmess:// / trojan:// / vless://"
placeholder="粘贴节点 URIss / 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>