feat: init proj

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

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"WebFetch(domain:manual.nssurge.com)",
"Bash(ssh:*)",
"Bash(sshpass:*)"
]
}
}

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
node_modules
server/node_modules
web/node_modules
web/dist
server/data
.claude
*.db

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
server/data/*.db
*.db-wal
*.db-shm

74
CLAUDE.md Normal file
View 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
View 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

Binary file not shown.

8
docker-compose.yml Normal file
View 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
View 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/';
// 额外节点的 URIVLESS 不被 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

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View 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
View 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
View 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
View 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}`);
});

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import { subscriptions as subsApi, nodes as nodesApi } from '../api';
export default function NodeSelector() {
const [subs, setSubs] = useState<any[]>([]);
const [selectedSub, setSelectedSub] = useState<number | null>(null);
const [nodeList, setNodeList] = useState<any[]>([]);
const [regexInput, setRegexInput] = useState('');
useEffect(() => {
subsApi.list().then(data => {
setSubs(data);
if (data.length > 0 && !selectedSub) {
setSelectedSub(data[0].id);
}
});
}, []);
useEffect(() => {
if (selectedSub) {
subsApi.nodes(selectedSub).then(setNodeList).catch(console.error);
}
}, [selectedSub]);
const handleToggle = async (id: number, enabled: number) => {
await nodesApi.fetchedToggle(id, !enabled);
if (selectedSub) {
subsApi.nodes(selectedSub).then(setNodeList);
}
};
const handleSelectAll = async (enabled: boolean) => {
const ids = nodeList.map(n => n.id);
await nodesApi.fetchedBatch(ids, enabled);
if (selectedSub) {
subsApi.nodes(selectedSub).then(setNodeList);
}
};
const handleRegexBatch = async (enabled: boolean) => {
if (!regexInput.trim()) return;
try {
const re = new RegExp(regexInput, 'i');
const matchedIds = nodeList.filter(n => re.test(n.name)).map(n => n.id);
if (matchedIds.length === 0) return;
await nodesApi.fetchedBatch(matchedIds, enabled);
if (selectedSub) {
subsApi.nodes(selectedSub).then(setNodeList);
}
} catch {
alert('无效的正则表达式');
}
};
const enabledCount = nodeList.filter(n => n.enabled).length;
return (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}></p>
{/* Subscription tabs */}
<div style={{ display: 'flex', gap: 6, marginBottom: 16 }}>
{subs.map(sub => (
<button
key={sub.id}
onClick={() => setSelectedSub(sub.id)}
style={{
background: selectedSub === sub.id ? 'var(--bg-active)' : 'transparent',
color: selectedSub === sub.id ? 'var(--accent)' : 'var(--text-secondary)',
borderColor: selectedSub === sub.id ? 'var(--accent)' : 'var(--border)',
}}
>
{sub.name}
<span style={{
marginLeft: 6,
fontSize: 10,
color: 'var(--text-muted)',
}}>
({sub.node_count || 0})
</span>
</button>
))}
</div>
{/* Batch controls */}
{nodeList.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="small" onClick={() => handleSelectAll(true)}></button>
<button className="small" onClick={() => handleSelectAll(false)}></button>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-secondary)',
marginLeft: 8,
}}>
{enabledCount}/{nodeList.length}
</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
placeholder="正则匹配节点名(如 香港|HK"
value={regexInput}
onChange={e => setRegexInput(e.target.value)}
style={{ width: 260, fontSize: 12 }}
/>
<button className="small" onClick={() => handleRegexBatch(true)}></button>
<button className="small" onClick={() => handleRegexBatch(false)}></button>
{regexInput && (
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-muted)',
}}>
{(() => {
try {
const re = new RegExp(regexInput, 'i');
const matched = nodeList.filter(n => re.test(n.name)).length;
return `匹配 ${matched}`;
} catch {
return '无效正则';
}
})()}
</span>
)}
</div>
</div>
)}
{/* Node list */}
<table>
<thead>
<tr>
<th style={{ width: 50 }}></th>
<th></th>
<th style={{ width: 80 }}></th>
<th></th>
<th style={{ width: 70 }}></th>
</tr>
</thead>
<tbody>
{nodeList.map(node => (
<tr key={node.id} style={{
opacity: node.enabled ? 1 : 0.5,
}}>
<td>
<input
type="checkbox"
className="toggle"
checked={!!node.enabled}
onChange={() => handleToggle(node.id, node.enabled)}
/>
</td>
<td style={{
fontFamily: 'var(--font-mono)',
color: node.enabled ? 'var(--accent)' : 'var(--text-secondary)',
fontSize: 12,
}}>
{node.name}
</td>
<td>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 6px',
borderRadius: 'var(--radius)',
background: 'var(--bg-active)',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
}}>
{node.type}
</span>
</td>
<td style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-secondary)',
}}>
{node.server}
</td>
<td style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
}}>
{node.port}
</td>
</tr>
))}
{nodeList.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
{subs.length === 0
? '请先添加订阅源'
: '请先抓取订阅源节点'
}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
const styles = {
title: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 16,
fontWeight: 600 as const,
color: 'var(--text-primary)',
marginBottom: 4,
},
subtitle: {
fontSize: 12,
color: 'var(--text-secondary)',
marginBottom: 20,
},
};

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

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

View 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="粘贴节点 URIss:// / 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,
},
};

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

BIN
归档.zip Normal file

Binary file not shown.