mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 14:34:15 +08:00
feat(web-ui): v1.19.7 服务器设置页面(测速+切换通达信服务器)
新增 web UI 第六个页面「服务器设置」,让用户在浏览器上: 1. 列出全部 50+ 候选服务器 2. 一键并发测速(显示延迟和可达性) 3. 点选切换到最快或可用的服务器(热重连,无需重启) 解决不同地区/运营商对通达信各服务器连通性不同的问题。 新增: - AsyncTdxClient.reconnect_to(host) 热切换方法(持锁,关旧建新) - GET/POST /api/v1/server/hosts|test|switch 三个端点 - ServerSettingsView.vue 页面(列表+测速+切换) - 导航栏第六个入口 设计: 先reconnect成功再save_best_host(避免污染config), host校验只允许候选列表内IP,不自动测速避免首屏卡顿。
This commit is contained in:
@@ -2,6 +2,26 @@
|
||||
|
||||
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
|
||||
|
||||
## [1.19.7] — 2026-07-07
|
||||
|
||||
**新增「服务器设置」页面:web UI 上测速 + 切换通达信服务器** —— 解决"有些用户获取到的 IP 能连通、有些不能"的问题。不同地区/运营商对通达信各服务器连通性不同,之前用户只能碰运气或手动改 config.json。现在在 web UI 上新增第六个页面「服务器设置」,列出全部 50+ 候选服务器、一键并发测速、点选切换——切换后立即生效(热重连),无需重启服务。
|
||||
|
||||
### 新增
|
||||
|
||||
- **`AsyncTdxClient.reconnect_to(host)`**(`src/easy_tdx/client.py`)—— 热切换 host 的核心方法:复用 `_execute_lock` 保证切换期间无并发请求撞半开连接,关旧连接→换 host→建新连接→重启心跳。切换失败抛异常(client 断开,路由层捕获返回友好提示)。
|
||||
- **服务器设置路由**(`src/easy_tdx/web/routers/server.py`)—— 3 个端点:
|
||||
- `GET /api/v1/server/hosts`:列出候选 host + 当前 host(不测速,首屏秒开)
|
||||
- `POST /api/v1/server/test`:并发 ping 测速,返回延迟(ms)和可达性,按延迟排序
|
||||
- `POST /api/v1/server/switch`:切换到指定 host(先 reconnect 成功再 save_best_host,避免连接失败污染 config)
|
||||
- **服务器设置页面**(`web-ui/src/views/ServerSettingsView.vue`)—— 左侧当前 host + 测速按钮,右侧 host 列表表格(IP/延迟颜色编码/状态徽章/使用按钮)。延迟 <100ms 绿色、<300ms 蓝色、≥300ms 红色、不可达灰色。
|
||||
- **导航入口**:顶部导航栏新增「服务器设置」(第 6 个页面)。
|
||||
|
||||
### 设计决策
|
||||
|
||||
- **不自动测速**:页面加载只列 host,点按钮才测速(50+ host 全 ping 要几秒,自动测速会卡首屏)。
|
||||
- **切换顺序**:先 `reconnect_to` 成功 → 再 `save_best_host` 持久化(v1.19.4 host 污染 bug 的教训)。
|
||||
- **host 校验**:只允许切换到候选列表里的 IP,防止任意地址注入。
|
||||
|
||||
## [1.19.6] — 2026-07-07
|
||||
|
||||
**修复 EXE 丢失所有第三方依赖(pandas/numpy/uvicorn 等)** —— v1.19.5 的 EXE 只有 11MB(正常 44MB),双击报 `ModuleNotFoundError: No module named 'pandas'`。根因:`release.yml` 的步骤顺序是先 `pip install -e ".[web,packaging]"` 再 `Build frontend`,但 `pyproject.toml` 的 `force-include` 要求 `web-ui/dist` 在 `pip install` 时就存在——install 阶段 dist 不存在导致 editable install 静默降级,PyInstaller 收集不到第三方包。修复:调换 `release.yml` 步骤顺序,先 `npm run build` 再 `pip install`。
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "easy-tdx"
|
||||
version = "1.19.6"
|
||||
version = "1.19.7"
|
||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -880,6 +880,22 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
|
||||
await self._stop_heartbeat()
|
||||
await self._conn.close()
|
||||
|
||||
async def reconnect_to(self, host: str) -> None:
|
||||
"""热切换到新 host:关旧连接 → 换 host → 建新连接。
|
||||
|
||||
用于 web UI 的"服务器设置"页面——用户点选一个 host 后,无需重启
|
||||
服务即可切换。复用 ``_execute_lock`` 保证切换期间没有并发行情请求
|
||||
撞到半开的连接。切换失败抛异常(旧连接已 close,client 处于断开
|
||||
状态,调用方应捕获并提示用户选别的 host)。
|
||||
"""
|
||||
async with self._execute_lock:
|
||||
await self._stop_heartbeat()
|
||||
await self._conn.close()
|
||||
self._host = host
|
||||
self._conn = AsyncTdxConnection(host, self._port, self._timeout)
|
||||
await self._conn.connect()
|
||||
self._start_heartbeat()
|
||||
|
||||
async def __aenter__(self) -> "AsyncTdxClient":
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
@@ -211,6 +211,7 @@ def _create_app(
|
||||
from easy_tdx.web.routers.mac_quotes import router as mac_quotes_router
|
||||
from easy_tdx.web.routers.market import router as market_router
|
||||
from easy_tdx.web.routers.realtime import router as realtime_router
|
||||
from easy_tdx.web.routers.server import router as server_router
|
||||
from easy_tdx.web.routers.sina import router as sina_router
|
||||
from easy_tdx.web.routers.strategies import router as strategies_router
|
||||
|
||||
@@ -236,6 +237,8 @@ def _create_app(
|
||||
app.include_router(backtest_router, prefix="/api/v1")
|
||||
# 策略库路由(SQLite 持久化,纯数据 CRUD)
|
||||
app.include_router(strategies_router, prefix="/api/v1")
|
||||
# 服务器设置路由(列出/测速/切换 TDX host)
|
||||
app.include_router(server_router, prefix="/api/v1")
|
||||
|
||||
# --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) ---
|
||||
# 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""服务器设置路由:列出/测速/切换标准 TDX 行情服务器。
|
||||
|
||||
让用户在 web UI 上看到候选 host 列表、一键测速、点选切换——解决"有些 IP
|
||||
能连通有些不能"的问题(不同地区/运营商对通达信各服务器连通性不同)。
|
||||
切换是热重连(``reconnect_to``),无需重启服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from easy_tdx.config import get_best_host, get_known_hosts, get_port, save_best_host
|
||||
from easy_tdx.transport.sync import ping_all
|
||||
|
||||
router = APIRouter(tags=["server"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schemas
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class HostInfo(BaseModel):
|
||||
"""单个 host 的状态信息。"""
|
||||
|
||||
host: str
|
||||
latency_ms: int | None = None # None = 未测速或不可达
|
||||
reachable: bool = False
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
class HostListResponse(BaseModel):
|
||||
"""GET /server/hosts 的响应。"""
|
||||
|
||||
hosts: list[HostInfo]
|
||||
current_host: str
|
||||
total: int
|
||||
|
||||
|
||||
class ServerTestRequest(BaseModel):
|
||||
"""POST /server/test 的请求。"""
|
||||
|
||||
hosts: list[str] | None = None # None = 测全部候选
|
||||
timeout: float = 5.0
|
||||
|
||||
|
||||
class ServerSwitchRequest(BaseModel):
|
||||
"""POST /server/switch 的请求。"""
|
||||
|
||||
host: str
|
||||
|
||||
|
||||
class SwitchResponse(BaseModel):
|
||||
"""POST /server/switch 的响应。"""
|
||||
|
||||
ok: bool
|
||||
host: str
|
||||
message: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Routes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/server/hosts", response_model=HostListResponse)
|
||||
async def list_hosts(request: Request) -> HostListResponse:
|
||||
"""列出所有候选 host + 当前正在使用的 host。
|
||||
|
||||
不做测速(避免 50+ host 全 ping 让首屏卡几秒)。前端点"测试全部"按钮
|
||||
后调 ``POST /server/test`` 获取延迟。
|
||||
"""
|
||||
candidates = get_known_hosts()
|
||||
current = _get_current_host(request)
|
||||
|
||||
host_infos = [HostInfo(host=h, is_current=(h == current)) for h in candidates]
|
||||
return HostListResponse(hosts=host_infos, current_host=current, total=len(host_infos))
|
||||
|
||||
|
||||
@router.post("/server/test", response_model=list[HostInfo])
|
||||
async def test_hosts(req: ServerTestRequest, request: Request) -> list[HostInfo]:
|
||||
"""并发 ping 测试 host 列表,返回延迟和可达性。
|
||||
|
||||
用 ``asyncio.to_thread`` 包装同步的 ``ping_all``(它内部用
|
||||
ThreadPoolExecutor 并发),避免阻塞事件循环。
|
||||
"""
|
||||
hosts = req.hosts if req.hosts else get_known_hosts()
|
||||
port = get_port()
|
||||
current = _get_current_host(request)
|
||||
|
||||
# ping_all 是同步阻塞函数,放到线程池跑
|
||||
ranked = await asyncio.to_thread(ping_all, hosts, port, req.timeout)
|
||||
|
||||
# ranked 是 [(host, latency_sec)],已按延迟升序排列,只含可达的
|
||||
reachable_map = {h: round(s * 1000) for h, s in ranked}
|
||||
|
||||
# 按原始 hosts 顺序返回(保持列表稳定),但把可达的排前面
|
||||
result = []
|
||||
for h in hosts:
|
||||
latency_ms = reachable_map.get(h)
|
||||
result.append(
|
||||
HostInfo(
|
||||
host=h,
|
||||
latency_ms=latency_ms,
|
||||
reachable=latency_ms is not None,
|
||||
is_current=(h == current),
|
||||
)
|
||||
)
|
||||
|
||||
# 可达的排前面(按延迟升序),不可达的排后面
|
||||
result.sort(key=lambda x: (x.reachable is False, x.latency_ms or 999999))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/server/switch", response_model=SwitchResponse)
|
||||
async def switch_host(req: ServerSwitchRequest, request: Request) -> SwitchResponse:
|
||||
"""切换到指定 host(热重连,无需重启服务)。
|
||||
|
||||
顺序:先 reconnect_to 成功 → 再 save_best_host 持久化。
|
||||
如果 reconnect 失败,不 save(避免污染 config,用户可再选别的)。
|
||||
"""
|
||||
candidates = get_known_hosts()
|
||||
if req.host not in candidates:
|
||||
return SwitchResponse(
|
||||
ok=False,
|
||||
host=req.host,
|
||||
message=f"主机 {req.host} 不在候选列表里,无法切换",
|
||||
)
|
||||
|
||||
client = request.app.state.tdx_client
|
||||
if client is None:
|
||||
return SwitchResponse(ok=False, host=req.host, message="TDX 客户端未初始化")
|
||||
|
||||
try:
|
||||
await client.reconnect_to(req.host)
|
||||
except Exception as e:
|
||||
return SwitchResponse(
|
||||
ok=False,
|
||||
host=req.host,
|
||||
message=f"连接 {req.host} 失败:{e}。请选其他服务器。",
|
||||
)
|
||||
|
||||
# 连接成功后才持久化
|
||||
save_best_host(req.host)
|
||||
return SwitchResponse(ok=True, host=req.host, message=f"已切换到 {req.host}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _get_current_host(request: Request) -> str:
|
||||
"""获取当前 TDX 客户端实际连接的 host。"""
|
||||
client = getattr(request.app.state, "tdx_client", None)
|
||||
if client is not None:
|
||||
# AsyncTdxClient._host 是实际连接的 host(reconnect_to 会更新它)
|
||||
return getattr(client, "_host", get_best_host())
|
||||
return get_best_host()
|
||||
@@ -12,6 +12,7 @@
|
||||
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
|
||||
<RouterLink to="/compare" active-class="active">结果对比</RouterLink>
|
||||
<RouterLink to="/strategies" active-class="active">策略库</RouterLink>
|
||||
<RouterLink to="/settings" active-class="active">服务器设置</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
SavedStrategy,
|
||||
SavedStrategyCreate,
|
||||
SavedStrategyListResponse,
|
||||
ServerHostInfo,
|
||||
ServerHostListResponse,
|
||||
ServerSwitchResult,
|
||||
StrategiesResponse,
|
||||
TaskListResponse,
|
||||
TaskState,
|
||||
@@ -264,3 +267,34 @@ export async function deleteSavedStrategy(id: string): Promise<void> {
|
||||
const resp = await fetch(`${BASE}/strategies/${id}`, { method: 'DELETE' })
|
||||
if (!resp.ok) await throwError(resp)
|
||||
}
|
||||
|
||||
// ── 服务器设置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** 列出所有候选通达信服务器 + 当前使用的 host(不含延迟,需点测速)。 */
|
||||
export async function fetchServerHosts(): Promise<ServerHostListResponse> {
|
||||
const resp = await fetch(`${BASE}/server/hosts`)
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as ServerHostListResponse
|
||||
}
|
||||
|
||||
/** 并发测速全部(或指定)host,返回延迟和可达性。 */
|
||||
export async function testServerHosts(hosts?: string[]): Promise<ServerHostInfo[]> {
|
||||
const resp = await fetch(`${BASE}/server/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hosts: hosts ?? null }),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as ServerHostInfo[]
|
||||
}
|
||||
|
||||
/** 切换到指定 host(热重连,无需重启服务)。 */
|
||||
export async function switchServerHost(host: string): Promise<ServerSwitchResult> {
|
||||
const resp = await fetch(`${BASE}/server/switch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host }),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as ServerSwitchResult
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import BacktestView from './views/BacktestView.vue'
|
||||
import CompareView from './views/CompareView.vue'
|
||||
import OptimizeView from './views/OptimizeView.vue'
|
||||
import PortfolioView from './views/PortfolioView.vue'
|
||||
import ServerSettingsView from './views/ServerSettingsView.vue'
|
||||
import StrategiesView from './views/StrategiesView.vue'
|
||||
|
||||
// 单标的回测(/)+ 组合回测(/portfolio)+ 参数寻优(/optimize)+ 结果对比(/compare)+ 策略库(/strategies)。
|
||||
// 单标的回测(/)+ 组合回测(/portfolio)+ 参数寻优(/optimize)+ 结果对比(/compare)+ 策略库(/strategies)+ 服务器设置(/settings)。
|
||||
const routes = [
|
||||
{ path: '/', name: 'backtest', component: BacktestView },
|
||||
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
|
||||
{ path: '/optimize', name: 'optimize', component: OptimizeView },
|
||||
{ path: '/compare', name: 'compare', component: CompareView },
|
||||
{ path: '/strategies', name: 'strategies', component: StrategiesView },
|
||||
{ path: '/settings', name: 'settings', component: ServerSettingsView },
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -330,3 +330,28 @@ export interface MultiStrategyBacktestRequest {
|
||||
slippage?: number
|
||||
execution?: ExecutionMode
|
||||
}
|
||||
|
||||
// ── 服务器设置(GET /api/v1/server/hosts 等) ────────────────────────────────
|
||||
|
||||
/** 单个通达信服务器的状态信息。 */
|
||||
export interface ServerHostInfo {
|
||||
host: string
|
||||
/** 延迟(毫秒)。null = 未测速或不可达。 */
|
||||
latency_ms: number | null
|
||||
reachable: boolean
|
||||
is_current: boolean
|
||||
}
|
||||
|
||||
/** GET /server/hosts 的响应。 */
|
||||
export interface ServerHostListResponse {
|
||||
hosts: ServerHostInfo[]
|
||||
current_host: string
|
||||
total: number
|
||||
}
|
||||
|
||||
/** POST /server/switch 的响应。 */
|
||||
export interface ServerSwitchResult {
|
||||
ok: boolean
|
||||
host: string
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
// 服务器设置页面:列出通达信行情服务器、测速、点选切换。
|
||||
// 解决"有些 IP 能连通有些不能"的问题——不同地区/运营商对各服务器连通性不同。
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { fetchServerHosts, testServerHosts, switchServerHost, formatError } from '../api'
|
||||
import type { ServerHostInfo } from '../types'
|
||||
|
||||
const hosts = ref<ServerHostInfo[]>([])
|
||||
const currentHost = ref('')
|
||||
const loading = ref(false)
|
||||
const testing = ref(false)
|
||||
const switchingHost = ref<string | null>(null)
|
||||
const error = ref('')
|
||||
const message = ref('')
|
||||
|
||||
onMounted(loadHosts)
|
||||
|
||||
async function loadHosts() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await fetchServerHosts()
|
||||
hosts.value = resp.hosts
|
||||
currentHost.value = resp.current_host
|
||||
} catch (e) {
|
||||
error.value = formatError(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testAll() {
|
||||
testing.value = true
|
||||
error.value = ''
|
||||
message.value = '正在测速,请稍候...'
|
||||
try {
|
||||
const results = await testServerHosts()
|
||||
// 合并测速结果到 hosts(保留 is_current 标记)
|
||||
const latencyMap = new Map(results.map((r) => [r.host, r]))
|
||||
hosts.value = hosts.value.map((h) => {
|
||||
const tested = latencyMap.get(h.host)
|
||||
return tested
|
||||
? { ...tested, is_current: h.is_current }
|
||||
: { ...h, latency_ms: null, reachable: false }
|
||||
})
|
||||
// 按可达+延迟排序
|
||||
hosts.value.sort((a, b) => {
|
||||
if (a.reachable !== b.reachable) return a.reachable ? -1 : 1
|
||||
return (a.latency_ms ?? 999999) - (b.latency_ms ?? 999999)
|
||||
})
|
||||
const reachable = results.filter((r) => r.reachable).length
|
||||
message.value = `测速完成:${reachable}/${results.length} 个服务器可达`
|
||||
} catch (e) {
|
||||
error.value = formatError(e)
|
||||
message.value = ''
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function switchHost(host: string) {
|
||||
switchingHost.value = host
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
try {
|
||||
const result = await switchServerHost(host)
|
||||
if (result.ok) {
|
||||
currentHost.value = host
|
||||
// 更新 is_current 标记
|
||||
hosts.value = hosts.value.map((h) => ({ ...h, is_current: h.host === host }))
|
||||
message.value = result.message
|
||||
} else {
|
||||
error.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = formatError(e)
|
||||
} finally {
|
||||
switchingHost.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function latencyColor(ms: number | null): string {
|
||||
if (ms === null) return 'var(--text-dim)'
|
||||
if (ms < 100) return 'var(--green, #4caf50)'
|
||||
if (ms < 300) return 'var(--accent)'
|
||||
return 'var(--red, #f44336)'
|
||||
}
|
||||
|
||||
function latencyText(ms: number | null): string {
|
||||
if (ms === null) return '—'
|
||||
return `${ms} ms`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="server-settings">
|
||||
<aside class="config-panel">
|
||||
<h2>服务器设置</h2>
|
||||
<div class="current-host">
|
||||
<span class="label">当前服务器</span>
|
||||
<span class="host-value">{{ currentHost || '未连接' }}</span>
|
||||
</div>
|
||||
<button class="btn-test" :disabled="testing || loading" @click="testAll">
|
||||
{{ testing ? '测速中...' : '🔄 测试全部服务器' }}
|
||||
</button>
|
||||
<p class="hint">
|
||||
点击"测试全部"测速各服务器延迟,然后点"使用"切换到最快或可用的服务器。
|
||||
切换后立即生效,无需重启。
|
||||
</p>
|
||||
<div v-if="message" class="message">{{ message }}</div>
|
||||
<div v-if="error" class="error-banner">⚠ {{ error }}</div>
|
||||
</aside>
|
||||
|
||||
<main class="report-panel">
|
||||
<table class="host-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>服务器 IP</th>
|
||||
<th>延迟</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="4" class="empty">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="hosts.length === 0">
|
||||
<td colspan="4" class="empty">暂无服务器列表</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="h in hosts"
|
||||
:key="h.host"
|
||||
:class="{ 'is-current': h.is_current, 'is-unreachable': !h.reachable && h.latency_ms === null && testing === false && hosts.some(x => x.latency_ms !== null) }"
|
||||
>
|
||||
<td class="host-ip">{{ h.host }}</td>
|
||||
<td class="latency" :style="{ color: latencyColor(h.latency_ms) }">
|
||||
{{ testing ? '...' : latencyText(h.latency_ms) }}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="h.is_current" class="badge badge-current">当前</span>
|
||||
<span v-else-if="h.reachable" class="badge badge-ok">可达</span>
|
||||
<span v-else-if="h.latency_ms === null" class="badge badge-unknown">未测速</span>
|
||||
<span v-else class="badge badge-bad">超时</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="!h.is_current"
|
||||
class="btn-switch"
|
||||
:disabled="switchingHost !== null"
|
||||
@click="switchHost(h.host)"
|
||||
>
|
||||
{{ switchingHost === h.host ? '切换中...' : '使用' }}
|
||||
</button>
|
||||
<span v-else class="current-mark">✓</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-settings {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.config-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
padding: 20px;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.config-panel h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.current-host {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.current-host .label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.current-host .host-value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn-test {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn-test:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-test:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.message {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--accent-bg, rgba(0, 120, 212, 0.1));
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.error-banner {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
color: var(--red, #f44336);
|
||||
}
|
||||
.report-panel {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.host-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.host-table th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.host-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.host-table tr.is-current {
|
||||
background: var(--accent-bg, rgba(0, 120, 212, 0.05));
|
||||
}
|
||||
.host-table tr.is-unreachable {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.host-ip {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.latency {
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.badge-current {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.badge-ok {
|
||||
background: rgba(76, 175, 80, 0.15);
|
||||
color: var(--green, #4caf50);
|
||||
}
|
||||
.badge-unknown {
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.badge-bad {
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
color: var(--red, #f44336);
|
||||
}
|
||||
.btn-switch {
|
||||
padding: 4px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-switch:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-switch:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.current-mark {
|
||||
color: var(--green, #4caf50);
|
||||
font-size: 16px;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user