feat: init proj

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

15
web/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sub Router</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

22
web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "sub-router-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

157
web/src/App.tsx Normal file
View File

@@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
import Layout, { type Panel } from './components/Layout';
import Subscriptions from './components/Subscriptions';
import StaticNodes from './components/StaticNodes';
import NodeSelector from './components/NodeSelector';
import Rules from './components/Rules';
import Output from './components/Output';
import { auth, setToken } from './api';
function LoginPage({ onLogin }: { onLogin: () => void }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSetup, setIsSetup] = useState(false);
useEffect(() => {
auth.status().then(data => {
setIsSetup(data.hasPassword);
});
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) return;
try {
await auth.login(password);
setToken(password);
onLogin();
} catch {
setError('密码错误');
}
};
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg-primary)',
}}>
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 40,
width: 360,
textAlign: 'center',
}}>
<div style={{
fontFamily: 'var(--font-mono)',
fontSize: 20,
fontWeight: 700,
color: 'var(--accent)',
marginBottom: 8,
letterSpacing: '0.1em',
}}>
Sub Router
</div>
<div style={{
fontSize: 12,
color: 'var(--text-secondary)',
marginBottom: 24,
}}>
{isSetup ? '输入密码以访问管理面板' : '设置管理密码'}
</div>
<form onSubmit={handleSubmit}>
<input
type="password"
placeholder={isSetup ? '密码' : '设置新密码'}
value={password}
onChange={e => setPassword(e.target.value)}
style={{
width: '100%',
marginBottom: 12,
textAlign: 'center',
fontSize: 14,
padding: 10,
}}
autoFocus
/>
{error && (
<div style={{ color: 'var(--danger)', fontSize: 12, marginBottom: 12 }}>
{error}
</div>
)}
<button className="primary" type="submit" style={{ width: '100%' }}>
{isSetup ? '登录' : '设置密码'}
</button>
</form>
</div>
</div>
);
}
export default function App() {
const [activePanel, setActivePanel] = useState<Panel>('subscriptions');
const [authenticated, setAuthenticated] = useState(false);
const [checking, setChecking] = useState(true);
useEffect(() => {
// Check if auth is needed and if we have a valid token
auth.status().then(data => {
if (!data.hasPassword) {
// No password set yet — show login to set one
setChecking(false);
return;
}
// Try existing token
const token = sessionStorage.getItem('sub-router-token');
if (token) {
setToken(token);
// Verify by calling stats
fetch('/api/stats', {
headers: { Authorization: `Bearer ${token}` },
}).then(res => {
if (res.ok) setAuthenticated(true);
setChecking(false);
}).catch(() => setChecking(false));
} else {
setChecking(false);
}
}).catch(() => setChecking(false));
}, []);
if (checking) {
return (
<div style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
fontFamily: 'var(--font-mono)',
}}>
Loading...
</div>
);
}
if (!authenticated) {
return <LoginPage onLogin={() => setAuthenticated(true)} />;
}
const panels: Record<Panel, React.ReactNode> = {
subscriptions: <Subscriptions />,
'static-nodes': <StaticNodes />,
'node-selector': <NodeSelector />,
rules: <Rules />,
output: <Output />,
};
return (
<Layout activePanel={activePanel} onPanelChange={setActivePanel}>
{panels[activePanel]}
</Layout>
);
}

122
web/src/api.ts Normal file
View File

