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