chore: 准备发布 v1.0.0 版本

This commit is contained in:
xiamuceer-j
2026-03-02 13:13:33 +08:00
parent f57ae3c712
commit 8163e4c193
58 changed files with 19545 additions and 1 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Easy Proxies</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3904
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"daisyui": "^5.5.19",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+398
View File
@@ -0,0 +1,398 @@
import React, { useState, useEffect, useCallback } from 'react'
import MonitorPanel from './components/MonitorPanel'
import ManagePanel from './components/ManagePanel'
import DebugPanel from './components/DebugPanel'
import SettingsPanel from './components/SettingsPanel'
import LoginPage from './components/LoginPage'
import { checkAuth, getToken, logout } from './api/client'
type AuthState = 'loading' | 'need_login' | 'authenticated'
type TabId = 'monitor' | 'manage' | 'debug' | 'settings'
const ALL_THEMES = [
'light', 'dark', 'cupcake', 'bumblebee', 'emerald', 'corporate',
'synthwave', 'retro', 'cyberpunk', 'valentine', 'halloween', 'garden',
'forest', 'aqua', 'lofi', 'pastel', 'fantasy', 'wireframe', 'black',
'luxury', 'dracula', 'cmyk', 'autumn', 'business', 'acid', 'lemonade',
'night', 'coffee', 'winter', 'dim', 'nord', 'sunset',
'caramellatte', 'abyss', 'silk',
]
const MENU_ITEMS: { id: TabId; label: string; icon: React.ReactNode; desc: string }[] = [
{
id: 'monitor',
label: '节点监控',
desc: '数据仪表盘',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
id: 'manage',
label: '节点管理',
desc: '操作与配置',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
),
},
{
id: 'debug',
label: '调试面板',
desc: '运行时信息',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
},
{
id: 'settings',
label: '系统设置',
desc: '配置与订阅',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
]
const VALID_TABS: TabId[] = ['monitor', 'manage', 'debug', 'settings']
const THEME_STORAGE_KEY = 'ep-theme'
const DARK_THEMES = new Set([
'dark', 'synthwave', 'halloween', 'forest', 'black', 'luxury',
'dracula', 'business', 'night', 'coffee', 'dim', 'sunset', 'abyss',
])
const VIBRANT_THEMES = new Set([
'cupcake', 'bumblebee', 'retro', 'cyberpunk', 'valentine', 'aqua',
'pastel', 'fantasy', 'cmyk', 'acid', 'lemonade', 'caramellatte', 'silk',
])
function getThemeProfile(themeName: string): { tag: string; icon: string; desc: string } {
if (DARK_THEMES.has(themeName)) {
return { tag: '深色', icon: '🌙', desc: '低亮度 · 护眼' }
}
if (VIBRANT_THEMES.has(themeName)) {
return { tag: '鲜艳', icon: '🎨', desc: '高对比 · 活力' }
}
return { tag: '清爽', icon: '☀️', desc: '中性 · 易读' }
}
function getSystemTheme(): string {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
}
function getInitialTheme(): string {
const saved = localStorage.getItem(THEME_STORAGE_KEY)
if (saved && ALL_THEMES.includes(saved)) {
return saved
}
return getSystemTheme()
}
function getTabFromHash(): TabId {
const hash = window.location.hash.replace('#', '')
if (VALID_TABS.includes(hash as TabId)) {
return hash as TabId
}
return 'monitor'
}
function App() {
const [activeTab, setActiveTab] = useState<TabId>(getTabFromHash)
const [authState, setAuthState] = useState<AuthState>('loading')
const [theme, setTheme] = useState(getInitialTheme)
const [sidebarOpen, setSidebarOpen] = useState(false)
// Sync hash → state on browser back/forward
useEffect(() => {
const onHashChange = () => setActiveTab(getTabFromHash())
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
}, [])
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(THEME_STORAGE_KEY, theme)
}, [theme])
useEffect(() => {
const doCheck = async () => {
try {
const res = await checkAuth()
if (res.no_password) {
setAuthState('authenticated')
} else if (getToken()) {
setAuthState('authenticated')
} else {
setAuthState('need_login')
}
} catch {
if (getToken()) {
setAuthState('authenticated')
} else {
setAuthState('need_login')
}
}
}
doCheck()
}, [])
useEffect(() => {
const handler = () => setAuthState('need_login')
window.addEventListener('auth:unauthorized', handler)
return () => window.removeEventListener('auth:unauthorized', handler)
}, [])
const handleLogin = useCallback(() => setAuthState('authenticated'), [])
const handleLogout = useCallback(() => {
logout()
setAuthState('need_login')
}, [])
const handleTabClick = useCallback((tab: TabId) => {
setActiveTab(tab)
window.location.hash = tab
setSidebarOpen(false)
}, [])
const renderContent = () => {
switch (activeTab) {
case 'monitor': return <MonitorPanel />
case 'manage': return <ManagePanel />
case 'debug': return <DebugPanel />
case 'settings': return <SettingsPanel />
default: return <MonitorPanel />
}
}
if (authState === 'loading') {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-base-200 gap-4">
<span className="text-2xl font-black text-primary">Easy Proxies</span>
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
)
}
if (authState === 'need_login') {
return <LoginPage onLogin={handleLogin} />
}
return (
<div className="flex flex-col h-screen overflow-hidden bg-base-100">
{/* ===== Top Navbar ===== */}
<div className="navbar bg-base-100/95 backdrop-blur-xl z-50 min-h-[4rem] h-16 px-4 lg:px-6 shrink-0 border-b border-base-300/40 shadow-sm sticky top-0">
<div className="flex-none lg:hidden mr-2">
<button
className="btn btn-square btn-ghost btn-sm rounded-lg"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="toggle sidebar"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
{/* Logo Area */}
<div className="flex-1 lg:flex-none lg:w-64 px-2 flex items-center gap-3.5 border-r-0 lg:border-r border-base-300/30">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center shadow-sm border border-primary/10">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<div className="font-black text-lg text-base-content tracking-tight leading-none mb-1">
Easy Proxies
</div>
<div className="text-[11px] font-medium text-base-content/40 tracking-wider uppercase">
Dashboard
</div>
</div>
</div>
{/* Top Right Actions */}
<div className="flex-none flex items-center gap-2 lg:gap-3 ml-auto">
{/* Theme Selector */}
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm h-9 px-3 gap-2 normal-case rounded-lg border border-base-300/50 hover:border-primary/30 hover:bg-primary/5 transition-all">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
<div className="hidden sm:flex items-center gap-1.5">
<span className="text-xs capitalize font-medium">{theme}</span>
<span className="badge badge-ghost badge-xs">{getThemeProfile(theme).tag}</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
<ul tabIndex={0} className="dropdown-content bg-base-200 border border-base-300/50 rounded-box shadow-2xl mt-2 p-2.5 w-80 max-h-96 overflow-y-auto z-[100] space-y-1">
{ALL_THEMES.map((t) => {
const profile = getThemeProfile(t)
const isActive = theme === t
return (
<li key={t}>
<button
className={`w-full rounded-xl px-2 py-2 border transition-all duration-150 ${
isActive
? 'bg-primary/10 border-primary/30 shadow-sm'
: 'border-transparent hover:border-base-300/70 hover:bg-base-300/40'
}`}
onClick={() => {
setTheme(t)
const el = document.activeElement as HTMLElement
el?.blur()
}}
>
<div className="flex items-center gap-3">
<div className="shrink-0 rounded-lg border border-base-300/60 p-1 bg-base-100/50" data-theme={t}>
<div className="w-16 h-10 rounded-md bg-base-100 border border-base-300/70 p-1.5 flex flex-col justify-between">
<div className="h-1.5 w-9 rounded bg-primary"></div>
<div className="flex items-center gap-1">
<span className="w-2.5 h-2.5 rounded bg-secondary"></span>
<span className="w-2.5 h-2.5 rounded bg-accent"></span>
<span className="w-2.5 h-2.5 rounded bg-neutral"></span>
<span className="w-2.5 h-2.5 rounded bg-info"></span>
</div>
</div>
</div>
<div className="min-w-0 flex-1 text-left">
<div className={`text-sm font-semibold capitalize leading-tight ${isActive ? 'text-primary' : ''}`}>{t}</div>
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-base-content/55">
<span>{profile.icon}</span>
<span>{profile.tag}</span>
<span className="opacity-40">·</span>
<span className="truncate">{profile.desc}</span>
</div>
</div>
{isActive && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</button>
</li>
)
})}
</ul>
</div>
<div className="w-px h-6 bg-base-300/50 mx-1"></div>
<button
className="btn btn-ghost btn-sm h-9 w-9 p-0 rounded-lg hover:bg-error/10 hover:text-error transition-colors"
onClick={handleLogout}
title="退出登录"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
</div>
{/* ===== Body: Sidebar + Content ===== */}
<div className="flex flex-1 overflow-hidden relative">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 lg:hidden backdrop-blur-sm"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed lg:static inset-y-0 left-0 z-40
w-64 bg-base-100/95 backdrop-blur-xl border-r border-base-300/40
transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full'}
lg:translate-x-0 lg:shrink-0 lg:shadow-none
flex flex-col
pt-16 lg:pt-0
`}
>
{/* Navigation */}
<nav className="flex-1 p-4 lg:p-5 overflow-y-auto">
<div className="text-[11px] font-bold text-base-content/40 uppercase tracking-widest mb-3 px-2">主要导航</div>
<ul className="space-y-1.5">
{MENU_ITEMS.map((item) => {
const isActive = activeTab === item.id;
return (
<li key={item.id}>
<button
className={`w-full flex items-center gap-3.5 px-3 py-3 rounded-xl transition-all duration-200 group
${isActive
? 'bg-primary/10 shadow-sm border border-primary/20'
: 'hover:bg-base-200/50 border border-transparent'
}
`}
onClick={() => handleTabClick(item.id)}
>
<div className={`p-2 rounded-lg transition-colors ${
isActive
? 'bg-primary text-primary-content shadow-md shadow-primary/20'
: 'bg-base-200 text-base-content/50 group-hover:bg-base-300 group-hover:text-base-content'
}`}>
{item.icon}
</div>
<div className="text-left flex-1 min-w-0">
<div className={`text-sm truncate ${isActive ? 'font-bold text-primary' : 'font-medium text-base-content/80 group-hover:text-base-content'}`}>
{item.label}
</div>
<div className={`text-xs truncate ${isActive ? 'text-primary/70' : 'text-base-content/40'}`}>
{item.desc}
</div>
</div>
{isActive && (
<div className="w-1.5 h-6 rounded-full bg-primary shrink-0 opacity-80"></div>
)}
</button>
</li>
)
})}
</ul>
</nav>
{/* Sidebar Footer */}
<div className="p-5 border-t border-base-300/30 bg-base-200/20 m-2 rounded-2xl mb-4">
<div className="flex items-center gap-3">
<div className="relative flex h-3 w-3 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-success"></span>
</div>
<div className="flex flex-col">
<span className="text-sm font-semibold text-base-content/80">系统运行正常</span>
<span className="text-xs text-base-content/40">v1.0.0</span>
</div>
</div>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto bg-base-200/40 relative">
{renderContent()}
</main>
</div>
</div>
)
}
export default App
+365
View File
@@ -0,0 +1,365 @@
import type {
AuthResponse,
NodesResponse,
DebugResponse,
SettingsData,
SettingsUpdateResponse,
ConfigNodesResponse,
ConfigNodePayload,
ConfigNodeMutationResponse,
SubscriptionStatus,
ProbeSSEEvent,
TrafficStreamEvent,
} from '../types'
// ---- Token management ----
let authToken: string | null = localStorage.getItem('auth_token')
export function getToken(): string | null {
return authToken
}
export function setToken(token: string | null) {
authToken = token
if (token) {
localStorage.setItem('auth_token', token)
} else {
localStorage.removeItem('auth_token')
}
}
export function clearToken() {
setToken(null)
}
// ---- Base request helper ----
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string> || {}),
}
// Add auth header if we have a token
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
// Set JSON content type for non-GET requests with body
if (options.body && typeof options.body === 'string') {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(path, {
...options,
headers,
credentials: 'include', // send cookies
})
if (res.status === 401) {
clearToken()
// Dispatch a custom event so App can react
window.dispatchEvent(new CustomEvent('auth:unauthorized'))
throw new ApiError('未授权,请重新登录', 401)
}
if (!res.ok) {
let msg = `HTTP ${res.status}`
try {
const body = await res.json()
if (body.error) msg = body.error
} catch { /* ignore parse errors */ }
throw new ApiError(msg, res.status)
}
// Handle empty responses
const text = await res.text()
if (!text) return {} as T
return JSON.parse(text) as T
}
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
// ---- Auth API ----
/** Check if password is required & login */
export async function checkAuth(): Promise<AuthResponse> {
// Use GET-like behavior: /api/auth without POST returns password status
const res = await fetch('/api/auth', { credentials: 'include' })
return res.json()
}
export async function login(password: string): Promise<AuthResponse> {
const res = await fetch('/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
credentials: 'include',
})
if (!res.ok) {
const body = await res.json()
throw new ApiError(body.error || '登录失败', res.status)
}
const data: AuthResponse = await res.json()
if (data.token) {
setToken(data.token)
}
return data
}
export function logout() {
clearToken()
}
// ---- Nodes API ----
export async function fetchNodes(): Promise<NodesResponse> {
return request<NodesResponse>('/api/nodes')
}
export async function probeNode(tag: string): Promise<{ message: string; latency_ms: number }> {
return request(`/api/nodes/${encodeURIComponent(tag)}/probe`, { method: 'POST' })
}
export async function releaseNode(tag: string): Promise<{ message: string }> {
return request(`/api/nodes/${encodeURIComponent(tag)}/release`, { method: 'POST' })
}
/** Probe all nodes with SSE progress updates */
export function probeAllNodes(
onEvent: (event: ProbeSSEEvent) => void,
onError?: (error: Error) => void
): AbortController {
const controller = new AbortController()
const doFetch = async () => {
try {
const headers: Record<string, string> = {}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
const res = await fetch('/api/nodes/probe-all', {
method: 'POST',
headers,
credentials: 'include',
signal: controller.signal,
})
if (!res.ok) {
throw new ApiError(`探测失败: HTTP ${res.status}`, res.status)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('data: ')) {
try {
const data = JSON.parse(trimmed.slice(6)) as ProbeSSEEvent
onEvent(data)
} catch { /* skip malformed events */ }
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
onError?.(err as Error)
}
}
}
doFetch()
return controller
}
// ---- Traffic Stream API ----
/** Subscribe real-time traffic speeds via SSE */
export function streamTraffic(
onEvent: (event: TrafficStreamEvent) => void,
onError?: (error: Error) => void
): AbortController {
const controller = new AbortController()
const doFetch = async () => {
try {
const headers: Record<string, string> = {}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
const res = await fetch('/api/nodes/traffic/stream', {
method: 'GET',
headers,
credentials: 'include',
signal: controller.signal,
})
if (!res.ok) {
throw new ApiError(`流量流订阅失败: HTTP ${res.status}`, res.status)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('No response body')
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('data: ')) {
try {
const data = JSON.parse(trimmed.slice(6)) as TrafficStreamEvent
if (data.type === 'traffic') {
onEvent(data)
}
} catch { /* skip malformed events */ }
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
onError?.(err as Error)
}
}
}
doFetch()
return controller
}
// ---- Debug API ----
export async function fetchDebug(): Promise<DebugResponse> {
return request<DebugResponse>('/api/debug')
}
// ---- Settings API ----
export async function fetchSettings(): Promise<SettingsData> {
return request<SettingsData>('/api/settings')
}
export async function updateSettings(settings: SettingsData): Promise<SettingsUpdateResponse> {
return request<SettingsUpdateResponse>('/api/settings', {
method: 'PUT',
body: JSON.stringify(settings),
})
}
// ---- Config Nodes CRUD API ----
export async function fetchConfigNodes(): Promise<ConfigNodesResponse> {
return request<ConfigNodesResponse>('/api/nodes/config')
}
export async function createConfigNode(payload: ConfigNodePayload): Promise<ConfigNodeMutationResponse> {
return request<ConfigNodeMutationResponse>('/api/nodes/config', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateConfigNode(name: string, payload: ConfigNodePayload): Promise<ConfigNodeMutationResponse> {
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export async function deleteConfigNode(name: string): Promise<ConfigNodeMutationResponse> {
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
method: 'DELETE',
})
}
export async function toggleConfigNode(name: string, enabled: boolean): Promise<ConfigNodeMutationResponse> {
return request<ConfigNodeMutationResponse>(`/api/nodes/config/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
})
}
export async function batchToggleConfigNodes(names: string[], enabled: boolean): Promise<{ message: string; success: number; total: number; errors?: string[] }> {
return request('/api/nodes/config/batch-toggle', {
method: 'POST',
body: JSON.stringify({ names, enabled }),
})
}
export async function batchDeleteConfigNodes(names: string[]): Promise<{ message: string; success: number; total: number; errors?: string[] }> {
return request('/api/nodes/config/batch-delete', {
method: 'POST',
body: JSON.stringify({ names }),
})
}
// ---- Reload API ----
export async function triggerReload(): Promise<{ message: string }> {
return request('/api/reload', { method: 'POST' })
}
// ---- Subscription API ----
export async function fetchSubscriptionStatus(): Promise<SubscriptionStatus> {
return request<SubscriptionStatus>('/api/subscription/status')
}
export async function refreshSubscription(): Promise<{ message: string; node_count: number }> {
return request('/api/subscription/refresh', { method: 'POST' })
}
// ---- Export API ----
export async function exportProxies(): Promise<string> {
const headers: Record<string, string> = {}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`
}
const res = await fetch('/api/export', {
headers,
credentials: 'include',
})
if (!res.ok) throw new ApiError('导出失败', res.status)
return res.text()
}
// ---- Import API ----
export async function importNodes(content: string): Promise<{ message: string; imported: number; errors?: string[] }> {
return request('/api/import', {
method: 'POST',
body: JSON.stringify({ content }),
})
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+350
View File
@@ -0,0 +1,350 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { DebugResponse, DebugNode } from '../types'
import { fetchDebug } from '../api/client'
import { formatBytes } from '../utils/format'
function formatTime(isoStr: string): string {
if (!isoStr || isoStr === '0001-01-01T00:00:00Z') return '-'
try {
return new Date(isoStr).toLocaleString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return isoStr
}
}
function formatLogTime(isoStr: string): string {
if (!isoStr || isoStr === '0001-01-01T00:00:00Z') return '--:--:--'
try {
const d = new Date(isoStr)
const pad = (n: number) => n.toString().padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
} catch {
return isoStr
}
}
export default function DebugPanel() {
const [data, setData] = useState<DebugResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [autoRefresh, setAutoRefresh] = useState(true)
const [filter, setFilter] = useState('')
const [expandedNode, setExpandedNode] = useState<string | null>(null)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const loadData = useCallback(async () => {
try {
setError('')
const res = await fetchDebug()
setData(res)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
// Auto refresh
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(loadData, 5000)
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [autoRefresh, loadData])
const filteredNodes = (data?.nodes || []).filter((node: DebugNode) => {
if (!filter) return true
return (
node.name.toLowerCase().includes(filter.toLowerCase()) ||
node.tag.toLowerCase().includes(filter.toLowerCase()) ||
(node.last_error || '').toLowerCase().includes(filter.toLowerCase())
)
})
// Sort: nodes with errors first, then by failure count desc
const sortedNodes = [...filteredNodes].sort((a, b) => {
if (a.blacklisted !== b.blacklisted) return a.blacklisted ? -1 : 1
if (a.failure_count !== b.failure_count) return b.failure_count - a.failure_count
return a.name.localeCompare(b.name)
})
if (loading && !data) {
return (
<div className="flex items-center justify-center h-64">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
)
}
return (
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
{/* Header */}
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1600px] mx-auto w-full">
<div>
<h2 className="text-2xl font-bold flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</div>
调试面板
</h2>
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">运行时内部状态及错误信息分析</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 cursor-pointer bg-base-200/50 px-3 py-1.5 rounded-lg border border-base-300/50 hover:border-base-300 transition-colors">
<span className="text-sm font-medium">自动刷新 (5s)</span>
<input
type="checkbox"
className="toggle toggle-primary toggle-sm"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
</label>
<button
className="btn btn-sm lg:btn-md btn-primary shadow-sm gap-2"
onClick={loadData}
disabled={loading}
>
{loading ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)}
手动刷新
</button>
</div>
</div>
</div>
<div className="p-4 lg:p-8 space-y-6 flex-1 pb-10 max-w-[1600px] mx-auto w-full">
{/* Error */}
{error && (
<div role="alert" className="alert alert-error alert-soft text-sm">
<span>{error}</span>
</div>
)}
{/* Overview Stats */}
{data && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
<div className="text-xs text-base-content/50 mb-1">总调用数</div>
<div className="text-2xl font-bold tabular-nums">{data.total_calls.toLocaleString()}</div>
</div>
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
<div className="text-xs text-base-content/50 mb-1">成功数</div>
<div className="text-2xl font-bold tabular-nums text-success">{data.total_success.toLocaleString()}</div>
</div>
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
<div className="text-xs text-base-content/50 mb-1">失败数</div>
<div className="text-2xl font-bold tabular-nums text-error">{(data.total_calls - data.total_success).toLocaleString()}</div>
</div>
<div className="rounded-xl bg-base-200/50 border border-base-300/30 p-4 hover:border-primary/30 transition-colors">
<div className="text-xs text-base-content/50 mb-1">成功率</div>
<div className={`text-2xl font-bold tabular-nums ${data.success_rate >= 90 ? 'text-success' : data.success_rate >= 70 ? 'text-warning' : 'text-error'}`}>
{data.success_rate.toFixed(1)}%
</div>
</div>
</div>
)}
{/* Filter */}
<label className="input input-sm w-full">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<input
type="text"
placeholder="搜索节点名称、标签或错误信息..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</label>
{/* Node Debug List */}
<div className="space-y-2 max-h-[60vh] overflow-y-auto pr-1">
{sortedNodes.length === 0 ? (
<div className="text-center text-base-content/50 py-8">
{filter ? '没有匹配的节点' : '暂无调试数据'}
</div>
) : (
sortedNodes.map((node: DebugNode) => (
<div
key={node.tag}
className={`collapse collapse-arrow bg-base-200/60 border rounded-xl transition-colors ${node.blacklisted ? 'border-error/30' : 'border-base-300/40 hover:border-base-300/70'}`}
>
<input
type="checkbox"
checked={expandedNode === node.tag}
onChange={() => setExpandedNode(expandedNode === node.tag ? null : node.tag)}
/>
<div className="collapse-title flex items-center gap-3 pr-12">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{node.name || node.tag}</span>
{node.blacklisted && <span className="badge badge-error badge-xs">黑名单</span>}
<span className="badge badge-ghost badge-xs">{node.mode}</span>
</div>
<div className="flex gap-4 text-xs text-base-content/60 mt-0.5 flex-wrap">
<span>成功: <span className="text-success">{node.success_count}</span></span>
<span>失败: <span className="text-error">{node.failure_count}</span></span>
<span>活跃: {node.active_connections}</span>
{node.last_latency_ms > 0 && (
<span>延迟: {node.last_latency_ms}ms</span>
)}
{((node.total_upload || 0) + (node.total_download || 0)) > 0 && (
<span className="text-blue-400/70">↑{formatBytes(node.total_upload || 0)}</span>
)}
{((node.total_upload || 0) + (node.total_download || 0)) > 0 && (
<span className="text-green-400/70">↓{formatBytes(node.total_download || 0)}</span>
)}
</div>
</div>
</div>
<div className="collapse-content">
<div className="pt-2 space-y-3">
{/* Detail Info */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-base-content/50">端口:</span>{' '}
<span className="font-mono">{node.port || '-'}</span>
</div>
<div>
<span className="text-base-content/50">最后成功:</span>{' '}
<span>{formatTime(node.last_success)}</span>
</div>
<div>
<span className="text-base-content/50">最后失败:</span>{' '}
<span>{formatTime(node.last_failure)}</span>
</div>
<div>
<span className="text-base-content/50">延迟:</span>{' '}
<span className="font-mono">{node.last_latency_ms > 0 ? `${node.last_latency_ms}ms` : '-'}</span>
</div>
<div>
<span className="text-base-content/50">上传:</span>{' '}
<span className="font-mono text-blue-400">{formatBytes(node.total_upload || 0)}</span>
</div>
<div>
<span className="text-base-content/50">下载:</span>{' '}
<span className="font-mono text-green-400">{formatBytes(node.total_download || 0)}</span>
</div>
<div>
<span className="text-base-content/50">总流量:</span>{' '}
<span className="font-mono">{formatBytes((node.total_upload || 0) + (node.total_download || 0))}</span>
</div>
</div>
{/* Last Error */}
{node.last_error && (
<div className="bg-error/5 border border-error/20 rounded-lg p-2.5">
<span className="text-xs font-semibold text-error">最后错误: </span>
<span className="text-xs text-error/70 font-mono break-all">{node.last_error}</span>
</div>
)}
{/* Timeline */}
{node.timeline && node.timeline.length > 0 && (
<div>
<h4 className="text-xs font-medium mb-2 text-base-content/70">
运行日志 (最近 {node.timeline.length} 条)
</h4>
<div className="bg-[#1a1b26] rounded-lg p-3 overflow-x-auto max-h-56 overflow-y-auto font-mono text-xs leading-5 space-y-px">
{node.timeline.map((evt, i) => {
const ts = formatLogTime(evt.time)
const isOk = evt.success
const isTraffic = !!evt.destination
return (
<div key={i} className="whitespace-nowrap">
<span className="text-gray-500">{ts}</span>
{' '}
{isOk ? (
<span className="text-green-400">INFO </span>
) : (
<span className="text-red-400">ERROR</span>
)}
{' '}
<span className="text-blue-300">[{node.name || node.tag}]</span>
{' '}
{isTraffic ? (
/* 流量事件:显示目标地址 */
isOk ? (
<>
<span className="text-gray-300">→ </span>
<span className="text-cyan-300">{evt.destination}</span>
<span className="text-green-300 ml-1">✓</span>
</>
) : (
<>
<span className="text-gray-300">→ </span>
<span className="text-cyan-300">{evt.destination}</span>
<span className="text-red-300 ml-1">✗</span>
{evt.error && (
<span className="text-red-400/70 ml-1">— {evt.error}</span>
)}
</>
)
) : (
/* 探测事件:显示延迟 */
isOk ? (
<>
<span className="text-gray-300">probe </span>
<span className="text-green-300">✓ success</span>
{evt.latency_ms > 0 && (
<span className={`ml-1 ${evt.latency_ms > 1000 ? 'text-yellow-400' : evt.latency_ms > 500 ? 'text-yellow-300' : 'text-gray-400'}`}>
({evt.latency_ms}ms)
</span>
)}
</>
) : (
<>
<span className="text-gray-300">probe </span>
<span className="text-red-300">✗ failed</span>
{evt.latency_ms > 0 && (
<span className="text-gray-500 ml-1">({evt.latency_ms}ms)</span>
)}
{evt.error && (
<span className="text-red-400/70 ml-1">— {evt.error}</span>
)}
</>
)
)}
</div>
)
})}
</div>
</div>
)}
</div>
</div>
</div>
))
)}
</div>
{/* Auto-refresh indicator */}
{autoRefresh && (
<div className="text-center text-xs text-base-content/30 flex items-center justify-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></span>
每 5 秒自动刷新 · {data?.nodes.length || 0} 个节点
</div>
)}
</div>
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
import { useState } from 'react'
import { login } from '../api/client'
interface LoginPageProps {
onLogin: () => void
}
export default function LoginPage({ onLogin }: LoginPageProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!password.trim()) {
setError('请输入密码')
return
}
setLoading(true)
setError('')
try {
await login(password)
onLogin()
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen w-full flex flex-col lg:flex-row bg-base-100 overflow-hidden">
{/* Left Side: Branding / Intro */}
<div className="lg:w-5/12 bg-primary/5 p-8 lg:p-16 xl:p-24 flex flex-col justify-between relative overflow-hidden border-b lg:border-b-0 lg:border-r border-base-300/30">
{/* Decorative Background Elements */}
<div className="absolute top-[-10%] left-[-10%] w-[60%] h-[60%] bg-primary/20 rounded-full blur-[100px] mix-blend-multiply opacity-60"></div>
<div className="absolute bottom-[-10%] right-[-10%] w-[60%] h-[60%] bg-secondary/20 rounded-full blur-[100px] mix-blend-multiply opacity-60"></div>
<div className="relative z-10 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-base-100 flex items-center justify-center shadow-sm border border-base-300/50">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-2xl font-black text-primary tracking-tight">Easy Proxies</span>
</div>
<div className="relative z-10 mt-16 lg:mt-0 flex-1 flex flex-col justify-center">
<h1 className="text-4xl lg:text-5xl xl:text-6xl font-black text-base-content tracking-tight leading-tight">
高效、轻量的<br className="hidden lg:block" />
<span className="text-primary mt-2 block">代理池管理系统</span>
</h1>
<p className="text-base-content/60 mt-6 text-base lg:text-lg max-w-md font-medium leading-relaxed">
为高可用代理服务提供强劲动力。一站式解决多节点负载均衡、健康检查、区域路由与实时监控。
</p>
<div className="mt-12 flex flex-col gap-4">
<div className="flex items-center gap-4 text-sm font-medium bg-base-100/60 backdrop-blur-md p-4 rounded-2xl border border-base-300/40 shadow-sm max-w-md transition-transform hover:-translate-y-1 duration-300">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<div className="text-base-content font-bold text-base">高可用架构</div>
<div className="text-base-content/60 text-xs mt-0.5">多重调度策略与毫秒级故障转移</div>
</div>
</div>
<div className="flex items-center gap-4 text-sm font-medium bg-base-100/60 backdrop-blur-md p-4 rounded-2xl border border-base-300/40 shadow-sm max-w-md transition-transform hover:-translate-y-1 duration-300">
<div className="w-12 h-12 rounded-full bg-secondary/10 flex items-center justify-center text-secondary shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
</div>
<div>
<div className="text-base-content font-bold text-base">自动化管理</div>
<div className="text-base-content/60 text-xs mt-0.5">订阅定时更新与 GeoIP 智能分发</div>
</div>
</div>
</div>
</div>
<div className="relative z-10 mt-12 hidden lg:block">
<p className="text-sm font-medium text-base-content/40">&copy; {new Date().getFullYear()} Easy Proxies. All rights reserved.</p>
</div>
</div>
{/* Right Side: Login Form */}
<div className="lg:w-7/12 p-8 lg:p-24 flex flex-col justify-center items-center bg-base-100 relative">
<div className="max-w-md w-full">
<div className="mb-12 text-center lg:text-left">
<h2 className="text-3xl lg:text-4xl font-bold text-base-content mb-3 tracking-tight">欢迎回来</h2>
<p className="text-base text-base-content/50 font-medium">请输入管理面板密码以继续使用系统</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/70">管理密码</legend>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-base-content/40">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input
type="password"
placeholder="请输入密码"
className={`input input-lg w-full pl-12 bg-base-200/50 focus:bg-base-100 transition-colors text-base ${error ? 'input-error ring-1 ring-error/50' : 'focus:ring-2 focus:ring-primary/20'}`}
value={password}
onChange={(e) => {
setPassword(e.target.value)
setError('')
}}
autoFocus
disabled={loading}
/>
</div>
</fieldset>
{error && (
<div role="alert" className="alert alert-error alert-soft py-3 text-sm animate-in fade-in slide-in-from-top-2 duration-300">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="font-medium">{error}</span>
</div>
)}
<button
type="submit"
className="btn btn-primary w-full h-14 text-lg font-semibold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-[0.98] mt-4"
disabled={loading}
>
{loading ? (
<span className="loading loading-spinner loading-md"></span>
) : (
<>
<span>登录系统</span>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</>
)}
</button>
</form>
<div className="mt-16 text-center lg:text-left lg:hidden">
<p className="text-xs text-base-content/30 font-medium">
&copy; {new Date().getFullYear()} Easy Proxies. All rights reserved.
</p>
</div>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import type { NodeSnapshot, NodesResponse, ConfigNodeConfig, ConfigNodesResponse, TrafficStreamEvent } from '../types'
import { fetchNodes, fetchConfigNodes, streamTraffic } from '../api/client'
import { formatBytes, formatSpeed } from '../utils/format'
import DonutChart from './charts/DonutChart'
import BarChart from './charts/BarChart'
import LatencyRanking from './charts/LatencyRanking'
import TrafficRanking from './charts/TrafficRanking'
import RegionCards from './charts/RegionCards'
function latencyColor(ms: number): string {
if (ms < 0) return 'text-base-content/50'
if (ms <= 100) return 'text-success'
if (ms <= 300) return 'text-warning'
return 'text-error'
}
type AutoRefreshInterval = 0 | 5 | 10 | 30 | 60
export default function MonitorPanel() {
const [data, setData] = useState<NodesResponse | null>(null)
const [configData, setConfigData] = useState<ConfigNodesResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [autoRefresh, setAutoRefresh] = useState<AutoRefreshInterval>(5)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const trafficAbortRef = useRef<AbortController | null>(null)
const [trafficRealtime, setTrafficRealtime] = useState<{
connected: boolean
uploadSpeed: number
downloadSpeed: number
sampledAt: string
}>({
connected: false,
uploadSpeed: 0,
downloadSpeed: 0,
sampledAt: '',
})
const loadData = useCallback(async () => {
try {
setError('')
const [monitorRes, configRes] = await Promise.all([
fetchNodes(),
fetchConfigNodes().catch(() => null),
])
setData(monitorRes)
if (configRes) setConfigData(configRes)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
// Auto-refresh timer
useEffect(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (autoRefresh > 0) {
timerRef.current = setInterval(() => {
loadData()
}, autoRefresh * 1000)
}
return () => {
if (timerRef.current) {
clearInterval(timerRef.current)
}
}
}, [autoRefresh, loadData])
const handleRefresh = async () => {
setLoading(true)
await loadData()
}
// Real-time speed stream (SSE): reconnect automatically on disconnect.
useEffect(() => {
let stopped = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
const connect = () => {
if (stopped) return
if (trafficAbortRef.current) {
trafficAbortRef.current.abort()
trafficAbortRef.current = null
}
trafficAbortRef.current = streamTraffic(
(event: TrafficStreamEvent) => {
setTrafficRealtime({
connected: true,
uploadSpeed: event.upload_speed || 0,
downloadSpeed: event.download_speed || 0,
sampledAt: event.sampled_at || '',
})
},
() => {
setTrafficRealtime((prev) => ({ ...prev, connected: false }))
if (!stopped) {
retryTimer = setTimeout(connect, 2000)
}
},
)
}
connect()
return () => {
stopped = true
if (retryTimer) {
clearTimeout(retryTimer)
}
if (trafficAbortRef.current) {
trafficAbortRef.current.abort()
trafficAbortRef.current = null
}
}
}, [])
// ---- Computed stats ----
const allNodes = useMemo(() => data?.nodes || [], [data])
const allConfigNodes = useMemo(() => configData?.nodes || [], [configData])
// Config-level counts (includes disabled nodes)
const totalConfigNodes = allConfigNodes.length
const disabledNodes = useMemo(
() => allConfigNodes.filter((n: ConfigNodeConfig) => n.disabled).length,
[allConfigNodes]
)
// enabledConfigNodes not directly displayed but used implicitly in monitor node count
// Runtime monitor counts (only enabled nodes that are loaded in the box)
const availableNodes = useMemo(
() => allNodes.filter((n: NodeSnapshot) => n.initial_check_done && n.available && !n.blacklisted).length,
[allNodes]
)
const unavailableNodes = useMemo(
() => allNodes.filter((n: NodeSnapshot) => n.initial_check_done && !n.available && !n.blacklisted).length,
[allNodes]
)
const blacklistedNodes = useMemo(
() => allNodes.filter((n: NodeSnapshot) => n.blacklisted).length,
[allNodes]
)
const pendingNodes = useMemo(
() => allNodes.filter((n: NodeSnapshot) => !n.initial_check_done && !n.blacklisted).length,
[allNodes]
)
const healthRate = useMemo(() => {
const checked = allNodes.filter((n: NodeSnapshot) => n.initial_check_done).length
if (checked === 0) return -1
return Math.round((availableNodes / checked) * 100)
}, [allNodes, availableNodes])
const avgLatency = useMemo(() => {
const validNodes = allNodes.filter((n: NodeSnapshot) => n.last_latency_ms > 0)
if (validNodes.length === 0) return -1
return Math.round(validNodes.reduce((sum: number, n: NodeSnapshot) => sum + n.last_latency_ms, 0) / validNodes.length)
}, [allNodes])
const totalConnections = useMemo(
() => allNodes.reduce((sum: number, n: NodeSnapshot) => sum + n.active_connections, 0),
[allNodes]
)
// Traffic totals
const totalUpload = useMemo(() => data?.total_upload || 0, [data])
const totalDownload = useMemo(() => data?.total_download || 0, [data])
// Speed from backend realtime stream, fallback to periodic /api/nodes snapshot values.
const uploadSpeed = trafficRealtime.connected
? trafficRealtime.uploadSpeed
: (data?.upload_speed || 0)
const downloadSpeed = trafficRealtime.connected
? trafficRealtime.downloadSpeed
: (data?.download_speed || 0)
// ---- Chart data ----
// Status donut - includes all node states
const statusSegments = useMemo(() => [
{ label: '正常', value: availableNodes, color: 'oklch(0.72 0.19 142)' },
{ label: '不可用', value: unavailableNodes, color: 'oklch(0.63 0.24 29)' },
{ label: '黑名单', value: blacklistedNodes, color: 'oklch(0.55 0.20 15)' },
{ label: '待检查', value: pendingNodes, color: 'oklch(0.80 0.18 84)' },
{ label: '已禁用', value: disabledNodes, color: 'oklch(0.65 0.05 250)' },
], [availableNodes, unavailableNodes, blacklistedNodes, pendingNodes, disabledNodes])
// Latency distribution bars
const latencyBars = useMemo(() => {
const buckets = [
{ label: '0-50', min: 0, max: 50, count: 0, color: 'oklch(0.72 0.19 142)' },
{ label: '50-100', min: 50, max: 100, count: 0, color: 'oklch(0.75 0.18 120)' },
{ label: '100-200', min: 100, max: 200, count: 0, color: 'oklch(0.80 0.18 84)' },
{ label: '200-300', min: 200, max: 300, count: 0, color: 'oklch(0.75 0.18 55)' },
{ label: '300+', min: 300, max: Infinity, count: 0, color: 'oklch(0.63 0.24 29)' },
{ label: '超时', min: -1, max: 0, count: 0, color: 'oklch(0.50 0.10 250)' },
]
for (const node of allNodes) {
const ms = node.last_latency_ms
if (ms < 0 || !node.initial_check_done) {
buckets[5].count++
} else if (ms <= 50) {
buckets[0].count++
} else if (ms <= 100) {
buckets[1].count++
} else if (ms <= 200) {
buckets[2].count++
} else if (ms <= 300) {
buckets[3].count++
} else {
buckets[4].count++
}
}
return buckets.map(b => ({ label: b.label, value: b.count, color: b.color }))
}, [allNodes])
// Latency ranking
const rankingItems = useMemo(() =>
allNodes
.filter((n: NodeSnapshot) => n.last_latency_ms > 0)
.map((n: NodeSnapshot) => ({
name: n.name || n.tag,
latency: n.last_latency_ms,
region: n.region,
})),
[allNodes]
)
// Traffic ranking
const trafficRankItems = useMemo(() =>
allNodes
.filter((n: NodeSnapshot) => (n.total_upload || 0) + (n.total_download || 0) > 0)
.map((n: NodeSnapshot) => ({
name: n.name || n.tag,
upload: n.total_upload || 0,
download: n.total_download || 0,
region: n.region,
})),
[allNodes]
)
// Region stats
const regionStats = useMemo(() => {
const map = new Map<string, { total: number; healthy: number; latencies: number[] }>()
for (const node of allNodes) {
const region = node.region || 'other'
const entry = map.get(region) || { total: 0, healthy: 0, latencies: [] }
entry.total++
if (node.initial_check_done && node.available && !node.blacklisted) {
entry.healthy++
}
if (node.last_latency_ms > 0) {
entry.latencies.push(node.last_latency_ms)
}
map.set(region, entry)
}
return Array.from(map.entries()).map(([region, stats]) => ({
region,
total: stats.total,
healthy: stats.healthy,
avgLatency: stats.latencies.length > 0
? Math.round(stats.latencies.reduce((a, b) => a + b, 0) / stats.latencies.length)
: 0,
}))
}, [allNodes])
// ---- Render ----
if (loading && !data) {
return (
<div className="flex items-center justify-center h-64">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
)
}
return (
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
{/* Header */}
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1600px] mx-auto w-full">
<div>
<h2 className="text-2xl font-bold flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
节点监控
</h2>
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">实时数据仪表盘 · 可视化节点健康与流量状况</p>
</div>
<div className="flex items-center gap-3">
{/* Auto refresh selector */}
<div className="flex items-center gap-2 bg-base-200/50 px-2 py-1 lg:py-1.5 rounded-lg border border-base-300/50 transition-colors">
{autoRefresh > 0 ? (
<div className="hidden sm:flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium text-success whitespace-nowrap">
<div className="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></div>
<span>自动刷新开启</span>
</div>
) : (
<span className="hidden sm:inline text-sm font-medium pl-2 whitespace-nowrap text-base-content/70">自动刷新</span>
)}
<select
className="select select-sm border-0 focus:outline-none focus:ring-0 bg-transparent min-w-[90px] text-sm"
value={autoRefresh}
onChange={(e) => setAutoRefresh(Number(e.target.value) as AutoRefreshInterval)}
>
<option value={0}>关闭</option>
<option value={5}>每 5 秒</option>
<option value={10}>每 10 秒</option>
<option value={30}>每 30 秒</option>
<option value={60}>每 60 秒</option>
</select>
</div>
<button className="btn btn-sm lg:btn-md btn-primary shadow-sm gap-2" onClick={handleRefresh} disabled={loading}>
{loading ? (
<span className="loading loading-spinner loading-sm"></span>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
)}
刷新
</button>
</div>
</div>
</div>
<div className="p-4 lg:p-8 space-y-6 flex-1 pb-10 max-w-[1600px] mx-auto w-full">
{/* Error */}
{error && (
<div role="alert" className="alert alert-error alert-soft">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{error}</span>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Total Nodes */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-primary/5 rounded-full blur-2xl group-hover:bg-primary/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">总节点</div>
</div>
<div className="text-3xl font-black tabular-nums tracking-tight text-base-content mb-2 relative z-10">{totalConfigNodes}</div>
<div className="text-xs flex flex-wrap gap-1.5 relative z-10">
<span className="badge badge-success badge-sm badge-outline border-success/30 bg-success/5 font-medium">可用 {availableNodes}</span>
{unavailableNodes > 0 && <span className="badge badge-error badge-sm badge-outline border-error/30 bg-error/5 font-medium">不可用 {unavailableNodes}</span>}
{blacklistedNodes > 0 && <span className="badge badge-error badge-sm badge-outline border-error/30 bg-error/5 font-medium">黑名单 {blacklistedNodes}</span>}
{pendingNodes > 0 && <span className="badge badge-warning badge-sm badge-outline border-warning/30 bg-warning/5 font-medium">待检查 {pendingNodes}</span>}
{disabledNodes > 0 && <span className="badge badge-ghost badge-sm bg-base-200/50 font-medium text-base-content/50">禁用 {disabledNodes}</span>}
</div>
</div>
{/* Health Rate */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-success/5 rounded-full blur-2xl group-hover:bg-success/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-success/10 flex items-center justify-center text-success">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">健康率</div>
</div>
<div className={`text-3xl font-black tabular-nums tracking-tight mb-2 relative z-10 ${
healthRate < 0 ? 'text-base-content/30' :
healthRate >= 80 ? 'text-success' :
healthRate >= 50 ? 'text-warning' : 'text-error'
}`}>
{healthRate >= 0 ? `${healthRate}%` : '-'}
</div>
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
{healthRate >= 0 ? `${availableNodes} / ${availableNodes + unavailableNodes} 已检查` : '等待检查'}
</div>
</div>
{/* Average Latency */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-warning/5 rounded-full blur-2xl group-hover:bg-warning/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-warning/10 flex items-center justify-center text-warning">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">平均延迟</div>
</div>
<div className={`text-3xl font-black tabular-nums tracking-tight mb-2 relative z-10 ${latencyColor(avgLatency)}`}>
{avgLatency > 0 ? `${avgLatency}ms` : '-'}
</div>
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
{allNodes.filter(n => n.last_latency_ms > 0).length} 个节点有数据
</div>
</div>
{/* Active Connections */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-info/5 rounded-full blur-2xl group-hover:bg-info/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-info/10 flex items-center justify-center text-info">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">活跃连接</div>
</div>
<div className="text-3xl font-black tabular-nums tracking-tight text-info mb-2 relative z-10">{totalConnections}</div>
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
{allNodes.filter(n => n.active_connections > 0).length} 个节点活跃
</div>
</div>
{/* Upload Speed */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-blue-500/5 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 11l5-5m0 0l5 5m-5-5v12" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">上传速度</div>
</div>
<div className="text-3xl font-black tabular-nums tracking-tight text-blue-500 mb-2 relative z-10">{formatSpeed(uploadSpeed)}</div>
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
{trafficRealtime.connected ? '实时流数据' : '轮询数据'}
</div>
</div>
{/* Download Speed */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 13l-5 5m0 0l-5-5m5 5V6" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">下载速度</div>
</div>
<div className="text-3xl font-black tabular-nums tracking-tight text-emerald-500 mb-2 relative z-10">{formatSpeed(downloadSpeed)}</div>
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">
{trafficRealtime.connected ? '实时流数据' : '轮询数据'}
</div>
</div>
{/* Upload Traffic */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-blue-500/5 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">总上传</div>
</div>
<div className="text-3xl font-black tabular-nums tracking-tight text-blue-500 mb-2 relative z-10">{formatBytes(totalUpload)}</div>
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">累计流量统计</div>
</div>
{/* Download Traffic */}
<div className="rounded-2xl bg-base-100 border border-base-300/50 p-5 shadow-sm hover:shadow-md transition-all group overflow-hidden relative">
<div className="absolute -right-4 -top-4 w-24 h-24 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors"></div>
<div className="flex items-center gap-3 mb-2 relative z-10">
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 flex items-center justify-center text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg>
</div>
<div className="text-sm font-medium text-base-content/60">总下载</div>
</div>
<div className="text-3xl font-black tabular-nums tracking-tight text-emerald-500 mb-2 relative z-10">{formatBytes(totalDownload)}</div>
<div className="text-xs font-medium text-base-content/40 relative z-10 bg-base-200/50 w-fit px-2 py-1 rounded-md">累计流量统计</div>
</div>
</div>
{/* Charts Row: Donut + Bar */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Status Distribution Donut */}
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
<span className="w-1 h-4 bg-primary rounded-full"></span>
状态分布
</h3>
<div className="flex justify-center">
<DonutChart
segments={statusSegments}
size={180}
strokeWidth={32}
centerValue={healthRate >= 0 ? `${healthRate}%` : '-'}
centerLabel="健康率"
/>
</div>
</div>
{/* Latency Distribution Bar */}
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
<BarChart
bars={latencyBars}
maxHeight={130}
title="延迟分布"
/>
</div>
</div>
{/* Region Cards */}
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
<RegionCards
regions={regionStats}
title="地区统计"
/>
</div>
{/* Rankings Row: Latency + Traffic */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Latency Ranking */}
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
<LatencyRanking
items={rankingItems}
maxItems={10}
title="延迟排行 TOP 10"
/>
</div>
{/* Traffic Ranking */}
<div className="rounded-xl border border-base-300/40 bg-base-200/30 p-5">
<TrafficRanking
items={trafficRankItems}
maxItems={10}
title="流量排行 TOP 10"
/>
</div>
</div>
{/* Footer info */}
<div className="text-center text-xs text-base-content/30">
{data && (
<>
共 {totalConfigNodes} 个节点 ·
{Object.keys(data.region_stats || {}).length} 个地区 ·
数据来自运行时监控
{autoRefresh > 0 && ` · 每 ${autoRefresh} 秒自动刷新`}
{trafficRealtime.connected ? ' · 速度流已连接' : ' · 速度流重连中'}
</>
)}
</div>
</div>
</div>
)
}
+865
View File
@@ -0,0 +1,865 @@
import { useState, useEffect, useCallback } from 'react'
import type { SettingsData, SubscriptionStatus } from '../types'
import { fetchSettings, updateSettings, triggerReload, fetchSubscriptionStatus, refreshSubscription } from '../api/client'
const defaultSettings: SettingsData = {
mode: 'pool',
log_level: 'info',
external_ip: '',
skip_cert_verify: false,
listener_address: '0.0.0.0',
listener_port: 2323,
listener_username: '',
listener_password: '',
multi_port_address: '0.0.0.0',
multi_port_base_port: 24000,
multi_port_username: '',
multi_port_password: '',
pool_mode: 'sequential',
pool_failure_threshold: 3,
pool_blacklist_duration: '24h0m0s',
management_enabled: true,
management_listen: '0.0.0.0:9090',
management_probe_target: '',
management_password: '',
sub_refresh_enabled: false,
sub_refresh_interval: '1h0m0s',
sub_refresh_timeout: '30s',
sub_refresh_health_check_timeout: '1m0s',
sub_refresh_drain_timeout: '30s',
sub_refresh_min_available_nodes: 1,
geoip_enabled: false,
geoip_database_path: './GeoLite2-Country.mmdb',
geoip_auto_update_enabled: true,
geoip_auto_update_interval: '24h0m0s',
subscriptions: [],
}
export default function SettingsPanel() {
const [settings, setSettings] = useState<SettingsData>(defaultSettings)
const [savedSettings, setSavedSettings] = useState<SettingsData>(defaultSettings)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [reloading, setReloading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [needReload, setNeedReload] = useState(false)
const [isDirty, setIsDirty] = useState(false)
// Subscription status
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(null)
const [subRefreshing, setSubRefreshing] = useState(false)
// New subscription input
const [newSubUrl, setNewSubUrl] = useState('')
const refreshSubStatus = useCallback(async () => {
try {
const subData = await fetchSubscriptionStatus()
if (subData) setSubStatus(subData)
} catch {
// ignore errors
}
}, [])
useEffect(() => {
const load = async () => {
try {
const [settingsData] = await Promise.all([
fetchSettings(),
refreshSubStatus(),
])
const merged = { ...defaultSettings, ...settingsData }
setSettings(merged)
setSavedSettings(merged)
setIsDirty(false)
} catch (err) {
setError(err instanceof Error ? err.message : '加载设置失败')
} finally {
setLoading(false)
}
}
load()
}, [refreshSubStatus])
useEffect(() => {
if (success) {
const timer = setTimeout(() => setSuccess(''), 5000)
return () => clearTimeout(timer)
}
}, [success])
const handleSave = async () => {
setSaving(true)
setError('')
setSuccess('')
try {
const res = await updateSettings(settings)
setSuccess(res.message || '设置已保存')
setSavedSettings({ ...settings })
setIsDirty(false)
if (res.need_reload) setNeedReload(true)
// Refresh subscription status after saving (config may have changed)
await refreshSubStatus()
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败')
} finally {
setSaving(false)
}
}
const handleReload = async () => {
setReloading(true)
setError('')
try {
const res = await triggerReload()
setSuccess(res.message || '重载成功')
setNeedReload(false)
// Refresh subscription status after reload (subscription manager config updated)
await refreshSubStatus()
} catch (err) {
setError(err instanceof Error ? err.message : '重载失败')
} finally {
setReloading(false)
}
}
const handleSubRefresh = async () => {
setSubRefreshing(true)
setError('')
try {
const res = await refreshSubscription()
setSuccess(`订阅刷新成功,共 ${res.node_count} 个节点`)
await refreshSubStatus()
} catch (err) {
setError(err instanceof Error ? err.message : '刷新订阅失败')
} finally {
setSubRefreshing(false)
}
}
const addSubscription = () => {
const url = newSubUrl.trim()
if (!url) return
if (settings.subscriptions.includes(url)) {
setError('该订阅地址已存在')
return
}
setSettings(s => {
const updated = { ...s, subscriptions: [...s.subscriptions, url] }
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
return updated
})
setNewSubUrl('')
}
const removeSubscription = (index: number) => {
setSettings(s => {
const updated = { ...s, subscriptions: s.subscriptions.filter((_, i) => i !== index) }
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
return updated
})
}
const updateField = <K extends keyof SettingsData>(key: K, value: SettingsData[K]) => {
setSettings(s => {
const updated = { ...s, [key]: value }
setIsDirty(JSON.stringify(updated) !== JSON.stringify(savedSettings))
return updated
})
}
// Warn before leaving with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
)
}
return (
<div className="flex flex-col min-h-full animate-in fade-in duration-500">
{/* Header - sticky */}
<div className="sticky top-0 z-30 bg-base-100/80 backdrop-blur-xl px-4 lg:px-8 py-4 border-b border-base-300/60 shadow-sm">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 max-w-[1200px] mx-auto w-full">
<div>
<h2 className="text-2xl font-bold flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0 border border-primary/20">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
系统设置
</h2>
<p className="text-sm text-base-content/50 mt-1.5 ml-[3.25rem]">管理系统所有配置项,修改后需保存生效</p>
</div>
<div className="flex gap-2">
<button
className={`btn btn-sm lg:btn-md gap-2 shadow-sm ${isDirty ? 'btn-primary' : 'btn-ghost border border-base-300'}`}
onClick={handleSave}
disabled={saving || !isDirty}
>
{saving ? <span className="loading loading-spinner loading-sm"></span> : isDirty ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
保存设置
</>
) : '✅ 已保存'}
</button>
{needReload && (
<button
className="btn btn-warning btn-sm lg:btn-md gap-2 shadow-sm animate-pulse"
onClick={handleReload}
disabled={reloading}
>
{reloading ? <span className="loading loading-spinner loading-sm"></span> : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
重载配置
</>
)}
</button>
)}
</div>
</div>
</div>
<div className="p-4 lg:p-8 space-y-6 max-w-[1200px] mx-auto w-full pb-10 flex-1">
{/* Alerts */}
{error && (
<div role="alert" className="alert alert-error alert-soft text-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{error}</span>
</div>
)}
{success && (
<div role="alert" className="alert alert-success alert-soft text-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{success}</span>
</div>
)}
{needReload && (
<div role="alert" className="alert alert-warning alert-soft text-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div>
<span>配置已保存。</span>
<span className="font-medium">WebUI 密码、探测目标、外部 IP、SSL 验证</span>
<span>已立即生效;其他配置(运行模式、监听端口、代理池等)需要点击「重载配置」才能生效。</span>
</div>
</div>
)}
{/* Settings Cards Grid */}
<div className="grid gap-5 lg:grid-cols-2">
{/* ===== 全局设置 ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
<div className="w-10 h-10 rounded-xl bg-info/10 flex items-center justify-center text-info shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">全局设置</h3>
<p className="text-xs text-base-content/50 font-medium">系统基础运行参数</p>
</div>
</div>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">运行模式</legend>
<select
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.mode}
onChange={(e) => updateField('mode', e.target.value)}
>
<option value="pool">pool - 单端口代理池</option>
<option value="multi-port">multi-port - 多端口模式</option>
<option value="hybrid">hybrid - 混合模式</option>
</select>
<p className="label text-base-content/50 mt-1">pool: 共享端口 | multi-port: 独立端口 | hybrid: 两者并存</p>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">日志级别</legend>
<select
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.log_level}
onChange={(e) => updateField('log_level', e.target.value)}
>
<option value="debug">debug</option>
<option value="info">info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">外部 IP 地址</legend>
<input
type="text"
placeholder="例如: 1.2.3.4"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.external_ip}
onChange={(e) => updateField('external_ip', e.target.value)}
/>
<p className="label text-base-content/50 mt-1">用于导出时替换 0.0.0.0</p>
</fieldset>
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
<div>
<span className="font-semibold text-base-content/90 block mb-0.5">跳过 SSL 证书验证</span>
<p className="text-xs text-base-content/50 m-0">全局跳过上游代理的 SSL 证书验证</p>
</div>
<input
type="checkbox"
className="toggle toggle-primary toggle-md"
checked={settings.skip_cert_verify}
onChange={(e) => updateField('skip_cert_verify', e.target.checked)}
/>
</label>
</div>
{/* ===== 监听配置 (Pool 入口) ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
<div className="w-10 h-10 rounded-xl bg-success/10 flex items-center justify-center text-success shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">监听配置 (Pool)</h3>
<p className="text-xs text-base-content/50 font-medium">代理池入口网络参数</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.listener_address}
onChange={(e) => updateField('listener_address', e.target.value)}
/>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">监听端口</legend>
<input
type="number"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.listener_port}
onChange={(e) => updateField('listener_port', parseInt(e.target.value) || 0)}
min={1}
max={65535}
/>
</fieldset>
</div>
<div className="grid grid-cols-2 gap-4 pt-2">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">代理用户名</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="可选,留空表示无验证"
value={settings.listener_username}
onChange={(e) => updateField('listener_username', e.target.value)}
/>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">代理密码</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="可选,留空表示无验证"
value={settings.listener_password}
onChange={(e) => updateField('listener_password', e.target.value)}
/>
</fieldset>
</div>
</div>
{/* ===== 多端口配置 ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
<div className="w-10 h-10 rounded-xl bg-secondary/10 flex items-center justify-center text-secondary shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">多端口配置</h3>
<p className="text-xs text-base-content/50 font-medium">用于 multi-port 或 hybrid 模式</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.multi_port_address}
onChange={(e) => updateField('multi_port_address', e.target.value)}
/>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">起始端口</legend>
<input
type="number"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.multi_port_base_port}
onChange={(e) => updateField('multi_port_base_port', parseInt(e.target.value) || 0)}
min={1}
max={65535}
/>
</fieldset>
</div>
<div className="grid grid-cols-2 gap-4 pt-2">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">默认用户名</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="可选"
value={settings.multi_port_username}
onChange={(e) => updateField('multi_port_username', e.target.value)}
/>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">默认密码</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="可选"
value={settings.multi_port_password}
onChange={(e) => updateField('multi_port_password', e.target.value)}
/>
</fieldset>
</div>
</div>
{/* ===== 代理池配置 ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
<div className="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center text-accent shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 002-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">代理池调度</h3>
<p className="text-xs text-base-content/50 font-medium">节点选择与高可用策略</p>
</div>
</div>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">调度模式</legend>
<select
className="select select-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.pool_mode}
onChange={(e) => updateField('pool_mode', e.target.value)}
>
<option value="sequential">sequential - 顺序轮询</option>
<option value="random">random - 随机选择</option>
<option value="balance">balance - 最小连接数负载均衡</option>
</select>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">失败阈值</legend>
<input
type="number"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.pool_failure_threshold}
onChange={(e) => updateField('pool_failure_threshold', parseInt(e.target.value) || 1)}
min={1}
/>
<p className="label text-base-content/50 mt-1">连续失败多少次后加入黑名单</p>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">黑名单持续时间</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="例如: 24h, 1h30m"
value={settings.pool_blacklist_duration}
onChange={(e) => updateField('pool_blacklist_duration', e.target.value)}
/>
<p className="label text-base-content/50 mt-1">Go duration 格式: 24h, 1h30m, 30m 等</p>
</fieldset>
</div>
{/* ===== 管理面板 ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">管理面板</h3>
<p className="text-xs text-base-content/50 font-medium">Web 界面及探针设置</p>
</div>
</div>
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
<span className="font-semibold text-base-content/90">启用管理面板</span>
<input
type="checkbox"
className="toggle toggle-primary toggle-md"
checked={settings.management_enabled}
onChange={(e) => updateField('management_enabled', e.target.checked)}
/>
</label>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">监听地址</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="0.0.0.0:9090"
value={settings.management_listen}
onChange={(e) => updateField('management_listen', e.target.value)}
/>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">探测目标</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="http://www.google.com"
value={settings.management_probe_target}
onChange={(e) => updateField('management_probe_target', e.target.value)}
/>
<p className="label text-base-content/50 mt-1">健康检查的目标地址</p>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">WebUI 密码</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="为空则无需密码"
value={settings.management_password}
onChange={(e) => updateField('management_password', e.target.value)}
/>
<p className="label text-base-content/50 mt-1">为空则不需要登录密码</p>
</fieldset>
</div>
{/* ===== GeoIP ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
<div className="w-10 h-10 rounded-xl bg-info/10 flex items-center justify-center text-info shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">GeoIP 地域分区</h3>
<p className="text-xs text-base-content/50 font-medium">节点地域解析与自动更新</p>
</div>
</div>
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
<div>
<span className="font-semibold text-base-content/90 block mb-0.5">启用 GeoIP</span>
<p className="text-xs text-base-content/50 m-0">按地域自动分组节点</p>
</div>
<input
type="checkbox"
className="toggle toggle-primary toggle-md"
checked={settings.geoip_enabled}
onChange={(e) => updateField('geoip_enabled', e.target.checked)}
/>
</label>
{settings.geoip_enabled && (
<div className="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">数据库路径</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.geoip_database_path}
onChange={(e) => updateField('geoip_database_path', e.target.value)}
/>
</fieldset>
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
<span className="font-semibold text-base-content/90">自动更新数据库</span>
<input
type="checkbox"
className="toggle toggle-primary toggle-md"
checked={settings.geoip_auto_update_enabled}
onChange={(e) => updateField('geoip_auto_update_enabled', e.target.checked)}
/>
</label>
{settings.geoip_auto_update_enabled && (
<fieldset className="fieldset animate-in fade-in slide-in-from-top-2">
<legend className="fieldset-legend font-semibold text-base-content/80">更新间隔</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="24h"
value={settings.geoip_auto_update_interval}
onChange={(e) => updateField('geoip_auto_update_interval', e.target.value)}
/>
</fieldset>
)}
</div>
)}
</div>
{/* ===== 订阅刷新 ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md">
<div className="flex items-center gap-3 mb-2 border-b border-base-200 pb-4">
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center text-warning shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">订阅自动刷新</h3>
<p className="text-xs text-base-content/50 font-medium">配置订阅定时更新及健康检查</p>
</div>
</div>
<label className="flex items-center justify-between cursor-pointer gap-4 bg-base-200/30 p-4 rounded-xl border border-base-200 hover:border-base-300 transition-colors">
<span className="font-semibold text-base-content/90">启用定时刷新</span>
<input
type="checkbox"
className="toggle toggle-primary toggle-md"
checked={settings.sub_refresh_enabled}
onChange={(e) => updateField('sub_refresh_enabled', e.target.checked)}
/>
</label>
{settings.sub_refresh_enabled && (
<div className="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2">
<div className="grid grid-cols-2 gap-4">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">刷新间隔</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="1h"
value={settings.sub_refresh_interval}
onChange={(e) => updateField('sub_refresh_interval', e.target.value)}
/>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">获取超时</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="30s"
value={settings.sub_refresh_timeout}
onChange={(e) => updateField('sub_refresh_timeout', e.target.value)}
/>
</fieldset>
</div>
<div className="grid grid-cols-2 gap-4">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">健康检查超时</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="1m"
value={settings.sub_refresh_health_check_timeout}
onChange={(e) => updateField('sub_refresh_health_check_timeout', e.target.value)}
/>
</fieldset>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">排空超时</legend>
<input
type="text"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="30s"
value={settings.sub_refresh_drain_timeout}
onChange={(e) => updateField('sub_refresh_drain_timeout', e.target.value)}
/>
</fieldset>
</div>
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-base-content/80">最少可用节点数</legend>
<input
type="number"
className="input input-md w-full bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
value={settings.sub_refresh_min_available_nodes}
onChange={(e) => updateField('sub_refresh_min_available_nodes', parseInt(e.target.value) || 1)}
min={0}
/>
<p className="label text-base-content/50 mt-1">低于此值时不切换新节点</p>
</fieldset>
</div>
)}
</div>
{/* ===== 订阅管理 (full width) ===== */}
<div className="rounded-2xl border border-base-300/50 bg-base-100 p-6 lg:p-8 space-y-5 shadow-sm transition-shadow hover:shadow-md lg:col-span-2">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-base-200 pb-4 mb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-error/10 flex items-center justify-center text-error shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<div>
<h3 className="font-bold text-lg text-base-content">订阅链接管理</h3>
<p className="text-xs text-base-content/50 font-medium">配置节点获取来源</p>
</div>
</div>
{/* Show refresh button when subscriptions exist (saved or in current settings) */}
{(subStatus?.has_subscriptions || settings.subscriptions.length > 0) && (
<div className="flex items-center gap-3 bg-base-200/50 px-3 py-1.5 rounded-lg border border-base-300/50">
{subStatus && subStatus.node_count != null && subStatus.node_count > 0 && (
<span className="text-sm font-medium text-base-content/70">
节点: <strong className="text-base-content">{subStatus.node_count}</strong>
</span>
)}
{subStatus?.enabled && (
<span className="badge badge-success badge-sm border-none bg-success/20 text-success font-semibold">自动刷新</span>
)}
<div className="w-px h-4 bg-base-300 mx-1"></div>
<button
className="btn btn-sm btn-ghost hover:bg-primary/10 hover:text-primary gap-1.5 px-2"
onClick={handleSubRefresh}
disabled={subRefreshing || subStatus?.is_refreshing || isDirty}
title={isDirty ? '请先保存设置并重载配置' : '立即刷新订阅'}
>
{subRefreshing || subStatus?.is_refreshing ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
立即刷新
</>
)}
</button>
</div>
)}
</div>
{subStatus?.last_error && (
<div role="alert" className="alert alert-error alert-soft text-sm py-3 animate-in fade-in">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{subStatus.last_error}</span>
</div>
)}
{/* Add subscription */}
<div className="flex gap-2">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-base-content/40">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<input
type="text"
className="input input-md w-full pl-10 font-mono text-sm bg-base-200/50 focus:bg-base-100 transition-colors focus:border-primary/50"
placeholder="https://example.com/subscribe?token=xxx"
value={newSubUrl}
onChange={(e) => setNewSubUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addSubscription()}
/>
</div>
<button
className="btn btn-md btn-primary shadow-sm"
onClick={addSubscription}
disabled={!newSubUrl.trim()}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
添加
</button>
</div>
{/* Subscription list */}
{settings.subscriptions.length > 0 ? (
<div className="space-y-3 mt-4">
{settings.subscriptions.map((url, index) => (
<div key={index} className="flex items-center gap-3 p-3 lg:p-4 rounded-xl border border-base-200 bg-base-200/30 hover:bg-base-200/60 transition-colors group">
<div className="flex-1 min-w-0">
<code className="text-sm font-mono text-base-content/80 break-all">{url}</code>
</div>
<button
className="btn btn-sm btn-square btn-ghost text-base-content/40 hover:text-error hover:bg-error/10 shrink-0 opacity-0 group-hover:opacity-100 transition-all"
onClick={() => removeSubscription(index)}
title="删除订阅"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center rounded-xl border border-dashed border-base-300 bg-base-200/20">
<div className="w-12 h-12 rounded-full bg-base-200 flex items-center justify-center text-base-content/30 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<p className="text-base font-medium text-base-content/60">暂无订阅链接</p>
<p className="text-sm text-base-content/40 mt-1">在上方输入框添加您的节点订阅地址</p>
</div>
)}
<p className="text-xs text-base-content/40 text-center mt-4">⚠️ 添加或删除订阅后,需点击顶部「保存设置」并「重载配置」才能生效</p>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
interface BarData {
label: string
value: number
color: string
}
interface BarChartProps {
bars: BarData[]
maxHeight?: number
title?: string
}
export default function BarChart({ bars, maxHeight = 120, title }: BarChartProps) {
const maxValue = Math.max(...bars.map(b => b.value), 1)
if (bars.every(b => b.value === 0)) {
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="text-base-content/30 text-sm">暂无数据</div>
</div>
)
}
return (
<div className="w-full">
{title && (
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="w-1 h-4 bg-primary rounded-full"></span>
{title}
</h4>
)}
<div className="flex items-end gap-2 justify-center" style={{ height: maxHeight + 32 }}>
{bars.map((bar, i) => {
const height = (bar.value / maxValue) * maxHeight
return (
<div key={i} className="flex flex-col items-center gap-1 flex-1 min-w-0">
{/* Value label */}
<span className="text-xs font-mono font-medium text-base-content/60 tabular-nums">
{bar.value > 0 ? bar.value : ''}
</span>
{/* Bar */}
<div
className="w-full max-w-12 rounded-t-md transition-all duration-500 min-h-[2px]"
style={{
height: Math.max(height, bar.value > 0 ? 4 : 2),
backgroundColor: bar.value > 0 ? bar.color : 'oklch(var(--bc) / 0.1)',
}}
/>
{/* Label */}
<span className="text-[10px] text-base-content/50 text-center leading-tight whitespace-nowrap">
{bar.label}
</span>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,127 @@
interface DonutSegment {
label: string
value: number
color: string
}
interface DonutChartProps {
segments: DonutSegment[]
size?: number
strokeWidth?: number
centerLabel?: string
centerValue?: string
}
export default function DonutChart({
segments,
size = 160,
strokeWidth = 28,
centerLabel,
centerValue,
}: DonutChartProps) {
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const center = size / 2
const total = segments.reduce((sum, s) => sum + s.value, 0)
if (total === 0) {
return (
<div className="flex flex-col items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-base-300/50"
/>
</svg>
<div className="absolute text-center">
<div className="text-2xl font-bold text-base-content/30">-</div>
<div className="text-xs text-base-content/30">无数据</div>
</div>
</div>
)
}
const filteredSegments = segments.filter(s => s.value > 0)
const arcs = filteredSegments.reduce<Array<DonutSegment & {
dashLength: number
gapLength: number
dashOffset: number
percentage: number
}>>((acc, segment) => {
const accumulated = acc.reduce((sum, a) => sum + a.value / total, 0)
const ratio = segment.value / total
const dashLength = ratio * circumference
const dashOffset = -accumulated * circumference + circumference * 0.25
acc.push({
...segment,
dashLength,
gapLength: circumference - dashLength,
dashOffset,
percentage: Math.round(ratio * 100),
})
return acc
}, [])
return (
<div className="flex flex-col items-center gap-3">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{/* Background circle */}
<circle
cx={center}
cy={center}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-base-300/30"
/>
{/* Segments */}
{arcs.map((arc, i) => (
<circle
key={i}
cx={center}
cy={center}
r={radius}
fill="none"
stroke={arc.color}
strokeWidth={strokeWidth}
strokeDasharray={`${arc.dashLength} ${arc.gapLength}`}
strokeDashoffset={arc.dashOffset}
strokeLinecap="butt"
className="transition-all duration-500"
/>
))}
</svg>
{/* Center text */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
{centerValue && (
<div className="text-2xl font-bold">{centerValue}</div>
)}
{centerLabel && (
<div className="text-xs text-base-content/50">{centerLabel}</div>
)}
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{arcs.map((arc, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: arc.color }}
/>
<span className="text-base-content/70">{arc.label}</span>
<span className="font-mono font-medium">{arc.value}</span>
<span className="text-base-content/40">({arc.percentage}%)</span>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,91 @@
interface RankItem {
name: string
latency: number
region?: string
flag?: string
}
interface LatencyRankingProps {
items: RankItem[]
maxItems?: number
title?: string
}
function latencyColor(ms: number): string {
if (ms <= 100) return 'oklch(0.72 0.19 142)' // green
if (ms <= 200) return 'oklch(0.80 0.18 84)' // yellow
if (ms <= 300) return 'oklch(0.75 0.18 55)' // orange
return 'oklch(0.63 0.24 29)' // red
}
function regionFlag(region?: string): string {
const flags: Record<string, string> = {
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
}
return flags[region?.toLowerCase() || ''] || '🌐'
}
export default function LatencyRanking({ items, maxItems = 10, title }: LatencyRankingProps) {
const sorted = [...items]
.filter(i => i.latency > 0)
.sort((a, b) => a.latency - b.latency)
.slice(0, maxItems)
if (sorted.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="text-base-content/30 text-sm">暂无延迟数据</div>
</div>
)
}
const maxLatency = sorted[sorted.length - 1].latency
return (
<div className="w-full">
{title && (
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="w-1 h-4 bg-accent rounded-full"></span>
{title}
</h4>
)}
<div className="space-y-2">
{sorted.map((item, i) => {
const widthPct = Math.max((item.latency / maxLatency) * 100, 8)
const flag = item.flag || regionFlag(item.region)
return (
<div key={i} className="flex items-center gap-2">
{/* Rank */}
<span className="w-5 text-xs font-mono text-base-content/40 text-right shrink-0">
{i + 1}
</span>
{/* Flag + Name */}
<span className="text-sm shrink-0 w-32 truncate">
{flag} {item.name}
</span>
{/* Bar */}
<div className="flex-1 h-5 bg-base-300/30 rounded-md overflow-hidden relative">
<div
className="h-full rounded-md transition-all duration-500"
style={{
width: `${widthPct}%`,
backgroundColor: latencyColor(item.latency),
opacity: 0.8,
}}
/>
</div>
{/* Latency value */}
<span className="text-xs font-mono font-medium w-14 text-right shrink-0 tabular-nums"
style={{ color: latencyColor(item.latency) }}
>
{item.latency}ms
</span>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,95 @@
interface RegionStat {
region: string
total: number
healthy: number
avgLatency: number
}
interface RegionCardsProps {
regions: RegionStat[]
title?: string
}
function regionFlag(region?: string): string {
const flags: Record<string, string> = {
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
}
return flags[region?.toLowerCase() || ''] || '🌐'
}
function healthColor(rate: number): string {
if (rate >= 0.8) return 'progress-success'
if (rate >= 0.5) return 'progress-warning'
return 'progress-error'
}
function latencyColorClass(ms: number): string {
if (ms <= 0) return 'text-base-content/40'
if (ms <= 100) return 'text-success'
if (ms <= 300) return 'text-warning'
return 'text-error'
}
export default function RegionCards({ regions, title }: RegionCardsProps) {
const sorted = [...regions].sort((a, b) => b.total - a.total)
if (sorted.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="text-base-content/30 text-sm">暂无地区数据</div>
</div>
)
}
return (
<div className="w-full">
{title && (
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="w-1 h-4 bg-secondary rounded-full"></span>
{title}
</h4>
)}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{sorted.map((r) => {
const healthRate = r.total > 0 ? r.healthy / r.total : 0
const healthPct = Math.round(healthRate * 100)
return (
<div
key={r.region}
className="rounded-xl bg-base-200/50 border border-base-300/30 p-3 hover:border-primary/30 transition-colors"
>
{/* Header: Flag + Region */}
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{regionFlag(r.region)}</span>
<span className="font-semibold text-sm uppercase">{r.region}</span>
<span className="text-xs text-base-content/40 ml-auto">{r.total} 节点</span>
</div>
{/* Health bar */}
<div className="flex items-center gap-2 mb-1.5">
<progress
className={`progress ${healthColor(healthRate)} h-2 flex-1`}
value={healthPct}
max={100}
/>
<span className="text-xs font-mono font-medium w-8 text-right tabular-nums">
{healthPct}%
</span>
</div>
{/* Stats row */}
<div className="flex justify-between text-xs text-base-content/50">
<span>
健康 <strong className="text-success">{r.healthy}</strong>/{r.total}
</span>
<span className={latencyColorClass(r.avgLatency)}>
{r.avgLatency > 0 ? `${r.avgLatency}ms` : '-'}
</span>
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,111 @@
import { formatBytes } from '../../utils/format'
interface TrafficRankItem {
name: string
upload: number
download: number
region?: string
}
interface TrafficRankingProps {
items: TrafficRankItem[]
maxItems?: number
title?: string
}
function regionFlag(region?: string): string {
const flags: Record<string, string> = {
hk: '🇭🇰', jp: '🇯🇵', kr: '🇰🇷', us: '🇺🇸', tw: '🇹🇼',
sg: '🇸🇬', de: '🇩🇪', gb: '🇬🇧', fr: '🇫🇷', ca: '🇨🇦',
au: '🇦🇺', in: '🇮🇳', br: '🇧🇷', ru: '🇷🇺', nl: '🇳🇱',
}
return flags[region?.toLowerCase() || ''] || '🌐'
}
export default function TrafficRanking({ items, maxItems = 10, title }: TrafficRankingProps) {
const sorted = [...items]
.filter(i => (i.upload + i.download) > 0)
.sort((a, b) => (b.upload + b.download) - (a.upload + a.download))
.slice(0, maxItems)
if (sorted.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8">
<div className="text-base-content/30 text-sm">暂无流量数据</div>
</div>
)
}
const maxTraffic = sorted[0].upload + sorted[0].download
return (
<div className="w-full">
{title && (
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="w-1 h-4 bg-secondary rounded-full"></span>
{title}
</h4>
)}
<div className="space-y-2">
{sorted.map((item, i) => {
const total = item.upload + item.download
const widthPct = Math.max((total / maxTraffic) * 100, 8)
const uploadPct = total > 0 ? (item.upload / total) * 100 : 0
const flag = regionFlag(item.region)
return (
<div key={i} className="flex items-center gap-2">
{/* Rank */}
<span className="w-5 text-xs font-mono text-base-content/40 text-right shrink-0">
{i + 1}
</span>
{/* Flag + Name */}
<span className="text-sm shrink-0 w-32 truncate">
{flag} {item.name}
</span>
{/* Stacked bar: upload + download */}
<div className="flex-1 h-5 bg-base-300/30 rounded-md overflow-hidden relative">
<div
className="h-full rounded-md flex transition-all duration-500"
style={{ width: `${widthPct}%` }}
>
{/* Upload portion */}
<div
className="h-full"
style={{
width: `${uploadPct}%`,
backgroundColor: 'oklch(0.70 0.15 250)',
opacity: 0.8,
}}
/>
{/* Download portion */}
<div
className="h-full flex-1"
style={{
backgroundColor: 'oklch(0.72 0.19 142)',
opacity: 0.8,
}}
/>
</div>
</div>
{/* Traffic values */}
<span className="text-xs font-mono w-20 text-right shrink-0 tabular-nums text-base-content/70">
{formatBytes(total)}
</span>
</div>
)
})}
</div>
{/* Legend */}
<div className="flex items-center justify-end gap-4 mt-2 text-xs text-base-content/40">
<span className="flex items-center gap-1">
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: 'oklch(0.70 0.15 250)', opacity: 0.8 }}></span>
上传
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: 'oklch(0.72 0.19 142)', opacity: 0.8 }}></span>
下载
</span>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: all;
}
/* Custom Scrollbar Styles */
@layer utilities {
/* For Chrome, Edge, and Safari */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-base-content/10;
border-radius: 10px;
transition: background 0.2s ease-in-out;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-base-content/30;
}
/* For Firefox */
* {
scrollbar-width: thin;
/* First color is thumb, second is track. Cannot use Tailwind arbitrary opacity syntax in plain CSS easily here without var(), so we fallback to a neutral color, or rely on webkit-scrollbar for webkit browsers which covers most */
scrollbar-color: var(--fallback-bc, rgba(128, 128, 128, 0.3)) transparent;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+234
View File
@@ -0,0 +1,234 @@
// ---- Node & Snapshot types (maps to monitor.Snapshot) ----
export interface NodeInfo {
tag: string
name: string
uri: string
mode: string
listen_address?: string
port?: number
region?: string
country?: string
}
export interface TimelineEvent {
time: string
success: boolean
latency_ms: number
error?: string
destination?: string
}
export interface NodeSnapshot extends NodeInfo {
failure_count: number
success_count: number
blacklisted: boolean
blacklisted_until: string
active_connections: number
last_error?: string
last_failure?: string
last_success?: string
last_probe_latency?: number
last_latency_ms: number
available: boolean
initial_check_done: boolean
total_upload: number
total_download: number
timeline?: TimelineEvent[]
}
// ---- API Response types ----
export interface NodesResponse {
nodes: NodeSnapshot[]
total_nodes: number
total_upload: number
total_download: number
upload_speed?: number
download_speed?: number
traffic_sampled?: string
region_stats: Record<string, number>
region_healthy: Record<string, number>
}
export interface DebugNode {
tag: string
name: string
mode: string
port: number
failure_count: number
success_count: number
active_connections: number
last_latency_ms: number
last_success: string
last_failure: string
last_error: string
blacklisted: boolean
total_upload: number
total_download: number
timeline: TimelineEvent[]
}
export interface DebugResponse {
nodes: DebugNode[]
total_calls: number
total_success: number
success_rate: number
}
export interface SettingsData {
// Global
mode: string
log_level: string
external_ip: string
skip_cert_verify: boolean
// Listener
listener_address: string
listener_port: number
listener_username: string
listener_password: string
// Multi-port
multi_port_address: string
multi_port_base_port: number
multi_port_username: string
multi_port_password: string
// Pool
pool_mode: string
pool_failure_threshold: number
pool_blacklist_duration: string
// Management
management_enabled: boolean
management_listen: string
management_probe_target: string
management_password: string
// Subscription refresh
sub_refresh_enabled: boolean
sub_refresh_interval: string
sub_refresh_timeout: string
sub_refresh_health_check_timeout: string
sub_refresh_drain_timeout: string
sub_refresh_min_available_nodes: number
// GeoIP
geoip_enabled: boolean
geoip_database_path: string
geoip_auto_update_enabled: boolean
geoip_auto_update_interval: string
// Subscriptions
subscriptions: string[]
}
export interface SettingsUpdateResponse {
message: string
need_reload: boolean
}
// ---- Auth types ----
export interface AuthResponse {
message: string
token?: string
no_password?: boolean
}
export interface ErrorResponse {
error: string
}
// ---- Config Node CRUD types ----
export interface ConfigNodePayload {
name: string
uri: string
port: number
username: string
password: string
}
export interface ConfigNodeConfig {
name: string
uri: string
port: number
username: string
password: string
source?: string
disabled?: boolean
}
export interface ConfigNodesResponse {
nodes: ConfigNodeConfig[]
}
export interface ConfigNodeMutationResponse {
node?: ConfigNodeConfig
message: string
}
// ---- Subscription types ----
export interface SubscriptionStatus {
enabled: boolean
has_subscriptions?: boolean
last_refresh?: string
next_refresh?: string
node_count?: number
last_error?: string
refresh_count?: number
is_refreshing?: boolean
message?: string
}
// ---- SSE Probe types ----
export interface ProbeSSEStart {
type: 'start'
total: number
}
export interface ProbeSSEProgress {
type: 'progress'
tag: string
name: string
latency: number
status: 'success' | 'error'
error: string
current: number
total: number
progress: number
}
export interface ProbeSSEComplete {
type: 'complete'
total: number
success: number
failed: number
}
export type ProbeSSEEvent = ProbeSSEStart | ProbeSSEProgress | ProbeSSEComplete
// ---- SSE Traffic stream types ----
export interface TrafficStreamNode {
tag: string
upload_speed: number
download_speed: number
total_upload: number
total_download: number
}
export interface TrafficStreamEvent {
type: 'traffic'
node_count: number
total_upload: number
total_download: number
upload_speed: number
download_speed: number
sampled_at: string
nodes: TrafficStreamNode[]
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Format a byte count into a human-readable string (e.g., "1.5 GB").
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
if (bytes < 0) return '-'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const idx = Math.min(i, sizes.length - 1)
return `${(bytes / Math.pow(k, idx)).toFixed(idx === 0 ? 0 : 1)} ${sizes[idx]}`
}
/**
* Format a speed in bytes/sec into a human-readable string (e.g., "2.3 MB/s").
*/
export function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec <= 0) return '0 B/s'
return `${formatBytes(bytesPerSec)}/s`
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react()
],
build: {
outDir: '../internal/monitor/assets',
emptyOutDir: true,
},
})