mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
Merge feat/tier-5level-none: API 检测重构为 5 档(新增「无」档)
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
"""TickFlow Stock Panel backend."""
|
||||
|
||||
__version__ = "0.1.35"
|
||||
__version__ = "0.1.36"
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import __version__
|
||||
from app.config import settings
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import detect_capabilities, tier_label
|
||||
|
||||
router = APIRouter()
|
||||
@@ -15,7 +15,8 @@ def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": __version__,
|
||||
"mode": "free" if settings.use_free_mode else "api_key",
|
||||
# 三态: none(无key/无效) / free(免费key) / api_key(付费档)
|
||||
"mode": tf_client.current_mode(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+89
-26
@@ -69,12 +69,12 @@ class SwitchEndpointIn(BaseModel):
|
||||
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
"""切换 TickFlow 端点并立即生效。
|
||||
|
||||
endpoints.json 里的端点都是 Starter+ 付费端点,Free 模式无 key
|
||||
无法使用,故 Free 模式下禁止切换。
|
||||
端点切换仅对付费档(starter+,走 api.tickflow.org)有意义;
|
||||
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
|
||||
"""
|
||||
# Free 模式没有付费端点权限,禁止切换
|
||||
if tf_client.current_mode() == "free":
|
||||
return {"ok": False, "error": "Free 模式无法切换端点,请先配置 API Key"}
|
||||
# none/free 档没有付费端点权限,禁止切换
|
||||
if tf_client.current_mode() != "api_key":
|
||||
return {"ok": False, "error": "当前档位无法切换端点,仅付费套餐(Starter+)支持"}
|
||||
|
||||
url = req.url.strip().rstrip("/")
|
||||
if not url.startswith("https://"):
|
||||
@@ -95,44 +95,89 @@ def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
"""保存 TickFlow API Key 并立即重新探测能力。
|
||||
|
||||
端点联动:Free → Starter+ 时,Free 模式残留的 free-api 端点不可用于
|
||||
付费 Key,故自动切到默认付费端点(api.tickflow.org)。
|
||||
先探后存(关键改动,修复乱填 key 也会被持久化的问题):
|
||||
1. 临时用新 key 探测(付费端点),判定档位
|
||||
2. 判定为 none(连单只日K都拿不到)→ key 无效:不存,清除已存的,
|
||||
返回 {ok: false, reason: "invalid"},前端提示「Key 无效」
|
||||
3. 判定为 free(免费有效 key)→ 存 key,客户端切到 free-api 服务器
|
||||
4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑)
|
||||
|
||||
端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用,
|
||||
故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。
|
||||
"""
|
||||
from app.tickflow.policy import (
|
||||
base_tier_name, is_invalid_key,
|
||||
)
|
||||
|
||||
key = req.api_key.strip()
|
||||
if not key:
|
||||
return {"ok": False, "error": "key empty"}
|
||||
|
||||
# 判断是否 Free → Starter+ 转换(此前无 key)
|
||||
was_free = tf_client.current_mode() == "free"
|
||||
updates: dict = {"tickflow_api_key": key}
|
||||
if was_free:
|
||||
# 自动切到默认端点;之前的残留自定义 URL 不再适用
|
||||
updates["tickflow_base_url"] = DEFAULT_PAID_ENDPOINT
|
||||
|
||||
secrets_store.save(updates)
|
||||
# ===== 1) 临时存 key + 重置客户端,让探测走付费端点 =====
|
||||
secrets_store.save({"tickflow_api_key": key})
|
||||
tf_client.reset_clients()
|
||||
|
||||
# 立即重新探测
|
||||
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
|
||||
# ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 =====
|
||||
if is_invalid_key() or base_tier_name() == "none":
|
||||
# 无效 key:清除刚存的,避免乱填被持久化;退回 none 档
|
||||
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
|
||||
tf_client.reset_clients()
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "invalid",
|
||||
"error": "Key 无效或已过期,请检查后重试",
|
||||
"mode": "none",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": [],
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
# ===== 3) free 档(免费有效 key)→ 存 key,切到 free-api 服务器 =====
|
||||
if base_tier_name() == "free":
|
||||
# 免费档运行时走 free-api 服务器,清除付费端点的自定义配置
|
||||
secrets_store.clear("tickflow_base_url")
|
||||
tf_client.reset_clients()
|
||||
return {
|
||||
"ok": True,
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
"mode": "free",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": [],
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
# ===== 4) starter+ 付费档 → 确保走付费端点(现有逻辑) =====
|
||||
# 若之前是 none/free(无自定义付费端点),切到默认付费端点
|
||||
base = secrets_store.load().get("tickflow_base_url")
|
||||
if not base:
|
||||
secrets_store.save({"tickflow_base_url": DEFAULT_PAID_ENDPOINT})
|
||||
tf_client.reset_clients()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
"mode": "api_key",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"probe_log": probe_log(),
|
||||
"probe_log": [],
|
||||
"capabilities_count": len(capset.all()),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/tickflow-key")
|
||||
def clear_tickflow_key(request: Request) -> dict:
|
||||
"""清除 Key,退回 Free 模式。
|
||||
"""清除 Key,退回无档(none)。
|
||||
|
||||
同时清除 tickflow_base_url(测速切换的自定义端点),使"当前使用"
|
||||
回到默认节点 api.tickflow.org;SDK 则自动用 free() 取免费数据。
|
||||
同时清除 tickflow_base_url(测速切换的自定义端点),使客户端走 free-api
|
||||
服务器取历史日K;档位标签为 None(无档)。
|
||||
"""
|
||||
secrets_store.clear("tickflow_api_key", "tickflow_base_url")
|
||||
tf_client.reset_clients()
|
||||
@@ -142,7 +187,7 @@ def clear_tickflow_key(request: Request) -> dict:
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "free",
|
||||
"mode": "none",
|
||||
"tier_label": tier_label(),
|
||||
"current_endpoint": tf_client.current_endpoint(),
|
||||
"capabilities_count": len(capset.all()),
|
||||
@@ -202,6 +247,12 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
|
||||
# ===== 偏好设置 =====
|
||||
|
||||
def _realtime_allowed() -> bool:
|
||||
"""当前档位是否允许实时行情(none/free 不允许)。"""
|
||||
from app.services.quote_service import QuoteService
|
||||
return QuoteService.is_realtime_allowed()
|
||||
|
||||
|
||||
class MinuteSyncPrefs(BaseModel):
|
||||
minute_sync_enabled: bool
|
||||
minute_sync_days: int = 5
|
||||
@@ -213,6 +264,7 @@ def get_preferences() -> dict:
|
||||
from app.services import preferences
|
||||
return {
|
||||
"realtime_quotes_enabled": preferences.get_realtime_quotes_enabled(),
|
||||
"realtime_allowed": _realtime_allowed(),
|
||||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||||
"minute_sync_days": preferences.get_minute_sync_days(),
|
||||
@@ -315,19 +367,30 @@ class RealtimeQuotesPrefs(BaseModel):
|
||||
|
||||
@router.put("/preferences/realtime-quotes")
|
||||
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
"""保存全局实时行情开关。"""
|
||||
from app.services import preferences
|
||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
||||
"""保存全局实时行情开关。
|
||||
|
||||
# 动态启停行情服务
|
||||
none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False,
|
||||
前端据此把开关置灰 / 回弹。
|
||||
"""
|
||||
from app.services import preferences
|
||||
qs = getattr(request.app.state, "quote_service", None)
|
||||
|
||||
allowed = qs.is_realtime_allowed() if qs else True
|
||||
if req.realtime_quotes_enabled and not allowed:
|
||||
# 当前档位不允许开启实时行情 — 强制关闭
|
||||
preferences.save({"realtime_quotes_enabled": False})
|
||||
if qs:
|
||||
qs.disable()
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
|
||||
|
||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
||||
if qs:
|
||||
if req.realtime_quotes_enabled:
|
||||
qs.enable()
|
||||
else:
|
||||
qs.disable()
|
||||
|
||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled}
|
||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
|
||||
|
||||
|
||||
class IndicesNavPinnedPrefs(BaseModel):
|
||||
|
||||
+18
-1
@@ -16,6 +16,7 @@ from app.api.routes import router as core_router
|
||||
from app.config import settings
|
||||
from app.jobs import daily_pipeline
|
||||
from app.services.quote_service import QuoteService
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import detect_capabilities
|
||||
from app.tickflow.repository import DataStore, KlineRepository
|
||||
|
||||
@@ -30,7 +31,7 @@ logger = logging.getLogger(__name__)
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info(
|
||||
"TickFlow Stock Panel v%s starting (mode=%s)",
|
||||
__version__, "free" if settings.use_free_mode else "api_key",
|
||||
__version__, tf_client.current_mode(),
|
||||
)
|
||||
|
||||
# 数据层
|
||||
@@ -197,6 +198,22 @@ app.include_router(signals.router)
|
||||
app.include_router(monitor_rules.router)
|
||||
app.include_router(alerts.router)
|
||||
|
||||
|
||||
# 能力门控异常 → 403(而非默认 500)
|
||||
# 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied;
|
||||
# 若不注册 handler 会冒泡成 500 Internal Server Error,对前端不友好且语义错误。
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from app.tickflow.capabilities import CapabilityDenied
|
||||
|
||||
|
||||
@app.exception_handler(CapabilityDenied)
|
||||
async def capability_denied_handler(request: Request, exc: CapabilityDenied) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": str(exc), "suggestion": exc.suggestion},
|
||||
)
|
||||
|
||||
# 生产期静态文件(前端 dist)
|
||||
_static = Path(settings.static_dir)
|
||||
if _static.exists():
|
||||
|
||||
@@ -99,8 +99,15 @@ class QuoteService:
|
||||
self._save_enabled(False)
|
||||
logger.info("行情服务已停止")
|
||||
|
||||
def enable(self) -> None:
|
||||
"""开启自动行情 (不立即启动线程,等下一个交易时段)。"""
|
||||
def enable(self) -> bool:
|
||||
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
|
||||
|
||||
none/free 档无实时行情权限,拒绝开启并返回 False;
|
||||
starter+ 正常启动。返回值表示是否真正开启。
|
||||
"""
|
||||
if not self.is_realtime_allowed():
|
||||
logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限")
|
||||
return False
|
||||
self._enabled = True
|
||||
self._save_enabled(True)
|
||||
if not self._running:
|
||||
@@ -117,8 +124,17 @@ class QuoteService:
|
||||
logger.info("行情服务已关闭")
|
||||
|
||||
def boot_check(self) -> None:
|
||||
"""启动时检查 preferences,若 enabled 则自动启动。"""
|
||||
"""启动时检查 preferences,若 enabled 则自动启动。
|
||||
|
||||
none/free 档无实时行情权限:即使 preferences 标记为 enabled,
|
||||
也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。
|
||||
"""
|
||||
from app.services import preferences
|
||||
if not self.is_realtime_allowed():
|
||||
if preferences.get_realtime_quotes_enabled():
|
||||
self._save_enabled(False)
|
||||
logger.info("实时行情未启动:当前档位(none/free)无实时行情权限")
|
||||
return
|
||||
if preferences.get_realtime_quotes_enabled():
|
||||
self.start()
|
||||
|
||||
@@ -182,6 +198,15 @@ class QuoteService:
|
||||
from app.tickflow.policy import tier_label
|
||||
return tier_label().split()[0].split("+")[0].strip().lower()
|
||||
|
||||
@classmethod
|
||||
def is_realtime_allowed(cls) -> bool:
|
||||
"""当前档位是否允许使用实时行情。
|
||||
|
||||
none/free 档走 free-api 服务器,无实时行情权限 → 不允许;
|
||||
starter+ 付费档走付费端点,有实时行情 → 允许。
|
||||
"""
|
||||
return cls._current_tier() not in ("none", "free")
|
||||
|
||||
@classmethod
|
||||
def _tier_min_interval(cls) -> float:
|
||||
tier = cls._current_tier()
|
||||
|
||||
@@ -97,6 +97,10 @@ def fetch_quotes(symbols: list[str], capset: CapabilitySet, timeout_s: float = 8
|
||||
elif capset.has(Cap.QUOTE_BY_SYMBOL):
|
||||
lim = capset.limits(Cap.QUOTE_BY_SYMBOL)
|
||||
batch_size = lim.batch if lim and lim.batch else 5
|
||||
else:
|
||||
# 无任何实时行情能力(none/free 档走 free-api 服务器,不提供实时行情)
|
||||
# 提前返回空,避免发起注定失败的请求
|
||||
return []
|
||||
|
||||
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
进程内单例;Key 来源(优先级):secrets.json > .env。
|
||||
用户改 Key 后需要 `reset_clients()`,然后 `get_client()` 会拿新的。
|
||||
|
||||
5 档体系下服务器归属:
|
||||
- none 档(无 key / 无效 key) → TickFlow.free()(free-api 服务器)
|
||||
- free 档(免费有效 key) → TickFlow.free()(key 被 SDK 忽略,运行时走 free-api)
|
||||
- starter/pro/expert(付费 key) → TickFlow(api_key=key, base_url)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,6 +20,27 @@ _sync_client: TickFlow | None = None
|
||||
_async_client: AsyncTickFlow | None = None
|
||||
|
||||
|
||||
# ===== 服务器归属判定 =====
|
||||
|
||||
# free-api 服务器默认节点(SDK 默认值),none/free 档运行时走这里。
|
||||
FREE_ENDPOINT = "https://free-api.tickflow.org"
|
||||
# 付费端点默认节点(starter+ 运行时走这里,也是端点切换的默认值)。
|
||||
PAID_ENDPOINT = "https://api.tickflow.org"
|
||||
|
||||
|
||||
def _should_use_free_server() -> bool:
|
||||
"""是否应走 free-api 服务器。
|
||||
|
||||
判定依据:无 key,或当前档位为 none/free。
|
||||
付费档(starter+)走付费端点。
|
||||
"""
|
||||
if not secrets_store.get_tickflow_key():
|
||||
return True
|
||||
# 有 key 时按探测出的档位判定(避免读 capabilities.json 在首次启动前未生成的边界)
|
||||
from app.tickflow.policy import base_tier_name
|
||||
return base_tier_name() in ("none", "free")
|
||||
|
||||
|
||||
def _base_url() -> str | None:
|
||||
"""从 secrets.json 读取用户自定义端点,没有则返回 None(用 SDK 默认)。"""
|
||||
return secrets_store.load().get("tickflow_base_url") or None
|
||||
@@ -25,8 +51,8 @@ def get_client() -> TickFlow:
|
||||
global _sync_client
|
||||
if _sync_client is None:
|
||||
key = secrets_store.get_tickflow_key()
|
||||
if not key:
|
||||
# Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api
|
||||
if _should_use_free_server():
|
||||
# none/free 档:走 free-api 服务器(无 key 或免费 key 被 SDK 忽略)
|
||||
_sync_client = TickFlow.free()
|
||||
else:
|
||||
_sync_client = TickFlow(api_key=key, base_url=_base_url())
|
||||
@@ -38,8 +64,7 @@ def get_async_client() -> AsyncTickFlow:
|
||||
global _async_client
|
||||
if _async_client is None:
|
||||
key = secrets_store.get_tickflow_key()
|
||||
if not key:
|
||||
# Free 模式:付费端点 URL 不可用,忽略 base_url 走 SDK 默认 free-api
|
||||
if _should_use_free_server():
|
||||
_async_client = AsyncTickFlow.free()
|
||||
else:
|
||||
_async_client = AsyncTickFlow(api_key=key, base_url=_base_url())
|
||||
@@ -54,19 +79,31 @@ def reset_clients() -> None:
|
||||
|
||||
|
||||
def current_mode() -> str:
|
||||
"""供 UI 显示当前模式。"""
|
||||
return "api_key" if secrets_store.get_tickflow_key() else "free"
|
||||
"""供 UI 显示当前模式。三态:
|
||||
|
||||
- "none" : 无 key / 无效 key(走 free-api,仅历史日K)
|
||||
- "free" : 免费有效 key(走 free-api,仅历史日K)
|
||||
- "api_key" : 付费 key(starter+,走付费端点,有实时行情)
|
||||
"""
|
||||
if not secrets_store.get_tickflow_key():
|
||||
return "none"
|
||||
from app.tickflow.policy import base_tier_name
|
||||
tier = base_tier_name()
|
||||
if tier in ("none", "free"):
|
||||
return "free" if tier == "free" else "none"
|
||||
return "api_key"
|
||||
|
||||
|
||||
def current_endpoint() -> str:
|
||||
"""返回当前显示用的端点 URL(对应 endpoints.json 列表项)。
|
||||
|
||||
注:SDK 的 TickFlow.free() 内部实际走 free-api,但 UI 显示统一用默认
|
||||
节点(api.tickflow.org),使"当前使用"始终对得上端点列表里的某一项。
|
||||
- none/free 档:显示 free-api 服务器节点
|
||||
- 付费档:显示用户自定义端点(测速切换后)或默认付费节点 api.tickflow.org
|
||||
"""
|
||||
if _should_use_free_server():
|
||||
return FREE_ENDPOINT
|
||||
# 自定义端点(付费模式测速切换后):优先返回
|
||||
base = _base_url()
|
||||
if base:
|
||||
return base.rstrip("/")
|
||||
# Free 模式或未自定义:统一显示默认节点
|
||||
return "https://api.tickflow.org"
|
||||
return PAID_ENDPOINT
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -29,7 +30,9 @@ _CAPSET_CACHE_FILE = "capabilities.json"
|
||||
# 旧缓存(无此字段或版本更低)会被判定过期,触发重新探测。
|
||||
# v2: 拆分 depth5 → depth5(单只) + depth5.batch(批量)
|
||||
# v3: 探测补全 quote.batch(此前 tiers.yaml 声明了但 _probe_real 漏探测)
|
||||
_CACHE_SCHEMA_VERSION = 3
|
||||
# v4: 5 档重构 —— 新增 none 档(无key/无效key),free 档重定义(走 free-api 服务器,
|
||||
# 仅历史日K)。判定改为复权因子分水岭:_classify_tier 接管档位判定。
|
||||
_CACHE_SCHEMA_VERSION = 4
|
||||
|
||||
# 探测用最小代价请求:挑流通性最好的 1 只标的试
|
||||
_PROBE_SYMBOL = "600000.SH" # 浦发银行,长期不会退市
|
||||
@@ -247,27 +250,36 @@ def detect_capabilities(force: bool = False) -> CapabilitySet:
|
||||
|
||||
tiers = _load_tiers_yaml()
|
||||
if settings.use_free_mode:
|
||||
capset = _tier_to_capset(tiers["free"])
|
||||
label, missing, extras = _compute_label_and_missing(capset, tiers)
|
||||
_persist(capset, label, log=["Free 模式(无 API Key)"], missing=missing, extras=extras)
|
||||
# 无 key —— 归 none 档(走 free-api 服务器,仅历史日K)
|
||||
capset = _tier_to_capset(tiers["none"])
|
||||
_persist(capset, "None", log=["无 API Key(无档 · free-api 服务器)"], missing=[], extras=[])
|
||||
return capset
|
||||
|
||||
# 有 API key — 真实探测
|
||||
try:
|
||||
capset, probe_log = _probe_real(tiers)
|
||||
if not capset.all():
|
||||
logger.warning("probe returned no caps; falling back to free baseline")
|
||||
# 判定档位:无效 key → none,免费 key → free,付费 → starter/pro/expert
|
||||
classified = _classify_tier(capset, tiers)
|
||||
if classified.is_invalid:
|
||||
# 无效 key(连单只日K都拿不到):归 none 档,标记要求清除 key
|
||||
capset = _tier_to_capset(tiers["none"])
|
||||
probe_log.append("⚠ Key 无效(单只日K也无法获取),判定为无档")
|
||||
_persist(capset, "None", log=probe_log, missing=[], extras=[], invalid_key=True)
|
||||
return capset
|
||||
if classified.is_free:
|
||||
# 免费有效 key:能力按 free 档(= none 档能力,走 free-api 服务器)
|
||||
capset = _tier_to_capset(tiers["free"])
|
||||
probe_log.append("⚠ 所有探测均失败,降级为 Free 占位")
|
||||
_persist(capset, "Free", log=probe_log + ["✓ 免费有效 key(运行时走 free-api 服务器)"], missing=[], extras=[])
|
||||
return capset
|
||||
# 付费档(starter+) — 探测出的能力即为真实可用
|
||||
label, missing, extras = _compute_label_and_missing(capset, tiers)
|
||||
# 探测时 limits 用了"任意档默认值",现在判档完成,用真实档位的 limits 覆盖
|
||||
capset = _override_limits_with_detected_tier(capset, label, tiers)
|
||||
_persist(capset, label, log=probe_log, missing=missing, extras=extras)
|
||||
return capset
|
||||
except Exception as e:
|
||||
logger.exception("detect_capabilities failed; using free baseline: %s", e)
|
||||
capset = _tier_to_capset(tiers["free"])
|
||||
_persist(capset, "Free(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[])
|
||||
logger.exception("detect_capabilities failed; using none baseline: %s", e)
|
||||
capset = _tier_to_capset(tiers["none"])
|
||||
_persist(capset, "None(探测失败)", log=[f"探测失败:{e}"], missing=[], extras=[])
|
||||
return capset
|
||||
|
||||
|
||||
@@ -280,9 +292,55 @@ TIER_SIGNATURES: dict[str, set[Cap]] = {
|
||||
Cap.INTRADAY, Cap.DEPTH5, Cap.DEPTH5_BATCH},
|
||||
"starter": {Cap.QUOTE_BATCH, Cap.KLINE_DAILY_BATCH,
|
||||
Cap.ADJ_FACTOR, Cap.QUOTE_POOL},
|
||||
# free 不需 signature — 默认兜底
|
||||
# free / none 不需 signature — 由 _classify_tier 的分水岭逻辑判定
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class TierClassification:
|
||||
"""档位判定结果。
|
||||
|
||||
判定依据是"复权因子分水岭":
|
||||
- 连单只日K都没有 → 无效 key(is_invalid),归 none 档
|
||||
- 有单只日K、无复权因子 → 免费 key(is_free)
|
||||
- 有复权因子 → 付费档(starter+),具体档位由 signature 决定
|
||||
"""
|
||||
|
||||
tier: str # "none" / "free" / "starter" / "pro" / "expert"
|
||||
is_invalid: bool # 无效 key(连单只日K都拿不到)
|
||||
is_free: bool # 免费有效 key(有日K、无复权因子)
|
||||
|
||||
|
||||
def _classify_tier(capset: CapabilitySet, tiers: dict) -> TierClassification:
|
||||
"""根据探测出的能力集判定档位。
|
||||
|
||||
分水岭是 KLINE_DAILY_BY_SYMBOL(单只日K)与 ADJ_FACTOR(复权因子):
|
||||
- 无单只日K → none(无效 key)
|
||||
- 有日K无复权 → free(免费 key)
|
||||
- 有复权因子 → 走 signature 判定 starter/pro/expert
|
||||
"""
|
||||
held = set(capset.all().keys())
|
||||
|
||||
# 1) 连单只日K都没有 → 无效 key
|
||||
if Cap.KLINE_DAILY_BY_SYMBOL not in held:
|
||||
return TierClassification(tier="none", is_invalid=True, is_free=False)
|
||||
|
||||
# 2) 有日K但无复权因子 → 免费 key
|
||||
if Cap.ADJ_FACTOR not in held:
|
||||
return TierClassification(tier="free", is_invalid=False, is_free=True)
|
||||
|
||||
# 3) 有复权因子 → 付费档,按 signature 自上而下判定
|
||||
if held & TIER_SIGNATURES["expert"]:
|
||||
base = "expert"
|
||||
elif held & TIER_SIGNATURES["pro"]:
|
||||
base = "pro"
|
||||
elif held & TIER_SIGNATURES["starter"]:
|
||||
base = "starter"
|
||||
else:
|
||||
# 有复权因子但无任何代表能力 — 兜底为 starter(复权本身是 starter 特征)
|
||||
base = "starter"
|
||||
return TierClassification(tier=base, is_invalid=False, is_free=False)
|
||||
|
||||
# 补丁友好命名(label 后缀用)
|
||||
_CAP_ALIASES: dict[Cap, str] = {
|
||||
Cap.KLINE_MINUTE_BATCH: "分钟K",
|
||||
@@ -395,6 +453,7 @@ def _persist(
|
||||
log: list[str] | None = None,
|
||||
missing: list[str] | None = None,
|
||||
extras: list[str] | None = None,
|
||||
invalid_key: bool = False,
|
||||
) -> None:
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
||||
@@ -405,6 +464,7 @@ def _persist(
|
||||
"probe_log": log or [],
|
||||
"missing_caps": missing or [], # 本档应有但未探测到
|
||||
"extras_caps": extras or [], # 超出本档的额外能力
|
||||
"invalid_key": invalid_key, # 探测出的 key 无效(连单只日K都拿不到)
|
||||
}
|
||||
with cache_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
@@ -456,3 +516,24 @@ def extras_caps() -> list[str]:
|
||||
with cache_path.open(encoding="utf-8") as f:
|
||||
return json.load(f).get("extras_caps", [])
|
||||
return []
|
||||
|
||||
|
||||
def is_invalid_key() -> bool:
|
||||
"""最近一次探测是否判定 key 无效(连单只日K都拿不到)。
|
||||
|
||||
settings 层据此清除已存的 key,避免乱填的 key 被持久化。
|
||||
"""
|
||||
cache_path = settings.data_dir / _CAPSET_CACHE_FILE
|
||||
if cache_path.exists():
|
||||
with cache_path.open(encoding="utf-8") as f:
|
||||
return bool(json.load(f).get("invalid_key", False))
|
||||
return False
|
||||
|
||||
|
||||
def base_tier_name() -> str:
|
||||
"""当前档位的基础名(小写): none / free / starter / pro / expert。
|
||||
|
||||
供 client 层判断"是否走 free-api 服务器"(none/free → free 服务器)。
|
||||
"""
|
||||
label = tier_label()
|
||||
return label.split()[0].split("+")[0].strip().lower()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tickflow-stock-panel-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.35",
|
||||
"version": "0.1.36",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useToggleRealtimeQuotes,
|
||||
} from '@/lib/useSharedMutations'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { tierRank } from '@/lib/capability-labels'
|
||||
import {
|
||||
Star,
|
||||
ScanSearch,
|
||||
@@ -134,7 +135,7 @@ function SidebarIndexQuotes({ rows, items }: { rows: IndexQuote[] | undefined; i
|
||||
// ===== 档位卡片 =====
|
||||
function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
const base = label.split(' ')[0].split('+')[0].toLowerCase()
|
||||
const isFree = base === 'free' || !label
|
||||
const isNone = base === 'none'
|
||||
|
||||
const tierConfig: Record<string, {
|
||||
desc: string
|
||||
@@ -142,6 +143,12 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
dotStyle: React.CSSProperties
|
||||
labelTextStyle: React.CSSProperties
|
||||
}> = {
|
||||
none: {
|
||||
desc: '未配置 Key · 仅历史日K',
|
||||
tagBg: { background: 'rgba(113,113,122,0.15)' },
|
||||
dotStyle: { background: '#52525b' },
|
||||
labelTextStyle: { color: '#71717a' },
|
||||
},
|
||||
free: {
|
||||
desc: '基础日K · 单股查询',
|
||||
tagBg: { background: 'rgba(113,113,122,0.3)' },
|
||||
@@ -168,7 +175,9 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
},
|
||||
}
|
||||
|
||||
const t = tierConfig[base] || tierConfig.free
|
||||
const t = tierConfig[base] || tierConfig.none
|
||||
// none 档显示中文「无」,无 label 时显示「无档」
|
||||
const displayLabel = isNone ? '无' : (label || '无')
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -191,14 +200,14 @@ function TierBadge({ label, hasKey }: { label: string; hasKey?: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] leading-tight text-muted">
|
||||
{isFree && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
|
||||
{isNone && !hasKey ? '配置 Key 解锁更多能力' : t.desc}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex h-[18px] max-w-[68px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none"
|
||||
style={t.tagBg}
|
||||
>
|
||||
<span className="truncate" style={t.labelTextStyle}>{label || 'Free'}</span>
|
||||
<span className="truncate" style={t.labelTextStyle}>{displayLabel}</span>
|
||||
</span>
|
||||
<Settings className="h-3 w-3 shrink-0 text-muted group-hover:text-blue-300 transition-colors" />
|
||||
</div>
|
||||
@@ -271,7 +280,8 @@ export function Layout() {
|
||||
const toggleQuote = useToggleRealtimeQuotes()
|
||||
const isRunning = quoteStatus?.running ?? false
|
||||
const isTrading = quoteStatus?.is_trading_hours ?? false
|
||||
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
|
||||
// none/free 档(无实时行情权限)→ rank < starter(1)
|
||||
const isFreeTier = tierRank(caps?.label ?? '') < 1
|
||||
|
||||
// 轮询触发记录总数 → 更新监控中心徽标 (每 15 秒)
|
||||
const alertsTotalQuery = useQuery({
|
||||
@@ -316,7 +326,7 @@ export function Layout() {
|
||||
queryKey: QK.capabilities,
|
||||
queryFn: api.capabilities,
|
||||
})
|
||||
if ((fresh.label ?? '').toLowerCase().startsWith('free')) return
|
||||
if (tierRank(fresh.label ?? '') < 1) return
|
||||
}
|
||||
await toggleQuote.mutateAsync(enabled)
|
||||
// 仅在交易时段立即获取一次行情
|
||||
@@ -356,7 +366,7 @@ export function Layout() {
|
||||
|
||||
<TierBadge
|
||||
label={caps?.label ?? ''}
|
||||
hasKey={settingsState?.mode === 'api_key'}
|
||||
hasKey={settingsState?.mode !== 'none'}
|
||||
/>
|
||||
<AIConfigBadge
|
||||
configured={settingsState?.has_ai_key}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { fmtDate } from '@/lib/format'
|
||||
import { Skeleton } from './Skeleton'
|
||||
|
||||
// 卡片能力定义:capKey → 查 capability limits;tierReq → 无权限时显示的档位要求
|
||||
// capKey 为空串表示该数据在 free-api 服务器(无档/免费档)即可获取,无需付费能力门控。
|
||||
export const CARD_META: Record<string, {
|
||||
capKey: string // 对应的 capability key,空串表示本地计算
|
||||
capKey: string // 对应的 capability key,空串表示本地计算 / free 服务器可用
|
||||
tierReq: string // 最低档位要求(无权限时显示)
|
||||
}> = {
|
||||
instruments: { capKey: 'quote.by_symbol', tierReq: 'Free' },
|
||||
// 标的维表走 exchanges 端点,free-api 服务器即可获取,无需付费能力
|
||||
instruments: { capKey: '', tierReq: '' },
|
||||
daily: { capKey: 'kline.daily.batch', tierReq: 'Starter+' },
|
||||
adj_factor: { capKey: 'adj_factor', tierReq: 'Starter+' },
|
||||
enriched: { capKey: '', tierReq: '' },
|
||||
|
||||
+15
-2
@@ -549,7 +549,7 @@ export interface EndpointManifest {
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
mode: 'free' | 'api_key'
|
||||
mode: 'none' | 'free' | 'api_key'
|
||||
tickflow_api_key_masked: string
|
||||
has_tickflow_key: boolean
|
||||
tier_label: string
|
||||
@@ -568,6 +568,19 @@ export interface SettingsState {
|
||||
ai_daily_token_budget: number
|
||||
}
|
||||
|
||||
/** 保存 TickFlow Key 的响应(先探后存) */
|
||||
export interface SaveTickflowKeyResult {
|
||||
ok: boolean
|
||||
/** ok=false 且 key 无效时的原因标识,前端据此提示「Key 无效」 */
|
||||
reason?: 'invalid'
|
||||
error?: string
|
||||
mode?: 'none' | 'free' | 'api_key'
|
||||
tier_label?: string
|
||||
current_endpoint?: string
|
||||
tickflow_api_key_masked?: string
|
||||
capabilities_count?: number
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
realtime_quotes_enabled: boolean
|
||||
indices_nav_pinned: boolean
|
||||
@@ -609,7 +622,7 @@ export const api = {
|
||||
|
||||
settings: () => request<SettingsState>('/api/settings'),
|
||||
saveTickflowKey: (api_key: string) =>
|
||||
request<any>('/api/settings/tickflow-key', {
|
||||
request<SaveTickflowKeyResult>('/api/settings/tickflow-key', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ api_key }),
|
||||
}),
|
||||
|
||||
@@ -16,7 +16,8 @@ export const CAP_LABELS: Record<string, { name: string; hint: string }> = {
|
||||
|
||||
// 套餐等级 —— 用于按档位门控功能(如专线端点 / 按月扩展分钟K)。
|
||||
// 基础档提取与后端 quote_service.py 一致:取 label 第一个词("Pro +" → "pro")。
|
||||
export const TIER_RANK: Record<string, number> = { free: 0, starter: 1, pro: 2, expert: 3 }
|
||||
// none = 无档(无 key / 无效 key),低于 free,仅历史日K无实时行情。
|
||||
export const TIER_RANK: Record<string, number> = { none: -1, free: 0, starter: 1, pro: 2, expert: 3 }
|
||||
export const EXPERT_RANK = TIER_RANK.expert
|
||||
|
||||
export function tierRank(label: string): number {
|
||||
@@ -37,6 +38,12 @@ export interface TierStyle {
|
||||
}
|
||||
|
||||
const TIER_STYLE: Record<string, TierStyle> = {
|
||||
none: {
|
||||
desc: '未配置 Key · 仅历史日K',
|
||||
tagBg: { background: 'rgba(113,113,122,0.15)' },
|
||||
dotStyle: { background: '#52525b' },
|
||||
labelTextStyle: { color: '#71717a' },
|
||||
},
|
||||
free: {
|
||||
desc: '基础日K · 单股查询',
|
||||
tagBg: { background: 'rgba(113,113,122,0.3)' },
|
||||
@@ -74,7 +81,7 @@ export function tierStyle(label: string): TierStyle {
|
||||
}
|
||||
|
||||
/** 所有档位(有序, 供档位列表渲染) */
|
||||
export const ALL_TIERS = ['free', 'starter', 'pro', 'expert'] as const
|
||||
export const ALL_TIERS = ['none', 'free', 'starter', 'pro', 'expert'] as const
|
||||
|
||||
/** 返回档位标签的渐变文字样式(用于大字显示, 如 Keys 页档位) */
|
||||
export function tierTextStyle(label: string): { color?: string; background?: string; WebkitBackgroundClip?: string; backgroundClip?: string } {
|
||||
@@ -85,12 +92,14 @@ export function tierTextStyle(label: string): { color?: string; background?: str
|
||||
export function TierTag({ label, className = '' }: { label: string; className?: string }) {
|
||||
const t = tierStyle(label)
|
||||
const base = tierBaseName(label)
|
||||
// none 档显示中文「无」,其余档显示英文档名
|
||||
const display = base === 'none' ? '无' : base
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex h-[18px] max-w-[80px] shrink-0 items-center overflow-hidden rounded px-1.5 text-[10px] font-bold font-mono leading-none ${className}`}
|
||||
style={t.tagBg}
|
||||
>
|
||||
<span className="truncate capitalize" style={t.labelTextStyle}>{base}</span>
|
||||
<span className="truncate capitalize" style={t.labelTextStyle}>{display}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer } from 'lucide-react'
|
||||
import { Activity, ArrowDownRight, ArrowUpRight, BarChart3, BellRing, Flame, Gauge, LineChart, Loader2, RefreshCw, Sparkles, Target, Timer, ExternalLink, Gift } from 'lucide-react'
|
||||
import { DatePicker } from '@/components/DatePicker'
|
||||
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket, type AlertEvent } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { fmtBigNum, fmtPct } from '@/lib/format'
|
||||
import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
|
||||
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
|
||||
import { SealedBadge } from '@/components/SealedBadge'
|
||||
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
|
||||
import { cn } from '@/lib/cn'
|
||||
@@ -480,9 +480,12 @@ export function Dashboard() {
|
||||
})
|
||||
const data = overview.data
|
||||
const caps = useCapabilities()
|
||||
const settings = useSettings()
|
||||
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
|
||||
const sealedReady = !!data?.limit?.sealed_ready
|
||||
const isSealedDegrade = !hasDepth || !sealedReady
|
||||
// none 档(无 key / 无效 key)→ 显示升级提示横幅
|
||||
const isNoKey = settings.data?.mode === 'none'
|
||||
|
||||
// 手动刷新: 显示旋转动画; SSE 自动刷新: 静默, 无体感
|
||||
const handleRefresh = () => {
|
||||
@@ -520,6 +523,27 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-base p-3">
|
||||
{/* none 档(无 key)提示横幅 —— 引导用户领取免费 Key 解锁完整能力 */}
|
||||
{isNoKey && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-card border border-warning/40 bg-warning/10 px-3 py-2 text-xs">
|
||||
<Gift className="h-4 w-4 shrink-0 text-warning" />
|
||||
<span className="text-secondary leading-relaxed">
|
||||
当前未配置 API Key,实时行情、批量同步等能力不可用。
|
||||
<a
|
||||
href="https://tickflow.org/auth/register?ref=V3KDKGXPEA"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mx-1 inline-flex items-baseline gap-0.5 font-medium text-warning hover:underline"
|
||||
>
|
||||
前往 TickFlow 官网
|
||||
<ExternalLink className="h-3 w-3 self-center" />
|
||||
</a>
|
||||
免费注册(或填邀请码{' '}
|
||||
<span className="font-mono font-semibold text-warning">V3KDKGXPEA</span>
|
||||
)即可领取免费 API Key,无需付费即可体验完整功能。
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-card border border-border bg-surface/85 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-4 w-4 text-accent" />
|
||||
|
||||
@@ -732,7 +732,7 @@ export function Data() {
|
||||
|
||||
{showEndpointTest && (
|
||||
<EndpointTestDialog
|
||||
hasKey={settings.data?.has_tickflow_key ?? false}
|
||||
hasKey={settings.data?.mode === 'api_key'}
|
||||
tierLabel={settings.data?.tier_label ?? ''}
|
||||
currentEndpoint={settings.data?.current_endpoint ?? ''}
|
||||
onClose={() => setShowEndpointTest(false)}
|
||||
|
||||
@@ -230,16 +230,20 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
|
||||
onSuccess: () => {
|
||||
setSaved(true)
|
||||
onSuccess: (data) => {
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
qc.invalidateQueries({ queryKey: QK.capabilities })
|
||||
// 保存成功后自动进入下一步看探测结果
|
||||
setTimeout(() => onNext(), 600)
|
||||
if (data.ok) {
|
||||
// 仅当 key 有效(被存储)时才进入下一步看探测结果
|
||||
setSaved(true)
|
||||
setTimeout(() => onNext(), 600)
|
||||
}
|
||||
// ok=false(key 无效):不进入下一步,错误提示由 save.error / save.data 渲染
|
||||
},
|
||||
})
|
||||
|
||||
const alreadyHasKey = settings.data?.has_tickflow_key
|
||||
// 已配置 key —— 免费档或付费档都算(只要不是无档 none)
|
||||
const alreadyHasKey = settings.data?.mode !== 'none' && settings.data?.mode !== undefined
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -250,8 +254,8 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
|
||||
<h2 className="text-xl font-bold text-foreground">配置 TickFlow API Key</h2>
|
||||
</div>
|
||||
<p className="mt-2.5 text-sm text-secondary leading-relaxed">
|
||||
Key 决定你能使用的数据范围。没有 Key 也能以 <span className="font-medium text-foreground">Free</span> 模式试用基础功能;
|
||||
配置后可解锁概念行业、财务数据等扩展能力。
|
||||
Key 决定你能使用的数据范围。没有 Key 也能以<span className="font-medium text-foreground"> 基础模式 </span>
|
||||
使用历史日K;配置有效 Key 后可解锁实时行情、批量同步等扩展能力。
|
||||
</p>
|
||||
|
||||
{/* 注册引导 */}
|
||||
@@ -339,6 +343,17 @@ function KeyStep({ onNext, onSkip, onBack }: { onNext: () => void; onSkip: () =>
|
||||
{save.isError && (
|
||||
<div className="text-xs text-danger">保存失败:{String((save.error as any).message)}</div>
|
||||
)}
|
||||
{/* 无效 key —— 探测失败(key 无效/乱填)未存储,提示用户 */}
|
||||
{save.data && !save.data.ok && (
|
||||
<div className="flex items-start gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] leading-snug text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
|
||||
<span>
|
||||
{save.data.reason === 'invalid'
|
||||
? 'Key 无效或已过期,请检查后重试(未保存该 Key)。'
|
||||
: save.data.error ?? '保存失败'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{/* 底部操作 */}
|
||||
@@ -384,7 +399,8 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
|
||||
const settings = useSettings()
|
||||
const caps = useCapabilities()
|
||||
|
||||
const hasKey = settings.data?.has_tickflow_key
|
||||
// 是否配置成功 —— 免费档(free)或付费档(api_key)都算;无档(none)算未配置
|
||||
const hasKey = settings.data?.mode === 'free' || settings.data?.mode === 'api_key'
|
||||
const capList = caps.data ? Object.entries(caps.data.capabilities) : []
|
||||
|
||||
return (
|
||||
@@ -442,11 +458,10 @@ function ResultStep({ onNext, onBack }: { onNext: () => void; onBack: () => void
|
||||
<div className="mx-auto w-fit rounded-xl bg-elevated p-3">
|
||||
<Zap className="h-6 w-6 text-warning" />
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-medium text-foreground">将以 Free 模式继续</div>
|
||||
<div className="mt-3 text-sm font-medium text-foreground">将以基础模式继续</div>
|
||||
<p className="mt-2 text-xs text-muted leading-relaxed max-w-sm mx-auto">
|
||||
你可以立即试用基础行情与选股功能。需要扩展数据时,随时在
|
||||
<span className="text-foreground font-medium"> 设置 → 账户 </span>
|
||||
配置 Key。
|
||||
当前未配置有效 Key,仅可使用历史日K数据。配置 Key 后可解锁实时行情、批量同步等能力,
|
||||
随时在<span className="text-foreground font-medium"> 设置 → 账户 </span>填写。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type StrategyParamDef,
|
||||
} from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { tierRank } from '@/lib/capability-labels'
|
||||
import { storage } from '@/lib/storage'
|
||||
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
||||
import { boardTag } from '@/lib/board'
|
||||
@@ -620,7 +621,7 @@ export function StrategyBacktest() {
|
||||
// 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能
|
||||
const [highGranularity, setHighGranularity] = useState(false)
|
||||
const { data: caps } = useCapabilities()
|
||||
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
|
||||
const isFreeTier = tierRank(caps?.label ?? '') < 1
|
||||
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
|
||||
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
|
||||
const [settingsTab, setSettingsTab] = useState<AdvancedSettingsTab>('params')
|
||||
|
||||
@@ -38,12 +38,15 @@ export function SettingsKeysPanel() {
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.saveTickflowKey(keyInput.trim()),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
setKeyInput('')
|
||||
setSaved(true)
|
||||
qc.invalidateQueries({ queryKey: QK.settings })
|
||||
qc.invalidateQueries({ queryKey: QK.capabilities })
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
if (data.ok) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
// ok=false 由 save.data 在下方渲染提示(reason=invalid),无需额外处理
|
||||
},
|
||||
})
|
||||
|
||||
@@ -119,15 +122,21 @@ export function SettingsKeysPanel() {
|
||||
<span className="text-sm font-medium shrink-0">已配置</span>
|
||||
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
|
||||
</>
|
||||
) : mode === 'free' ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 text-bear shrink-0" />
|
||||
<span className="text-sm font-medium shrink-0">免费 Key</span>
|
||||
<span className="font-mono text-xs text-secondary truncate">{masked}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-warning shrink-0" />
|
||||
<span className="text-sm font-medium text-warning">Free 试用</span>
|
||||
<AlertCircle className="h-4 w-4 text-muted shrink-0" />
|
||||
<span className="text-sm font-medium text-muted">未配置 · Free 数据</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{mode === 'api_key' && (
|
||||
{(mode === 'api_key' || mode === 'free') && (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
disabled={clear.isPending}
|
||||
@@ -150,7 +159,7 @@ export function SettingsKeysPanel() {
|
||||
<div className="relative">
|
||||
<input
|
||||
type={revealing ? 'text' : 'password'}
|
||||
placeholder={mode === 'api_key' ? '粘贴新 Key 替换当前' : '粘贴 TickFlow API Key'}
|
||||
placeholder={mode === 'none' ? '粘贴 TickFlow API Key' : '粘贴新 Key 替换当前'}
|
||||
value={keyInput}
|
||||
onChange={(e) => { setKeyInput(e.target.value); if (saved) setSaved(false) }}
|
||||
className="w-full px-3 py-2 pr-9 rounded-input bg-base border border-border text-sm font-mono focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth"
|
||||
@@ -198,10 +207,20 @@ export function SettingsKeysPanel() {
|
||||
保存失败:{String((save.error as any).message)}
|
||||
</div>
|
||||
)}
|
||||
{/* 无效 key —— 先探后存:探测失败(key 无效/乱填)时不存储,提示用户 */}
|
||||
{save.data && !save.data.ok && (
|
||||
<div className="mt-3 text-xs text-danger flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 shrink-0" />
|
||||
{save.data.reason === 'invalid'
|
||||
? 'Key 无效或已过期,请检查后重试(未保存该 Key)'
|
||||
: save.data.error ?? '保存失败'}
|
||||
</div>
|
||||
)}
|
||||
{save.data?.ok && (
|
||||
<div className="mt-3 text-xs text-bear flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
保存成功 — 检测到 {save.data.capabilities_count} 项功能,档位 {save.data.tier_label}
|
||||
保存成功 — 档位 {save.data.tier_label}
|
||||
{save.data.mode === 'free' && '(免费档 · 历史日K)'}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -322,7 +341,7 @@ export function SettingsKeysPanel() {
|
||||
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">清除 API Key</h3>
|
||||
<p className="text-xs text-secondary mb-5">
|
||||
清除后将退回 Free 模式,需要重新输入 Key 才能恢复。
|
||||
清除后将退回无档(仅历史日K),需要重新输入 Key 才能恢复。
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
@@ -372,7 +391,7 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
|
||||
className="absolute top-full left-0 mt-1 z-50 w-72 bg-surface border border-border rounded-lg shadow-xl p-3.5 text-[11px] leading-relaxed"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 4 档位 tag 横排 */}
|
||||
{/* 档位 tag 横排 */}
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
{ALL_TIERS.map(t => (
|
||||
<div key={t} className={`flex flex-col items-center gap-1 ${t === currentBase ? '' : 'opacity-60'}`}>
|
||||
@@ -388,7 +407,7 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
|
||||
return (
|
||||
<div key={t} className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 rounded-full shrink-0" style={s.dotStyle} />
|
||||
<span className="capitalize font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t}</span>
|
||||
<span className="font-mono font-bold w-12 shrink-0" style={s.labelTextStyle}>{t === 'none' ? '无' : t}</span>
|
||||
<span className="text-secondary">{s.desc}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -398,8 +417,8 @@ function TierHelpPopover({ currentLabel }: { currentLabel: string }) {
|
||||
{/* 检测说明 */}
|
||||
<div className="text-secondary space-y-1.5">
|
||||
<div className="font-medium text-foreground">档位检测说明</div>
|
||||
<p>系统保存 API Key 后会逐一试探各项数据能力,根据实际可用的功能自动匹配档位。拥有某档"代表性能力"(如 Expert 的财务数据)即判定为该档。</p>
|
||||
<p className="text-muted">"代表性能力"任一命中即认作该档及以上,单个能力探测失败不会误降档位。补购单项能力(如 Pro + 分钟K)会在档位标签后显示 + 号。</p>
|
||||
<p>保存 Key 后系统会在付费端点逐一试探数据能力:连单只日K都拿不到则判为「无」(不存 Key);有日K但无复权因子则判为「Free」;有复权因子再按代表能力判定 Starter/Pro/Expert。</p>
|
||||
<p className="text-muted">无档与免费档运行时都走免费数据通道(仅历史日K),区别仅在于是否保存了 Key。付费档走付费端点,享有实时行情等完整能力。</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { tierRank } from '@/lib/capability-labels'
|
||||
import { toast } from '@/components/Toast'
|
||||
import { DepthConfigContent } from '@/components/data/DepthConfigCard'
|
||||
|
||||
@@ -45,7 +46,8 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } =
|
||||
const { data: intervalData } = useQuoteInterval()
|
||||
const updateInterval = useUpdateQuoteInterval()
|
||||
const toggleQuote = useToggleRealtimeQuotes()
|
||||
const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
|
||||
// none/free 档(无实时行情权限)→ rank < starter(1)
|
||||
const isFreeTier = tierRank(caps?.label ?? '') < 1
|
||||
const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
|
||||
const refreshPages = prefs?.sse_refresh_pages ?? {}
|
||||
const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false
|
||||
|
||||
+17
-2
@@ -14,10 +14,25 @@
|
||||
# 下表数值除明确标注「推断」的条目外,均沿用 2026-05-21 旧版抓取。
|
||||
# FAQ 印证的能力归属:免费=行情+日K;Starter+=标的池+批量;Pro+=分钟K;Expert+=财务+WS。
|
||||
# 标注「推断」的条目按 tiers.yaml 既有 batch 类接口规律推得,建议后续用真实套餐核对。
|
||||
#
|
||||
# 说明(2026-06-24 5 档重构):
|
||||
# none = 无 key / 乱填 / 无效 key。运行时走 free-api 服务器(free 通道),
|
||||
# 仅历史日K(含批量),无实时行情。不存 API Key。
|
||||
# free = 免费有效 key(付费端点验证:有单只日K、无复权因子)。
|
||||
# 运行时同样走 free-api 服务器(key 被忽略),能力与 none 等价;
|
||||
# 存 key 仅作档位标记,便于将来 SDK 支持免费 key 时自动升级。
|
||||
# 两者运行时服务器相同(free-api),区别只在是否存 key。
|
||||
# starter+ 才走付费端点 api.tickflow.org,有实时行情。
|
||||
|
||||
none:
|
||||
# 无 key / 无效 key —— 走 free-api 服务器,仅历史日K(含批量),无实时行情
|
||||
kline.daily.by_symbol: { rpm: 60, batch: 1 }
|
||||
kline.daily.batch: { rpm: 60, batch: 50 } # free-api 服务器批量日K
|
||||
|
||||
free:
|
||||
quote.by_symbol: { rpm: 10, batch: 5 }
|
||||
kline.daily.by_symbol: { rpm: 10, batch: 1 }
|
||||
# 免费有效 key —— 运行时同样走 free-api 服务器,能力与 none 等价
|
||||
kline.daily.by_symbol: { rpm: 60, batch: 1 }
|
||||
kline.daily.batch: { rpm: 60, batch: 50 } # [补] 批量日K(此前缺失)
|
||||
|
||||
starter:
|
||||
quote.by_symbol: { rpm: 60, batch: 50 }
|
||||
|
||||
Reference in New Issue
Block a user