diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1dc146f..947667c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,9 @@ "Bash(scp server/src/routes/surge.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/server/src/routes/surge.ts)", "Bash(scp web/src/api.ts ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/api.ts)", "Bash(scp web/src/components/Output.tsx ubuntu@118.195.187.179:/opt/1panel/apps/sub-router/web/src/components/Output.tsx)", - "Bash(scp:*)" + "Bash(scp:*)", + "Bash(./node_modules/.bin/tsc --noEmit)", + "Bash(../node_modules/.bin/tsc --noEmit)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index d1f321e..cafb845 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,28 +4,31 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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`. +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/:token`. ## Commands ```bash # Development (starts both server and web dev server concurrently) -npm run dev +pnpm dev # Server only (port 3456, hot-reload via tsx watch) -npm run dev:server +pnpm dev:server # Frontend only (port 5173, proxies /api and /surge to :3456) -npm run dev:web +pnpm dev:web # Build frontend for production -npm run build:web +pnpm build:web # Production start (serves built frontend + API on port 3456) -npm run start +pnpm start # Type check frontend -cd web && npx tsc --noEmit +cd web && pnpm exec tsc --noEmit + +# Type check server +cd server && pnpm exec tsc --noEmit # Docker docker compose up -d @@ -39,7 +42,7 @@ docker compose up -d 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: +3. `GET /surge/:token` → `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 @@ -48,11 +51,23 @@ docker compose up -d ### 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 `. +Two separate credentials exist: + +- **Admin password**: Protects all `/api/*` routes (except `/api/auth/login` and `/api/auth/status`). Sent as `Authorization: Bearer `. On first login with no password set, the submitted password becomes the password. +- **Surge token**: UUID stored in `config` table as `surge_token`. Embedded in the Surge subscription URL itself (`/surge/:token`). Returns 404 for wrong tokens. Regeneratable via `POST /api/config/surge-token`. + +### API routes + +- `POST /api/auth/login`, `GET /api/auth/status` — auth (no auth required) +- `GET /api/stats` — node/rule counts summary +- `/api/subscriptions` → `subscriptions.ts` (CRUD + `/:id/fetch`, `/:id/nodes`) +- `/api/nodes` → `nodes.ts` (fetched node toggle, static node CRUD) +- `/api/rules` → `rules.ts` (CRUD + `/reorder`) +- `/api/config` → `surge.ts` (`/surge-token` GET/POST, `/preview` GET) ### 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). +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` and `surge_token`). ### Parsers (`server/src/parsers/`) @@ -61,14 +76,28 @@ Convert protocol URIs to Surge proxy lines. Each parser returns `{ name, type, s - **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) +On re-fetch, enabled state is preserved by node name: existing name→enabled mapping is saved before delete, then reapplied to new rows. + +When renaming a static node (`PUT /api/nodes/static/:id`), the `surge_line` prefix is also updated to match the new name. + ### 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. +### Generator limitation + +`rebuildProxyGroup` in `generator.ts` only updates the **first** `= select,` group (due to `handled = true` flag). If a Surge template has multiple select groups, only the first gets its node list replaced. + ## 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. +## Legacy file + +`index.js` at the repo root is a pre-rewrite single-file prototype. It is not used by the current system and can be ignored. + ## 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). +Multi-stage Docker build: frontend built on `node:22-slim`, output copied into production image alongside the server. The `./data` volume maps to `server/data/` inside the container for SQLite persistence. + +The `Content-Disposition` header on `/surge/:token` controls the config profile name in Surge client (currently `IPLC.MAX.conf`). Certificate renewal uses acme.sh with `dns_cf` (Cloudflare DNS API validation). diff --git a/server/src/db.ts b/server/src/db.ts index 5bc1c3c..9676ee8 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -57,4 +57,14 @@ db.exec(` ); `); +// Migration: add Clash and SSR URL/config columns to subscriptions +for (const sql of [ + "ALTER TABLE subscriptions ADD COLUMN url_clash TEXT", + "ALTER TABLE subscriptions ADD COLUMN url_ssr TEXT", + "ALTER TABLE subscriptions ADD COLUMN raw_config_clash TEXT", + "ALTER TABLE subscriptions ADD COLUMN raw_config_ssr TEXT", +]) { + try { db.exec(sql); } catch { /* column already exists */ } +} + export default db; diff --git a/server/src/index.ts b/server/src/index.ts index 2cb9e48..2158d68 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -8,6 +8,8 @@ import rulesRouter from './routes/rules.js'; import surgeRouter from './routes/surge.js'; import db from './db.js'; import { generateSurgeConfig } from './services/generator.js'; +import { generateClashConfig } from './services/clashGenerator.js'; +import { generateSSRConfig } from './services/ssrGenerator.js'; // Ensure surge_token exists function ensureSurgeToken(): string { @@ -24,15 +26,17 @@ const PORT = parseInt(process.env.PORT || '3456', 10); app.use(express.json()); +function verifySurgeToken(token: string): boolean { + const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any; + return !!row?.value && token === row.value; +} + // Surge endpoint (no auth, token-protected path) app.get('/surge/:token', (req, res) => { - const row = db.prepare("SELECT value FROM config WHERE key = 'surge_token'").get() as any; - if (!row?.value || req.params.token !== row.value) { - return res.status(404).send('Not Found'); - } + if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found'); const host = req.headers.host || 'localhost:3456'; const protocol = req.secure ? 'https' : 'http'; - const hostUrl = `${protocol}://${host}/surge/${row.value}`; + const hostUrl = `${protocol}://${host}/surge/${req.params.token}`; const config = generateSurgeConfig(hostUrl); res.set({ 'Content-Type': 'text/plain; charset=utf-8', @@ -41,6 +45,28 @@ app.get('/surge/:token', (req, res) => { res.send(config); }); +// Clash endpoint (no auth, token-protected path) +app.get('/clash/:token', (req, res) => { + if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found'); + const config = generateClashConfig(); + res.set({ + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Disposition': 'attachment; filename=clash.yaml', + }); + res.send(config); +}); + +// SSR endpoint (no auth, token-protected path) +app.get('/ssr/:token', (req, res) => { + if (!verifySurgeToken(req.params.token)) return res.status(404).send('Not Found'); + const config = generateSSRConfig(); + res.set({ + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Disposition': 'attachment; filename=proxy.txt', + }); + res.send(config); +}); + // Auth routes (no auth required) app.post('/api/auth/login', (req, res) => { const { password } = req.body; @@ -104,7 +130,7 @@ app.get('/api/stats', (_req, res) => { const webDist = path.join(__dirname, '..', '..', 'web', 'dist'); app.use(express.static(webDist)); app.get('*', (req, res, next) => { - if (req.path.startsWith('/api') || req.path.startsWith('/surge')) return next(); + if (req.path.startsWith('/api') || req.path.startsWith('/surge') || req.path.startsWith('/clash') || req.path.startsWith('/ssr')) return next(); res.sendFile(path.join(webDist, 'index.html')); }); diff --git a/server/src/parsers/toClash.ts b/server/src/parsers/toClash.ts new file mode 100644 index 0000000..cdfef5c --- /dev/null +++ b/server/src/parsers/toClash.ts @@ -0,0 +1,79 @@ +/** + * Convert proxy URIs (ss://, vmess://, trojan://) to Clash YAML flow-style proxy lines. + * Returns a string like ` - {name: "NodeName", type: ss, server: ..., port: ..., ...}` + * or null if the URI is unsupported/invalid. + */ +export function uriToClashLine(uri: string): string | null { + try { + if (uri.startsWith('ss://')) return ssToClash(uri); + if (uri.startsWith('vmess://')) return vmessToClash(uri); + if (uri.startsWith('trojan://')) return trojanToClash(uri); + return null; + } catch { + return null; + } +} + +/** Escape a string value for use inside double-quoted YAML flow style */ +function esc(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function ssToClash(uri: string): string | null { + const url = new URL(uri); + const name = decodeURIComponent(url.hash.slice(1)); + const server = url.hostname; + const port = url.port; + + // userinfo is base64(method:password) + const decoded = Buffer.from(url.username, 'base64').toString(); + const firstColon = decoded.indexOf(':'); + if (firstColon === -1) return null; + const cipher = decoded.slice(0, firstColon); + const password = decoded.slice(firstColon + 1); + + return ` - {name: "${esc(name)}", type: ss, server: ${server}, port: ${port}, cipher: ${cipher}, password: "${esc(password)}"}`; +} + +function vmessToClash(uri: string): string | null { + 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; + + if (!server || !port || !uuid) return null; + + let line = ` - {name: "${esc(name)}", type: vmess, server: ${server}, port: ${port}, uuid: ${uuid}, alterId: 0, cipher: auto`; + + if (json.tls === 'tls') { + line += ', tls: true'; + if (json.sni) line += `, sni: ${json.sni}`; + } + + if (json.net === 'ws') { + line += ', network: ws'; + const wsOpts: string[] = []; + if (json.path) wsOpts.push(`path: "${esc(json.path)}"`); + if (json.host) wsOpts.push(`headers: {Host: ${json.host}}`); + if (wsOpts.length > 0) { + line += `, ws-opts: {${wsOpts.join(', ')}}`; + } + } + + line += '}'; + return line; +} + +function trojanToClash(uri: string): string | null { + 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 sni = url.searchParams.get('sni') || server; + + return ` - {name: "${esc(name)}", type: trojan, server: ${server}, port: ${port}, password: "${esc(password)}", sni: ${sni}}`; +} diff --git a/server/src/routes/subscriptions.ts b/server/src/routes/subscriptions.ts index fdc59a9..2987d81 100644 --- a/server/src/routes/subscriptions.ts +++ b/server/src/routes/subscriptions.ts @@ -12,23 +12,37 @@ router.get('/', (_req, res) => { // POST /api/subscriptions router.post('/', (req, res) => { - const { name, url } = req.body; + const { name, url, url_clash, url_ssr } = 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); + const result = db.prepare( + 'INSERT INTO subscriptions (name, url, url_clash, url_ssr) VALUES (?, ?, ?, ?)' + ).run(name, url, url_clash || null, url_ssr || null); 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); + const sub = db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) as any; 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); + const { name, url, enabled } = req.body; + // Only update Clash/SSR URLs if explicitly included in request body + const url_clash = 'url_clash' in req.body ? (req.body.url_clash || null) : sub.url_clash; + const url_ssr = 'url_ssr' in req.body ? (req.body.url_ssr || null) : sub.url_ssr; + + db.prepare( + 'UPDATE subscriptions SET name = ?, url = ?, url_clash = ?, url_ssr = ?, enabled = ? WHERE id = ?' + ).run( + name ?? sub.name, + url ?? sub.url, + url_clash, + url_ssr, + enabled ?? sub.enabled, + id + ); res.json({ ok: true }); }); @@ -50,11 +64,21 @@ router.post('/:id/fetch', async (req, res) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); const rawConfig = await response.text(); + const fetchOptional = (url: string | null) => + url + ? fetch(url).then(r => (r.ok ? r.text() : null)).catch(() => null) + : Promise.resolve(null); + + const [rawClash, rawSsr] = await Promise.all([ + fetchOptional(sub.url_clash), + fetchOptional(sub.url_ssr), + ]); + // Save existing enabled states by node name const existingNodes = db.prepare('SELECT name, enabled FROM fetched_nodes WHERE subscription_id = ?').all(id) as any[]; const enabledMap = new Map(existingNodes.map((n: any) => [n.name, n.enabled])); - // Parse nodes + // Parse nodes from primary Surge config const nodes = parseSubscriptionContent(rawConfig); // Replace nodes in transaction @@ -67,8 +91,9 @@ router.post('/:id/fetch', async (req, res) => { 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); + db.prepare( + 'UPDATE subscriptions SET raw_config = ?, raw_config_clash = ?, raw_config_ssr = ?, last_fetch = ?, node_count = ? WHERE id = ?' + ).run(rawConfig, rawClash, rawSsr, new Date().toISOString(), nodes.length, id); }); replace(); diff --git a/server/src/services/clashGenerator.ts b/server/src/services/clashGenerator.ts new file mode 100644 index 0000000..4c9f681 --- /dev/null +++ b/server/src/services/clashGenerator.ts @@ -0,0 +1,171 @@ +import db from '../db.js'; +import { uriToClashLine } from '../parsers/toClash.js'; + +export function generateClashConfig(): string { + const sub = db.prepare( + 'SELECT raw_config_clash FROM subscriptions WHERE enabled = 1 AND raw_config_clash IS NOT NULL ORDER BY id LIMIT 1' + ).get() as any; + + if (!sub?.raw_config_clash) { + return '# No Clash config available. Add a subscription with a Clash URL and fetch it.'; + } + + const staticNodes = db.prepare( + 'SELECT uri, name FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id' + ).all() as any[]; + + if (staticNodes.length === 0) { + return sub.raw_config_clash; + } + + const clashLines = staticNodes + .map((n: any) => uriToClashLine(n.uri)) + .filter((l): l is string => l !== null); + + const staticNames = staticNodes.map((n: any) => n.name as string); + + if (clashLines.length === 0) { + return sub.raw_config_clash; + } + + let config = injectProxies(sub.raw_config_clash, clashLines); + config = injectProxyGroupNames(config, staticNames); + return config; +} + +/** + * Inject static node YAML lines into the proxies: section. + * Inserts immediately after the `proxies:` key line. + * If no `proxies:` key exists, inserts a new proxies block before `proxy-groups:`. + */ +function injectProxies(config: string, clashLines: string[]): string { + const lines = config.split('\n'); + const result: string[] = []; + let injected = false; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + + if (!injected && trimmed === 'proxies:') { + result.push(lines[i]); + // Inject right after the `proxies:` key + for (const cl of clashLines) result.push(cl); + injected = true; + continue; + } + + // If proxies: never appeared, inject before proxy-groups: + if (!injected && trimmed === 'proxy-groups:') { + result.push('proxies:'); + for (const cl of clashLines) result.push(cl); + result.push(''); + injected = true; + } + + result.push(lines[i]); + } + + // Fallback: append at end if neither key was found + if (!injected) { + result.push(''); + result.push('proxies:'); + for (const cl of clashLines) result.push(cl); + } + + return result.join('\n'); +} + +/** + * Inject static node names into every `type: select` proxy-group's proxies sub-list. + * Handles both block sequence (` - name`) and inline array (`proxies: [a, b]`) formats. + */ +function injectProxyGroupNames(config: string, names: string[]): string { + const lines = config.split('\n'); + const result: string[] = []; + + let inProxyGroups = false; + let inSelectGroup = false; + let inProxiesSublist = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Detect top-level section changes + if (/^[a-zA-Z]/.test(line) && line.includes(':')) { + const key = line.split(':')[0].trim(); + if (key === 'proxy-groups') { + inProxyGroups = true; + inSelectGroup = false; + inProxiesSublist = false; + } else if (!line.startsWith(' ') && !line.startsWith('-')) { + inProxyGroups = false; + inSelectGroup = false; + inProxiesSublist = false; + } + } + + if (!inProxyGroups) { + result.push(line); + continue; + } + + // New group entry: reset state + if (/^\s*-\s+name:/.test(line)) { + inSelectGroup = false; + inProxiesSublist = false; + result.push(line); + continue; + } + + // Detect `type: select` within a group + if (trimmed === 'type: select') { + inSelectGroup = true; + result.push(line); + continue; + } + + if (inSelectGroup) { + // Handle inline array: proxies: [node1, node2, ...] + if (/^\s+proxies:\s*\[/.test(line)) { + // Append names before the closing ] + const closeBracket = line.lastIndexOf(']'); + if (closeBracket !== -1) { + const before = line.slice(0, closeBracket); + const after = line.slice(closeBracket); + result.push(before + ', ' + names.join(', ') + after); + } else { + result.push(line); + } + continue; + } + + // Handle block sequence: ` proxies:` followed by ` - name` items + if (/^\s+proxies:\s*$/.test(line)) { + inProxiesSublist = true; + result.push(line); + continue; + } + + if (inProxiesSublist) { + // Check if next line is still a proxies list item + const nextLine = lines[i + 1]; + const isLastItem = !nextLine || !/^\s{4,}-\s/.test(nextLine); + + result.push(line); + + if (isLastItem && /^\s{4,}-\s/.test(line)) { + // Inject names after last item + const indent = line.match(/^(\s+)/)?.[1] || ' '; + for (const name of names) result.push(`${indent}- ${name}`); + inProxiesSublist = false; + } + continue; + } + } + + result.push(line); + } + + return result.join('\n'); +} diff --git a/server/src/services/ssrGenerator.ts b/server/src/services/ssrGenerator.ts new file mode 100644 index 0000000..db801a6 --- /dev/null +++ b/server/src/services/ssrGenerator.ts @@ -0,0 +1,26 @@ +import db from '../db.js'; + +export function generateSSRConfig(): string { + const sub = db.prepare( + 'SELECT raw_config_ssr FROM subscriptions WHERE enabled = 1 AND raw_config_ssr IS NOT NULL ORDER BY id LIMIT 1' + ).get() as any; + + if (!sub?.raw_config_ssr) { + return ''; + } + + const staticNodes = db.prepare( + 'SELECT uri FROM static_nodes WHERE enabled = 1 ORDER BY sort_order, id' + ).all() as any[]; + + // Decode base64 content → URI list + const decoded = Buffer.from(sub.raw_config_ssr.trim(), 'base64').toString(); + const lines = decoded.split(/\r?\n/).map((l: string) => l.trim()).filter(Boolean); + + // Append static node URIs + for (const node of staticNodes) { + if (node.uri) lines.push(node.uri); + } + + return Buffer.from(lines.join('\n')).toString('base64'); +} diff --git a/web/src/api.ts b/web/src/api.ts index b3a1233..332ef31 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -50,9 +50,9 @@ export const auth = { // Subscriptions export const subscriptions = { list: () => request('/subscriptions'), - create: (name: string, url: string) => request<{ id: number }>('/subscriptions', { + create: (name: string, url: string, urlClash?: string, urlSsr?: string) => request<{ id: number }>('/subscriptions', { method: 'POST', - body: JSON.stringify({ name, url }), + body: JSON.stringify({ name, url, url_clash: urlClash || null, url_ssr: urlSsr || null }), }), update: (id: number, data: any) => request<{ ok: boolean }>(`/subscriptions/${id}`, { method: 'PUT', diff --git a/web/src/components/Output.tsx b/web/src/components/Output.tsx index a361b2d..ee11c5d 100644 --- a/web/src/components/Output.tsx +++ b/web/src/components/Output.tsx @@ -4,10 +4,14 @@ 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 [copiedSurge, setCopiedSurge] = useState(false); + const [copiedClash, setCopiedClash] = useState(false); + const [copiedSsr, setCopiedSsr] = useState(false); const [surgeToken, setSurgeToken] = useState(''); const surgeUrl = surgeToken ? `${window.location.origin}/surge/${surgeToken}` : ''; + const clashUrl = surgeToken ? `${window.location.origin}/clash/${surgeToken}` : ''; + const ssrUrl = surgeToken ? `${window.location.origin}/ssr/${surgeToken}` : ''; const loadToken = async () => { try { @@ -17,7 +21,7 @@ export default function Output() { }; const handleRegenerate = async () => { - if (!confirm('重新生成后,旧的订阅链接将失效,Surge 客户端需要更新订阅地址。确定继续?')) return; + if (!confirm('重新生成后,旧的订阅链接将失效(Surge / Clash / SSR 三条链接同时更新)。确定继续?')) return; try { const data = await configApi.regenerateSurgeToken(); setSurgeToken(data.token); @@ -42,93 +46,88 @@ export default function Output() { loadPreview(); }, []); - const handleCopy = () => { - navigator.clipboard.writeText(surgeUrl); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const makeCopyHandler = (url: string, setter: (v: boolean) => void) => () => { + navigator.clipboard.writeText(url); + setter(true); + setTimeout(() => setter(false), 2000); }; return (

