feat: init proj
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebFetch(domain:manual.nssurge.com)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(sshpass:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
server/node_modules
|
||||
web/node_modules
|
||||
web/dist
|
||||
server/data
|
||||
.claude
|
||||
*.db
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
server/data/*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
74
CLAUDE.md
Normal file
74
CLAUDE.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Sub Router is a Surge proxy subscription management system. It aggregates nodes from upstream subscriptions, allows adding custom static nodes and rules, and generates a combined Surge configuration file served at `/surge`.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development (starts both server and web dev server concurrently)
|
||||
npm run dev
|
||||
|
||||
# Server only (port 3456, hot-reload via tsx watch)
|
||||
npm run dev:server
|
||||
|
||||
# Frontend only (port 5173, proxies /api and /surge to :3456)
|
||||
npm run dev:web
|
||||
|
||||
# Build frontend for production
|
||||
npm run build:web
|
||||
|
||||
# Production start (serves built frontend + API on port 3456)
|
||||
npm run start
|
||||
|
||||
# Type check frontend
|
||||
cd web && npx tsc --noEmit
|
||||
|
||||
# Docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Monorepo with NPM Workspaces**: `server/` (Express + better-sqlite3) and `web/` (React + Vite). In production, Express serves the built frontend static files and API from a single port (3456).
|
||||
|
||||
### Key data flow
|
||||
|
||||
1. User adds upstream subscription URLs → backend fetches and parses nodes into `fetched_nodes` table
|
||||
2. User adds static nodes via URI (ss://, vmess://, trojan://) → parsed and stored in `static_nodes` table
|
||||
3. `GET /surge` → `generator.ts` rebuilds the Surge config:
|
||||
- Takes first enabled subscription's `raw_config` as base template
|
||||
- **Replaces** `[Proxy]` section entirely with only enabled nodes (static first, then fetched)
|
||||
- **Rebuilds** `[Proxy Group]` select groups with only enabled node names
|
||||
- **Injects** user rules at the top of `[Rule]` section
|
||||
- Rewrites `#!MANAGED-CONFIG` URL
|
||||
|
||||
### Auth model
|
||||
|
||||
`/surge` endpoint requires no auth (for Surge client access). All `/api/*` routes require Bearer token auth, except `/api/auth/login` and `/api/auth/status`. Password is stored in the `config` table. Token is the raw password sent via `Authorization: Bearer <password>`.
|
||||
|
||||
### Database
|
||||
|
||||
SQLite with WAL mode at `server/data/sub-router.db`. Five tables: `subscriptions`, `fetched_nodes` (FK to subscriptions, CASCADE delete), `static_nodes`, `rules`, `config` (KV store for password).
|
||||
|
||||
### Parsers (`server/src/parsers/`)
|
||||
|
||||
Convert protocol URIs to Surge proxy lines. Each parser returns `{ name, type, server, port, surgeLine }`. The subscription content parser handles both base64-encoded URI lists and raw Surge config format (extracting from `[Proxy]` section).
|
||||
|
||||
- **SS**: No TLS wrapping (Surge doesn't support SS over TLS; SS 2022 has built-in encryption)
|
||||
- **VMess/Trojan**: `skip-cert-verify=false` (strict TLS verification)
|
||||
|
||||
### Frontend
|
||||
|
||||
Five panels: Subscriptions (CRUD + fetch trigger), Static Nodes (paste URI, custom naming, double-click to rename), Node Selector (per-subscription toggle with regex batch operations), Rules (CRUD + drag reorder), Output (subscription URL + config preview). Dark cyberpunk theme with CSS variables, JetBrains Mono font.
|
||||
|
||||
## Route ordering matters
|
||||
|
||||
In `server/src/routes/`: specific paths like `/reorder` and `/fetched/batch` must be registered **before** parameterized `/:id` routes to avoid being captured by Express route matching.
|
||||
|
||||
## Deployment
|
||||
|
||||
The `Content-Disposition` header on `/surge` controls the config profile name in Surge client (currently `IPLC.MAX.conf`). Docker mounts `./data` volume for SQLite persistence. Certificate renewal uses acme.sh with `dns_cf` (Cloudflare DNS API validation).
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:22-slim AS frontend
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json ./
|
||||
RUN npm install
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-slim AS production
|
||||
WORKDIR /app
|
||||
|
||||
COPY server/package.json ./server/
|
||||
WORKDIR /app/server
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY server/ ./
|
||||
COPY --from=frontend /app/web/dist /app/web/dist
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["npx", "tsx", "src/index.ts"]
|
||||
BIN
data/sub-router.db
Normal file
BIN
data/sub-router.db
Normal file
Binary file not shown.
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
sub-router:
|
||||
build: .
|
||||
ports:
|
||||
- "3456:3456"
|
||||
volumes:
|
||||
- ./data:/app/server/data
|
||||
restart: unless-stopped
|
||||
210
index.js
Normal file
210
index.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const PORT = 3456;
|
||||
const UPSTREAM_URL = 'https://iplcsub.com/subscribe/36769/gVZJsFgUA7/surge/';
|
||||
|
||||
// 额外节点的 URI(VLESS 不被 Surge 支持,跳过)
|
||||
const EXTRA_NODES_RAW = [
|
||||
// VLESS-Reality — Surge 不支持,以注释形式保留
|
||||
// 'vless://d42937a5-cf84-46c9-a304-36e0b6753c4c@coding.njcqtechaicoding.com:8443?...',
|
||||
'ss://MjAyMi1ibGFrZTMtYWVzLTEyOC1nY206L3lzcGo3RFFGSm9za0MvbkFvSDRNUT09Oi95c3BqN0RRRkpvc2tDL25Bb0g0TVE9PQ@coding.njcqtechaicoding.com:8446?type=tcp#Shadowsocks-2022-ss-2022%40x3ui',
|
||||
'vmess://ewogICJ2IjogIjIiLAogICJwcyI6ICJWTWVzcy1XUy1UTFMtdm1lc3Mtd3NAeDN1aSIsCiAgImFkZCI6ICJjb2RpbmcubmpjcXRlY2hhaWNvZGluZy5jb20iLAogICJwb3J0IjogMjA4MywKICAiaWQiOiAiNTI4ODkyMDUtNmY2ZC00ZWY2LTg4ZjItMmM2YTRmYThmOTJkIiwKICAic2N5IjogIiIsCiAgIm5ldCI6ICJ3cyIsCiAgInRscyI6ICJ0bHMiLAogICJwYXRoIjogIi92bWVzc3dzIiwKICAiaG9zdCI6ICJjb2RpbmcubmpjcXRlY2hhaWNvZGluZy5jb20iLAogICJzbmkiOiAiY29kaW5nLm5qY3F0ZWNoYWljb2RpbmcuY29tIiwKICAiZnAiOiAiY2hyb21lIiwKICAiYWxwbiI6ICJoMixodHRwLzEuMSIKfQ==',
|
||||
'trojan://1d834ec2-a09b-4f59-978e-bbd557f1ee3a@coding.njcqtechaicoding.com:2087?type=tcp&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=coding.njcqtechaicoding.com#Trojan-TLS-trojan-tls%40x3ui',
|
||||
];
|
||||
|
||||
/**
|
||||
* 解析 ss:// URI 为 Surge 代理行
|
||||
*/
|
||||
function parseSS(uri) {
|
||||
const url = new URL(uri);
|
||||
const name = decodeURIComponent(url.hash.slice(1));
|
||||
const server = url.hostname;
|
||||
const port = url.port;
|
||||
|
||||
// userinfo 是 base64(method:password)
|
||||
const decoded = Buffer.from(url.username, 'base64').toString();
|
||||
// SS 2022 格式: method:serverKey:userKey 或 method:password
|
||||
const firstColon = decoded.indexOf(':');
|
||||
const method = decoded.slice(0, firstColon);
|
||||
const password = decoded.slice(firstColon + 1);
|
||||
|
||||
return `${name} = ss, ${server}, ${port}, encrypt-method=${method}, password=${password}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 vmess:// URI 为 Surge 代理行
|
||||
*/
|
||||
function parseVMess(uri) {
|
||||
const b64 = uri.replace('vmess://', '');
|
||||
const json = JSON.parse(Buffer.from(b64, 'base64').toString());
|
||||
|
||||
const name = json.ps || 'VMess';
|
||||
const server = json.add;
|
||||
const port = json.port;
|
||||
const uuid = json.id;
|
||||
|
||||
let line = `${name} = vmess, ${server}, ${port}, username=${uuid}, vmess-aead=true`;
|
||||
|
||||
if (json.tls === 'tls') {
|
||||
line += ', tls=true';
|
||||
if (json.sni) line += `, sni=${json.sni}`;
|
||||
line += ', skip-cert-verify=true';
|
||||
}
|
||||
|
||||
if (json.net === 'ws') {
|
||||
line += ', ws=true';
|
||||
if (json.path) line += `, ws-path=${json.path}`;
|
||||
if (json.host) line += `, ws-headers=Host:${json.host}`;
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 trojan:// URI 为 Surge 代理行
|
||||
*/
|
||||
function parseTrojan(uri) {
|
||||
const url = new URL(uri);
|
||||
const name = decodeURIComponent(url.hash.slice(1));
|
||||
const server = url.hostname;
|
||||
const port = url.port;
|
||||
const password = url.username;
|
||||
const params = url.searchParams;
|
||||
const sni = params.get('sni') || server;
|
||||
|
||||
let line = `${name} = trojan, ${server}, ${port}, password=${password}, sni=${sni}, skip-cert-verify=true`;
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将各协议的 URI 转为 Surge 代理行
|
||||
*/
|
||||
function convertToSurgeLine(uri) {
|
||||
if (uri.startsWith('ss://')) return parseSS(uri);
|
||||
if (uri.startsWith('vmess://')) return parseVMess(uri);
|
||||
if (uri.startsWith('trojan://')) return parseTrojan(uri);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 HTTPS 获取上游订阅内容
|
||||
*/
|
||||
function fetchUpstream() {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(UPSTREAM_URL, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => resolve(data));
|
||||
res.on('error', reject);
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将额外节点注入到 Surge 配置中
|
||||
*/
|
||||
function mergeNodes(config, extraLines) {
|
||||
if (extraLines.length === 0) return config;
|
||||
|
||||
const extraNames = extraLines.map((l) => l.split(' = ')[0].trim());
|
||||
const lines = config.split('\n');
|
||||
const result = [];
|
||||
let inProxySection = false;
|
||||
let inProxyGroupSection = false;
|
||||
let proxyGroupHandled = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 检测 section 边界
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
// 如果正在 [Proxy] section,插入额外节点
|
||||
if (inProxySection) {
|
||||
result.push('# --- 自定义节点 ---');
|
||||
extraLines.forEach((l) => result.push(l));
|
||||
result.push('');
|
||||
inProxySection = false;
|
||||
}
|
||||
if (trimmed === '[Proxy]') {
|
||||
inProxySection = true;
|
||||
} else if (trimmed === '[Proxy Group]') {
|
||||
inProxyGroupSection = true;
|
||||
} else {
|
||||
inProxyGroupSection = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 Proxy Group,在第一个 select 组中追加自定义节点名
|
||||
if (inProxyGroupSection && !proxyGroupHandled && trimmed.includes('= select,')) {
|
||||
const appended = line + ',' + extraNames.join(',');
|
||||
result.push(appended);
|
||||
proxyGroupHandled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
// 如果 [Proxy] 是最后一个 section
|
||||
if (inProxySection) {
|
||||
result.push('# --- 自定义节点 ---');
|
||||
extraLines.forEach((l) => result.push(l));
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换 MANAGED-CONFIG URL 指向本地服务
|
||||
*/
|
||||
function rewriteManagedConfig(config, localUrl) {
|
||||
return config.replace(
|
||||
/^#!MANAGED-CONFIG\s+\S+/m,
|
||||
`#!MANAGED-CONFIG ${localUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
// 创建 HTTP 服务
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.url === '/' || req.url === '/surge') {
|
||||
try {
|
||||
console.log(`[${new Date().toISOString()}] Fetching upstream subscription...`);
|
||||
const upstream = await fetchUpstream();
|
||||
|
||||
// 解析额外节点
|
||||
const extraLines = EXTRA_NODES_RAW.map(convertToSurgeLine).filter(Boolean);
|
||||
|
||||
// 合并配置
|
||||
let merged = mergeNodes(upstream, extraLines);
|
||||
|
||||
// 替换 MANAGED-CONFIG URL
|
||||
const localUrl = `http://${req.headers.host || `127.0.0.1:${PORT}`}/surge`;
|
||||
merged = rewriteManagedConfig(merged, localUrl);
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Serving merged config (${extraLines.length} extra nodes)`);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename=merged.conf',
|
||||
});
|
||||
res.end(merged);
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
||||
res.end('Failed to fetch upstream subscription');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Sub-Router running at http://127.0.0.1:${PORT}/surge`);
|
||||
console.log('Use this URL in Surge as your subscription link.');
|
||||
console.log('');
|
||||
console.log('Note: VLESS-Reality node is skipped (not supported by Surge).');
|
||||
});
|
||||
4099
package-lock.json
generated
Normal file
4099
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
Normal file
16
package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "sub-router",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": ["server", "web"],
|
||||
"scripts": {
|
||||
"dev:server": "cd server && npm run dev",
|
||||
"dev:web": "cd web && npm run dev",
|
||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:web\"",
|
||||
"build:web": "cd web && npm run build",
|
||||
"start": "cd server && npm run start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0"
|
||||
}
|
||||
}
|
||||
20
server/package.json
Normal file
20
server/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "sub-router-server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
60
server/src/db.ts
Normal file
60
server/src/db.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
|
||||
const DB_PATH = path.join(__dirname, '..', 'data', 'sub-router.db');
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
raw_config TEXT,
|
||||
last_fetch TEXT,
|
||||
node_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fetched_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subscription_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
server TEXT,
|
||||
port INTEGER,
|
||||
surge_line TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS static_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
uri TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
surge_line TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
action TEXT NOT NULL DEFAULT 'PROXY',
|
||||
comment TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
export default db;
|
||||
100
server/src/index.ts
Normal file
100
server/src/index.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import './db.js'; // Initialize database
|
||||
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 db from './db.js';
|
||||
import { generateSurgeConfig } from './services/generator.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3456', 10);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Surge endpoint (no auth, before everything)
|
||||
app.get('/surge', (req, res) => {
|
||||
const host = req.headers.host || 'localhost:3456';
|
||||
const protocol = req.secure ? 'https' : 'http';
|
||||
const hostUrl = `${protocol}://${host}/surge`;
|
||||
const config = generateSurgeConfig(hostUrl);
|
||||
res.set({
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename=IPLC.MAX.conf',
|
||||
});
|
||||
res.send(config);
|
||||
});
|
||||
|
||||
// Auth routes (no auth required)
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { password } = req.body;
|
||||
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
||||
|
||||
if (!configRow?.value) {
|
||||
db.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('password', ?)").run(password);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
if (password === configRow.value) {
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.status(401).json({ error: 'wrong password' });
|
||||
});
|
||||
|
||||
app.get('/api/auth/status', (_req, res) => {
|
||||
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
||||
res.json({ hasPassword: !!configRow?.value });
|
||||
});
|
||||
|
||||
// Auth middleware for other /api routes
|
||||
app.use('/api', (req, res, next) => {
|
||||
const configRow = db.prepare("SELECT value FROM config WHERE key = 'password'").get() as any;
|
||||
if (!configRow?.value) return next();
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || authHeader !== `Bearer ${configRow.value}`) {
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/subscriptions', subscriptionsRouter);
|
||||
app.use('/api/nodes', nodesRouter);
|
||||
app.use('/api/rules', rulesRouter);
|
||||
app.use('/api/config', surgeRouter);
|
||||
|
||||
// Stats endpoint
|
||||
app.get('/api/stats', (_req, res) => {
|
||||
const subs = db.prepare('SELECT COUNT(*) as count FROM subscriptions WHERE enabled = 1').get() as any;
|
||||
const fetchedEnabled = db.prepare('SELECT COUNT(*) as count FROM fetched_nodes WHERE enabled = 1').get() as any;
|
||||
const fetchedTotal = db.prepare('SELECT COUNT(*) as count FROM fetched_nodes').get() as any;
|
||||
const staticEnabled = db.prepare('SELECT COUNT(*) as count FROM static_nodes WHERE enabled = 1').get() as any;
|
||||
const staticTotal = db.prepare('SELECT COUNT(*) as count FROM static_nodes').get() as any;
|
||||
const rulesCount = db.prepare('SELECT COUNT(*) as count FROM rules WHERE enabled = 1').get() as any;
|
||||
|
||||
res.json({
|
||||
subscriptions: subs.count,
|
||||
nodes: {
|
||||
fetched: { enabled: fetchedEnabled.count, total: fetchedTotal.count },
|
||||
static: { enabled: staticEnabled.count, total: staticTotal.count },
|
||||
},
|
||||
rules: rulesCount.count,
|
||||
});
|
||||
});
|
||||
|
||||
// Serve static frontend files
|
||||
const webDist = path.join(__dirname, '..', '..', 'web', 'dist');
|
||||
app.use(express.static(webDist));
|
||||
app.get('*', (req, res, next) => {
|
||||
if (req.path.startsWith('/api') || req.path === '/surge') return next();
|
||||
res.sendFile(path.join(webDist, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Sub Router running at http://127.0.0.1:${PORT}`);
|
||||
console.log(`Surge subscription: http://127.0.0.1:${PORT}/surge`);
|
||||
console.log(`Admin panel: http://127.0.0.1:${PORT}`);
|
||||
});
|
||||
82
server/src/parsers/index.ts
Normal file
82
server/src/parsers/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { parseSS, type ParsedNode } from './ss.js';
|
||||
import { parseVMess } from './vmess.js';
|
||||
import { parseTrojan } from './trojan.js';
|
||||
|
||||
export type { ParsedNode };
|
||||
|
||||
export function parseNodeUri(uri: string): ParsedNode | null {
|
||||
try {
|
||||
if (uri.startsWith('ss://')) return parseSS(uri);
|
||||
if (uri.startsWith('vmess://')) return parseVMess(uri);
|
||||
if (uri.startsWith('trojan://')) return parseTrojan(uri);
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubscriptionContent(content: string): ParsedNode[] {
|
||||
// Try base64 decode first (common subscription format)
|
||||
let text = content;
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), 'base64').toString();
|
||||
if (decoded.includes('://')) {
|
||||
text = decoded;
|
||||
}
|
||||
} catch {
|
||||
// Not base64, use as-is
|
||||
}
|
||||
|
||||
// If it's a Surge config (contains sections), extract proxy lines
|
||||
if (text.includes('[Proxy]') || text.includes('[General]')) {
|
||||
return parseSurgeConfig(text);
|
||||
}
|
||||
|
||||
// Otherwise treat as URI list
|
||||
const lines = text.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
||||
const nodes: ParsedNode[] = [];
|
||||
for (const line of lines) {
|
||||
const node = parseNodeUri(line);
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function parseSurgeConfig(config: string): ParsedNode[] {
|
||||
const lines = config.split('\n');
|
||||
const nodes: ParsedNode[] = [];
|
||||
let inProxySection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
inProxySection = trimmed === '[Proxy]';
|
||||
continue;
|
||||
}
|
||||
if (!inProxySection) continue;
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
|
||||
|
||||
// Parse "Name = type, server, port, ..."
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
|
||||
const name = trimmed.slice(0, eqIdx).trim();
|
||||
const rest = trimmed.slice(eqIdx + 1).trim();
|
||||
const parts = rest.split(',').map(p => p.trim());
|
||||
const type = parts[0] || '';
|
||||
const server = parts[1] || '';
|
||||
const port = parseInt(parts[2] || '0', 10);
|
||||
|
||||
if (name && type && server) {
|
||||
nodes.push({
|
||||
name,
|
||||
type,
|
||||
server,
|
||||
port,
|
||||
surgeLine: trimmed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
24
server/src/parsers/ss.ts
Normal file
24
server/src/parsers/ss.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface ParsedNode {
|
||||
name: string;
|
||||
type: string;
|
||||
server: string;
|
||||
port: number;
|
||||
surgeLine: string;
|
||||
}
|
||||
|
||||
export function parseSS(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const name = decodeURIComponent(url.hash.slice(1));
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
|
||||
const decoded = Buffer.from(url.username, 'base64').toString();
|
||||
const firstColon = decoded.indexOf(':');
|
||||
const method = decoded.slice(0, firstColon);
|
||||
const password = decoded.slice(firstColon + 1);
|
||||
|
||||
// SS 2022 has built-in encryption, Surge does not support SS over TLS
|
||||
const surgeLine = `${name} = ss, ${server}, ${port}, encrypt-method=${method}, password=${password}`;
|
||||
|
||||
return { name, type: 'ss', server, port, surgeLine };
|
||||
}
|
||||
15
server/src/parsers/trojan.ts
Normal file
15
server/src/parsers/trojan.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
|
||||
export function parseTrojan(uri: string): ParsedNode {
|
||||
const url = new URL(uri);
|
||||
const name = decodeURIComponent(url.hash.slice(1));
|
||||
const server = url.hostname;
|
||||
const port = parseInt(url.port, 10);
|
||||
const password = url.username;
|
||||
const params = url.searchParams;
|
||||
const sni = params.get('sni') || server;
|
||||
|
||||
const surgeLine = `${name} = trojan, ${server}, ${port}, password=${password}, tls=true, sni=${sni}, skip-cert-verify=false`;
|
||||
|
||||
return { name, type: 'trojan', server, port, surgeLine };
|
||||
}
|
||||
27
server/src/parsers/vmess.ts
Normal file
27
server/src/parsers/vmess.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ParsedNode } from './ss.js';
|
||||
|
||||
export function parseVMess(uri: string): ParsedNode {
|
||||
const b64 = uri.replace('vmess://', '');
|
||||
const json = JSON.parse(Buffer.from(b64, 'base64').toString());
|
||||
|
||||
const name = json.ps || 'VMess';
|
||||
const server = json.add;
|
||||
const port = parseInt(json.port, 10);
|
||||
const uuid = json.id;
|
||||
|
||||
let line = `${name} = vmess, ${server}, ${port}, username=${uuid}, vmess-aead=true`;
|
||||
|
||||
if (json.tls === 'tls') {
|
||||
line += ', tls=true';
|
||||
if (json.sni) line += `, sni=${json.sni}`;
|
||||
line += ', skip-cert-verify=false';
|
||||
}
|
||||
|
||||
if (json.net === 'ws') {
|
||||
line += ', ws=true';
|
||||
if (json.path) line += `, ws-path=${json.path}`;
|
||||
if (json.host) line += `, ws-headers=Host:${json.host}`;
|
||||
}
|
||||
|
||||
return { name, type: 'vmess', server, port, surgeLine: line };
|
||||
}
|
||||
98
server/src/routes/nodes.ts
Normal file
98
server/src/routes/nodes.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { parseNodeUri } from '../parsers/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Replace the node name prefix in a surge_line like "OldName = ss, ..." → "NewName = ss, ..." */
|
||||
function renameSurgeLine(surgeLine: string, oldName: string, newName: string): string {
|
||||
if (surgeLine.startsWith(oldName + ' = ')) {
|
||||
return newName + surgeLine.slice(oldName.length);
|
||||
}
|
||||
return surgeLine;
|
||||
}
|
||||
|
||||
// --- Fetched nodes ---
|
||||
|
||||
// PUT /api/nodes/fetched/batch — MUST be before /fetched/:id
|
||||
router.put('/fetched/batch', (req, res) => {
|
||||
const { ids, enabled } = req.body;
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids must be array' });
|
||||
const stmt = db.prepare('UPDATE fetched_nodes SET enabled = ? WHERE id = ?');
|
||||
const batch = db.transaction(() => {
|
||||
for (const id of ids) {
|
||||
stmt.run(enabled ? 1 : 0, id);
|
||||
}
|
||||
});
|
||||
batch();
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// PUT /api/nodes/fetched/:id - toggle enabled
|
||||
router.put('/fetched/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { enabled } = req.body;
|
||||
db.prepare('UPDATE fetched_nodes SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// --- Static nodes ---
|
||||
|
||||
// GET /api/nodes/static
|
||||
router.get('/static', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM static_nodes ORDER BY sort_order, id').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/nodes/static
|
||||
router.post('/static', (req, res) => {
|
||||
const { uri, name: customName } = req.body;
|
||||
if (!uri) return res.status(400).json({ error: 'uri is required' });
|
||||
|
||||
const node = parseNodeUri(uri);
|
||||
if (!node) return res.status(400).json({ error: 'unsupported or invalid URI' });
|
||||
|
||||
// Allow custom name override
|
||||
const finalName = customName?.trim() || node.name;
|
||||
const surgeLine = renameSurgeLine(node.surgeLine, node.name, finalName);
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM static_nodes').get() as any;
|
||||
const sortOrder = (maxOrder?.m ?? -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO static_nodes (name, uri, type, surge_line, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(finalName, uri, node.type, surgeLine, sortOrder);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, name: finalName, type: node.type, server: node.server, port: node.port, surgeLine });
|
||||
});
|
||||
|
||||
// PUT /api/nodes/static/:id
|
||||
router.put('/static/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, enabled, sort_order } = req.body;
|
||||
const node = db.prepare('SELECT * FROM static_nodes WHERE id = ?').get(id) as any;
|
||||
if (!node) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const newName = name ?? node.name;
|
||||
const newEnabled = enabled ?? node.enabled;
|
||||
const newSortOrder = sort_order ?? node.sort_order;
|
||||
|
||||
// If name changed, update surge_line too
|
||||
let newSurgeLine = node.surge_line;
|
||||
if (name && name !== node.name) {
|
||||
newSurgeLine = renameSurgeLine(node.surge_line, node.name, name);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE static_nodes SET name = ?, surge_line = ?, enabled = ?, sort_order = ? WHERE id = ?')
|
||||
.run(newName, newSurgeLine, newEnabled, newSortOrder, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/nodes/static/:id
|
||||
router.delete('/static/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare('DELETE FROM static_nodes WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
68
server/src/routes/rules.ts
Normal file
68
server/src/routes/rules.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/rules
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM rules ORDER BY sort_order, id').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/rules
|
||||
router.post('/', (req, res) => {
|
||||
const { type, value, action, comment } = req.body;
|
||||
if (!type || !value) return res.status(400).json({ error: 'type and value are required' });
|
||||
|
||||
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM rules').get() as any;
|
||||
const sortOrder = (maxOrder?.m ?? -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO rules (type, value, action, comment, sort_order) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(type, value, action || 'PROXY', comment || null, sortOrder);
|
||||
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// PUT /api/rules/reorder — MUST be before /:id
|
||||
router.put('/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 rules 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/rules/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { type, value, action, comment, enabled } = req.body;
|
||||
const rule = db.prepare('SELECT * FROM rules WHERE id = ?').get(id) as any;
|
||||
if (!rule) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
db.prepare('UPDATE rules SET type = ?, value = ?, action = ?, comment = ?, enabled = ? WHERE id = ?')
|
||||
.run(
|
||||
type ?? rule.type,
|
||||
value ?? rule.value,
|
||||
action ?? rule.action,
|
||||
comment ?? rule.comment,
|
||||
enabled ?? rule.enabled,
|
||||
id
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/rules/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare('DELETE FROM rules WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
88
server/src/routes/subscriptions.ts
Normal file
88
server/src/routes/subscriptions.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { parseSubscriptionContent } from '../parsers/index.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/subscriptions
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM subscriptions ORDER BY id').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// POST /api/subscriptions
|
||||
router.post('/', (req, res) => {
|
||||
const { name, url } = req.body;
|
||||
if (!name || !url) {
|
||||
return res.status(400).json({ error: 'name and url are required' });
|
||||
}
|
||||
const result = db.prepare('INSERT INTO subscriptions (name, url) VALUES (?, ?)').run(name, url);
|
||||
res.json({ id: result.lastInsertRowid });
|
||||
});
|
||||
|
||||
// PUT /api/subscriptions/:id
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, url, enabled } = req.body;
|
||||
const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id);
|
||||
if (!sub) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
db.prepare('UPDATE subscriptions SET name = ?, url = ?, enabled = ? WHERE id = ?')
|
||||
.run(name ?? (sub as any).name, url ?? (sub as any).url, enabled ?? (sub as any).enabled, id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/subscriptions/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
db.prepare('DELETE FROM subscriptions WHERE id = ?').run(id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /api/subscriptions/:id/fetch - trigger fetch and parse
|
||||
router.post('/:id/fetch', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) as any;
|
||||
if (!sub) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
try {
|
||||
const response = await fetch(sub.url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const rawConfig = await response.text();
|
||||
|
||||
// Save existing enabled states by node name
|
||||
const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[];
|
||||
const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled]));
|
||||
|
||||
// Parse nodes
|
||||
const nodes = parseSubscriptionContent(rawConfig);
|
||||
|
||||
// Replace nodes in transaction
|
||||
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 (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
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);
|
||||
}
|
||||
db.prepare('UPDATE subscriptions SET raw_config = ?, last_fetch = ?, node_count = ? WHERE id = ?')
|
||||
.run(rawConfig, new Date().toISOString(), nodes.length, id);
|
||||
});
|
||||
replace();
|
||||
|
||||
res.json({ nodeCount: nodes.length });
|
||||
} catch (err: any) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
res.json(nodes);
|
||||
});
|
||||
|
||||
export default router;
|
||||
31
server/src/routes/surge.ts
Normal file
31
server/src/routes/surge.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Router } from 'express';
|
||||
import { generateSurgeConfig } from '../services/generator.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /surge - Surge client subscription endpoint (no auth required)
|
||||
router.get('/', (req, res) => {
|
||||
const host = req.headers.host || 'localhost:3456';
|
||||
const protocol = req.secure ? 'https' : 'http';
|
||||
const hostUrl = `${protocol}://${host}/surge`;
|
||||
|
||||
const config = generateSurgeConfig(hostUrl);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename=sub-router.conf',
|
||||
});
|
||||
res.send(config);
|
||||
});
|
||||
|
||||
// GET /api/config/preview - preview generated config
|
||||
router.get('/preview', (req, res) => {
|
||||
const host = req.headers.host || 'localhost:3456';
|
||||
const protocol = req.secure ? 'https' : 'http';
|
||||
const hostUrl = `${protocol}://${host}/surge`;
|
||||
|
||||
const config = generateSurgeConfig(hostUrl);
|
||||
res.json({ config });
|
||||
});
|
||||
|
||||
export default router;
|
||||
167
server/src/services/generator.ts
Normal file
167
server/src/services/generator.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import db from '../db.js';
|
||||
|
||||
export function generateSurgeConfig(hostUrl: 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'
|
||||
).get() as any;
|
||||
|
||||
if (!sub?.raw_config) {
|
||||
return '# No subscription config available. Add and fetch a subscription first.';
|
||||
}
|
||||
|
||||
// Collect enabled fetched nodes
|
||||
const fetchedNodes = db.prepare(
|
||||
'SELECT surge_line FROM fetched_nodes WHERE enabled = 1 ORDER BY subscription_id, id'
|
||||
).all() as any[];
|
||||
|
||||
// Collect enabled static nodes (these go FIRST)
|
||||
const staticNodes = db.prepare(
|
||||
'SELECT surge_line FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
).all() as any[];
|
||||
|
||||
// Collect enabled rules
|
||||
const userRules = db.prepare(
|
||||
'SELECT type, value, action, comment FROM rules WHERE enabled = 1 ORDER BY sort_order, id'
|
||||
).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 allNodeLines = [...staticLines, ...fetchedLines];
|
||||
const allNodeNames = allNodeLines.map((l: string) => l.split(' = ')[0].trim());
|
||||
|
||||
// Build rule lines
|
||||
const ruleLines = userRules.map((r: any) => {
|
||||
const line = `${r.type},${r.value},${r.action}`;
|
||||
return r.comment ? `${line} // ${r.comment}` : line;
|
||||
});
|
||||
|
||||
let config = sub.raw_config;
|
||||
|
||||
// Replace [Proxy] section with only enabled nodes
|
||||
config = rebuildProxySection(config, staticLines, fetchedLines);
|
||||
|
||||
// Rebuild [Proxy Group] select groups with only enabled node names
|
||||
config = rebuildProxyGroup(config, allNodeNames);
|
||||
|
||||
// Inject user rules at the beginning of [Rule] section
|
||||
if (ruleLines.length > 0) {
|
||||
config = injectRules(config, ruleLines);
|
||||
}
|
||||
|
||||
// Rewrite MANAGED-CONFIG URL
|
||||
config = config.replace(
|
||||
/^#!MANAGED-CONFIG\s+\S+/m,
|
||||
`#!MANAGED-CONFIG ${hostUrl}`
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire [Proxy] section content with only enabled nodes.
|
||||
* Static nodes go first, then fetched nodes.
|
||||
*/
|
||||
function rebuildProxySection(config: string, staticLines: string[], fetchedLines: string[]): string {
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
let inProxySection = false;
|
||||
let proxyHeaderEmitted = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
if (inProxySection && !proxyHeaderEmitted) {
|
||||
// Emit our rebuilt proxy content before leaving the section
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
proxyHeaderEmitted = true;
|
||||
}
|
||||
inProxySection = trimmed === '[Proxy]';
|
||||
result.push(line);
|
||||
if (inProxySection) {
|
||||
// Emit all enabled nodes right after [Proxy] header
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
proxyHeaderEmitted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inProxySection) {
|
||||
// Skip original proxy lines (we replaced them)
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
// If [Proxy] was the last section
|
||||
if (inProxySection && !proxyHeaderEmitted) {
|
||||
emitProxyContent(result, staticLines, fetchedLines);
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function emitProxyContent(result: string[], staticLines: string[], fetchedLines: string[]) {
|
||||
if (staticLines.length > 0) {
|
||||
result.push('# --- 自定义节点 ---');
|
||||
staticLines.forEach(l => result.push(l));
|
||||
result.push('');
|
||||
}
|
||||
if (fetchedLines.length > 0) {
|
||||
result.push('# --- 订阅节点 ---');
|
||||
fetchedLines.forEach(l => result.push(l));
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild [Proxy Group] select groups to contain only the enabled node names.
|
||||
*/
|
||||
function rebuildProxyGroup(config: string, allNodeNames: string[]): string {
|
||||
if (allNodeNames.length === 0) return config;
|
||||
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
let inProxyGroupSection = false;
|
||||
let handled = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
inProxyGroupSection = trimmed === '[Proxy Group]';
|
||||
}
|
||||
|
||||
if (inProxyGroupSection && !handled && trimmed.includes('= select,')) {
|
||||
// Rebuild: keep group name and "= select," prefix, replace node list
|
||||
const eqSelect = trimmed.indexOf('= select,');
|
||||
const prefix = trimmed.slice(0, eqSelect + '= select,'.length);
|
||||
result.push(prefix + ' ' + allNodeNames.join(', '));
|
||||
handled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
function injectRules(config: string, ruleLines: string[]): string {
|
||||
const lines = config.split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
result.push(line);
|
||||
if (line.trim() === '[Rule]') {
|
||||
result.push('# --- 自定义规则 ---');
|
||||
ruleLines.forEach(r => result.push(r));
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
14
server/tsconfig.json
Normal file
14
server/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
web/index.html
Normal file
15
web/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sub Router</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
web/package.json
Normal file
22
web/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "sub-router-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
157
web/src/App.tsx
Normal file
157
web/src/App.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Layout, { type Panel } from './components/Layout';
|
||||
import Subscriptions from './components/Subscriptions';
|
||||
import StaticNodes from './components/StaticNodes';
|
||||
import NodeSelector from './components/NodeSelector';
|
||||
import Rules from './components/Rules';
|
||||
import Output from './components/Output';
|
||||
import { auth, setToken } from './api';
|
||||
|
||||
function LoginPage({ onLogin }: { onLogin: () => void }) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSetup, setIsSetup] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
auth.status().then(data => {
|
||||
setIsSetup(data.hasPassword);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) return;
|
||||
try {
|
||||
await auth.login(password);
|
||||
setToken(password);
|
||||
onLogin();
|
||||
} catch {
|
||||
setError('密码错误');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--bg-primary)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 40,
|
||||
width: 360,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
marginBottom: 8,
|
||||
letterSpacing: '0.1em',
|
||||
}}>
|
||||
Sub Router
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
{isSetup ? '输入密码以访问管理面板' : '设置管理密码'}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={isSetup ? '密码' : '设置新密码'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
padding: 10,
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--danger)', fontSize: 12, marginBottom: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button className="primary" type="submit" style={{ width: '100%' }}>
|
||||
{isSetup ? '登录' : '设置密码'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [activePanel, setActivePanel] = useState<Panel>('subscriptions');
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if auth is needed and if we have a valid token
|
||||
auth.status().then(data => {
|
||||
if (!data.hasPassword) {
|
||||
// No password set yet — show login to set one
|
||||
setChecking(false);
|
||||
return;
|
||||
}
|
||||
// Try existing token
|
||||
const token = sessionStorage.getItem('sub-router-token');
|
||||
if (token) {
|
||||
setToken(token);
|
||||
// Verify by calling stats
|
||||
fetch('/api/stats', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).then(res => {
|
||||
if (res.ok) setAuthenticated(true);
|
||||
setChecking(false);
|
||||
}).catch(() => setChecking(false));
|
||||
} else {
|
||||
setChecking(false);
|
||||
}
|
||||
}).catch(() => setChecking(false));
|
||||
}, []);
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return <LoginPage onLogin={() => setAuthenticated(true)} />;
|
||||
}
|
||||
|
||||
const panels: Record<Panel, React.ReactNode> = {
|
||||
subscriptions: <Subscriptions />,
|
||||
'static-nodes': <StaticNodes />,
|
||||
'node-selector': <NodeSelector />,
|
||||
rules: <Rules />,
|
||||
output: <Output />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout activePanel={activePanel} onPanelChange={setActivePanel}>
|
||||
{panels[activePanel]}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
122
web/src/api.ts
Normal file
122
web/src/api.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
function getToken(): string | null {
|
||||
return sessionStorage.getItem('sub-router-token');
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
sessionStorage.setItem('sub-router-token', token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
sessionStorage.removeItem('sub-router-token');
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
window.location.reload();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error || res.statusText);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const auth = {
|
||||
status: () => request<{ hasPassword: boolean }>('/auth/status'),
|
||||
login: (password: string) => request<{ ok: boolean }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Subscriptions
|
||||
export const subscriptions = {
|
||||
list: () => request<any[]>('/subscriptions'),
|
||||
create: (name: string, url: string) => request<{ id: number }>('/subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, url }),
|
||||
}),
|
||||
update: (id: number, data: any) => request<{ ok: boolean }>(`/subscriptions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (id: number) => request<{ ok: boolean }>(`/subscriptions/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
fetch: (id: number) => request<{ nodeCount: number }>(`/subscriptions/${id}/fetch`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
nodes: (id: number) => request<any[]>(`/subscriptions/${id}/nodes`),
|
||||
};
|
||||
|
||||
// Nodes
|
||||
export const nodes = {
|
||||
fetchedToggle: (id: number, enabled: boolean) => request<{ ok: boolean }>(`/nodes/fetched/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
fetchedBatch: (ids: number[], enabled: boolean) => request<{ ok: boolean }>('/nodes/fetched/batch', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ids, enabled }),
|
||||
}),
|
||||
staticList: () => request<any[]>('/nodes/static'),
|
||||
staticCreate: (uri: string, name?: string) => request<any>('/nodes/static', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uri, name }),
|
||||
}),
|
||||
staticUpdate: (id: number, data: any) => request<{ ok: boolean }>(`/nodes/static/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
staticDelete: (id: number) => request<{ ok: boolean }>(`/nodes/static/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Rules
|
||||
export const rules = {
|
||||
list: () => request<any[]>('/rules'),
|
||||
create: (data: any) => request<{ id: number }>('/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
update: (id: number, data: any) => request<{ ok: boolean }>(`/rules/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (id: number) => request<{ ok: boolean }>(`/rules/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
reorder: (ids: number[]) => request<{ ok: boolean }>('/rules/reorder', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Config
|
||||
export const config = {
|
||||
preview: () => request<{ config: string }>('/config/preview'),
|
||||
};
|
||||
|
||||
// Stats
|
||||
export const stats = {
|
||||
get: () => request<any>('/stats'),
|
||||
};
|
||||
148
web/src/components/Layout.tsx
Normal file
148
web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { stats as statsApi } from '../api';
|
||||
|
||||
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output';
|
||||
|
||||
const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
|
||||
{ key: 'subscriptions', label: '订阅', icon: '⟐' },
|
||||
{ key: 'static-nodes', label: '节点', icon: '◈' },
|
||||
{ key: 'node-selector', label: '选择', icon: '☰' },
|
||||
{ key: 'rules', label: '规则', icon: '⧖' },
|
||||
{ key: 'output', label: '输出', icon: '▸' },
|
||||
];
|
||||
|
||||
interface LayoutProps {
|
||||
activePanel: Panel;
|
||||
onPanelChange: (panel: Panel) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ activePanel, onPanelChange, children }: LayoutProps) {
|
||||
const [statsData, setStatsData] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => statsApi.get().then(setStatsData).catch(() => {});
|
||||
load();
|
||||
const interval = setInterval(load, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const totalNodes = statsData
|
||||
? statsData.nodes.fetched.enabled + statsData.nodes.static.enabled
|
||||
: 0;
|
||||
const totalNodesAll = statsData
|
||||
? statsData.nodes.fetched.total + statsData.nodes.static.total
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
background: 'var(--bg-primary)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 20px',
|
||||
height: 40,
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: 'var(--bg-panel)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
letterSpacing: '0.1em',
|
||||
color: 'var(--accent)',
|
||||
}}>
|
||||
Sub Router
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-muted)',
|
||||
}}>
|
||||
v1.0.0
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main */}
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Sidebar */}
|
||||
<nav style={{
|
||||
width: 72,
|
||||
background: 'var(--bg-panel)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingTop: 8,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{NAV_ITEMS.map(item => (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => onPanelChange(item.key)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '12px 0',
|
||||
margin: '2px 6px',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: activePanel === item.key ? 'var(--bg-active)' : 'transparent',
|
||||
color: activePanel === item.key ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all var(--transition)',
|
||||
textTransform: 'none',
|
||||
letterSpacing: 'normal',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
fontSize: 10,
|
||||
fontWeight: activePanel === item.key ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18, lineHeight: 1 }}>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<main style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: 24,
|
||||
}}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<footer style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
padding: '0 16px',
|
||||
height: 28,
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--bg-panel)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--text-secondary)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: 'var(--success)' }}>● online</span>
|
||||
<span>nodes: <span style={{ color: 'var(--accent)' }}>{totalNodes}</span>/{totalNodesAll}</span>
|
||||
<span>rules: <span style={{ color: 'var(--accent)' }}>{statsData?.rules ?? 0}</span></span>
|
||||
<span>port: <span style={{ color: 'var(--accent)' }}>3456</span></span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { Panel };
|
||||
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,
|
||||
},
|
||||
};
|
||||
119
web/src/components/Output.tsx
Normal file
119
web/src/components/Output.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { config as configApi } from '../api';
|
||||
|
||||
export default function Output() {
|
||||
const [preview, setPreview] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const surgeUrl = `${window.location.origin}/surge`;
|
||||
|
||||
const loadPreview = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await configApi.preview();
|
||||
setPreview(data.config);
|
||||
} catch (err: any) {
|
||||
setPreview(`# Error: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadPreview(); }, []);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(surgeUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={styles.title}>输出配置</h2>
|
||||
<p style={styles.subtitle}>Surge 客户端订阅链接和配置预览</p>
|
||||
|
||||
{/* Subscription URL */}
|
||||
<div style={{
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
marginBottom: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
Surge 订阅链接
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<code style={{
|
||||
flex: 1,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 13,
|
||||
color: 'var(--accent)',
|
||||
userSelect: 'all',
|
||||
}}>
|
||||
{surgeUrl}
|
||||
</code>
|
||||
<button className="small" onClick={handleCopy}>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
}}>
|
||||
配置预览
|
||||
</span>
|
||||
<button className="small" onClick={loadPreview} disabled={loading}>
|
||||
{loading ? '加载中...' : '刷新预览'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre style={{
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 16,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-secondary)',
|
||||
overflow: 'auto',
|
||||
maxHeight: 'calc(100vh - 360px)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{preview || '(empty)'}
|
||||
</pre>
|
||||
</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,
|
||||
},
|
||||
};
|
||||
197
web/src/components/Rules.tsx
Normal file
197
web/src/components/Rules.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { rules as api } from '../api';
|
||||
|
||||
const RULE_TYPES = [
|
||||
'DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD',
|
||||
'IP-CIDR', 'IP-CIDR6', 'GEOIP',
|
||||
'URL-REGEX', 'USER-AGENT',
|
||||
'PROCESS-NAME', 'SUBNET',
|
||||
];
|
||||
|
||||
const ACTIONS = ['PROXY', 'DIRECT', 'REJECT', 'REJECT-TINYGIF'];
|
||||
|
||||
export default function Rules() {
|
||||
const [ruleList, setRuleList] = useState<any[]>([]);
|
||||
const [type, setType] = useState('DOMAIN-SUFFIX');
|
||||
const [value, setValue] = useState('');
|
||||
const [action, setAction] = useState('PROXY');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const load = () => api.list().then(setRuleList).catch(console.error);
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!value.trim()) return;
|
||||
await api.create({ type, value: value.trim(), action, comment: comment.trim() || undefined });
|
||||
setValue('');
|
||||
setComment('');
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await api.delete(id);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleToggle = async (id: number, rule: any) => {
|
||||
await api.update(id, { enabled: rule.enabled ? 0 : 1 });
|
||||
load();
|
||||
};
|
||||
|
||||
const handleMoveUp = async (index: number) => {
|
||||
if (index === 0) return;
|
||||
const ids = ruleList.map(r => r.id);
|
||||
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||
await api.reorder(ids);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleMoveDown = async (index: number) => {
|
||||
if (index === ruleList.length - 1) return;
|
||||
const ids = ruleList.map(r => r.id);
|
||||
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||
await api.reorder(ids);
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={styles.title}>规则管理</h2>
|
||||
<p style={styles.subtitle}>自定义规则会注入到 Surge 配置的 [Rule] 开头(优先匹配)</p>
|
||||
|
||||
{/* Add form */}
|
||||
<div style={styles.form}>
|
||||
<select value={type} onChange={e => setType(e.target.value)} style={{ width: 160 }}>
|
||||
{RULE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<input
|
||||
placeholder="匹配值 (如 google.com)"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<select value={action} onChange={e => setAction(e.target.value)} style={{ width: 140 }}>
|
||||
{ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<input
|
||||
placeholder="备注(可选)"
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<button className="primary" onClick={handleAdd}>添加</button>
|
||||
</div>
|
||||
|
||||
{/* Rules table */}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 50 }}>状态</th>
|
||||
<th style={{ width: 60 }}>排序</th>
|
||||
<th style={{ width: 140 }}>类型</th>
|
||||
<th>匹配值</th>
|
||||
<th style={{ width: 120 }}>策略</th>
|
||||
<th>备注</th>
|
||||
<th style={{ width: 60 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ruleList.map((rule, idx) => (
|
||||
<tr key={rule.id} style={{ opacity: rule.enabled ? 1 : 0.5 }}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle"
|
||||
checked={!!rule.enabled}
|
||||
onChange={() => handleToggle(rule.id, rule)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button
|
||||
className="small"
|
||||
onClick={() => handleMoveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
style={{ padding: '2px 6px', fontSize: 10 }}
|
||||
>▲</button>
|
||||
<button
|
||||
className="small"
|
||||
onClick={() => handleMoveDown(idx)}
|
||||
disabled={idx === ruleList.length - 1}
|
||||
style={{ padding: '2px 6px', fontSize: 10 }}
|
||||
>▼</button>
|
||||
</div>
|
||||
</td>
|
||||
<td style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 11,
|
||||
color: 'var(--warning)',
|
||||
}}>
|
||||
{rule.type}
|
||||
</td>
|
||||
<td style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 12,
|
||||
color: 'var(--accent)',
|
||||
}}>
|
||||
{rule.value}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 10,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: rule.action === 'PROXY' ? 'var(--accent-glow)' :
|
||||
rule.action === 'REJECT' ? 'rgba(255, 95, 87, 0.15)' :
|
||||
'var(--bg-active)',
|
||||
color: rule.action === 'PROXY' ? 'var(--accent)' :
|
||||
rule.action === 'REJECT' ? 'var(--danger)' :
|
||||
'var(--text-secondary)',
|
||||
}}>
|
||||
{rule.action}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
{rule.comment || '—'}
|
||||
</td>
|
||||
<td>
|
||||
<button className="small danger" onClick={() => handleDelete(rule.id)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ruleList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
暂无自定义规则
|
||||
</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,
|
||||
},
|
||||
form: {
|
||||
display: 'flex' as const,
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
flexWrap: 'wrap' as const,
|
||||
},
|
||||
};
|
||||
177
web/src/components/StaticNodes.tsx
Normal file
177
web/src/components/StaticNodes.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { nodes as api } from '../api';
|
||||
|
||||
export default function StaticNodes() {
|
||||
const [nodeList, setNodeList] = useState<any[]>([]);
|
||||
const [uri, setUri] = useState('');
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
const load = () => api.staticList().then(setNodeList).catch(console.error);
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!uri.trim()) return;
|
||||
try {
|
||||
await api.staticCreate(uri.trim(), customName.trim() || undefined);
|
||||
setUri('');
|
||||
setCustomName('');
|
||||
load();
|
||||
} catch (err: any) {
|
||||
alert(`解析失败: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await api.staticDelete(id);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleToggle = async (id: number, current: number) => {
|
||||
await api.staticUpdate(id, { enabled: current ? 0 : 1 });
|
||||
load();
|
||||
};
|
||||
|
||||
const handleRename = async (id: number) => {
|
||||
if (!editingName.trim()) return;
|
||||
await api.staticUpdate(id, { name: editingName.trim() });
|
||||
setEditingId(null);
|
||||
setEditingName('');
|
||||
load();
|
||||
};
|
||||
|
||||
const handleKeyDown = async (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
await handleAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={styles.title}>静态节点</h2>
|
||||
<p style={styles.subtitle}>粘贴 ss:// / vmess:// / trojan:// URI 自动解析</p>
|
||||
|
||||
<div style={styles.form}>
|
||||
<input
|
||||
placeholder="自定义名称(可选)"
|
||||
value={customName}
|
||||
onChange={e => setCustomName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="粘贴节点 URI(ss:// / vmess:// / trojan://)"
|
||||
value={uri}
|
||||
onChange={e => setUri(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="primary" onClick={handleAdd}>添加</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 50 }}>状态</th>
|
||||
<th>名称</th>
|
||||
<th style={{ width: 80 }}>协议</th>
|
||||
<th>Surge 配置行</th>
|
||||
<th style={{ width: 80 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodeList.map(node => (
|
||||
<tr key={node.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle"
|
||||
checked={!!node.enabled}
|
||||
onChange={() => handleToggle(node.id, node.enabled)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', color: 'var(--accent)' }}>
|
||||
{editingId === node.id ? (
|
||||
<input
|
||||
value={editingName}
|
||||
onChange={e => setEditingName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleRename(node.id);
|
||||
if (e.key === 'Escape') { setEditingId(null); setEditingName(''); }
|
||||
}}
|
||||
onBlur={() => handleRename(node.id)}
|
||||
autoFocus
|
||||
style={{ width: '100%', fontSize: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onDoubleClick={() => { setEditingId(node.id); setEditingName(node.name); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="双击编辑名称"
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<span className="ellipsis mono" style={{
|
||||
fontSize: 11,
|
||||
maxWidth: 400,
|
||||
display: 'inline-block',
|
||||
color: 'var(--text-secondary)',
|
||||
}}>
|
||||
{node.surge_line}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="small danger" onClick={() => handleDelete(node.id)}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{nodeList.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
暂无静态节点
|
||||
</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,
|
||||
},
|
||||
form: {
|
||||
display: 'flex' as const,
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
};
|
||||
150
web/src/components/Subscriptions.tsx
Normal file
150
web/src/components/Subscriptions.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { subscriptions as api } from '../api';
|
||||
|
||||
export default function Subscriptions() {
|
||||
const [subs, setSubs] = useState<any[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [fetching, setFetching] = useState<number | null>(null);
|
||||
|
||||
const load = () => api.list().then(setSubs).catch(console.error);
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!name.trim() || !url.trim()) return;
|
||||
await api.create(name.trim(), url.trim());
|
||||
setName('');
|
||||
setUrl('');
|
||||
load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await api.delete(id);
|
||||
load();
|
||||
};
|
||||
|
||||
const handleToggle = async (id: number, enabled: number) => {
|
||||
await api.update(id, { enabled: enabled ? 0 : 1 });
|
||||
load();
|
||||
};
|
||||
|
||||
const handleFetch = async (id: number) => {
|
||||
setFetching(id);
|
||||
try {
|
||||
const result = await api.fetch(id);
|
||||
alert(`抓取成功,解析到 ${result.nodeCount} 个节点`);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
alert(`抓取失败: ${err.message}`);
|
||||
} finally {
|
||||
setFetching(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={styles.title}>订阅源管理</h2>
|
||||
<p style={styles.subtitle}>添加和管理上游订阅链接,点击刷新按钮抓取节点</p>
|
||||
|
||||
{/* Add form */}
|
||||
<div style={styles.form}>
|
||||
<input
|
||||
placeholder="名称"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<input
|
||||
placeholder="订阅 URL"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button className="primary" onClick={handleAdd}>添加</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 50 }}>状态</th>
|
||||
<th>名称</th>
|
||||
<th>URL</th>
|
||||
<th style={{ width: 80 }}>节点数</th>
|
||||
<th style={{ width: 140 }}>最后抓取</th>
|
||||
<th style={{ width: 140 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subs.map(sub => (
|
||||
<tr key={sub.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle"
|
||||
checked={!!sub.enabled}
|
||||
onChange={() => handleToggle(sub.id, sub.enabled)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', color: 'var(--accent)' }}>
|
||||
{sub.name}
|
||||
</td>
|
||||
<td>
|
||||
<span className="ellipsis" style={{ maxWidth: 300, display: 'inline-block' }}>
|
||||
{sub.url}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>
|
||||
{sub.node_count || '—'}
|
||||
</td>
|
||||
<td style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
|
||||
{sub.last_fetch ? new Date(sub.last_fetch).toLocaleString() : '未抓取'}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
className="small"
|
||||
onClick={() => handleFetch(sub.id)}
|
||||
disabled={fetching === sub.id}
|
||||
>
|
||||
{fetching === sub.id ? '抓取中...' : '刷新'}
|
||||
</button>
|
||||
<button className="small danger" onClick={() => handleDelete(sub.id)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
|
||||
暂无订阅源,请添加
|
||||
</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,
|
||||
},
|
||||
form: {
|
||||
display: 'flex' as const,
|
||||
gap: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
};
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
219
web/src/styles/global.css
Normal file
219
web/src/styles/global.css
Normal file
@@ -0,0 +1,219 @@
|
||||
:root {
|
||||
--bg-primary: #0a0e14;
|
||||
--bg-panel: #111820;
|
||||
--bg-input: #0d1117;
|
||||
--bg-hover: #1a2332;
|
||||
--bg-active: #1e2d3d;
|
||||
--border: #1e2d3d;
|
||||
--border-bright: #2a3f54;
|
||||
--text-primary: #c5cdd8;
|
||||
--text-secondary: #6b7d8e;
|
||||
--text-muted: #3d4f5f;
|
||||
--accent: #00e5c7;
|
||||
--accent-dim: #00b39e;
|
||||
--accent-glow: rgba(0, 229, 199, 0.15);
|
||||
--danger: #ff5f57;
|
||||
--danger-dim: #cc4c46;
|
||||
--warning: #ffbd2e;
|
||||
--success: #28c840;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--radius: 4px;
|
||||
--transition: 150ms ease;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scanline overlay */
|
||||
#root::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.03) 2px,
|
||||
rgba(0, 0, 0, 0.03) 4px
|
||||
);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-bright);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
input, textarea, select {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 10px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 14px;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-panel);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
button:active {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
border-color: transparent;
|
||||
}
|
||||
button.danger:hover {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
button.small {
|
||||
padding: 3px 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 18px;
|
||||
appearance: none;
|
||||
background: var(--border);
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
padding: 0;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.toggle:checked {
|
||||
background: var(--accent);
|
||||
}
|
||||
.toggle:checked::after {
|
||||
left: 20px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.text-accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
16
web/tsconfig.json
Normal file
16
web/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
web/vite.config.ts
Normal file
13
web/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3456',
|
||||
'/surge': 'http://localhost:3456',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user