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:
Justin Gu
2026-07-07 23:08:32 +08:00
parent c2c3e7ffb4
commit 14debdb446
10 changed files with 587 additions and 2 deletions
+20
View File
@@ -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
View File
@@ -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"
+16
View File
@@ -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
+3
View File
@@ -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) 挂在 "/" 会吞掉
+162
View File
@@ -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 是实际连接的 hostreconnect_to 会更新它)
return getattr(client, "_host", get_best_host())
return get_best_host()
+1
View File
@@ -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">
+34
View File
@@ -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
}
+3 -1
View File
@@ -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({
+25
View File
@@ -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
}
+322
View File
@@ -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>