@@ -0,0 +1,122 @@
const API_BASE = '/api';
function getToken(): string | null {
return sessionStorage.getItem('sub-router-token');
}
export function setToken(token: string) {
sessionStorage.setItem('sub-router-token', token);
}
export function clearToken() {
sessionStorage.removeItem('sub-router-token');
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (res.status === 401) {
clearToken();
window.location.reload();
throw new Error('Unauthorized');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || res.statusText);
}
return res.json();
}
// Auth
export const auth = {
status: () => request<{ hasPassword: boolean }>('/auth/status'),
login: (password: string) => request<{ ok: boolean }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
}),
};
// Subscriptions
export const subscriptions = {
list: () => request<any[]>('/subscriptions'),
create: (name: string, url: string) => request<{ id: number }>('/subscriptions', {
method: 'POST',
body: JSON.stringify({ name, url }),
}),
update: (id: number, data: any) => request<{ ok: boolean }>(`/subscriptions/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: number) => request<{ ok: boolean }>(`/subscriptions/${id}`, {
method: 'DELETE',
}),
fetch: (id: number) => request<{ nodeCount: number }>(`/subscriptions/${id}/fetch`, {
method: 'POST',
}),
nodes: (id: number) => request<any[]>(`/subscriptions/${id}/nodes`),
};
// Nodes
export const nodes = {
fetchedToggle: (id: number, enabled: boolean) => request<{ ok: boolean }>(`/nodes/fetched/${id}`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
}),
fetchedBatch: (ids: number[], enabled: boolean) => request<{ ok: boolean }>('/nodes/fetched/batch', {
method: 'PUT',
body: JSON.stringify({ ids, enabled }),
}),
staticList: () => request<any[]>('/nodes/static'),
staticCreate: (uri: string, name?: string) => request<any>('/nodes/static', {
method: 'POST',
body: JSON.stringify({ uri, name }),
}),
staticUpdate: (id: number, data: any) => request<{ ok: boolean }>(`/nodes/static/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
staticDelete: (id: number) => request<{ ok: boolean }>(`/nodes/static/${id}`, {
method: 'DELETE',
}),
};
// Rules
export const rules = {
list: () => request<any[]>('/rules'),
create: (data: any) => request<{ id: number }>('/rules', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: number, data: any) => request<{ ok: boolean }>(`/rules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: number) => request<{ ok: boolean }>(`/rules/${id}`, {
method: 'DELETE',
}),
reorder: (ids: number[]) => request<{ ok: boolean }>('/rules/reorder', {
method: 'PUT',
body: JSON.stringify({ ids }),
}),
};
// Config
export const config = {
preview: () => request<{ config: string }>('/config/preview'),
};
// Stats
export const stats = {
get: () => request<any>('/stats'),
};

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react';
import { stats as statsApi } from '../api';
type Panel = 'subscriptions' | 'static-nodes' | 'node-selector' | 'rules' | 'output';
const NAV_ITEMS: { key: Panel; label: string; icon: string }[] = [
{ key: 'subscriptions', label: '订阅', icon: '⟐' },
{ key: 'static-nodes', label: '节点', icon: '◈' },
{ key: 'node-selector', label: '选择', icon: '☰' },
{ key: 'rules', label: '规则', icon: '⧖' },
{ key: 'output', label: '输出', icon: '▸' },
];
interface LayoutProps {
activePanel: Panel;
onPanelChange: (panel: Panel) => void;
children: React.ReactNode;
}
export default function Layout({ activePanel, onPanelChange, children }: LayoutProps) {
const [statsData, setStatsData] = useState<any>(null);
useEffect(() => {
const load = () => statsApi.get().then(setStatsData).catch(() => {});
load();
const interval = setInterval(load, 10000);
return () => clearInterval(interval);
}, []);
const totalNodes = statsData
? statsData.nodes.fetched.enabled + statsData.nodes.static.enabled
: 0;
const totalNodesAll = statsData
? statsData.nodes.fetched.total + statsData.nodes.static.total
: 0;
return (
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
background: 'var(--bg-primary)',
}}>
{/* Header */}
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 20px',
height: 40,
borderBottom: '1px solid var(--border)',
background: 'var(--bg-panel)',
flexShrink: 0,
}}>
<div style={{
fontFamily: 'var(--font-mono)',
fontWeight: 700,
fontSize: 14,
letterSpacing: '0.1em',
color: 'var(--accent)',
}}>
Sub Router
</div>
<div style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-muted)',
}}>
v1.0.0
</div>
</header>
{/* Main */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Sidebar */}
<nav style={{
width: 72,
background: 'var(--bg-panel)',
borderRight: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
paddingTop: 8,
flexShrink: 0,
}}>
{NAV_ITEMS.map(item => (
<button
key={item.key}
onClick={() => onPanelChange(item.key)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
padding: '12px 0',
margin: '2px 6px',
border: 'none',
borderRadius: 'var(--radius)',
background: activePanel === item.key ? 'var(--bg-active)' : 'transparent',
color: activePanel === item.key ? 'var(--accent)' : 'var(--text-secondary)',
cursor: 'pointer',
transition: 'all var(--transition)',
textTransform: 'none',
letterSpacing: 'normal',
fontFamily: 'var(--font-sans)',
fontSize: 10,
fontWeight: activePanel === item.key ? 600 : 400,
}}
>
<span style={{ fontSize: 18, lineHeight: 1 }}>{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</nav>
{/* Content */}
<main style={{
flex: 1,
overflow: 'auto',
padding: 24,
}}>
{children}
</main>
</div>
{/* Status bar */}
<footer style={{
display: 'flex',
alignItems: 'center',
gap: 24,
padding: '0 16px',
height: 28,
borderTop: '1px solid var(--border)',
background: 'var(--bg-panel)',
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--text-secondary)',
flexShrink: 0,
}}>
<span style={{ color: 'var(--success)' }}> online</span>
<span>nodes: <span style={{ color: 'var(--accent)' }}>{totalNodes}</span>/{totalNodesAll}</span>
<span>rules: <span style={{ color: 'var(--accent)' }}>{statsData?.rules ?? 0}</span></span>
<span>port: <span style={{ color: 'var(--accent)' }}>3456</span></span>
</footer>
</div>
);
}
export type { Panel };

View File

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

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react';
import { config as configApi } from '../api';
export default function Output() {
const [preview, setPreview] = useState('');
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const surgeUrl = `${window.location.origin}/surge`;
const loadPreview = async () => {
setLoading(true);
try {
const data = await configApi.preview();
setPreview(data.config);
} catch (err: any) {
setPreview(`# Error: ${err.message}`);
} finally {
setLoading(false);
}
};
useEffect(() => { loadPreview(); }, []);
const handleCopy = () => {
navigator.clipboard.writeText(surgeUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}>Surge </p>
{/* Subscription URL */}
<div style={{
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
marginBottom: 20,
}}>
<div style={{
fontSize: 10,
fontFamily: 'var(--font-mono)',
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginBottom: 8,
}}>
Surge
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<code style={{
flex: 1,
fontFamily: 'var(--font-mono)',
fontSize: 13,
color: 'var(--accent)',
userSelect: 'all',
}}>
{surgeUrl}
</code>
<button className="small" onClick={handleCopy}>
{copied ? '已复制' : '复制'}
</button>
</div>
</div>
{/* Preview */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{
fontSize: 10,
fontFamily: 'var(--font-mono)',
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}>
</span>
<button className="small" onClick={loadPreview} disabled={loading}>
{loading ? '加载中...' : '刷新预览'}
</button>
</div>
<pre style={{
background: 'var(--bg-input)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: 16,
fontFamily: 'var(--font-mono)',
fontSize: 11,
lineHeight: 1.6,
color: 'var(--text-secondary)',
overflow: 'auto',
maxHeight: 'calc(100vh - 360px)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}>
{preview || '(empty)'}
</pre>
</div>
);
}
const styles = {
title: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 16,
fontWeight: 600 as const,
color: 'var(--text-primary)',
marginBottom: 4,
},
subtitle: {
fontSize: 12,
color: 'var(--text-secondary)',
marginBottom: 20,
},
};

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react';
import { rules as api } from '../api';
const RULE_TYPES = [
'DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD',
'IP-CIDR', 'IP-CIDR6', 'GEOIP',
'URL-REGEX', 'USER-AGENT',
'PROCESS-NAME', 'SUBNET',
];
const ACTIONS = ['PROXY', 'DIRECT', 'REJECT', 'REJECT-TINYGIF'];
export default function Rules() {
const [ruleList, setRuleList] = useState<any[]>([]);
const [type, setType] = useState('DOMAIN-SUFFIX');
const [value, setValue] = useState('');
const [action, setAction] = useState('PROXY');
const [comment, setComment] = useState('');
const load = () => api.list().then(setRuleList).catch(console.error);
useEffect(() => { load(); }, []);
const handleAdd = async () => {
if (!value.trim()) return;
await api.create({ type, value: value.trim(), action, comment: comment.trim() || undefined });
setValue('');
setComment('');
load();
};
const handleDelete = async (id: number) => {
await api.delete(id);
load();
};
const handleToggle = async (id: number, rule: any) => {
await api.update(id, { enabled: rule.enabled ? 0 : 1 });
load();
};
const handleMoveUp = async (index: number) => {
if (index === 0) return;
const ids = ruleList.map(r => r.id);
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
await api.reorder(ids);
load();
};
const handleMoveDown = async (index: number) => {
if (index === ruleList.length - 1) return;
const ids = ruleList.map(r => r.id);
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
await api.reorder(ids);
load();
};
return (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}> Surge [Rule] </p>
{/* Add form */}
<div style={styles.form}>
<select value={type} onChange={e => setType(e.target.value)} style={{ width: 160 }}>
{RULE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<input
placeholder="匹配值 (如 google.com)"
value={value}
onChange={e => setValue(e.target.value)}
style={{ flex: 1 }}
/>
<select value={action} onChange={e => setAction(e.target.value)} style={{ width: 140 }}>
{ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<input
placeholder="备注(可选)"
value={comment}
onChange={e => setComment(e.target.value)}
style={{ width: 160 }}
/>
<button className="primary" onClick={handleAdd}></button>
</div>
{/* Rules table */}
<table>
<thead>
<tr>
<th style={{ width: 50 }}></th>
<th style={{ width: 60 }}></th>
<th style={{ width: 140 }}></th>
<th></th>
<th style={{ width: 120 }}></th>
<th></th>
<th style={{ width: 60 }}></th>
</tr>
</thead>
<tbody>
{ruleList.map((rule, idx) => (
<tr key={rule.id} style={{ opacity: rule.enabled ? 1 : 0.5 }}>
<td>
<input
type="checkbox"
className="toggle"
checked={!!rule.enabled}
onChange={() => handleToggle(rule.id, rule)}
/>
</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button
className="small"
onClick={() => handleMoveUp(idx)}
disabled={idx === 0}
style={{ padding: '2px 6px', fontSize: 10 }}
></button>
<button
className="small"
onClick={() => handleMoveDown(idx)}
disabled={idx === ruleList.length - 1}
style={{ padding: '2px 6px', fontSize: 10 }}
></button>
</div>
</td>
<td style={{
fontFamily: 'var(--font-mono)',
fontSize: 11,
color: 'var(--warning)',
}}>
{rule.type}
</td>
<td style={{
fontFamily: 'var(--font-mono)',
fontSize: 12,
color: 'var(--accent)',
}}>
{rule.value}
</td>
<td>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 8px',
borderRadius: 'var(--radius)',
background: rule.action === 'PROXY' ? 'var(--accent-glow)' :
rule.action === 'REJECT' ? 'rgba(255, 95, 87, 0.15)' :
'var(--bg-active)',
color: rule.action === 'PROXY' ? 'var(--accent)' :
rule.action === 'REJECT' ? 'var(--danger)' :
'var(--text-secondary)',
}}>
{rule.action}
</span>
</td>
<td style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{rule.comment || '—'}
</td>
<td>
<button className="small danger" onClick={() => handleDelete(rule.id)}>
</button>
</td>
</tr>
))}
{ruleList.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
const styles = {
title: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 16,
fontWeight: 600 as const,
color: 'var(--text-primary)',
marginBottom: 4,
},
subtitle: {
fontSize: 12,
color: 'var(--text-secondary)',
marginBottom: 20,
},
form: {
display: 'flex' as const,
gap: 8,
marginBottom: 20,
flexWrap: 'wrap' as const,
},
};

View File

@@ -0,0 +1,177 @@
import { useState, useEffect } from 'react';
import { nodes as api } from '../api';
export default function StaticNodes() {
const [nodeList, setNodeList] = useState<any[]>([]);
const [uri, setUri] = useState('');
const [customName, setCustomName] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const load = () => api.staticList().then(setNodeList).catch(console.error);
useEffect(() => { load(); }, []);
const handleAdd = async () => {
if (!uri.trim()) return;
try {
await api.staticCreate(uri.trim(), customName.trim() || undefined);
setUri('');
setCustomName('');
load();
} catch (err: any) {
alert(`解析失败: ${err.message}`);
}
};
const handleDelete = async (id: number) => {
await api.staticDelete(id);
load();
};
const handleToggle = async (id: number, current: number) => {
await api.staticUpdate(id, { enabled: current ? 0 : 1 });
load();
};
const handleRename = async (id: number) => {
if (!editingName.trim()) return;
await api.staticUpdate(id, { name: editingName.trim() });
setEditingId(null);
setEditingName('');
load();
};
const handleKeyDown = async (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
await handleAdd();
}
};
return (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}> ss:// / vmess:// / trojan:// URI 自动解析</p>
<div style={styles.form}>
<input
placeholder="自定义名称(可选)"
value={customName}
onChange={e => setCustomName(e.target.value)}
style={{ width: 180 }}
/>
<input
placeholder="粘贴节点 URIss:// / vmess:// / trojan://"
value={uri}
onChange={e => setUri(e.target.value)}
onKeyDown={handleKeyDown}
style={{ flex: 1 }}
/>
<button className="primary" onClick={handleAdd}></button>
</div>
<table>
<thead>
<tr>
<th style={{ width: 50 }}></th>
<th></th>
<th style={{ width: 80 }}></th>
<th>Surge </th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{nodeList.map(node => (
<tr key={node.id}>
<td>
<input
type="checkbox"
className="toggle"
checked={!!node.enabled}
onChange={() => handleToggle(node.id, node.enabled)}
/>
</td>
<td style={{ fontFamily: 'var(--font-mono)', color: 'var(--accent)' }}>
{editingId === node.id ? (
<input
value={editingName}
onChange={e => setEditingName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleRename(node.id);
if (e.key === 'Escape') { setEditingId(null); setEditingName(''); }
}}
onBlur={() => handleRename(node.id)}
autoFocus
style={{ width: '100%', fontSize: 12 }}
/>
) : (
<span
onDoubleClick={() => { setEditingId(node.id); setEditingName(node.name); }}
style={{ cursor: 'pointer' }}
title="双击编辑名称"
>
{node.name}
</span>
)}
</td>
<td>
<span style={{
fontFamily: 'var(--font-mono)',
fontSize: 10,
padding: '2px 6px',
borderRadius: 'var(--radius)',
background: 'var(--bg-active)',
color: 'var(--text-secondary)',
textTransform: 'uppercase',
}}>
{node.type}
</span>
</td>
<td>
<span className="ellipsis mono" style={{
fontSize: 11,
maxWidth: 400,
display: 'inline-block',
color: 'var(--text-secondary)',
}}>
{node.surge_line}
</span>
</td>
<td>
<button className="small danger" onClick={() => handleDelete(node.id)}>
</button>
</td>
</tr>
))}
{nodeList.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
const styles = {
title: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 16,
fontWeight: 600 as const,
color: 'var(--text-primary)',
marginBottom: 4,
},
subtitle: {
fontSize: 12,
color: 'var(--text-secondary)',
marginBottom: 20,
},
form: {
display: 'flex' as const,
gap: 8,
marginBottom: 20,
},
};

View File

@@ -0,0 +1,150 @@
import { useState, useEffect } from 'react';
import { subscriptions as api } from '../api';
export default function Subscriptions() {
const [subs, setSubs] = useState<any[]>([]);
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [fetching, setFetching] = useState<number | null>(null);
const load = () => api.list().then(setSubs).catch(console.error);
useEffect(() => { load(); }, []);
const handleAdd = async () => {
if (!name.trim() || !url.trim()) return;
await api.create(name.trim(), url.trim());
setName('');
setUrl('');
load();
};
const handleDelete = async (id: number) => {
await api.delete(id);
load();
};
const handleToggle = async (id: number, enabled: number) => {
await api.update(id, { enabled: enabled ? 0 : 1 });
load();
};
const handleFetch = async (id: number) => {
setFetching(id);
try {
const result = await api.fetch(id);
alert(`抓取成功,解析到 ${result.nodeCount} 个节点`);
load();
} catch (err: any) {
alert(`抓取失败: ${err.message}`);
} finally {
setFetching(null);
}
};
return (
<div>
<h2 style={styles.title}></h2>
<p style={styles.subtitle}></p>
{/* Add form */}
<div style={styles.form}>
<input
placeholder="名称"
value={name}
onChange={e => setName(e.target.value)}
style={{ width: 160 }}
/>
<input
placeholder="订阅 URL"
value={url}
onChange={e => setUrl(e.target.value)}
style={{ flex: 1 }}
/>
<button className="primary" onClick={handleAdd}></button>
</div>
{/* Table */}
<table>
<thead>
<tr>
<th style={{ width: 50 }}></th>
<th></th>
<th>URL</th>
<th style={{ width: 80 }}></th>
<th style={{ width: 140 }}></th>
<th style={{ width: 140 }}></th>
</tr>
</thead>
<tbody>
{subs.map(sub => (
<tr key={sub.id}>
<td>
<input
type="checkbox"
className="toggle"
checked={!!sub.enabled}
onChange={() => handleToggle(sub.id, sub.enabled)}
/>
</td>
<td style={{ fontFamily: 'var(--font-mono)', color: 'var(--accent)' }}>
{sub.name}
</td>
<td>
<span className="ellipsis" style={{ maxWidth: 300, display: 'inline-block' }}>
{sub.url}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
{sub.node_count || '—'}
</td>
<td style={{ fontSize: 11, color: 'var(--text-secondary)' }}>
{sub.last_fetch ? new Date(sub.last_fetch).toLocaleString() : '未抓取'}
</td>
<td>
<div style={{ display: 'flex', gap: 6 }}>
<button
className="small"
onClick={() => handleFetch(sub.id)}
disabled={fetching === sub.id}
>
{fetching === sub.id ? '抓取中...' : '刷新'}
</button>
<button className="small danger" onClick={() => handleDelete(sub.id)}>
</button>
</div>
</td>
</tr>
))}
{subs.length === 0 && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--text-muted)', padding: 40 }}>
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
const styles = {
title: {
fontFamily: 'var(--font-mono)' as const,
fontSize: 16,
fontWeight: 600 as const,
color: 'var(--text-primary)',
marginBottom: 4,
},
subtitle: {
fontSize: 12,
color: 'var(--text-secondary)',
marginBottom: 20,
},
form: {
display: 'flex' as const,
gap: 8,
marginBottom: 20,
},
};

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/global.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

219
web/src/styles/global.css Normal file
View File

@@ -0,0 +1,219 @@
:root {
--bg-primary: #0a0e14;
--bg-panel: #111820;
--bg-input: #0d1117;
--bg-hover: #1a2332;
--bg-active: #1e2d3d;
--border: #1e2d3d;
--border-bright: #2a3f54;
--text-primary: #c5cdd8;
--text-secondary: #6b7d8e;
--text-muted: #3d4f5f;
--accent: #00e5c7;
--accent-dim: #00b39e;
--accent-glow: rgba(0, 229, 199, 0.15);
--danger: #ff5f57;
--danger-dim: #cc4c46;
--warning: #ffbd2e;
--success: #28c840;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--radius: 4px;
--transition: 150ms ease;
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--font-sans);
font-size: 13px;
color: var(--text-primary);
background: var(--bg-primary);
-webkit-font-smoothing: antialiased;
}
/* Scanline overlay */
#root::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-bright);
}
/* Inputs */
input, textarea, select {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
outline: none;
transition: border-color var(--transition);
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
}
input::placeholder, textarea::placeholder {
color: var(--text-muted);
}
/* Buttons */
button {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 14px;
color: var(--text-primary);
background: var(--bg-panel);
transition: all var(--transition);
}
button:hover {
border-color: var(--accent);
color: var(--accent);
}
button:active {
background: var(--bg-active);
}
button.primary {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
button.primary:hover {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: var(--bg-primary);
}
button.danger {
color: var(--danger);
border-color: transparent;
}
button.danger:hover {
border-color: var(--danger);
}
button.small {
padding: 3px 8px;
font-size: 10px;
}
/* Toggle switch */
.toggle {
position: relative;
width: 36px;
height: 18px;
appearance: none;
background: var(--border);
border-radius: 9px;
cursor: pointer;
border: none;
padding: 0;
transition: background var(--transition);
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: var(--text-secondary);
border-radius: 50%;
transition: all var(--transition);
}
.toggle:checked {
background: var(--accent);
}
.toggle:checked::after {
left: 20px;
background: var(--bg-primary);
}
/* Table */
table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 12px;
}
th {
text-align: left;
padding: 8px 12px;
color: var(--text-secondary);
font-weight: 500;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tr:hover td {
background: var(--bg-hover);
}
/* Utility */
.mono {
font-family: var(--font-mono);
}
.text-accent {
color: var(--accent);
}
.text-muted {
color: var(--text-secondary);
}
.text-danger {
color: var(--danger);
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

16
web/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"]
}

13
web/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3456',
'/surge': 'http://localhost:3456',
},
},
});