Initialize Clash Web source and deployment documentation

This commit is contained in:
lhc committed 2026-10-05 17:32:00 +08:00
commit 8e0fa00a73
66 files changed
+8532

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
package web
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed all:dist
var assets embed.FS
func Handler() http.Handler {
root, _ := fs.Sub(assets, "dist")
files := http.FileServer(http.FS(root))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(root, path); err != nil {
if strings.Contains(path, ".") {
http.NotFound(w, r)
return
}
r = r.Clone(r.Context())
r.URL.Path = "/"
}
if strings.HasPrefix(path, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
})
}
+147
View File
@@ -0,0 +1,147 @@
import { test, expect } from '@playwright/test'
test('real workspace: auth, subscription, selection, rules, mobile and backup', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.goto('/')
await expect(page.getByRole('heading', { name: '从这里,连接世界。' })).toBeVisible()
await page.getByPlaceholder('从服务启动日志复制令牌').fill(process.env.CW_E2E_SETUP_TOKEN!)
await page.getByPlaceholder('至少 12 字节,最多 72 字节').fill('e2e-test-password-long')
await page.getByRole('button', { name: '创建工作空间' }).click()
await expect(page.getByRole('heading', { name: '网络总览' })).toBeVisible()
await expect(page.getByText('规则代理已启用', { exact: true })).toBeVisible()
const auth = await (await page.request.get('/api/v1/auth/status')).json()
const settings = await (await page.request.get('/api/v1/settings')).json()
// The local proxy answers this documentation address; no Internet request.
// A health target equal to the proxy endpoint is rejected as a routing loop.
const settingsResponse = await page.request.put('/api/v1/settings', { headers: { 'X-CSRF-Token': auth.csrf }, data: { ...settings, testURL: 'http://198.51.100.42/health' } })
expect(settingsResponse.ok()).toBe(true)
await page.getByRole('link', { name: '订阅管理', exact: true }).click()
await page.getByRole('button', { name: '添加订阅', exact: true }).click()
await page.getByPlaceholder('例如:我的主力订阅').fill('本地测试订阅')
await page.getByPlaceholder('https://example.com/subscribe?token=…').fill(process.env.CW_E2E_SUBSCRIPTION_URL!)
await page.getByRole('button', { name: '添加并更新' }).click()
await expect(page.getByRole('heading', { name: '本地测试订阅', exact: true })).toBeVisible()
await expect(page.locator('.subscription-node-count')).toContainText('3')
await page.getByRole('link', { name: '代理节点', exact: false }).first().click()
await expect(page.getByRole('heading', { name: '香港 · 测试节点' })).toBeVisible()
await page.getByPlaceholder('搜索节点、协议或订阅').fill('香港')
await expect(page.locator('.node-card')).toHaveCount(1)
await page.getByRole('button', { name: '选择节点', exact: true }).click()
await expect(page.getByRole('button', { name: '当前节点', exact: true })).toBeVisible()
await page.getByPlaceholder('搜索节点、协议或订阅').clear()
await page.getByRole('button', { name: '测试延迟', exact: true }).click()
await expect(page.getByText('3 个最近测试可用', { exact: false })).toBeVisible()
// Test progressive results without contacting third-party services in browser CI.
let connectivityRequests: string[] = []
await page.route('**/api/v1/system/connectivity/test', async route => {
const id = route.request().postDataJSON().siteId
connectivityRequests.push(id)
await new Promise(resolve => setTimeout(resolve, 80))
const status = ({ claude: 'challenge', netflix: 'restricted', grok: 'failed', github: 'http_error' } as Record<string, string>)[id] || 'reachable'
await route.fulfill({ json: { siteId: id, status, detail: status === 'reachable' ? 'HTTPS 主页响应正常' : '测试夹具响应', httpStatus: status === 'failed' ? 0 : status === 'reachable' ? 200 : status === 'http_error' ? 503 : 403, durationMS: 640, tlsVerified: status !== 'failed', checkedAt: new Date().toISOString() } })
})
await page.getByRole('link', { name: '连通性测试', exact: true }).click()
await expect(page.locator('.connectivity-card')).toHaveCount(9)
await expect(page.locator('.connectivity-grid')).toHaveCount(1)
await expect(page.locator('.connectivity-card-heading h3')).toHaveText(['GitHub', 'Docker Hub', 'Apple', 'Google', 'Netflix', 'YouTube', 'ChatGPT', 'Claude', 'Grok'])
await expect(page.locator('.connectivity-state.reachable')).toHaveCount(0)
await page.getByRole('button', { name: '测试 Apple', exact: true }).click()
await expect(page.locator('[data-site="apple"] .connectivity-state')).toContainText('可访问')
expect(connectivityRequests).toEqual(['apple'])
await page.getByRole('button', { name: '一键测试', exact: true }).click()
await expect(page.getByText('本次完成 9 / 9', { exact: true })).toBeVisible()
expect(connectivityRequests.length).toBe(10)
expect(connectivityRequests.slice(1)).toEqual(['github', 'dockerhub', 'apple', 'google', 'netflix', 'youtube', 'chatgpt', 'claude', 'grok'])
await expect(page.locator('.connectivity-state.reachable')).toHaveCount(5)
await expect(page.locator('[data-site="claude"] .connectivity-state')).toContainText('需要验证')
await expect(page.locator('[data-site="netflix"] .connectivity-state')).toContainText('访问受限')
await expect(page.locator('[data-site="grok"] .connectivity-state')).toContainText('连接失败')
await expect(page.locator('[data-site="github"]')).toContainText('HTTP 503')
await page.setViewportSize({ width: 1366, height: 900 })
const lastResult = await page.locator('.connectivity-card').last().boundingBox()
expect(lastResult).not.toBeNull()
expect(lastResult!.y + lastResult!.height).toBeLessThanOrEqual(900)
for (const pair of [['github', 'dockerhub'], ['netflix', 'youtube'], ['chatgpt', 'claude'], ['claude', 'grok']]) {
const left = await page.locator(`[data-site="${pair[0]}"]`).boundingBox()
const right = await page.locator(`[data-site="${pair[1]}"]`).boundingBox()
expect(Math.abs(left!.y - right!.y)).toBeLessThan(1)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.setViewportSize({ width: 1440, height: 1080 })
await page.screenshot({ path: '../.cache/screenshots/connectivity-desktop.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
await page.getByRole('button', { name: '切换深色主题' }).click()
await page.screenshot({ path: '../.cache/screenshots/connectivity-dark.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.setViewportSize({ width: 390, height: 844 })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: '../.cache/screenshots/connectivity-mobile.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
await page.setViewportSize({ width: 1440, height: 1080 })
await page.unroute('**/api/v1/system/connectivity/test')
connectivityRequests = []
await page.route('**/api/v1/system/connectivity/test', async route => {
connectivityRequests.push(route.request().postDataJSON().siteId)
await new Promise(resolve => setTimeout(resolve, 1000))
await route.fulfill({ json: { siteId: route.request().postDataJSON().siteId, status: 'reachable', detail: 'ok', httpStatus: 200, durationMS: 1000, tlsVerified: true, checkedAt: new Date().toISOString() } }).catch(() => {})
})
await page.getByRole('button', { name: '一键测试', exact: true }).click()
await expect(page.locator('.connectivity-state.testing')).toHaveCount(3)
await page.getByRole('button', { name: '取消测试', exact: true }).click()
await expect(page.locator('.connectivity-state.cancelled')).toHaveCount(9)
await expect(page.getByRole('button', { name: '一键测试', exact: true })).toBeEnabled()
expect(connectivityRequests.length).toBe(3)
await page.unroute('**/api/v1/system/connectivity/test')
await page.getByRole('link', { name: '分流规则', exact: true }).click()
await page.getByRole('button', { name: '添加规则' }).click()
await page.getByPlaceholder('例如 example.com').fill('custom.example.com')
await page.getByRole('button', { name: '保存并应用' }).click()
await expect(page.locator('.data-table')).toContainText('custom.example.com')
await page.getByRole('link', { name: '连接与日志', exact: true }).click()
await page.getByText('内核日志', { exact: true }).click()
await expect(page.locator('.log-console')).toBeVisible()
await page.getByRole('link', { name: '系统设置', exact: true }).click()
await expect(page.getByText('当前光猫 DHCP 无法关闭。', { exact: false })).toBeVisible()
await page.route('**/api/v1/system/updates', route => route.fulfill({ json: {
currentVersion: 'v1.19.32', latestVersion: 'v1.19.33', updateAvailable: true,
checkedAt: new Date().toISOString(), publishedAt: new Date().toISOString(),
releaseURL: 'https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.33',
} }))
await page.getByRole('button', { name: '检查更新', exact: true }).click()
await expect(page.getByText('发现新版本,可查看发布说明后手动升级。', { exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: '查看官方发布说明' })).toHaveAttribute('href', 'https://github.com/MetaCubeX/mihomo/releases/tag/v1.19.33')
await page.unroute('**/api/v1/system/updates')
await page.route('**/api/v1/system/updates', route => route.fulfill({ status: 502, json: { error: 'GitHub 更新检查暂时受限,请稍后重试' } }))
await page.getByRole('button', { name: '检查更新', exact: true }).click()
await expect(page.getByText('GitHub 更新检查暂时受限,请稍后重试', { exact: true })).toBeVisible()
await page.getByText('如何升级软件与内核', { exact: true }).click()
await expect(page.getByText('完整操作与恢复步骤见项目 docs/upgrading.md。', { exact: true })).toBeVisible()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: '导出备份' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toMatch(/^clashweb-backup/)
await page.getByRole('button', { name: '切换深色主题' }).click()
await expect(page.locator('.application')).toHaveClass(/dark/)
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.getByRole('link', { name: '网络总览', exact: true }).click()
await page.setViewportSize({ width: 1440, height: 1080 })
await expect(page.getByRole('img', { name: 'Clash Web 网络猫' })).toBeVisible()
await page.screenshot({ path: '../.cache/screenshots/dashboard-desktop.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
await page.getByRole('button', { name: '切换深色主题' }).click()
await page.screenshot({ path: '../.cache/screenshots/dashboard-dark.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
await page.getByRole('button', { name: '切换浅色主题' }).click()
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: '打开导航' }).click()
await page.getByRole('link', { name: '代理节点', exact: false }).first().click()
await expect(page.getByRole('heading', { name: '代理节点', exact: true })).toBeVisible()
await expect(page.locator('.node-card')).toHaveCount(3)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: '../.cache/screenshots/nodes-mobile.png', fullPage: true, animations: 'disabled', style: '.n-message-container { visibility: hidden; }' })
await page.getByRole('button', { name: '打开导航' }).click()
await page.getByRole('button', { name: '退出登录' }).click()
await expect(page.getByRole('heading', { name: '欢迎回来。' })).toBeVisible()
await page.getByPlaceholder('输入你的密码').fill('e2e-test-password-long')
await page.getByRole('button', { name: '进入工作空间' }).click()
await expect(page.getByRole('heading', { name: '代理节点', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '当前节点', exact: true })).toBeVisible()
expect(errors).toEqual([])
})
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#42b883" />
<link rel="icon" href="/favicon.svg?v=5" type="image/svg+xml" />
<title>Clash Web · 内网代理管理</title>
</head>
<body><div id="app"></div><script type="module" src="/src/main.ts"></script></body>
</html>
+2843
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "clash-web-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run src",
"test:e2e": "playwright test"
},
"dependencies": {
"@lucide/vue": "^1.51.0",
"echarts": "^6.0.0",
"naive-ui": "^2.42.0",
"pinia": "^3.0.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@playwright/test": "^1.56.0",
"@types/node": "^24.7.0",
"@vitejs/plugin-vue": "^6.0.1",
"typescript": "^5.9.3",
"vite": "^7.1.9",
"vitest": "^4.1.11",
"vue-tsc": "^3.1.1"
}
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 60000,
workers: 1,
reporter: 'list',
use: { baseURL: process.env.CW_E2E_URL || 'http://127.0.0.1:18080', browserName: 'chromium', trace: 'retain-on-failure', screenshot: 'only-on-failure' },
})
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Clash Web · 网络猫</title>
<circle cx="32" cy="32" r="32" fill="#42b883"/>
<path d="M16 28V17c0-2 1-3 3-2l9 7a24 24 0 0 1 8 0l9-7c2-1 3 0 3 2v11c3 4 4 8 2 13-3 7-10 11-18 11S17 48 14 41c-2-5-1-9 2-13Z" fill="#fff"/>
<g fill="#2d9d78">
<ellipse cx="24" cy="35" rx="4" ry="2.6" transform="rotate(18 24 35)"/>
<ellipse cx="40" cy="35" rx="4" ry="2.6" transform="rotate(-18 40 35)"/>
<path d="M30 41h4c.8 0 1.1.9.5 1.4l-1.8 1.5c-.4.3-1 .3-1.4 0l-1.8-1.5c-.6-.5-.3-1.4.5-1.4Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 580 B

+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NConfigProvider, NDialogProvider, NMessageProvider, darkTheme, zhCN, dateZhCN } from 'naive-ui'
import { useApp } from './stores/app'
import Shell from './components/Shell.vue'
const app = useApp()
const overrides = computed(() => ({
Button: { textColorPrimary: app.dark ? '#142b23' : '#103b2b', textColorHoverPrimary: '#103b2b', textColorPressedPrimary: '#103b2b' },
common: { primaryColor: app.dark ? '#65d6a3' : '#42b883', primaryColorHover: app.dark ? '#83e4ba' : '#54c795', primaryColorPressed: app.dark ? '#42b883' : '#2ea976', borderRadius: '8px', fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif' },
}))
</script>
<template>
<NConfigProvider :theme="app.dark ? darkTheme : null" :theme-overrides="overrides" :locale="zhCN" :date-locale="dateZhCN">
<NDialogProvider><NMessageProvider><Shell /></NMessageProvider></NDialogProvider>
</NConfigProvider>
</template>
+28
View File
@@ -0,0 +1,28 @@
let csrf = ''
export function setCSRF(value: string) { csrf = value }
export async function api<T>(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise<T> {
const response = await fetch(`/api/v1${path}`, {
method,
signal,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
})
const result = await response.json().catch(() => ({ error: '服务返回了无效响应' }))
if (!response.ok) {
if (response.status === 401 && !path.startsWith('/auth/')) window.dispatchEvent(new Event('session-expired'))
throw new Error(result.error || `请求失败(${response.status})`)
}
return result as T
}
export async function downloadBackup() {
const backup = await api<unknown>('/system/backup')
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }))
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `clashweb-backup-${new Date().toISOString().slice(0, 10)}.json`
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+5
View File
@@ -0,0 +1,5 @@
<script setup lang="ts">
import { Globe2 } from '@lucide/vue'
defineProps<{ title: string; description: string }>()
</script>
<template><div class="empty-state"><div class="empty-icon"><slot name="icon"><Globe2 :size="27" /></slot></div><h3>{{ title }}</h3><p>{{ description }}</p><slot /></div></template>
+2
View File
@@ -0,0 +1,2 @@
<script setup lang="ts">defineProps<{ title: string; description: string; eyebrow?: string }>()</script>
<template><div class="page-header"><div><div v-if="eyebrow" class="eyebrow">{{ eyebrow }}</div><h1>{{ title }}</h1><p>{{ description }}</p></div><div class="page-actions"><slot /></div></div></template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { NButton, NSpin } from 'naive-ui'
import { LayoutDashboard, Rss, Globe2, Route, Activity, Radar, Settings2, Moon, Sun, Menu, X, LogOut, ArrowUpRight } from '@lucide/vue'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import Auth from '../views/Auth.vue'
const app = useApp()
const route = useRoute()
const mobileOpen = ref(false)
const { run } = useTask()
const nav = [
{ path: '/', label: '网络总览', icon: LayoutDashboard },
{ path: '/subscriptions', label: '订阅管理', icon: Rss },
{ path: '/nodes', label: '代理节点', icon: Globe2 },
{ path: '/connectivity', label: '连通性测试', icon: Radar },
{ path: '/rules', label: '分流规则', icon: Route },
{ path: '/activity', label: '连接与日志', icon: Activity },
{ path: '/settings', label: '系统设置', icon: Settings2 },
]
onMounted(() => void app.checkAuth())
</script>
<template>
<div :class="['application', { dark: app.dark }]">
<div v-if="!app.ready" class="loading-screen"><NSpin size="large" /><span>正在连接管理服务</span></div>
<Auth v-else-if="!app.authenticated" />
<template v-else>
<div v-if="mobileOpen" class="sidebar-overlay" @click="mobileOpen = false" />
<aside :class="['sidebar', { open: mobileOpen }]">
<RouterLink to="/" class="brand" @click="mobileOpen = false"><img class="brand-icon" src="/favicon.svg?v=5" alt="Clash Web 网络猫" /><span>Clash<span class="brand-light"> Web</span><small>YOUR NETWORK, YOUR WAY</small></span></RouterLink>
<button class="mobile-close icon-button" aria-label="关闭导航" @click="mobileOpen = false"><X :size="20" /></button>
<div class="nav-label">工作空间</div>
<nav><RouterLink v-for="item in nav" :key="item.path" :to="item.path" :class="['nav-item', { active: route.path === item.path }]" @click="mobileOpen = false"><component :is="item.icon" :size="19" /><span>{{ item.label }}</span><span v-if="item.path === '/nodes' && app.status?.nodeCount" class="nav-count">{{ app.status.nodeCount }}</span></RouterLink></nav>
<div class="sidebar-bottom">
<div class="engine-card"><div class="engine-top"><span :class="['status-dot', { online: app.status?.coreOnline }]" />Mihomo 内核<span class="engine-tag">CORE</span></div><p>{{ app.status?.coreOnline ? `已连接 · ${app.status.version}` : '等待内核连接' }}</p><RouterLink to="/settings">服务与网络设置<ArrowUpRight :size="14" /></RouterLink></div>
<div class="account"><div class="avatar">A</div><div><strong>管理员</strong><small>个人工作空间</small></div><button class="icon-button" aria-label="退出登录" @click="run(() => app.logout())"><LogOut :size="17" /></button></div>
</div>
</aside>
<div class="workspace">
<header class="topbar"><div class="breadcrumb"><button class="mobile-menu icon-button" aria-label="打开导航" @click="mobileOpen = true"><Menu :size="21" /></button><span>工作空间</span><span class="slash">/</span><strong>{{ route.meta.title }}</strong></div><div class="topbar-actions"><span class="local-badge"><span class="status-dot online" />内网管理</span><button class="theme-button" :aria-label="app.dark ? '切换浅色主题' : '切换深色主题'" @click="app.toggleTheme()"><Sun v-if="app.dark" :size="18" /><Moon v-else :size="18" /></button></div></header>
<main class="main-content"><div v-if="app.error" class="service-error">{{ app.error }} <NButton text size="small" @click="app.reload">重新连接</NButton></div><RouterView /></main>
<footer class="workspace-footer"><span>Clash Web <span class="footer-separator">·</span> 为你的内网而建</span><span><span :class="['status-dot', { online: app.streamConnected }]" />{{ app.streamConnected ? '实时状态已连接' : '正在连接实时状态' }}</span></footer>
</div>
</template>
</div>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { init, type ECharts } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { use } from 'echarts/core'
import { useApp } from '../stores/app'
import { bytes } from '../format'
use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
const element = ref<HTMLDivElement>()
const app = useApp()
let chart: ECharts | undefined
let observer: ResizeObserver | undefined
function render() {
const text = app.dark ? '#9aafbd' : '#74858b'
chart?.setOption({
animation: false,
grid: { left: 60, right: 20, top: 25, bottom: 25 },
tooltip: { trigger: 'axis', valueFormatter: (value: number) => `${bytes(value)}/s`, borderWidth: 0, backgroundColor: app.dark ? '#1d2c39' : '#fff', textStyle: { color: app.dark ? '#fff' : '#35495e' } },
xAxis: { type: 'category', boundaryGap: false, data: app.history.map(m => new Date(m.time).toLocaleTimeString('zh-CN', { hour12: false })), axisLine: { show: false }, axisTick: { show: false }, axisLabel: { color: text, fontSize: 10, hideOverlap: true } },
yAxis: { type: 'value', splitNumber: 3, axisLabel: { color: text, fontSize: 10, formatter: (v: number) => `${bytes(v)}/s` }, splitLine: { lineStyle: { color: app.dark ? '#324553' : '#e1ebe7', type: 'dashed' } } },
series: [
{ name: '下载', type: 'line', data: app.history.map(m => m.down), smooth: .35, symbol: 'none', lineStyle: { width: 2, color: '#42b883' }, areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(66,184,131,.18)' }, { offset: 1, color: 'rgba(66,184,131,.01)' }] } } },
{ name: '上传', type: 'line', data: app.history.map(m => m.up), smooth: .35, symbol: 'none', lineStyle: { width: 2, color: '#a4b7c9' } },
],
})
}
onMounted(() => { if (element.value) { chart = init(element.value); observer = new ResizeObserver(() => chart?.resize()); observer.observe(element.value); render() } })
watch(() => [app.history, app.dark], render)
onBeforeUnmount(() => { observer?.disconnect(); chart?.dispose() })
</script>
<template><div ref="element" class="traffic-chart" role="img" aria-label="最近两分钟上传与下载速率" /></template>
+15
View File
@@ -0,0 +1,15 @@
import { ref } from 'vue'
import { useMessage } from 'naive-ui'
export function useTask() {
const busy = ref(false)
const message = useMessage()
async function run(task: () => Promise<unknown>, success?: string) {
if (busy.value) return false
busy.value = true
try { await task(); if (success) message.success(success); return true }
catch (e) { message.error((e as Error).message, { duration: 6000 }); return false }
finally { busy.value = false }
}
return { busy, run, message }
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { bytes, nodeName, subscriptionHost, region } from './format'
describe('network display values', () => {
it('formats zero, nonfinite values and transferred bytes', () => {
expect(bytes(0)).toBe('0 B')
expect(bytes(NaN)).toBe('0 B')
expect(bytes(0.5)).toBe('0.5 B')
expect(bytes(1024)).toBe('1.0 KB')
expect(bytes(1024 ** 3)).toBe('1.0 GB')
})
it('keeps slashes inside a node display name and identifies inactive selections', () => {
expect(nodeName('subscription/provider/HK / 01')).toBe('HK / 01')
expect(nodeName('REJECT')).toBe('尚未选择节点')
expect(nodeName('')).toBe('尚未选择节点')
})
it('shows a subscription host without its private token', () => {
expect(subscriptionHost('https://example.com/sub?token=secret')).toBe('example.com')
expect(subscriptionHost('invalid')).toBe('无效地址')
})
it('identifies common node region labels', () => {
expect(region('香港 01')).toBe('HK')
expect(region('🇯🇵 Tokyo')).toBe('JP')
expect(region('unknown')).toBe('GL')
})
})
+31
View File
@@ -0,0 +1,31 @@
export function bytes(value: number, digits = 1): string {
if (!Number.isFinite(value) || value <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const index = Math.max(0, Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1))
return `${(value / 1024 ** index).toFixed(index === 0 && value >= 1 ? 0 : digits)} ${units[index]}`
}
export function nodeName(id: string): string {
if (!id || id === 'REJECT') return '尚未选择节点'
if (id === 'CW-AUTO') return '自动选择'
return id.split('/').slice(2).join('/') || id
}
export function date(value: string): string {
if (!value) return '尚未更新'
const parsed = new Date(value)
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
}
export function uptime(seconds: number): string {
if (seconds < 60) return `${seconds} 秒`
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟`
return `${Math.floor(seconds / 3600)} 小时 ${Math.floor(seconds % 3600 / 60)} 分钟`
}
export function subscriptionHost(raw: string): string { try { return new URL(raw).host } catch { return '无效地址' } }
export function region(name: string): string {
if (/香港|hong\s*kong|\bhk\b|🇭🇰/i.test(name)) return 'HK'
if (/日本|japan|\bjp\b|东京|🇯🇵/i.test(name)) return 'JP'
if (/新加坡|singapore|\bsg\b|🇸🇬/i.test(name)) return 'SG'
if (/美国|united\s*states|\bus\b|🇺🇸/i.test(name)) return 'US'
if (/台湾|taiwan|\btw\b|🇹🇼/i.test(name)) return 'TW'
if (/韩国|korea|\bkr\b|🇰🇷/i.test(name)) return 'KR'
return 'GL'
}
+21
View File
@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import './style.css'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('./views/Dashboard.vue'), meta: { title: '网络总览' } },
{ path: '/subscriptions', component: () => import('./views/Subscriptions.vue'), meta: { title: '订阅管理' } },
{ path: '/nodes', component: () => import('./views/Nodes.vue'), meta: { title: '代理节点' } },
{ path: '/connectivity', component: () => import('./views/Connectivity.vue'), meta: { title: '连通性测试' } },
{ path: '/rules', component: () => import('./views/Rules.vue'), meta: { title: '分流规则' } },
{ path: '/activity', component: () => import('./views/Activity.vue'), meta: { title: '连接与日志' } },
{ path: '/settings', component: () => import('./views/Settings.vue'), meta: { title: '系统设置' } },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
createApp(App).use(createPinia()).use(router).mount('#app')
+63
View File
@@ -0,0 +1,63 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { api, setCSRF } from '../api'
import type { Metric, Status } from '../types'
export const useApp = defineStore('app', () => {
const authenticated = ref(false)
const initialized = ref(true)
const ready = ref(false)
const status = ref<Status | null>(null)
const error = ref('')
const streamConnected = ref(false)
const metrics = ref<Metric>({ up: 0, down: 0, upTotal: 0, downTotal: 0, connections: 0, coreOnline: false, time: '' })
const history = ref<Metric[]>([])
const theme = ref(localStorage.getItem('cw-theme') || 'light')
const dark = computed(() => theme.value === 'dark')
let stream: EventSource | null = null
let timer: ReturnType<typeof setInterval> | undefined
async function checkAuth() {
try {
const auth = await api<{ initialized: boolean; authenticated: boolean; csrf: string }>('/auth/status')
initialized.value = auth.initialized
authenticated.value = auth.authenticated
setCSRF(auth.csrf)
if (auth.authenticated) start()
error.value = ''
} catch (e) { error.value = (e as Error).message }
finally { ready.value = true }
}
async function login(password: string, token?: string) {
const result = await api<{ csrf: string }>(initialized.value ? '/auth/login' : '/auth/setup', 'POST', { password, ...(token ? { token } : {}) })
setCSRF(result.csrf)
initialized.value = true
authenticated.value = true
start()
}
async function reload() {
try { status.value = await api<Status>('/status'); error.value = '' }
catch (e) { error.value = (e as Error).message }
}
function start() {
stop()
void reload()
timer = setInterval(() => void reload(), 15000)
stream = new EventSource('/api/v1/events')
stream.addEventListener('metrics', (event) => {
const value = JSON.parse((event as MessageEvent).data) as Metric
metrics.value = value
streamConnected.value = true
history.value = [...history.value, value].slice(-60)
if (status.value) status.value.coreOnline = value.coreOnline
})
stream.addEventListener('expired', expire)
stream.onerror = () => { streamConnected.value = false }
}
function stop() { stream?.close(); stream = null; if (timer) clearInterval(timer); timer = undefined; streamConnected.value = false }
function expire() { authenticated.value = false; setCSRF(''); stop(); status.value = null; history.value = [] }
async function logout() { await api('/auth/logout', 'POST', {}); expire() }
function toggleTheme() { theme.value = dark.value ? 'light' : 'dark'; localStorage.setItem('cw-theme', theme.value) }
window.addEventListener('session-expired', expire)
return { authenticated, initialized, ready, status, error, metrics, history, dark, theme, streamConnected, checkAuth, login, reload, logout, toggleTheme }
})
+193
View File
@@ -0,0 +1,193 @@
:root { font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif; color: #35495e; background: #f5f9f8; font-synthesis: none; -webkit-font-smoothing: antialiased; }
* { box-sizing: border-box; }
body { margin: 0; }
a { color: inherit; text-decoration: none; }
button, input { font: inherit; }
button { cursor: pointer; }
button:focus-visible, a:focus-visible { outline: 2px solid #42b883; outline-offset: 4px; }
code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; font-size: .91em; }
h1, h2, h3, p { margin: 0; }
.application { --canvas: #f5f9f8; --surface: #fff; --text: #35495e; --muted: #74858b; --border: #e1ebe7; --soft: #f1f7f4; --accent: #27865f; --accent-bright: #42b883; --accent-soft: #e5f7ef; --hero: #e0f7ed; --shadow: 0 2px 8px #35495e04; min-height: 100vh; color: var(--text); background: var(--canvas); }
.application.dark { --canvas: #15202b; --surface: #1d2c39; --text: #e2edf3; --muted: #9aafbd; --border: #324553; --soft: #263a47; --accent: #65d6a3; --accent-bright: #65d6a3; --accent-soft: #203f3b; --hero: #23483f; --shadow: none; }
.loading-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; gap: 20px; color: var(--muted); }
.sidebar { position: fixed; inset: 0 auto 0 0; width: 238px; background: var(--surface); color: var(--muted); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 36px 20px 0; z-index: 20; }
.brand { display: flex; align-items: center; gap: 11px; color: var(--text); font-size: 23px; font-weight: 700; letter-spacing: -.8px; padding-left: 8px; white-space: nowrap; }
.brand-icon { width: 40px; height: 42px; display: inline-flex; align-items: center; justify-content: center; object-fit: contain; background: transparent; border: 0; border-radius: 12px; flex-shrink: 0; }
.brand-light { font-weight: 450; }
.brand small { display: block; font-size: 8px; letter-spacing: 1.75px; color: var(--muted); margin-top: 2px; font-weight: 500; }
.nav-label { font-size: 10px; font-weight: 500; letter-spacing: 1.5px; padding: 44px 15px 13px; color: var(--muted); }
.nav-item { display: flex; align-items: center; gap: 13px; margin-bottom: 6px; padding: 13px 15px; font-size: 13px; border-radius: 8px; transition: background .15s, color .15s; }
.nav-item:hover { color: var(--accent); background: var(--soft); }
.nav-item.active { color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent-bright); }
.nav-item.active svg { color: var(--accent); }
.nav-count { margin-left: auto; font-size: 10px; background: var(--surface); border-radius: 5px; padding: 2px 6px; color: var(--accent); }
.sidebar-bottom { margin-top: auto; padding-top: 45px; }
.engine-card { background: var(--soft); border: 1px solid var(--border); border-radius: 10px; padding: 15px 13px; }
.engine-top { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text); }
.engine-tag { margin-left: auto; font-size: 8px; letter-spacing: .7px; color: var(--muted); }
.engine-card p { font-size: 10px; color: var(--muted); margin: 8px 0 14px 15px; overflow-wrap: anywhere; }
.engine-card a { display: flex; justify-content: space-between; font-size: 11px; color: var(--accent); padding-top: 12px; border-top: 1px solid var(--border); }
.account { display: flex; align-items: center; gap: 10px; padding: 21px 2px; }
.avatar { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--accent-soft); color: var(--accent); font-size: 12px; }
.account strong { display: block; font-size: 11px; color: var(--text); font-weight: 500; }
.account small { display: block; font-size: 9px; margin-top: 3px; }
.account button { margin-left: auto; color: var(--muted); }
.icon-button, .theme-button { border: 0; background: transparent; color: inherit; display: inline-flex; align-items: center; justify-content: center; padding: 7px; border-radius: 7px; }
.icon-button:hover { background: #7eac8f15; }
.workspace { margin-left: 238px; min-height: 100vh; display: flex; flex-direction: column; }
.topbar { height: 70px; padding: 0 38px; border-bottom: 1px solid var(--border); background: var(--surface); display: flex; align-items: center; justify-content: space-between; }
.breadcrumb { display: flex; align-items: center; gap: 16px; font-size: 11px; color: var(--muted); }
.breadcrumb strong { font-weight: 500; color: var(--text); }
.slash { color: #c2cbc5; }
.topbar-actions { display: flex; align-items: center; gap: 21px; }
.local-badge { display: inline-flex; align-items: center; gap: 7px; background: var(--accent-soft); color: var(--accent); font-size: 10px; padding: 5px 9px; border-radius: 5px; }
.theme-button { border: 1px solid var(--border); background: var(--surface); color: var(--muted); width: 32px; height: 32px; }
.main-content { width: 100%; max-width: 1530px; padding: 34px 38px 25px; margin: 0 auto; flex: 1; }
.page-header { display: flex; justify-content: space-between; gap: 20px; align-items: center; margin-bottom: 27px; }
.eyebrow { font-size: 9px; letter-spacing: 1.9px; color: var(--muted); font-weight: 600; margin-bottom: 8px; }
.page-header h1 { font-size: 26px; font-weight: 650; letter-spacing: -.8px; line-height: 1.3; }
.page-header p { margin-top: 8px; color: var(--muted); font-size: 12px; }
.page-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.workspace-footer { display: flex; justify-content: space-between; padding: 18px 38px 23px; color: var(--muted); font-size: 10px; }
.workspace-footer > span:last-child { display: flex; align-items: center; gap: 7px; }
.footer-separator { margin: 0 7px; color: #c4cfc7; }
.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: #8c9891; flex-shrink: 0; }
.status-dot.online { background: #60c996; box-shadow: 0 0 0 3px #60c99614; }
.status-label { color: var(--muted); background: var(--soft); font-size: 10px; border-radius: 5px; padding: 5px 9px; white-space: nowrap; }
.status-label.connected { color: var(--accent); background: var(--accent-soft); }
.overview-hero { border-radius: 13px; display: flex; background: var(--hero); color: var(--text); min-height: 246px; position: relative; overflow: hidden; margin-bottom: 21px; background-image: radial-gradient(ellipse at 84% 30%, #42b88338 0%, transparent 58%); border: 1px solid #42b88326; }
.hero-copy { padding: 28px 33px; position: relative; z-index: 2; }
.hero-eyebrow { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 10px; }
.hero-copy h2 { font-size: 31px; letter-spacing: -1px; font-weight: 550; line-height: 1.38; margin: 15px 0 10px; }
.hero-copy p { color: var(--muted); font-size: 11px; }
.hero-pills { display: flex; gap: 8px; margin-top: 21px; }
.hero-pills span { display: inline-flex; align-items: center; gap: 5px; font-size: 9px; border: 1px solid #42b88338; background: var(--surface); padding: 4px 8px; border-radius: 5px; color: var(--accent); }
.hero-network { position: absolute; width: 400px; height: 290px; right: 35px; top: -12px; }
.orbit { position: absolute; border-radius: 50%; border: 1px solid #42b88340; left: 50%; top: 49%; transform: translate(-50%, -50%); }
.orbit-one { width: 170px; height: 170px; }
.orbit-two { width: 274px; height: 274px; border-style: dashed; border-color: #42b88328; }
.network-center { position: absolute; width: 97px; height: 97px; display: flex; align-items: center; justify-content: center; top: 93px; left: 152px; border-radius: 50%; border: 1px solid #42b88340; color: var(--accent); background: linear-gradient(135deg, #42b88335, #42b88310); box-shadow: 0 0 70px #42b88315; }
.network-point { display: flex; align-items: center; justify-content: center; position: absolute; width: 41px; height: 41px; border: 1px solid #42b88340; color: var(--accent); background: var(--surface); border-radius: 12px; box-shadow: 0 5px 25px #16291a30; }
.point-one { top: 52px; left: 116px; }
.point-two { top: 180px; left: 225px; }
.point-three { top: 118px; left: 317px; color: var(--accent); background: var(--accent-soft); }
.orbit-label { position: absolute; color: var(--accent); font-size: 8px; letter-spacing: 1.6px; }
.label-one { top: 43px; left: 238px; }.label-two { top: 225px; left: 122px; }
.network-coordinate { position: absolute; bottom: 18px; left: 107px; font-size: 7px; letter-spacing: 2px; color: var(--muted); }
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 21px; }
.stat-card, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: 11px; box-shadow: var(--shadow); }
.stat-card { padding: 18px 21px; }
.stat-label { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 11px; }
.stat-icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 8px; }
.stat-icon.green { background: #edf8f0; color: #3b9366; }.stat-icon.blue { background: #edf3f8; color: #6b91af; }.stat-icon.amber { background: #fcf5e9; color: #ba975c; }.stat-icon.purple { background: #f3eef8; color: #9c85b5; }
.dark .stat-icon { background: var(--soft); }
.stat-value { font-size: 28px; letter-spacing: -1px; font-weight: 600; margin: 11px 0 8px; font-variant-numeric: tabular-nums; }
.stat-value small { color: var(--muted); font-size: 11px; font-weight: 400; letter-spacing: 0; margin-left: 5px; }
.stat-card p { font-size: 10px; color: var(--muted); }.stat-card p strong { color: #788a7c; font-weight: 500; margin-left: 3px; }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.9fr) minmax(260px, 1fr); gap: 20px; margin-bottom: 20px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding: 21px 23px 0; }
.panel-heading h3 { font-size: 13px; font-weight: 600; }.panel-heading p { font-size: 10px; color: var(--muted); margin-top: 6px; }
.chart-legend { display: flex; gap: 15px; font-size: 9px; color: var(--muted); white-space: nowrap; }.chart-legend span { display: flex; align-items: center; gap: 5px; }
.legend-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; }.legend-dot.download { background: var(--accent-bright); }.legend-dot.upload { background: #a4b7c9; }
.traffic-chart { height: 230px; width: 100%; margin-top: 12px; }
.panel-foot { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--border); padding: 13px 23px; color: var(--muted); font-size: 9px; }.foot-right { margin-left: auto; }
.current-node { padding-bottom: 0; overflow: hidden; }.current-node-symbol { width: 54px; height: 54px; border-radius: 14px; background: var(--accent-soft); color: var(--accent); display: flex; align-items: center; justify-content: center; margin: 18px auto 12px; }
.selected-name { font-size: 15px; text-align: center; font-weight: 550; padding: 0 18px; overflow-wrap: anywhere; }.selected-description { font-size: 9px; text-align: center; color: var(--muted); margin: 7px 0 15px; }
.node-detail-row { display: flex; align-items: center; justify-content: space-between; padding: 7px 23px; font-size: 10px; color: var(--muted); }.node-detail-row strong { font-weight: 500; color: var(--text); }
.panel-link { display: flex; align-items: center; justify-content: space-between; color: var(--accent); background: var(--accent-soft); padding: 12px 23px; font-size: 10px; margin-top: 13px; border-top: 1px solid var(--border); }
.small-tag { display: inline-flex; align-items: center; background: var(--soft); border: 1px solid var(--border); color: var(--muted); padding: 3px 7px; font-size: 9px; border-radius: 5px; white-space: nowrap; }
.text-link { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; color: var(--accent); }
.dashboard-bottom { display: grid; grid-template-columns: 1.45fr 1fr; gap: 20px; }
.compact-empty { display: flex; align-items: center; gap: 14px; padding: 29px 23px 35px; color: var(--muted); }.compact-empty strong { color: var(--text); font-size: 11px; font-weight: 500; }.compact-empty p { font-size: 10px; margin-top: 5px; }.compact-empty a { display: flex; align-items: center; margin-left: auto; color: var(--accent); font-size: 10px; white-space: nowrap; }
.quick-start { padding-bottom: 20px; }.quick-start > p { font-size: 10px; color: var(--muted); line-height: 1.8; padding: 14px 23px; }.network-values { display: flex; gap: 25px; padding: 0 23px 16px; }.network-values span { display: block; font-size: 9px; color: var(--muted); margin-bottom: 5px; }.network-values code { font-size: 11px; }.quick-start > a { margin-left: 23px; }
.subscription-summary { display: flex; align-items: center; gap: 11px; padding: 15px 23px; border-bottom: 1px solid var(--border); }.subscription-summary:last-child { border-bottom: 0; }.summary-icon { width: 30px; height: 30px; background: var(--accent-soft); color: var(--accent); display: flex; align-items: center; justify-content: center; border-radius: 8px; }.subscription-summary strong { font-size: 11px; font-weight: 500; }.subscription-summary small { display: block; color: var(--muted); font-size: 9px; margin-top: 3px; }.subscription-summary .small-tag { margin-left: auto; }
.section-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 12px; margin: 0 0 17px; }.muted { color: var(--muted); }.small { font-size: 10px; }.nowrap { white-space: nowrap; }
.empty-state { padding: 66px 20px 71px; text-align: center; }.empty-icon { display: flex; align-items: center; justify-content: center; width: 62px; height: 62px; background: var(--accent-soft); color: var(--accent); border: 1px solid var(--border); border-radius: 18px; margin: 0 auto 20px; }.empty-state h3 { font-size: 17px; font-weight: 550; }.empty-state p { font-size: 12px; color: var(--muted); margin: 12px 0 23px; line-height: 1.8; }
.info-note { display: flex; align-items: flex-start; gap: 12px; color: var(--muted); padding: 20px 5px; font-size: 11px; line-height: 1.9; }.info-note svg { flex-shrink: 0; margin-top: 3px; }
.subscription-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }.subscription-card { padding: 22px; }.subscription-card.disabled { opacity: .65; }.subscription-card-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 19px; }.subscription-icon { display: flex; align-items: center; justify-content: center; width: 43px; height: 43px; background: var(--accent-soft); color: var(--accent); border-radius: 12px; }.subscription-card h3 { font-size: 17px; font-weight: 550; overflow-wrap: anywhere; }.subscription-host { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; margin-top: 8px; overflow-wrap: anywhere; }.subscription-node-count { margin: 24px 0 20px; font-size: 33px; font-weight: 600; letter-spacing: -1px; }.subscription-node-count span { color: var(--muted); font-size: 10px; letter-spacing: 0; font-weight: 400; margin-left: 10px; }.subscription-facts { display: flex; flex-direction: column; gap: 9px; font-size: 10px; color: var(--muted); }.subscription-facts span { display: flex; align-items: center; gap: 6px; }.subscription-card-actions { display: flex; gap: 5px; padding-top: 19px; margin-top: 19px; border-top: 1px solid var(--border); }.subscription-card-actions > :nth-child(2) { margin-left: auto; }.subscription-error { margin-top: 15px; }.subscription-error small { display: block; font-size: 9px; margin-top: 5px; }
.selection-banner { display: flex; align-items: center; gap: 13px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 17px 21px; margin-bottom: 24px; }.selection-symbol { width: 41px; height: 41px; background: var(--accent-soft); color: var(--accent); border-radius: 10px; display: flex; align-items: center; justify-content: center; }.selection-banner div > span { font-size: 10px; }.selection-banner strong { display: block; font-size: 14px; font-weight: 550; margin-top: 4px; overflow-wrap: anywhere; }.selection-banner > .status-label { margin-left: auto; }
.nodes-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 21px; }.node-search { flex: 1; min-width: 160px; }.subscription-select { width: 160px; }.sort-select { width: 150px; }
.node-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }.node-card { padding: 20px; transition: border-color .15s, box-shadow .15s; }.node-card:hover { border-color: var(--accent-bright); }.node-card.selected { border-color: var(--accent-bright); box-shadow: 0 0 0 1px #42b88330; background: linear-gradient(120deg, var(--accent-soft), var(--surface) 70%); }.node-card-main { display: flex; align-items: center; gap: 12px; }.region-icon { display: flex; align-items: center; justify-content: center; width: 39px; height: 39px; border-radius: 10px; font-size: 11px; letter-spacing: .4px; font-weight: 600; background: #edf2f7; color: #658397; flex-shrink: 0; }.region-icon.hk { background: #f9eeed; color: #be726c; }.region-icon.jp { background: #f8eef2; color: #bf7693; }.region-icon.sg { background: #f8f0ea; color: #bd926f; }.region-icon.us { background: #edf2fa; color: #7395c2; }.region-icon.tw { background: #eceef9; color: #878dba; }.dark .region-icon { background: var(--soft); }.node-title { min-width: 0; }.node-title h3 { font-size: 12px; font-weight: 550; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }.node-title p { font-size: 9px; color: var(--muted); margin-top: 5px; }.selected-check { display: inline-flex; color: white; background: var(--accent); border-radius: 50%; padding: 3px; margin-left: auto; }.node-card-meta { display: flex; gap: 6px; align-items: center; margin: 20px 0 18px; }.protocol-tag { background: var(--soft); border: 1px solid var(--border); color: var(--muted); font-size: 8px; padding: 3px 6px; border-radius: 4px; white-space: nowrap; }.protocol-tag.subtle { border-color: transparent; background: transparent; }.node-delay { display: flex; align-items: center; gap: 5px; margin-left: auto; font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }.node-delay i { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }.node-delay.good { color: #47a371; }.node-delay.fair { color: #b39354; }.node-delay.bad { color: #c87873; }.node-delay.unknown { color: var(--muted); }.node-card-actions { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--border); padding-top: 14px; }
.node-list { grid-template-columns: 1fr; }.node-list .node-card { display: grid; grid-template-columns: 1.4fr 1fr 145px; align-items: center; gap: 20px; padding: 14px 20px; }.node-list .node-card-meta { margin: 0; }.node-list .node-card-actions { padding: 0; border: 0; }
.routing-overview { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 22px; }.routing-step { display: flex; align-items: center; gap: 15px; padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 11px; }.routing-icon { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 12px; background: var(--accent-soft); color: var(--accent); }.routing-icon.proxy { background: #eff1f8; color: #8291ba; }.routing-step strong { display: block; font-size: 13px; font-weight: 550; }.routing-step div span { display: block; font-size: 10px; color: var(--muted); margin-top: 7px; }.routing-step > .route-badge { margin-left: auto; }.route-badge { display: inline-block; font-size: 9px; border-radius: 4px; padding: 4px 8px; white-space: nowrap; }.route-badge.direct { color: #398c5e; background: #edf7ef; }.route-badge.proxy { color: #7c87b1; background: #eff1f9; }.dark .route-badge { background: var(--soft); }
.rule-empty { display: flex; justify-content: center; align-items: center; gap: 15px; padding: 48px 23px; color: var(--muted); font-size: 12px; line-height: 1.8; }.table-scroll { width: 100%; overflow-x: auto; margin-top: 20px; }.data-table { width: 100%; border-collapse: collapse; font-size: 11px; text-align: left; }.data-table th { font-size: 10px; color: var(--muted); font-weight: 500; background: var(--soft); padding: 12px 23px; border-block: 1px solid var(--border); white-space: nowrap; }.data-table td { padding: 15px 23px; border-bottom: 1px solid var(--border); }.data-table tbody tr:last-child td { border-bottom: 0; }.data-table small { display: block; font-size: 9px; color: var(--muted); margin-top: 6px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.align-right { text-align: right; }
.rule-order { margin-top: 23px; padding-bottom: 13px; }.rule-order-step { display: flex; align-items: center; gap: 17px; padding: 14px 23px; font-size: 11px; border-bottom: 1px solid var(--border); }.rule-order-step:last-child { border: 0; }.rule-order-step > svg, .rule-order-step > .small-tag { margin-left: auto; color: var(--muted); }.step-number { font-size: 10px; color: var(--muted); background: var(--soft); padding: 5px 7px; border-radius: 5px; }
.activity-panel { padding: 9px 23px 23px; }.activity-toolbar { display: flex; align-items: center; gap: 12px; margin: 12px 0 16px; }.activity-toolbar .n-input { flex: 1; }.log-level-select { width: 130px; }.log-status { font-size: 10px; color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 8px 0 17px; }.log-status > span:last-child { margin-left: auto; }.log-console { min-height: 390px; max-height: 65vh; overflow-y: auto; background: #17221c; border: 1px solid #293b2e; border-radius: 8px; padding: 14px; color: #b3cbb7; font-family: Consolas, monospace; font-size: 10px; }.log-line { display: flex; align-items: flex-start; gap: 11px; line-height: 1.7; padding: 5px 0; border-bottom: 1px solid #ffffff05; }.log-line time { color: #60876b; flex-shrink: 0; }.log-level { color: #7fc19a; font-size: 9px; width: 50px; flex-shrink: 0; }.log-level.warning { color: #c8ac72; }.log-level.error { color: #ce8b81; }.log-text { overflow-wrap: anywhere; min-width: 0; }.log-console-empty { display: flex; gap: 13px; align-items: center; justify-content: center; height: 270px; color: #6d8d75; }.connections-table strong { font-weight: 500; }
.settings-grid { display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(270px, 1fr); gap: 22px; }.settings-primary, .settings-secondary { display: flex; flex-direction: column; gap: 22px; }.settings-section { padding: 23px; }.settings-heading { display: flex; align-items: center; gap: 13px; margin-bottom: 24px; }.settings-heading h3 { font-size: 14px; font-weight: 550; }.settings-heading p { font-size: 10px; color: var(--muted); margin-top: 6px; }.settings-icon { display: flex; align-items: center; justify-content: center; width: 37px; height: 37px; border-radius: 10px; background: var(--soft); color: var(--accent); flex-shrink: 0; }.settings-heading > .status-label { margin-left: auto; }.setting-switch { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); padding: 13px 0; gap: 16px; }.setting-switch strong { font-size: 12px; font-weight: 500; }.setting-switch p { font-size: 10px; color: var(--muted); margin-top: 5px; }.settings-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0 19px; margin-top: 23px; }.field-label { display: block; color: inherit; font-size: 11px; font-weight: 500; margin-bottom: 19px; }.field-label > .n-input, .field-label > .n-input-number, .field-label > .n-select { margin-top: 8px; }.field-label small { display: block; color: var(--muted); font-size: 9px; font-weight: 400; line-height: 1.7; margin-top: 6px; }.service-facts { display: flex; gap: 27px; margin-bottom: 20px; }.service-facts span { display: block; font-size: 10px; color: var(--muted); margin-bottom: 7px; }.service-facts code { font-size: 11px; }.service-buttons { display: flex; gap: 8px; margin-bottom: 12px; }.test-settings { grid-template-columns: 1.6fr 1fr; border-top: 1px solid var(--border); padding-top: 20px; }.network-checks { padding-bottom: 20px; }.check-row { display: flex; align-items: center; gap: 10px; padding: 10px 23px; font-size: 11px; }.network-checks .panel-heading { margin-bottom: 13px; }.check-ok { color: #58a87c; }.network-checks > .n-button { margin: 12px 23px 0; }.deployment-note { padding: 23px; background: var(--accent-soft); border-color: #b8d2bf45; }.deployment-note h3 { font-size: 14px; font-weight: 550; }.deployment-note p { color: var(--muted); line-height: 1.9; font-size: 10px; margin-top: 13px; }.deployment-note ol { font-size: 10px; color: var(--text); padding-left: 17px; line-height: 2.15; margin: 17px 0; }.deployment-foot { padding-top: 14px; border-top: 1px solid #b8d2bf45; }.settings-backup { padding-bottom: 21px; }.settings-backup p { padding: 14px 23px; font-size: 10px; color: var(--muted); line-height: 1.9; }.settings-backup > .n-button { margin: 7px 23px; width: calc(100% - 46px); }.settings-doc-link { display: flex; gap: 6px; font-size: 9px; color: var(--muted); line-height: 1.8; padding: 0 3px; }.settings-doc-link svg { flex-shrink: 0; }
.form-modal { width: min(490px, calc(100vw - 32px)); }.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }.form-switch-row { display: flex; justify-content: space-between; align-items: center; font-size: 12px; }.page-alert { margin-bottom: 22px; }.inline-error { color: #b46360; font-size: 11px; line-height: 1.8; background: #c8685c0c; border-radius: 7px; padding: 9px 11px; }.service-error { display: flex; justify-content: space-between; align-items: center; color: #bd7863; background: #b9894d0d; padding: 10px 15px; font-size: 12px; border-radius: 8px; margin-bottom: 20px; }
.auth-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; padding: 40px 20px; background: radial-gradient(ellipse at 50% 8%, #42b88316, transparent 60%); }.auth-theme { position: absolute; top: 24px; right: 28px; }.auth-brand { display: flex; align-items: center; gap: 12px; font-size: 24px; font-weight: 600; letter-spacing: -.6px; margin-bottom: 33px; }.auth-card { background: var(--surface); border: 1px solid var(--border); padding: 37px 37px 28px; width: min(420px, 100%); border-radius: 16px; box-shadow: 0 20px 70px #17372a06; }.auth-card h1 { font-size: 26px; font-weight: 550; letter-spacing: -.7px; margin: 14px 0 11px; }.auth-card > p { font-size: 12px; color: var(--muted); line-height: 1.8; margin-bottom: 27px; }.auth-card form > .n-button { margin-top: 8px; }.auth-footnote { display: flex; justify-content: center; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; border-top: 1px solid var(--border); margin-top: 28px; padding-top: 21px; }.auth-card .setup-hint { font-size: 10px; margin-top: 18px; margin-bottom: 0; overflow-wrap: anywhere; }.auth-bottom { font-size: 10px; color: var(--muted); margin-top: 28px; letter-spacing: .5px; }
.mobile-menu, .mobile-close { display: none; }
@media (min-width: 1600px) { .main-content { padding-top: 40px; }.overview-hero { min-height: 270px; }.hero-copy { padding: 33px 38px; }.hero-copy h2 { font-size: 36px; }.hero-network { top: 0; right: 70px; }.traffic-chart { height: 270px; }.stat-card { padding-block: 23px; } }
@media (max-width: 1150px) { .sidebar { width: 211px; padding-inline: 15px; }.brand { font-size: 21px; }.workspace { margin-left: 211px; }.topbar { padding-inline: 25px; }.main-content { padding-inline: 25px; }.workspace-footer { padding-inline: 25px; }.hero-network { right: -50px; }.hero-copy { padding-inline: 26px; }.stat-grid { gap: 10px; }.stat-card { padding-inline: 15px; }.stat-value { font-size: 24px; }.dashboard-grid { grid-template-columns: minmax(0, 1.6fr) minmax(230px, 1fr); gap: 15px; }.dashboard-bottom { gap: 15px; }.subscription-grid, .node-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.node-list { grid-template-columns: 1fr; }.nodes-toolbar { flex-wrap: wrap; }.node-search { min-width: 250px; }.settings-grid { grid-template-columns: minmax(0, 1.6fr) minmax(240px, 1fr); gap: 15px; }.routing-step { padding: 20px 16px; gap: 10px; }.routing-step > .route-badge { display: none; }.compact-empty { flex-wrap: wrap; }.compact-empty a { margin-left: 38px; }.network-values { gap: 15px; }.settings-fields { grid-template-columns: 1fr; }.settings-section { padding: 20px; } }
@media (max-width: 860px) { .sidebar { width: 200px; padding-inline: 13px; }.brand { font-size: 19px; }.brand small { font-size: 7px; letter-spacing: 1.4px; }.brand-icon { width: 35px; height: 38px; }.workspace { margin-left: 200px; }.main-content { padding: 25px 20px; }.page-header { align-items: flex-start; }.page-header h1 { font-size: 23px; }.page-actions { flex-wrap: wrap; justify-content: flex-end; max-width: 210px; }.overview-hero { min-height: 234px; }.hero-network { right: -137px; opacity: .65; }.hero-copy h2 { font-size: 29px; }.hero-copy p { max-width: 260px; font-size: 10px; }.stat-grid { grid-template-columns: repeat(2, 1fr); }.dashboard-grid, .dashboard-bottom { grid-template-columns: 1fr; }.current-node { display: none; }.settings-grid { grid-template-columns: 1fr; }.settings-fields { grid-template-columns: 1fr 1fr; }.settings-secondary { display: grid; grid-template-columns: 1fr 1fr; }.settings-doc-link { grid-column: 1 / -1; }.routing-overview { gap: 12px; }.routing-step { padding: 17px 14px; }.routing-icon { width: 34px; height: 34px; }.routing-step div span { font-size: 9px; }.node-list .node-card { grid-template-columns: 1fr 1fr; }.node-list .node-card-actions { grid-column: 1 / -1; }.activity-toolbar { flex-wrap: wrap; }.activity-toolbar .n-input { min-width: 180px; } }
@media (max-width: 680px) { .sidebar { transform: translateX(-100%); transition: transform .2s; width: 238px; padding: 30px 20px 0; }.sidebar.open { transform: translateX(0); }.sidebar-overlay { position: fixed; inset: 0; background: #15202b70; z-index: 19; }.mobile-close { display: flex; position: absolute; right: 13px; top: 12px; }.mobile-menu { display: inline-flex; }.brand { font-size: 23px; }.brand small { font-size: 8px; }.brand-icon { width: 40px; height: 42px; }.workspace { margin-left: 0; }.topbar { height: 60px; padding-inline: 14px; }.breadcrumb { gap: 9px; font-size: 10px; }.topbar-actions { gap: 10px; }.local-badge { font-size: 9px; }.main-content { padding: 25px 17px 16px; }.page-header { flex-wrap: wrap; gap: 17px; margin-bottom: 22px; }.page-header h1 { font-size: 25px; }.page-actions { max-width: none; }.page-header p { font-size: 11px; }.overview-hero { min-height: 236px; }.hero-copy { padding: 25px; }.hero-network { right: -152px; opacity: .42; }.hero-copy p { max-width: 250px; }.hero-copy h2 { font-size: 31px; }.stat-card { padding: 16px; }.stat-value { font-size: 25px; }.stat-grid { gap: 12px; }.stat-card p { font-size: 9px; }.subscription-grid, .node-grid { grid-template-columns: 1fr; }.node-list .node-card { grid-template-columns: 1fr; }.node-list .node-card-actions { grid-column: auto; padding-top: 12px; border-top: 1px solid var(--border); }.node-list .node-card-meta { margin: 13px 0 0; }.nodes-toolbar { gap: 8px; }.node-search { flex-basis: 100%; }.subscription-select, .sort-select { flex: 1; min-width: 130px; }.section-toolbar { font-size: 11px; align-items: flex-start; }.section-toolbar > .small { display: none; }.selection-banner { padding-inline: 15px; }.selection-banner strong { font-size: 12px; }.selection-banner .status-label { font-size: 8px; }.routing-overview { grid-template-columns: 1fr; }.routing-step > .route-badge { display: block; }.settings-fields { grid-template-columns: 1fr; }.settings-secondary { display: flex; }.activity-panel { padding-inline: 15px; }.activity-toolbar { gap: 9px; }.activity-toolbar .n-input { flex-basis: 100%; }.data-table th, .data-table td { padding-inline: 15px; }.connections-table { min-width: 670px; }.log-status > span:last-child { display: none; }.log-line { gap: 7px; flex-wrap: wrap; }.log-text { flex-basis: 100%; }.panel-heading { padding-inline: 18px; }.panel-heading p { font-size: 9px; }.chart-legend { gap: 9px; }.traffic-chart { height: 205px; }.workspace-footer { padding: 15px 17px 22px; font-size: 9px; }.info-note { font-size: 10px; }.auth-card { padding-inline: 27px; }.empty-state { padding-block: 47px; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
.core-update-section { border-top: 1px solid var(--border); padding-top: 20px; margin-top: 22px; }
.core-update-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.core-update-heading h4 { font-size: 13px; margin: 0; font-weight: 600; }
.core-update-heading p, .update-result { color: var(--muted); font-size: 11px; line-height: 1.8; margin: 7px 0; }
.update-alert { margin-top: 14px; }
.upgrade-guide { margin-top: 17px; border-radius: 8px; padding: 12px 14px; background: var(--soft); font-size: 11px; line-height: 1.9; }
.upgrade-guide summary { color: var(--accent); cursor: pointer; }
.upgrade-guide p { margin: 9px 0 0; color: var(--muted); }
@media (max-width: 680px) { .core-update-heading { align-items: flex-start; flex-wrap: wrap; } }
/* Compact result grid: all nine sites together, with similar sites adjacent. */
.connectivity-page .page-header { margin-bottom: 20px; }
.connectivity-page .section-toolbar { margin-bottom: 12px; }
.connectivity-overview { padding: 14px 18px; margin-bottom: 16px; }
.connectivity-overview-top { display: flex; align-items: center; gap: 24px; }
.connectivity-intro { display: flex; align-items: center; gap: 11px; }
.connectivity-radar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--accent-soft); flex-shrink: 0; }
.connectivity-intro h2 { font-size: 14px; }
.connectivity-intro p { color: var(--muted); font-size: 10px; margin-top: 4px; line-height: 1.5; }
.connectivity-intro > .protocol-tag { display: none; }
.connectivity-summary { display: flex; justify-content: flex-end; gap: 24px; flex: 1; }
.connectivity-summary > div { display: flex; align-items: center; gap: 7px; font-size: 11px; color: var(--muted); }
.connectivity-summary strong { font-size: 22px; color: var(--text); font-weight: 650; }
.connectivity-summary > div:first-child svg { color: var(--accent); }
.connectivity-summary > div:nth-child(2) svg { color: #c18d43; }
.connectivity-summary > div:last-child svg { color: #d87878; }
.connectivity-progress { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 6px 16px; color: var(--muted); font-size: 10px; margin-top: 12px; }
.connectivity-overview .n-progress { margin-top: 8px; }
.connectivity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.connectivity-card { padding: 12px 16px; transition: border-color .2s; min-width: 0; }
.connectivity-card.reachable { border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); }
.connectivity-card-heading { display: flex; align-items: center; gap: 10px; }
.connectivity-card-heading h3 { font-size: 14px; line-height: 1.25; }
.connectivity-card-heading p { color: var(--muted); font-size: 10px; line-height: 1.35; margin-top: 3px; overflow-wrap: anywhere; }
.connectivity-card-heading a { margin-left: auto; color: var(--muted); }
.site-monogram { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px; flex-shrink: 0; font-size: 16px; font-weight: 650; background: var(--soft); color: var(--text); }
.site-monogram.google { color: #4285f4; background: #4285f411; }
.site-monogram.netflix, .site-monogram.youtube { color: #e95050; background: #e9505011; }
.site-monogram.chatgpt { color: var(--accent); background: var(--accent-soft); font-size: 13px; }
.site-monogram.claude { color: #c78160; background: #c7816014; }
.site-monogram.dockerhub { color: #2496ed; background: #2496ed11; font-size: 15px; }
.connectivity-state { display: flex; align-items: center; gap: 8px; margin: 8px 0 4px; font-size: 13px; color: var(--muted); }
.connectivity-state strong { font-weight: 550; }
.connectivity-state span { margin-left: auto; font-size: 12px; font-variant-numeric: tabular-nums; }
.connectivity-state.reachable { color: var(--accent); }
.connectivity-state.challenge, .connectivity-state.restricted { color: #bb8c45; }
.connectivity-state.failed, .connectivity-state.http_error { color: #d87878; }
.connectivity-state.testing { color: var(--accent); }
.connectivity-detail { font-size: 11px; color: var(--muted); line-height: 1.5; min-height: 33px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.connectivity-card-footer { border-top: 1px solid var(--border); margin-top: 8px; padding-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.connectivity-result-meta { display: flex; flex-direction: column; gap: 2px; color: var(--muted); font-size: 10px; line-height: 1.3; }
.connectivity-page .info-note { padding-block: 14px; }
.spin { animation: connectivity-spin 1.2s linear infinite; }
@keyframes connectivity-spin { to { transform: rotate(360deg); } }
@media (max-width: 1050px) { .connectivity-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 651px) and (max-width: 1150px) { .connectivity-overview-top { flex-wrap: wrap; gap: 12px; }.connectivity-summary { justify-content: flex-start; } }
@media (max-width: 650px) {
.connectivity-grid { grid-template-columns: 1fr; }
.connectivity-overview { padding: 14px; }
.connectivity-overview-top { flex-wrap: wrap; gap: 14px; }
.connectivity-summary { justify-content: flex-start; flex-basis: 100%; }
.connectivity-summary { gap: 16px; }
.connectivity-summary > div { flex-wrap: wrap; gap: 5px; }
.connectivity-summary > div span { width: 100%; font-size: 10px; }
.connectivity-summary strong { font-size: 23px; }
}
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
+112
View File
@@ -0,0 +1,112 @@
export interface Settings {
proxyEnabled: boolean
tunEnabled: boolean
selectionMode: 'manual' | 'auto'
selectedNode: string
lanIP: string
lanCIDR: string
gateway: string
interface: string
mixedPort: number
dnsPort: number
testURL: string
testTimeoutMS: number
}
export interface Status {
coreOnline: boolean
coreInstalled: boolean
tunActive: boolean
version: string
uptimeSeconds: number
nodeCount: number
subscriptionCount: number
settings: Settings
configError: string
coreError?: string
actualNode: string
selectedAvailable: boolean
}
export interface Subscription {
id: string
name: string
url: string
enabled: boolean
intervalHours: number
updatedAt: string
attemptedAt: string
lastError: string
usage: string
nodeCount: number
refreshing: boolean
}
export interface Node {
id: string
name: string
subscriptionID: string
subscriptionName: string
type: string
udp: boolean
delay: number | null
alive: boolean | null
selected: boolean
}
export interface Rule {
id: string
type: 'DOMAIN' | 'DOMAIN-SUFFIX' | 'IP-CIDR'
value: string
action: 'DIRECT' | 'PROXY'
enabled: boolean
}
export interface Metric {
up: number
down: number
upTotal: number
downTotal: number
connections: number
coreOnline: boolean
time: string
}
export interface Connection {
id: string
metadata: { host: string; destinationIP: string; destinationPort: string; sourceIP: string; sourcePort: string; network: string; type: string }
upload: number
download: number
chains: string[]
rule: string
rulePayload: string
start: string
}
export interface Network {
interfaces: { name: string; addresses: string[]; up: boolean }[]
ipForward: boolean
tunDevice: boolean
rulesInstalled: boolean
serviceControl: boolean
dhcpManaged: boolean
guidance: string
}
export interface CoreUpdate {
currentVersion: string
latestVersion: string
updateAvailable: boolean | null
checkedAt: string
publishedAt: string
releaseURL: string
}
export interface ConnectivitySite {
id: string
name: string
url: string
category: string
}
export interface ConnectivityResult {
siteId: string
status: 'reachable' | 'challenge' | 'restricted' | 'http_error' | 'failed'
detail: string
httpStatus: number
durationMS: number
tlsVerified: boolean
checkedAt: string
}
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { NButton, NInput, NSelect, NTabs, NTabPane, useDialog } from 'naive-ui'
import { Search, Pause, Play, Trash2, X, Activity, Terminal, RefreshCw } from '@lucide/vue'
import { api } from '../api'
import { useTask } from '../composables/task'
import { useApp } from '../stores/app'
import { bytes } from '../format'
import type { Connection } from '../types'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const app = useApp(), dialog = useDialog(), { busy, run } = useTask()
const tab = ref('connections'), query = ref(''), paused = ref(false), level = ref('all'), coreError = ref(''), streamOnline = ref(false)
const connections = ref<Connection[]>([])
const logs = ref<{ type: string; payload: string; time: string }[]>([])
const filtered = computed(() => connections.value.filter(c => `${c.metadata.host} ${c.metadata.sourceIP} ${c.metadata.destinationIP} ${c.rule} ${c.chains.join(' ')}`.toLowerCase().includes(query.value.toLowerCase())))
const visibleLogs = computed(() => logs.value.filter(l => (level.value === 'all' || l.type === level.value) && l.payload.toLowerCase().includes(query.value.toLowerCase())))
let timer: ReturnType<typeof setInterval> | undefined, stream: EventSource | undefined
async function reload() { try { const data = await api<{ connections: Connection[] }>('/connections'); connections.value = data.connections || []; coreError.value = '' } catch (e) { coreError.value = (e as Error).message; connections.value = [] } }
function close(id?: string) { return run(async () => { await api(id ? `/connections/${encodeURIComponent(id)}` : '/connections', 'DELETE'); await reload() }, id ? '连接已关闭' : '全部连接已关闭') }
function closeAll() { dialog.warning({ title: '关闭全部连接', content: '现有连接将中断,应用通常会自动建立新连接。', positiveText: '关闭全部', negativeText: '取消', onPositiveClick: () => close() }) }
onMounted(() => {
void reload()
timer = setInterval(() => { if (!paused.value && tab.value === 'connections') void reload() }, 3000)
stream = new EventSource('/api/v1/logs')
stream.onopen = () => { streamOnline.value = true }
stream.addEventListener('log', (event) => { streamOnline.value = true; if (paused.value) return; const value = JSON.parse((event as MessageEvent).data); logs.value = [{ ...value, time: new Date().toLocaleTimeString('zh-CN', { hour12: false }) }, ...logs.value].slice(0, 500) })
stream.addEventListener('disconnected', () => { streamOnline.value = false })
stream.addEventListener('expired', () => { stream?.close(); void app.checkAuth() })
stream.onerror = () => { streamOnline.value = false }
})
onBeforeUnmount(() => { if (timer) clearInterval(timer); stream?.close() })
</script>
<template>
<PageHeader title="连接与日志" description="看见流量的去向,了解每一次连接。" eyebrow="LIVE ACTIVITY"><NButton @click="paused = !paused"><template #icon><Play v-if="paused" :size="16" /><Pause v-else :size="16" /></template>{{ paused ? '继续更新' : '暂停更新' }}</NButton></PageHeader>
<section class="panel activity-panel"><NTabs v-model:value="tab" type="line" animated><NTabPane name="connections" tab="实时连接"><div class="activity-toolbar"><NInput v-model:value="query" clearable placeholder="搜索域名、设备 IP、节点或规则"><template #prefix><Search :size="16" /></template></NInput><span class="muted nowrap">{{ filtered.length }} 条连接</span><NButton :disabled="busy || !connections.length" @click="closeAll"><template #icon><X :size="15" /></template>关闭全部</NButton><NButton quaternary aria-label="刷新连接" @click="reload"><template #icon><RefreshCw :size="17" /></template></NButton></div><EmptyState v-if="coreError" title="等待内核连接" :description="coreError"><template #icon><Activity :size="27" /></template></EmptyState><EmptyState v-else-if="!filtered.length" title="暂时没有活跃连接" description="流量经过 Mihomo 时,可以在这里查看目标、来源和命中规则。"><template #icon><Activity :size="27" /></template></EmptyState><div v-else class="table-scroll"><table class="data-table connections-table"><thead><tr><th>目标</th><th>来源设备</th><th>命中规则 / 出口</th><th>传输</th><th></th></tr></thead><tbody><tr v-for="c in filtered" :key="c.id"><td><strong>{{ c.metadata.host || c.metadata.destinationIP }}</strong><small>{{ c.metadata.network.toUpperCase() }} · {{ c.metadata.destinationIP }}:{{ c.metadata.destinationPort }}</small></td><td><code>{{ c.metadata.sourceIP }}</code><small>{{ c.metadata.type }}</small></td><td><span class="protocol-tag">{{ c.rule }}</span><small :title="c.chains.join(' → ')">{{ c.chains.map(n => n.split('/').slice(2).join('/') || n).join(' → ') }}</small></td><td><span>↓ {{ bytes(c.download) }}</span><small>↑ {{ bytes(c.upload) }}</small></td><td><NButton quaternary size="small" :disabled="busy" aria-label="关闭连接" @click="close(c.id)"><template #icon><X :size="15" /></template></NButton></td></tr></tbody></table></div></NTabPane><NTabPane name="logs" tab="内核日志"><div class="activity-toolbar"><NInput v-model:value="query" clearable placeholder="搜索日志内容"><template #prefix><Search :size="16" /></template></NInput><NSelect v-model:value="level" :options="[{ label: '全部级别', value: 'all' }, { label: '信息', value: 'info' }, { label: '警告', value: 'warning' }, { label: '错误', value: 'error' }]" class="log-level-select" /><NButton @click="logs = []"><template #icon><Trash2 :size="15" /></template>清空显示</NButton></div><div class="log-status"><span :class="['status-dot', { online: streamOnline }]" />{{ streamOnline ? '日志流已连接' : '正在等待内核日志' }}<span>保留最近 500 条,不写入数据库</span></div><div class="log-console"><div v-if="!visibleLogs.length" class="log-console-empty"><Terminal :size="25" /><span>等待新的内核日志…</span></div><div v-for="(log, index) in visibleLogs" :key="index" class="log-line"><time>{{ log.time }}</time><span :class="['log-level', log.type]">{{ log.type.toUpperCase() }}</span><span class="log-text">{{ log.payload }}</span></div></div></NTabPane></NTabs></section>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import { NButton, NInput } from 'naive-ui'
import { ArrowRight, ShieldCheck, Sun, Moon } from '@lucide/vue'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
const app = useApp()
const password = ref('')
const token = ref('')
const { busy, run } = useTask()
const submit = () => run(() => app.login(password.value, token.value || undefined))
</script>
<template><div class="auth-screen"><button class="auth-theme theme-button" aria-label="切换主题" @click="app.toggleTheme()"><Sun v-if="app.dark" :size="20" /><Moon v-else :size="20" /></button><div class="auth-brand"><img class="brand-icon" src="/favicon.svg?v=5" alt="Clash Web 网络猫" /><span>Clash Web</span></div><div class="auth-card"><div class="eyebrow">YOUR NETWORK, YOUR WAY</div><h1>{{ app.initialized ? '欢迎回来。' : '从这里,连接世界。' }}</h1><p>{{ app.initialized ? '登录你的内网代理管理工作空间。' : '设置管理员密码,开启你的个人网络工作空间。' }}</p><form @submit.prevent="submit"><label v-if="!app.initialized" class="field-label">首次设置令牌<NInput v-model:value="token" type="password" show-password-on="click" placeholder="从服务启动日志复制令牌" autocomplete="off" /></label><label class="field-label">{{ app.initialized ? '管理员密码' : '设置管理员密码' }}<NInput v-model:value="password" type="password" show-password-on="click" :placeholder="app.initialized ? '输入你的密码' : '至少 12 字节,最多 72 字节'" :input-props="{ autocomplete: app.initialized ? 'current-password' : 'new-password' }" @keydown.enter.prevent="submit" /></label><div v-if="app.error" class="inline-error">{{ app.error }}</div><NButton type="primary" size="large" block :loading="busy" :disabled="!password || (!app.initialized && !token)" attr-type="submit">{{ app.initialized ? '进入工作空间' : '创建工作空间' }}<template #icon><ArrowRight :size="18" /></template></NButton></form><div class="auth-footnote"><ShieldCheck :size="15" /><span>自托管 · 数据保存在你的设备上</span></div><p v-if="!app.initialized" class="setup-hint">在 VM 上运行 <code>journalctl -u clashweb -n 30</code> 查看首次设置令牌。</p></div><div class="auth-bottom">简洁的界面,自由的网络。</div></div></template>
+101
View File
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { NAlert, NButton, NProgress, useMessage } from 'naive-ui'
import { ArrowUpRight, Check, CircleAlert, Clock3, Globe2, Play, Radar, RotateCw, ShieldCheck, Square, X } from '@lucide/vue'
import { api } from '../api'
import { useApp } from '../stores/app'
import { nodeName } from '../format'
import type { ConnectivityResult, ConnectivitySite } from '../types'
import PageHeader from '../components/PageHeader.vue'
type TestState = 'idle' | 'queued' | 'testing' | 'cancelled' | ConnectivityResult['status']
const app = useApp(), message = useMessage()
const sites = ref<ConnectivitySite[]>([]), loading = ref(true), loadError = ref('')
const results = ref<Record<string, ConnectivityResult>>({}), states = ref<Record<string, TestState>>({})
const running = ref(false), total = ref(0), finished = ref(0), runNode = ref(''), runProxy = ref(true)
// Sort similar sites next to each other without adding visible categories.
const orderedSites = computed(() => {
const order = ['github', 'dockerhub', 'apple', 'google', 'netflix', 'youtube', 'chatgpt', 'claude', 'grok']
return [...sites.value].sort((a, b) => {
const rank = (id: string) => { const index = order.indexOf(id); return index < 0 ? order.length : index }
return rank(a.id) - rank(b.id)
})
})
let controller: AbortController | undefined
const counts = computed(() => ({
reachable: Object.values(states.value).filter(s => s === 'reachable').length,
limited: Object.values(states.value).filter(s => ['challenge', 'restricted'].includes(s)).length,
failed: Object.values(states.value).filter(s => ['failed', 'http_error'].includes(s)).length,
}))
const labels: Record<TestState, string> = { idle: '未测试', queued: '等待测试', testing: '连接中', cancelled: '已取消', reachable: '可访问', challenge: '需要验证', restricted: '访问受限', http_error: '响应异常', failed: '连接失败' }
const monograms: Record<string, string> = { apple: 'A', github: 'GH', google: 'G', netflix: 'N', youtube: '▶', chatgpt: 'GPT', claude: 'C', grok: 'x', dockerhub: 'DH' }
async function load() {
loading.value = true; loadError.value = ''
try { sites.value = await api<ConnectivitySite[]>('/system/connectivity') }
catch (error) { loadError.value = error instanceof Error ? error.message : '无法读取测试网站' }
finally { loading.value = false }
}
async function test(items: ConnectivitySite[]) {
if (running.value || !items.length) return
controller = new AbortController()
const signal = controller.signal
running.value = true; total.value = items.length; finished.value = 0
runNode.value = nodeName(app.status?.actualNode || app.status?.settings.selectedNode || '')
runProxy.value = app.status?.settings.proxyEnabled ?? true
for (const site of items) { states.value[site.id] = 'queued'; delete results.value[site.id] }
let next = 0
async function worker() {
while (!signal.aborted && next < items.length) {
const site = items[next++]!
states.value[site.id] = 'testing'
try {
const result = await api<ConnectivityResult>('/system/connectivity/test', 'POST', { siteId: site.id }, signal)
if (!signal.aborted) { results.value[site.id] = result; states.value[site.id] = result.status }
} catch (error) {
if (!signal.aborted) {
results.value[site.id] = { siteId: site.id, status: 'failed', detail: error instanceof Error ? error.message : '测试请求失败', httpStatus: 0, durationMS: 0, tlsVerified: false, checkedAt: new Date().toISOString() }
states.value[site.id] = 'failed'
}
} finally { if (!signal.aborted) finished.value++ }
}
}
await Promise.all(Array.from({ length: Math.min(3, items.length) }, worker))
for (const site of items) if (['queued', 'testing'].includes(states.value[site.id])) states.value[site.id] = 'cancelled'
running.value = false
if (!signal.aborted) message.success('连通性测试已完成')
}
function cancel() { controller?.abort() }
function hostname(value: string) { return new URL(value).hostname }
function time(value: string) { return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) }
onMounted(() => void load())
onBeforeUnmount(cancel)
</script>
<template>
<div class="connectivity-page">
<PageHeader title="连通性测试" description="检查常用网站,了解当前出口的连接情况。">
<NButton v-if="running" @click="cancel"><template #icon><Square :size="15" /></template>取消测试</NButton>
<NButton type="primary" :loading="running" :disabled="running || loading || !sites.length || !app.status?.coreOnline" @click="test(orderedSites)"><template #icon><Play :size="16" /></template>一键测试</NButton>
</PageHeader>
<NAlert v-if="!app.status?.coreOnline" type="warning" class="page-alert">Mihomo 内核未连接,请在系统设置中启动内核后测试。</NAlert>
<NAlert v-if="loadError" type="error" class="page-alert">{{ loadError }} <NButton text @click="load">重新加载</NButton></NAlert>
<section class="panel connectivity-overview">
<div class="connectivity-overview-top">
<div class="connectivity-intro"><span class="connectivity-radar"><Radar :size="29" /></span><div><h2>当前连接概况</h2><p>通过此 VM 的 Mihomo,遵循当前分流规则。</p></div><span class="protocol-tag">HTTPS</span></div>
<div class="connectivity-summary"><div><Check :size="18" /><strong>{{ counts.reachable }}</strong><span>可访问</span></div><div><ShieldCheck :size="18" /><strong>{{ counts.limited }}</strong><span>验证或受限</span></div><div><CircleAlert :size="18" /><strong>{{ counts.failed }}</strong><span>连接或响应异常</span></div></div>
</div>
<div class="connectivity-progress"><span>{{ running ? `正在测试 · ${finished} / ${total}` : total ? `本次完成 ${finished} / ${total}` : '按需测试 · 每个网站最长 12 秒 · 同时测试 3 个' }}</span><span>{{ total ? (runProxy ? `开始时所选节点:${runNode}` : '开始时代理关闭 · 直连模式') : '不会切换节点或修改网络配置' }}</span></div>
<NProgress v-if="total" type="line" :percentage="Math.round(finished / total * 100)" :show-indicator="false" :height="4" />
</section>
<div class="section-toolbar"><span>{{ sites.length }} 个常用网站</span><span class="muted small">切换节点后可重新测试</span></div>
<div class="connectivity-grid" :aria-busy="running">
<section v-for="site in orderedSites" :key="site.id" :class="['panel connectivity-card', states[site.id] || 'idle']" :data-site="site.id">
<div class="connectivity-card-heading"><span :class="['site-monogram', site.id]">{{ monograms[site.id] }}</span><div><h3>{{ site.name }}</h3><p>{{ hostname(site.url) }}</p></div><a :href="site.url" target="_blank" rel="noopener noreferrer" :aria-label="`打开 ${site.name} 官网`"><ArrowUpRight :size="17" /></a></div>
<div :class="['connectivity-state', states[site.id] || 'idle']"><RotateCw v-if="states[site.id] === 'testing'" class="spin" :size="17" /><Check v-else-if="states[site.id] === 'reachable'" :size="17" /><ShieldCheck v-else-if="states[site.id] === 'challenge'" :size="17" /><X v-else-if="['failed', 'http_error'].includes(states[site.id])" :size="17" /><CircleAlert v-else-if="states[site.id] === 'restricted'" :size="17" /><Clock3 v-else :size="17" /><strong>{{ labels[states[site.id] || 'idle'] }}</strong><span v-if="results[site.id]?.durationMS">{{ (results[site.id].durationMS / 1000).toFixed(2) }} s</span></div>
<p class="connectivity-detail" :title="results[site.id]?.detail">{{ results[site.id]?.detail || (states[site.id] === 'cancelled' ? '本次测试已取消,可重新测试。' : '检查主页的 HTTPS 响应情况。') }}</p>
<div class="connectivity-card-footer"><div class="connectivity-result-meta"><span v-if="results[site.id]?.httpStatus">HTTP {{ results[site.id].httpStatus }}<template v-if="results[site.id].tlsVerified"> · TLS 已验证</template></span><span>{{ results[site.id] ? time(results[site.id].checkedAt) : '尚无测试结果' }}</span></div><NButton size="small" :disabled="running || !app.status?.coreOnline" :aria-label="`测试 ${site.name}`" @click="test([site])"><template #icon><RotateCw :size="14" /></template>{{ results[site.id] ? '重试' : '测试' }}</NButton></div>
</section>
</div>
<div class="info-note"><Globe2 :size="18" /><p>测试的是 VM 到网站主页的连接,耗时包含建立连接和读取部分响应,不是节点延迟或带宽。网站可能要求浏览器验证、登录或地区授权;主页可访问不代表视频解锁、AI 对话或镜像拉取可用,也不能代替客户端网关与 TUN 接入测试。大陆规则命中的网站会直连。</p></div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { NButton, NSwitch, NAlert } from 'naive-ui'
import { ArrowDownLeft, ArrowUpRight, Activity, Globe2, ChevronRight, RefreshCw, ShieldCheck, Radio, Rss, Route } from '@lucide/vue'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import { api } from '../api'
import { bytes, nodeName, uptime, date } from '../format'
import type { Subscription } from '../types'
import PageHeader from '../components/PageHeader.vue'
import TrafficChart from '../components/TrafficChart.vue'
const app = useApp()
const { busy, run } = useTask()
const subscriptions = ref<Subscription[]>([])
const selected = computed(() => nodeName(app.status?.actualNode || app.status?.settings.selectedNode || ''))
const proxy = computed(() => app.status?.settings.proxyEnabled ?? false)
async function reload() { await Promise.all([app.reload(), api<Subscription[]>('/subscriptions').then(v => subscriptions.value = v)]) }
async function toggle(value: boolean) { if (!app.status) return; await run(async () => { await api('/settings', 'PUT', { ...app.status!.settings, proxyEnabled: value }); await app.reload() }, value ? '已启用规则代理' : '已切换直连模式') }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="网络总览" description="你的网络,一目了然。" eyebrow="NETWORK OVERVIEW"><NButton :loading="busy" @click="run(reload)"><template #icon><RefreshCw :size="16" /></template>刷新状态</NButton></PageHeader>
<NAlert v-if="app.status?.configError" type="error" class="page-alert">{{ app.status.configError }}</NAlert>
<NAlert v-if="app.status?.settings.tunEnabled && !app.status.tunActive" type="warning" class="page-alert">透明代理已配置,但尚未检测到运行中的 cw-tun 网卡。请检查目标 VM 的内核权限、服务日志与网络部署步骤。</NAlert>
<div class="overview-hero"><div class="hero-copy"><div class="hero-eyebrow"><span :class="['status-dot', { online: app.status?.coreOnline }]" />{{ app.status?.coreOnline ? (proxy ? '规则代理已启用' : '当前为直连模式') : '等待代理内核连接' }}</div><h2>让连接,<br />更自由一些。</h2><p>大陆与内网直连,其他访问交给你选择的节点。</p><div class="hero-pills"><span><ShieldCheck :size="14" />{{ app.status?.settings.tunEnabled ? '透明代理' : '手动代理' }}</span><span>IPv4</span><span>{{ app.status?.settings.selectionMode === 'auto' ? '自动选节点' : '手动选节点' }}</span></div></div><div class="hero-network" aria-hidden="true"><div class="orbit orbit-one" /><div class="orbit orbit-two" /><div class="network-center"><Globe2 :size="49" :stroke-width="1.25" /></div><div class="network-point point-one"><Radio :size="19" /></div><div class="network-point point-two"><ShieldCheck :size="20" /></div><div class="network-point point-three"><Route :size="19" /></div><span class="orbit-label label-one">DIRECT</span><span class="orbit-label label-two">PROXY</span><span class="network-coordinate">CONNECTED TO YOUR WORLD</span></div></div>
<div class="stat-grid"><div class="stat-card"><div class="stat-label">实时下载<span class="stat-icon green"><ArrowDownLeft :size="17" /></span></div><div class="stat-value">{{ bytes(app.metrics.down) }}<small>/s</small></div><p>累计下载 <strong>{{ bytes(app.metrics.downTotal) }}</strong></p></div><div class="stat-card"><div class="stat-label">实时上传<span class="stat-icon blue"><ArrowUpRight :size="17" /></span></div><div class="stat-value">{{ bytes(app.metrics.up) }}<small>/s</small></div><p>累计上传 <strong>{{ bytes(app.metrics.upTotal) }}</strong></p></div><div class="stat-card"><div class="stat-label">活跃连接<span class="stat-icon amber"><Activity :size="17" /></span></div><div class="stat-value">{{ app.metrics.connections }}<small>条</small></div><p>{{ app.metrics.coreOnline ? '正在监测内核连接' : '内核连接后显示实时数据' }}</p></div><div class="stat-card"><div class="stat-label">可选节点<span class="stat-icon purple"><Globe2 :size="17" /></span></div><div class="stat-value">{{ app.status?.nodeCount ?? 0 }}<small>个</small></div><p>来自 <strong>{{ app.status?.subscriptionCount ?? 0 }}</strong> 个订阅</p></div></div>
<div class="dashboard-grid"><section class="panel traffic-panel"><div class="panel-heading"><div><h3>流量趋势</h3><p>最近两分钟的实时传输速率</p></div><div class="chart-legend"><span><i class="legend-dot download" />下载</span><span><i class="legend-dot upload" />上传</span></div></div><TrafficChart /><div class="panel-foot"><span :class="['status-dot', { online: app.metrics.coreOnline }]" />{{ app.metrics.coreOnline ? '每 2 秒更新' : '连接内核后自动更新' }}<span class="foot-right">{{ app.history.length }} 个采样</span></div></section><section class="panel current-node"><div class="panel-heading"><h3>当前出口</h3><span class="small-tag">{{ app.status?.settings.selectionMode === 'auto' ? '自动选择' : '手动选择' }}</span></div><div class="current-node-symbol"><Globe2 :size="31" :stroke-width="1.5" /></div><h3 class="selected-name">{{ selected }}</h3><p class="selected-description">{{ app.status?.coreOnline ? (app.status.selectedAvailable ? '当前内核使用的代理出口' : '选择一个节点以开始代理访问') : '启动内核后确认实际代理出口' }}</p><div class="node-detail-row"><span>代理开关</span><NSwitch :value="proxy" :disabled="busy || !app.status" @update:value="toggle" /></div><div class="node-detail-row"><span>内核版本</span><strong>{{ app.status?.version || '未连接' }}</strong></div><div class="node-detail-row"><span>管理服务运行</span><strong>{{ uptime(app.status?.uptimeSeconds ?? 0) }}</strong></div><RouterLink to="/nodes" class="panel-link">选择代理节点<ChevronRight :size="16" /></RouterLink></section></div>
<div class="dashboard-bottom"><section class="panel"><div class="panel-heading"><h3>订阅概览</h3><RouterLink to="/subscriptions" class="text-link">管理订阅<ArrowUpRight :size="14" /></RouterLink></div><div v-if="!subscriptions.length" class="compact-empty"><Rss :size="24" /><div><strong>还没有订阅</strong><p>添加 Clash 订阅,发现可用的代理节点。</p></div><RouterLink to="/subscriptions">添加订阅<ChevronRight :size="15" /></RouterLink></div><div v-for="s in subscriptions.slice(0, 3)" :key="s.id" class="subscription-summary"><span class="summary-icon"><Rss :size="17" /></span><div><strong>{{ s.name }}</strong><small>{{ s.lastError ? '最近更新失败,保留已有节点' : date(s.updatedAt) }}</small></div><span class="small-tag">{{ s.nodeCount }} 个节点</span><span :class="['status-dot', { online: s.enabled && s.nodeCount > 0 }]" /></div></section><section class="panel quick-start"><div class="panel-heading"><h3>接入你的内网</h3><span class="small-tag">PVE VM</span></div><p>将测试设备的 IPv4 网关和 DNS 指向 VM 的固定地址,即可使用透明代理。</p><div class="network-values"><div><span>VM 地址</span><code>{{ app.status?.settings.lanIP || '待配置' }}</code></div><div><span>上游网关</span><code>{{ app.status?.settings.gateway || '原光猫地址' }}</code></div></div><RouterLink to="/settings" class="text-link">查看网络配置与检查<ArrowUpRight :size="14" /></RouterLink></section></div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NButton, NInput, NSelect, NRadioGroup, NRadioButton, NAlert } from 'naive-ui'
import { Search, Gauge, Check, Globe2, WandSparkles, RefreshCw, ArrowUpRight, Radio } from '@lucide/vue'
import { api } from '../api'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import { region, nodeName } from '../format'
import type { Node } from '../types'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const app = useApp(), { busy, run } = useTask()
const nodes = ref<Node[]>([]), query = ref(''), subscription = ref('all'), sort = ref('default'), layout = ref('卡片')
const testing = ref('')
const groups = computed(() => [{ label: '全部订阅', value: 'all' }, ...Array.from(new Map(nodes.value.map(n => [n.subscriptionID, { label: n.subscriptionName, value: n.subscriptionID }])).values())])
const filtered = computed(() => {
const items = nodes.value.filter(n => (subscription.value === 'all' || n.subscriptionID === subscription.value) && `${n.name} ${n.type} ${n.subscriptionName}`.toLowerCase().includes(query.value.toLowerCase()))
return sort.value === 'delay' ? items.sort((a, b) => (a.delay && a.delay > 0 ? a.delay : Infinity) - (b.delay && b.delay > 0 ? b.delay : Infinity)) : items
})
const failedSelected = computed(() => app.status?.settings.selectionMode === 'manual' && !!app.status.settings.selectedNode && (nodes.value.find(n => n.id === app.status?.settings.selectedNode)?.alive === false || !nodes.value.some(n => n.id === app.status?.settings.selectedNode)))
const reload = async () => { nodes.value = await api<Node[]>('/nodes'); await app.reload() }
const select = (node?: Node) => run(async () => { await api('/nodes/select', 'POST', { mode: node ? 'manual' : 'auto', ...(node ? { id: node.id } : {}) }); await reload() }, node ? `已选择 ${node.name}` : '已启用自动选择')
async function test(items: Node[]) { testing.value = items.length === 1 ? items[0].id : 'all'; await run(async () => { const results = await api<Record<string, { delay: number; error: string }>>('/nodes/test', 'POST', { ids: items.slice(0, 500).map(n => n.id) }); for (const node of nodes.value) if (results[node.id]) { node.delay = results[node.id].delay; node.alive = !results[node.id].error && results[node.id].delay > 0 } }, '延迟测试已完成'); testing.value = '' }
function delayText(node: Node) { return node.alive === false ? '超时' : node.delay ? `${node.delay} ms` : '未测试' }
function delayClass(node: Node) { return node.alive === false ? 'bad' : node.delay && node.delay < 200 ? 'good' : node.delay ? 'fair' : 'unknown' }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="代理节点" description="找到适合你的连接,选择一个出口。" eyebrow="PROXY NODES"><NButton :loading="busy && testing !== ''" :disabled="busy || !filtered.length || !app.status?.coreOnline" @click="test(filtered)"><template #icon><Gauge :size="17" /></template>测试延迟</NButton><NButton type="primary" :disabled="busy || !nodes.length" @click="select()"><template #icon><WandSparkles :size="16" /></template>自动选择</NButton></PageHeader>
<NAlert v-if="failedSelected" type="warning" class="page-alert">所选节点「{{ nodeName(app.status?.settings.selectedNode || '') }}」已失效或已从订阅中移除。手动模式保持原选择,请选择其他节点或主动开启自动选择。</NAlert>
<div class="selection-banner"><span class="selection-symbol"><Radio :size="21" /></span><div><span class="muted">当前选择 · {{ app.status?.settings.selectionMode === 'auto' ? '自动模式' : '手动模式' }}</span><strong>{{ nodeName(app.status?.actualNode || app.status?.settings.selectedNode || '') }}</strong></div><span :class="['status-label', { connected: app.status?.coreOnline }]">{{ app.status?.coreOnline ? '内核已连接' : '等待内核' }}</span></div>
<div class="nodes-toolbar"><NInput v-model:value="query" clearable placeholder="搜索节点、协议或订阅" class="node-search"><template #prefix><Search :size="16" /></template></NInput><NSelect v-model:value="subscription" :options="groups" class="subscription-select" /><NSelect v-model:value="sort" :options="[{ label: '默认排序', value: 'default' }, { label: '延迟从低到高', value: 'delay' }]" class="sort-select" /><NRadioGroup v-model:value="layout"><NRadioButton value="卡片">卡片</NRadioButton><NRadioButton value="列表">列表</NRadioButton></NRadioGroup><NButton quaternary :disabled="busy" aria-label="刷新节点" @click="run(reload)"><template #icon><RefreshCw :size="17" /></template></NButton></div>
<div class="section-toolbar"><span>{{ filtered.length }} 个节点<span class="muted"> · {{ nodes.filter(n => n.alive === true).length }} 个最近测试可用</span></span><span class="muted small">延迟测试按需执行,单次最多 500 个</span></div>
<section v-if="!nodes.length" class="panel"><EmptyState title="还没有可选节点" description="添加并更新 Clash 订阅,节点就会出现在这里。"><NButton type="primary" @click="$router.push('/subscriptions')">前往订阅管理<template #icon><ArrowUpRight :size="16" /></template></NButton></EmptyState></section>
<section v-else-if="!filtered.length" class="panel"><EmptyState title="没有匹配的节点" description="试试其他关键词,或切换订阅筛选条件。" /></section>
<div v-else :class="['node-grid', { 'node-list': layout === '列表' }]"><section v-for="node in filtered" :key="node.id" :class="['panel node-card', { selected: node.selected }]"><div class="node-card-main"><span :class="['region-icon', region(node.name).toLowerCase()]">{{ region(node.name) }}</span><div class="node-title"><h3>{{ node.name }}</h3><p>{{ node.subscriptionName }}</p></div><span v-if="node.selected" class="selected-check"><Check :size="14" /></span></div><div class="node-card-meta"><span class="protocol-tag">{{ node.type.toUpperCase() }}</span><span v-if="node.udp" class="protocol-tag subtle">UDP</span><span :class="['node-delay', delayClass(node)]"><i />{{ delayText(node) }}</span></div><div class="node-card-actions"><NButton size="small" :type="node.selected ? 'primary' : 'default'" :secondary="node.selected" :disabled="busy || node.selected" @click="select(node)">{{ node.selected ? '当前节点' : '选择节点' }}</NButton><NButton quaternary size="small" :loading="busy && testing === node.id" :disabled="busy || !app.status?.coreOnline" :aria-label="`测试 ${node.name}`" @click="test([node])"><template #icon><Gauge :size="16" /></template></NButton></div></section></div>
<div class="info-note"><Globe2 :size="17" /><p>手动模式下,节点故障会提示而不会自动切换。你也可以主动选择自动模式,由内核约每 5 分钟检测,使用 50 ms 切换容差选择低延迟节点;延迟不代表下载带宽。</p></div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { NButton, NInput, NModal, NSelect, NSwitch, useDialog } from 'naive-ui'
import { Plus, Route, ArrowDown, Pencil, Trash2, ShieldCheck, Globe2 } from '@lucide/vue'
import { api } from '../api'
import { useTask } from '../composables/task'
import type { Rule } from '../types'
import PageHeader from '../components/PageHeader.vue'
const { busy, run } = useTask(), dialog = useDialog()
const rules = ref<Rule[]>([]), modal = ref(false), editing = ref('')
const form = reactive<{ type: Rule['type']; value: string; action: Rule['action']; enabled: boolean }>({ type: 'DOMAIN-SUFFIX', value: '', action: 'DIRECT', enabled: true })
const types = [{ label: '域名后缀', value: 'DOMAIN-SUFFIX' }, { label: '完整域名', value: 'DOMAIN' }, { label: 'IPv4 网段', value: 'IP-CIDR' }]
const reload = async () => { const data = await api<{ custom: Rule[] }>('/rules'); rules.value = data.custom }
function open(rule?: Rule) { editing.value = rule?.id || ''; Object.assign(form, { type: rule?.type || 'DOMAIN-SUFFIX', value: rule?.value || '', action: rule?.action || 'DIRECT', enabled: rule?.enabled ?? true }); modal.value = true }
async function save() { await run(async () => { await api(editing.value ? `/rules/${editing.value}` : '/rules', editing.value ? 'PUT' : 'POST', form); modal.value = false; await reload() }, '规则已保存并应用') }
function toggle(rule: Rule, value: boolean) { return run(async () => { const { id, ...data } = rule; await api(`/rules/${id}`, 'PUT', { ...data, enabled: value }); await reload() }) }
function remove(rule: Rule) { dialog.warning({ title: '删除规则', content: `删除 ${rule.value} 的分流规则?`, positiveText: '删除', negativeText: '取消', onPositiveClick: () => run(async () => { await api(`/rules/${rule.id}`, 'DELETE'); await reload() }, '规则已删除') }) }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="分流规则" description="让每一次访问,走适合它的路线。" eyebrow="ROUTING RULES"><NButton type="primary" @click="open()"><template #icon><Plus :size="17" /></template>添加规则</NButton></PageHeader>
<div class="routing-overview"><div class="routing-step"><span class="routing-icon"><ShieldCheck :size="21" /></span><div><strong>内网与大陆</strong><span>直接连接,保留本地速度</span></div><span class="route-badge direct">DIRECT</span></div><div class="routing-step"><span class="routing-icon proxy"><Globe2 :size="21" /></span><div><strong>其他目标</strong><span>通过当前选择的代理节点</span></div><span class="route-badge proxy">PROXY</span></div></div>
<section class="panel"><div class="panel-heading"><div><h3>自定义规则</h3><p>优先于大陆规则库,按添加顺序匹配;内网保留直连优先级。</p></div><span class="small-tag">{{ rules.length }} 条</span></div><div v-if="!rules.length" class="rule-empty"><Route :size="25" /><span>默认分流已经就绪。需要特别处理某个网站时,在这里添加规则。</span></div><div v-else class="table-scroll"><table class="data-table"><thead><tr><th>匹配类型</th><th>目标</th><th>动作</th><th>启用</th><th class="align-right">操作</th></tr></thead><tbody><tr v-for="rule in rules" :key="rule.id"><td><span class="protocol-tag">{{ rule.type }}</span></td><td><code>{{ rule.value }}</code></td><td><span :class="['route-badge', rule.action === 'DIRECT' ? 'direct' : 'proxy']">{{ rule.action === 'DIRECT' ? '直连' : '代理' }}</span></td><td><NSwitch :value="rule.enabled" :disabled="busy" @update:value="value => toggle(rule, value)" /></td><td class="align-right"><NButton quaternary size="small" :disabled="busy" aria-label="编辑规则" @click="open(rule)"><template #icon><Pencil :size="15" /></template></NButton><NButton quaternary size="small" :disabled="busy" aria-label="删除规则" @click="remove(rule)"><template #icon><Trash2 :size="15" /></template></NButton></td></tr></tbody></table></div></section>
<section class="panel rule-order"><div class="panel-heading"><h3>默认匹配顺序</h3><span class="muted small">首条匹配生效</span></div><div v-for="(step, index) in ['内网、回环及保留地址 → 直连', '自定义规则 → 按你的设置处理', '大陆域名与 IP 规则库 → 直连', '其余目标 → 当前代理节点']" :key="step" class="rule-order-step"><span class="step-number">0{{ index + 1 }}</span><span>{{ step }}</span><ArrowDown v-if="index < 3" :size="15" /><span v-else class="small-tag">兜底</span></div></section>
<div class="info-note"><Route :size="17" /><p>大陆分类来自域名和 IP 规则库。若某个目标分类不符合你的需要,可用自定义规则修正。规则库更新在 VM 上运行 <code>scripts/update-rules.sh</code>。</p></div>
<NModal v-model:show="modal" preset="card" :title="editing ? '编辑分流规则' : '添加分流规则'" class="form-modal"><form @submit.prevent="save"><label class="field-label">匹配类型<NSelect v-model:value="form.type" :options="types" /></label><label class="field-label">目标<NInput v-model:value="form.value" :placeholder="form.type === 'IP-CIDR' ? '例如 203.0.113.0/24' : '例如 example.com'" /></label><label class="field-label">访问方式<NSelect v-model:value="form.action" :options="[{ label: '直连 DIRECT', value: 'DIRECT' }, { label: '代理 PROXY', value: 'PROXY' }]" /></label><div class="form-switch-row"><span>启用规则</span><NSwitch v-model:value="form.enabled" /></div><div class="modal-actions"><NButton @click="modal = false">取消</NButton><NButton type="primary" :loading="busy" :disabled="!form.value.trim()" attr-type="submit">保存并应用</NButton></div></form></NModal>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { NButton, NInput, NInputNumber, NSelect, NSwitch, NAlert, useDialog } from 'naive-ui'
import { Save, Play, Square, RotateCw, Download, Upload, CheckCircle2, Circle, Network as NetworkIcon, ShieldCheck, Server, ArrowUpRight } from '@lucide/vue'
import { api, downloadBackup, setCSRF } from '../api'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import type { CoreUpdate, Network, Settings } from '../types'
import PageHeader from '../components/PageHeader.vue'
const app = useApp(), dialog = useDialog(), { busy, run } = useTask()
const loaded = ref(false), network = ref<Network | null>(null), fileInput = ref<HTMLInputElement>()
const update = ref<CoreUpdate | null>(null), checkingUpdate = ref(false), updateError = ref('')
async function checkUpdate() {
checkingUpdate.value = true
updateError.value = ''
try { update.value = await api<CoreUpdate>('/system/updates') }
catch (error) { updateError.value = (error as Error).message }
finally { checkingUpdate.value = false }
}
const form = reactive<Settings>({ proxyEnabled: true, tunEnabled: false, selectionMode: 'manual', selectedNode: '', lanIP: '', lanCIDR: '', gateway: '', interface: '', mixedPort: 7890, dnsPort: 1053, testURL: 'https://www.gstatic.com/generate_204', testTimeoutMS: 5000 })
const credentials = reactive({ currentPassword: '', password: '' })
async function reload() { const [settings, data] = await Promise.all([api<Settings>('/settings'), api<Network>('/system/network')]); Object.assign(form, settings); network.value = data; loaded.value = true; await app.reload() }
const save = () => run(async () => { await api('/settings', 'PUT', form); await reload() }, '设置已校验并保存')
function service(action: string) { const execute = () => run(async () => { await api('/system/service', 'POST', { action }); await app.reload() }, '服务操作已完成'); if (action === 'stop') dialog.warning({ title: '停止代理内核', content: '使用此 VM 作为网关或 DNS 的设备可能无法上网。需要恢复时,请启动内核或将客户端网关和 DNS 改回光猫。', positiveText: '停止内核', negativeText: '取消', onPositiveClick: execute }); else void execute() }
const changePassword = () => run(async () => { const result = await api<{ csrf: string }>('/auth/password', 'PUT', credentials); setCSRF(result.csrf); credentials.currentPassword = ''; credentials.password = ''; app.checkAuth() }, '密码已更新,其他会话已退出')
async function restore(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; if (file.size > 16 * 1024 * 1024) { await run(async () => { throw new Error('备份文件不能超过 16 MiB') }); return } let backup: unknown; try { backup = JSON.parse(await file.text()) } catch { await run(async () => { throw new Error('备份不是有效 JSON 文件') }); return } dialog.warning({ title: '恢复配置备份', content: '将替换订阅、规则、节点选择和网络设置。管理员密码保持当前设置。加载失败会保留原配置。', positiveText: '恢复配置', negativeText: '取消', onPositiveClick: () => run(async () => { await api('/system/restore', 'POST', backup); await reload() }, '配置已恢复') }); (event.target as HTMLInputElement).value = '' }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="系统设置" description="让服务稳定运行,让网络配置清晰可控。" eyebrow="SYSTEM SETTINGS"><NButton type="primary" :loading="busy" :disabled="!loaded" @click="save"><template #icon><Save :size="16" /></template>保存并应用</NButton></PageHeader>
<div class="settings-grid"><div class="settings-primary"><section class="panel settings-section"><div class="settings-heading"><span class="settings-icon"><NetworkIcon :size="20" /></span><div><h3>网络与代理</h3><p>在 PVE 的服务 VM 上配置,先验证单台客户端。</p></div></div><div class="setting-switch"><div><strong>规则代理</strong><p>关闭后切换为直连模式,管理界面继续运行。</p></div><NSwitch v-model:value="form.proxyEnabled" /></div><div class="setting-switch"><div><strong>TUN 透明代理</strong><p>先在目标 VM 完成网络部署,再启用。</p></div><NSwitch v-model:value="form.tunEnabled" /></div><div class="settings-fields"><label class="field-label">VM 固定 IPv4<NInput v-model:value="form.lanIP" placeholder="例如 192.168.1.10" /></label><label class="field-label">内网网段<NInput v-model:value="form.lanCIDR" placeholder="例如 192.168.1.0/24" /></label><label class="field-label">上游网关(光猫)<NInput v-model:value="form.gateway" placeholder="例如 192.168.1.1" /></label><label class="field-label">桥接网卡<NSelect v-model:value="form.interface" clearable :options="(network?.interfaces || []).map(i => ({ label: `${i.name} · ${i.addresses.join(', ') || '无 IPv4'}`, value: i.name }))" placeholder="选择目标 VM 网卡" @update:value="value => form.interface = value || ''" /></label><label class="field-label">HTTP / SOCKS 混合端口<NInputNumber v-model:value="form.mixedPort" :min="1024" :max="65535" /></label><label class="field-label">DNS 端口<NInputNumber v-model:value="form.dnsPort" :min="1" :max="65535" /><small>TUN 网关模式使用 53;开发测试使用 1053。</small></label></div><NAlert type="info" :show-icon="false">{{ network?.guidance || '当前光猫 DHCP 无法关闭,VM 不提供 DHCP。客户端需手动指定网关和 DNS。' }}</NAlert></section><section class="panel settings-section"><div class="settings-heading"><span class="settings-icon"><Server :size="20" /></span><div><h3>Mihomo 服务</h3><p>独立运行的代理内核,由 systemd 管理。</p></div><span :class="['status-label', { connected: app.status?.coreOnline }]">{{ app.status?.coreOnline ? '运行中' : '未连接' }}</span></div><div class="service-facts"><div><span>内核版本</span><code>{{ app.status?.version || '—' }}</code></div><div><span>控制接口</span><code>127.0.0.1:9090</code></div></div><div class="service-buttons"><NButton :disabled="busy || !network?.serviceControl" @click="service('start')"><template #icon><Play :size="15" /></template>启动</NButton><NButton :disabled="busy || !network?.serviceControl" @click="service('restart')"><template #icon><RotateCw :size="15" /></template>重启</NButton><NButton :disabled="busy || !network?.serviceControl" @click="service('stop')"><template #icon><Square :size="15" /></template>停止</NButton></div><p v-if="!network?.serviceControl" class="muted small">正式 VM 安装服务控制辅助程序后,可在这里启停内核。</p><div class="core-update-section"><div class="core-update-heading"><div><h4>内核更新</h4><p>按需检查 GitHub 稳定版,升级时保留配置与旧内核。</p></div><NButton size="small" :loading="checkingUpdate" @click="checkUpdate"><template #icon><RotateCw :size="15" /></template>检查更新</NButton></div><NAlert v-if="updateError" type="warning" class="update-alert">{{ updateError }}</NAlert><template v-if="update"><div class="service-facts"><div><span>最新稳定版</span><code>{{ update.latestVersion }}</code></div><div><span>上次检查</span><span>{{ new Date(update.checkedAt).toLocaleString('zh-CN') }}</span></div></div><p class="update-result">{{ update.updateAvailable === true ? '发现新版本,可查看发布说明后手动升级。' : update.updateAvailable === false ? '当前版本不低于最新稳定版。' : '当前内核版本无法比较;启动稳定版内核后再检查。' }}</p><a class="text-link" :href="update.releaseURL" target="_blank" rel="noopener noreferrer">查看官方发布说明<ArrowUpRight :size="14" /></a></template><details class="upgrade-guide"><summary>如何升级软件与内核</summary><p>Web 管理程序和 Mihomo 分别升级。升级前导出备份;内核先下载到新文件、校验当前配置,再替换并重启。失败时恢复旧程序。</p><p>完整操作与恢复步骤见项目 docs/upgrading.md。</p></details></div><div class="settings-fields test-settings"><label class="field-label">延迟测试地址<NInput v-model:value="form.testURL" /></label><label class="field-label">测试超时(毫秒)<NInputNumber v-model:value="form.testTimeoutMS" :min="1000" :max="15000" :step="1000" /></label></div></section><section class="panel settings-section"><div class="settings-heading"><span class="settings-icon"><ShieldCheck :size="20" /></span><div><h3>管理员密码</h3><p>更改后其他登录会话会退出。</p></div></div><form @submit.prevent="changePassword"><div clasLine truncated
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { NButton, NModal, NInput, NInputNumber, NSwitch, useDialog } from 'naive-ui'
import { Plus, RefreshCw, Rss, Pencil, Trash2, Clock3, ExternalLink } from '@lucide/vue'
import { api } from '../api'
import { useApp } from '../stores/app'
import { useTask } from '../composables/task'
import { subscriptionHost, date, bytes } from '../format'
import type { Subscription } from '../types'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const app = useApp(), dialog = useDialog()
const { busy, run } = useTask()
const items = ref<Subscription[]>([]), modal = ref(false), editing = ref('')
const form = reactive({ name: '', url: '', enabled: true, intervalHours: 6 })
const reload = async () => { items.value = await api<Subscription[]>('/subscriptions'); await app.reload() }
function open(item?: Subscription) { editing.value = item?.id || ''; Object.assign(form, { name: item?.name || '', url: item?.url || '', enabled: item?.enabled ?? true, intervalHours: item?.intervalHours || 6 }); modal.value = true }
async function save() {
const ok = await run(async () => {
const result = await api<{ id: string }>(editing.value ? `/subscriptions/${editing.value}` : '/subscriptions', editing.value ? 'PUT' : 'POST', form)
modal.value = false
await reload()
if (!editing.value) { await api(`/subscriptions/${result.id}/refresh`, 'POST', {}); await reload() }
}, editing.value ? '订阅设置已保存' : '订阅已添加并更新')
if (!ok) await reload().catch(() => {})
}
function remove(item: Subscription) { dialog.warning({ title: '删除订阅', content: `删除「${item.name}」及缓存节点?如果正在使用该订阅的节点,国外访问将停止,直到你选择其他节点。`, positiveText: '删除', negativeText: '取消', onPositiveClick: () => run(async () => { await api(`/subscriptions/${item.id}`, 'DELETE'); await reload() }, '订阅已删除') }) }
function refresh(item: Subscription) { return run(async () => { try { await api(`/subscriptions/${item.id}/refresh`, 'POST', {}) } finally { await reload() } }, '订阅已更新') }
function toggle(item: Subscription, value: boolean) { return run(async () => { await api(`/subscriptions/${item.id}`, 'PUT', { name: item.name, url: item.url, intervalHours: item.intervalHours, enabled: value }); await reload() }, '订阅状态已更新') }
function usage(raw: string) { const fields = Object.fromEntries(raw.split(';').map(x => x.trim().split('='))); const used = Number(fields.upload || 0) + Number(fields.download || 0); const total = Number(fields.total || 0); return total ? `${bytes(used)} / ${bytes(total)}` : '' }
onMounted(() => void run(reload))
</script>
<template>
<PageHeader title="订阅管理" description="集中管理订阅,让节点始终保持更新。" eyebrow="SUBSCRIPTIONS"><NButton type="primary" @click="open()"><template #icon><Plus :size="17" /></template>添加订阅</NButton></PageHeader>
<div class="section-toolbar"><span>{{ items.length }} 个订阅 <span class="muted">· Clash / Mihomo YAML</span></span><NButton text :loading="busy" @click="run(reload)"><template #icon><RefreshCw :size="15" /></template>刷新列表</NButton></div>
<section v-if="!items.length" class="panel"><EmptyState title="你的节点,从一个订阅开始" description="粘贴 Clash 订阅地址,我们会读取节点并保持定时更新。"><template #icon><Rss :size="28" /></template><NButton type="primary" @click="open()">添加第一个订阅</NButton></EmptyState></section>
<div v-else class="subscription-grid"><section v-for="item in items" :key="item.id" :class="['panel subscription-card', { disabled: !item.enabled }]"><div class="subscription-card-top"><span class="subscription-icon"><Rss :size="22" /></span><NSwitch :value="item.enabled" :disabled="busy" :aria-label="`启用 ${item.name}`" @update:value="v => toggle(item, v)" /></div><h3>{{ item.name }}</h3><p class="subscription-host"><ExternalLink :size="13" />{{ subscriptionHost(item.url) }}</p><div class="subscription-node-count">{{ item.nodeCount }}<span>个代理节点</span></div><div class="subscription-facts"><span><Clock3 :size="14" />每 {{ item.intervalHours }} 小时更新</span><span>最近更新 {{ date(item.updatedAt) }}</span><span v-if="usage(item.usage)">已用流量 {{ usage(item.usage) }}</span></div><div v-if="item.lastError" class="inline-error subscription-error">{{ item.lastError }}<small>已有节点和可用配置会保留。</small></div><div class="subscription-card-actions"><NButton size="small" :loading="busy || item.refreshing" @click="refresh(item)"><template #icon><RefreshCw :size="14" /></template>更新订阅</NButton><NButton quaternary size="small" :disabled="busy" :aria-label="`编辑 ${item.name}`" @click="open(item)"><template #icon><Pencil :size="15" /></template></NButton><NButton quaternary size="small" :disabled="busy" :aria-label="`删除 ${item.name}`" @click="remove(item)"><template #icon><Trash2 :size="15" /></template></NButton></div></section></div>
<div class="info-note"><Rss :size="17" /><p>订阅提供节点,分流和 DNS 由 Clash Web 统一管理。支持多个订阅;更新失败时继续使用最后可用版本。</p></div>
<NModal v-model:show="modal" preset="card" :title="editing ? '编辑订阅' : '添加订阅'" class="form-modal" :mask-closable="!busy"><form @submit.prevent="save"><label class="field-label">订阅名称<NInput v-model:value="form.name" placeholder="例如:我的主力订阅" :maxlength="160" /></label><label class="field-label">Clash 订阅地址<NInput v-model:value="form.url" type="textarea" placeholder="https://example.com/subscribe?token=…" :autosize="{ minRows: 2, maxRows: 4 }" /></label><label class="field-label">自动更新间隔(小时)<NInputNumber v-model:value="form.intervalHours" :min="1" :max="720" /></label><div class="form-switch-row"><span>启用此订阅</span><NSwitch v-model:value="form.enabled" /></div><div class="modal-actions"><NButton :disabled="busy" @click="modal = false">取消</NButton><NButton type="primary" :loading="busy" :disabled="!form.name.trim() || !form.url.trim()" attr-type="submit">{{ editing ? '保存设置' : '添加并更新' }}</NButton></div></form></NModal>
</template>
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: { proxy: { '/api': 'http://127.0.0.1:8080' } },
build: { chunkSizeWarningLimit: 1100 },
})