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
+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()