feat: init proj
This commit is contained in:
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