输出配置

-

Surge 客户端订阅链接和配置预览

+

Surge / Clash / SSR 订阅链接和配置预览

- {/* Subscription URL */} -
-
- Surge 订阅链接 -
-
- - {surgeUrl || '加载中...'} - - - -
-
+ {/* Surge URL */} + + + {/* Clash URL */} + + + {/* SSR URL */} + {/* Preview */}
- - 配置预览 - + SURGE 配置预览
-
+      
         {preview || '(empty)'}
       
); } +function UrlCard({ + label, + url, + copied, + onCopy, + onRegenerate, +}: { + label: string; + url: string; + copied: boolean; + onCopy: () => void; + onRegenerate?: () => void; +}) { + return ( +
+
{label}
+
+ {url || '加载中...'} + + {onRegenerate && ( + + )} +
+
+ ); +} + const styles = { title: { fontFamily: 'var(--font-mono)' as const, @@ -142,4 +141,50 @@ const styles = { color: 'var(--text-secondary)', marginBottom: 20, }, + card: { + background: 'var(--bg-input)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius)', + padding: 16, + marginBottom: 12, + }, + cardLabel: { + fontSize: 10, + fontFamily: 'var(--font-mono)' as const, + color: 'var(--text-muted)', + textTransform: 'uppercase' as const, + letterSpacing: '0.1em', + marginBottom: 8, + }, + urlCode: { + flex: 1, + fontFamily: 'var(--font-mono)' as const, + fontSize: 13, + color: 'var(--accent)', + userSelect: 'all' as const, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + }, + sectionLabel: { + fontSize: 10, + fontFamily: 'var(--font-mono)' as const, + color: 'var(--text-muted)', + textTransform: 'uppercase' as const, + letterSpacing: '0.1em', + }, + pre: { + background: 'var(--bg-input)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius)', + padding: 16, + fontFamily: 'var(--font-mono)' as const, + fontSize: 11, + lineHeight: 1.6, + color: 'var(--text-secondary)', + overflow: 'auto', + maxHeight: 'calc(100vh - 480px)', + whiteSpace: 'pre-wrap' as const, + wordBreak: 'break-all' as const, + }, }; diff --git a/web/src/components/Subscriptions.tsx b/web/src/components/Subscriptions.tsx index 472f27f..523f257 100644 --- a/web/src/components/Subscriptions.tsx +++ b/web/src/components/Subscriptions.tsx @@ -3,18 +3,46 @@ import { subscriptions as api } from '../api'; export default function Subscriptions() { const [subs, setSubs] = useState([]); - const [name, setName] = useState(''); - const [url, setUrl] = useState(''); const [fetching, setFetching] = useState(null); + // Form state + const [formOpen, setFormOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formName, setFormName] = useState(''); + const [formUrl, setFormUrl] = useState(''); + const [formUrlClash, setFormUrlClash] = useState(''); + const [formUrlSsr, setFormUrlSsr] = useState(''); + 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(''); + const openForm = (sub?: any) => { + setEditingId(sub?.id ?? null); + setFormName(sub?.name ?? ''); + setFormUrl(sub?.url ?? ''); + setFormUrlClash(sub?.url_clash ?? ''); + setFormUrlSsr(sub?.url_ssr ?? ''); + setFormOpen(true); + }; + + const closeForm = () => { + setFormOpen(false); + setEditingId(null); + }; + + const handleSubmit = async () => { + if (!formName.trim() || !formUrl.trim()) return; + if (editingId !== null) { + await api.update(editingId, { + name: formName.trim(), + url: formUrl.trim(), + url_clash: formUrlClash.trim() || null, + url_ssr: formUrlSsr.trim() || null, + }); + } else { + await api.create(formName.trim(), formUrl.trim(), formUrlClash.trim(), formUrlSsr.trim()); + } + closeForm(); load(); }; @@ -46,22 +74,63 @@ export default function Subscriptions() {

订阅源管理

添加和管理上游订阅链接,点击刷新按钮抓取节点

- {/* Add form */} -
- setName(e.target.value)} - style={{ width: 160 }} - /> - setUrl(e.target.value)} - style={{ flex: 1 }} - /> - -
+ {/* Add button */} + {!formOpen && ( + + )} + + {/* Add / Edit form */} + {formOpen && ( +
+
+ {editingId !== null ? '编辑订阅' : '添加订阅'} +
+
+ + setFormName(e.target.value)} + style={{ width: '100%' }} + /> +
+
+ + setFormUrl(e.target.value)} + style={{ width: '100%' }} + /> +
+
+ + setFormUrlClash(e.target.value)} + style={{ width: '100%' }} + /> +
+
+ + setFormUrlSsr(e.target.value)} + style={{ width: '100%' }} + /> +
+
+ + +
+
+ )} {/* Table */} @@ -69,10 +138,10 @@ export default function Subscriptions() { - + - + @@ -90,9 +159,15 @@ export default function Subscriptions() { {sub.name}
状态 名称URL格式 节点数 最后抓取操作操作
- - {sub.url} - +
+ SURGE + {sub.url_clash && ( + CLASH + )} + {sub.url_ssr && ( + SSR + )} +
{sub.node_count || '—'} @@ -102,6 +177,9 @@ export default function Subscriptions() {
+