perf: 收窄缓存失效并优化异步任务与首屏加载

按 enriched 写入最早影响日期维护有界区间版本,保留全局发布一致性校验,使历史读取与回测缓存复用不受较晚行情更新影响;历史修订和未知范围变更仍失效。

将扩展数据解析、落盘与备份/策略导入移出事件循环;串行限制扩展数据写入,并在请求取消后保持执行中的写入限额及临时文件清理。

拆分 11 个扩展页面为轻量注册清单和按需页面,助手抽屉首次使用时加载;并发获取扩展后按固定顺序注册,为运行时脚本增加超时隔离。保持金融计算、业务数据格式与扩展 v1 契约不变。

验证:隔离全量后端 3923 项通过、7 项跳过、1 项预期失败;前端 447 项通过,TypeScript 与生产构建通过。Ruff 无新增告警,git diff --check 通过。浏览器验证 11 个扩展路由与脚本超时隔离。

性能:1000 标的、120 天、12 万行、30 次更新,历史面板读取中位数 7.19ms -> 0.14ms,计算 31 -> 1 次,结果一致。注入 250ms 解析耗时的心跳间隔中位最大值 269ms -> 11.3ms。启动必需 JS 2.33MB -> 0.65MB;10Mbps/40ms 网络与固定 API 响应下,登录页 3 次冷启动中位数 3.15s -> 1.12s。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-11-01 22:32:34 +08:00
parent f443627377
commit ab2b82da37
43 changed files with 12374 additions and 11475 deletions
+6 -1
View File
@@ -9,6 +9,7 @@ import logging
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from starlette.concurrency import run_in_threadpool
from app.data_providers.custom import loader as custom_loader
from app.services import config_backup
@@ -59,7 +60,7 @@ async def inspect_backup(file: UploadFile = _UPLOAD_FILE) -> dict:
"""解析备份给出预览 (类别/数量/冲突), 不写任何文件。"""
data = await _read_upload(file)
try:
return config_backup.inspect_archive(data)
return await run_in_threadpool(config_backup.inspect_archive, data)
except config_backup.ConfigBackupError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@@ -72,6 +73,10 @@ async def apply_backup(
categories: str = Form(""),
) -> dict:
data = await _read_upload(file)
return await run_in_threadpool(_apply_backup, request, data, allow_overwrite, categories)
def _apply_backup(request: Request, data: bytes, allow_overwrite: bool, categories: str) -> dict:
try:
result = config_backup.apply_archive(
data,
+24 -11
View File
@@ -31,6 +31,7 @@ from app.services.ext_data import (
infer_fields_from_df,
parse_upload_file,
rows_to_parquet,
run_ext_data_io,
write_ext_parquet,
)
from app.services.ext_pull import _request_json, fetch_and_ingest, pull_scheduler
@@ -979,6 +980,10 @@ _UPLOAD_CHUNK_BYTES = 1024 * 1024
async def _write_upload_capped(file: UploadFile, dest: Path, max_bytes: int) -> None:
await run_ext_data_io(_copy_upload_capped, file, dest, max_bytes)
def _copy_upload_capped(file: UploadFile, dest: Path, max_bytes: int) -> None:
"""分块把上传文件写入 dest, 累计超过 max_bytes 立即拒绝(413)。
避免一次性 `await file.read()` 把整个文件读入内存(大文件可能触发高内存占用、
@@ -987,7 +992,7 @@ async def _write_upload_capped(file: UploadFile, dest: Path, max_bytes: int) ->
total = 0
with dest.open("wb") as f:
while True:
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
chunk = file.file.read(_UPLOAD_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
@@ -1004,6 +1009,10 @@ async def upload_data(
snapshot_date: str | None = None,
):
"""上传 CSV/Excel 文件写入扩展数据。"""
return await run_ext_data_io(_upload_data, request, config_id, file, snapshot_date)
def _upload_data(request: Request, config_id: str, file: UploadFile, snapshot_date: str | None):
store = _store(request)
config = store.get(config_id)
if not config:
@@ -1018,7 +1027,7 @@ async def upload_data(
tmp_dir = Path(tempfile.mkdtemp())
tmp_path = tmp_dir / f"upload{suffix}"
try:
await _write_upload_capped(file, tmp_path, _MAX_UPLOAD_BYTES)
_copy_upload_capped(file, tmp_path, _MAX_UPLOAD_BYTES)
# 直接读取文件,不做列重命名
if suffix == ".csv":
@@ -1243,7 +1252,7 @@ async def test_pull(request: Request, config_id: str):
async def run_pull(request: Request, config_id: str):
"""手动触发一次拉取并写入。"""
store = _store(request)
config = store.get(config_id)
config = await run_ext_data_io(store.get, config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
if not config.pull or not config.pull.url:
@@ -1251,26 +1260,26 @@ async def run_pull(request: Request, config_id: str):
try:
n, d = await fetch_and_ingest(config, _data_dir(request))
_refresh_views(request)
await run_ext_data_io(_refresh_views, request)
# 写回执行状态, 让前端"上次执行"面板立即反映
updated = store.get(config_id)
updated = await run_ext_data_io(store.get, config_id)
if updated and updated.pull:
from datetime import datetime, timezone
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
updated.pull.last_status = "success"
updated.pull.last_message = f"{n} rows @ {d}"
updated.pull.last_rows = n
store.upsert(updated)
await run_ext_data_io(store.upsert, updated)
return {"status": "ok", "rows": n, "date": d}
except Exception as e:
# 失败也写回状态, 记录错误信息
failed = store.get(config_id)
failed = await run_ext_data_io(store.get, config_id)
if failed and failed.pull:
from datetime import datetime, timezone
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
failed.pull.last_status = "error"
failed.pull.last_message = str(e)[:200]
store.upsert(failed)
await run_ext_data_io(store.upsert, failed)
raise HTTPException(400, f"拉取失败: {e}") from e
@@ -1287,7 +1296,7 @@ async def backfill_history_ep(
查询)。幂等 —— 已存在的分区跳过, 失败单日不中断, 结果逐项返回。
"""
store = _store(request)
config = store.get(config_id)
config = await run_ext_data_io(store.get, config_id)
if not config:
raise HTTPException(404, f"配置 '{config_id}' 不存在")
try:
@@ -1302,7 +1311,7 @@ async def backfill_history_ep(
result = await backfill_history(config, _data_dir(request), start_d, end_d)
except ValueError as e:
raise HTTPException(400, str(e)) from e
_refresh_views(request)
await run_ext_data_io(_refresh_views, request)
return {"status": "ok", **result}
@@ -1471,6 +1480,10 @@ async def detect_fields(
返回 symbol_candidates(数据匹配 000001.SZ 格式的列)和
code_candidates(数据匹配 6位纯数字的列)。
"""
return await run_ext_data_io(_detect_fields, file)
def _detect_fields(file: UploadFile):
suffix = Path(file.filename or "").suffix.lower()
if suffix not in (".csv", ".xlsx", ".xls"):
raise HTTPException(400, "仅支持 CSV / Excel 文件")
@@ -1478,7 +1491,7 @@ async def detect_fields(
tmp_dir = Path(tempfile.mkdtemp())
tmp_path = tmp_dir / f"upload{suffix}"
try:
await _write_upload_capped(file, tmp_path, _MAX_UPLOAD_BYTES)
_copy_upload_capped(file, tmp_path, _MAX_UPLOAD_BYTES)
# 直接读取,不要求 symbol 列
if suffix == ".csv":
+6 -1
View File
@@ -17,6 +17,7 @@ from typing import Any, Literal
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from app.backtest.minute_trigger import MINUTE_EXIT_TRIGGER_SIGNALS
from app.services.ndjson_heartbeat import with_heartbeat
@@ -992,7 +993,7 @@ async def inspect_strategy_bundle(file: UploadFile = _BUNDLE_FILE):
"""导入预览: 条目清单 (create/conflict) + 缺失依赖预警, 不落盘。"""
data = await file.read()
try:
return _bundle_service().inspect_bundle(data)
return await run_in_threadpool(_bundle_service().inspect_bundle, data)
except _bundle_service().StrategyBundleError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@@ -1003,6 +1004,10 @@ async def apply_strategy_bundle(
):
"""导入策略包: 复用配置备份的校验/写盘/导入即冒烟/热重载管线。"""
data = await file.read()
return await run_in_threadpool(_apply_strategy_bundle, request, data, allow_overwrite)
def _apply_strategy_bundle(request: Request, data: bytes, allow_overwrite: bool):
service = _bundle_service()
try:
result = service.apply_bundle(data, allow_overwrite=allow_overwrite)
+27 -10
View File
@@ -436,6 +436,17 @@ class BacktestEngine:
"enriched data changed while the snapshot was being read"
)
def _range_cache_generation(self, asset_type: str, end: date, expected: str | None) -> str | None:
loader = getattr(self.repo, "get_enriched_generation_snapshot", None)
if not callable(loader) or expected is None:
return expected
snapshot = loader(asset_type)
if snapshot.generation != expected:
raise EnrichedGenerationUnavailableError(
"enriched data changed while the snapshot was being read"
)
return snapshot.through(end)
def load_panel(
self,
symbols: list[str] | None,
@@ -457,16 +468,19 @@ class BacktestEngine:
if expected is not None
else self.data_generation(asset_type)
)
panel = self._cache.get_or_compute(
symbols,
start,
end,
columns,
self._load_panel_inner,
asset_type=asset_type,
generation=generation,
)
try:
# Cache identity follows the consumed prefix. The global generation
# still guards the read, including in-progress/partial publications.
cache_generation = self._range_cache_generation(asset_type, end, generation)
panel = self._cache.get_or_compute(
symbols,
start,
end,
columns,
self._load_panel_inner,
asset_type=asset_type,
generation=cache_generation,
)
self.assert_data_generation(asset_type, generation)
except EnrichedGenerationUnavailableError:
if attempt + 1 >= attempts:
@@ -670,6 +684,9 @@ class BacktestEngine:
attempts = 2
for attempt in range(attempts):
try:
cache_generation = self._range_cache_generation(
asset_type, coverage_end or end, source_generation,
)
market = load_market_data_matrix_from_parquet(
parquet_root,
start,
@@ -685,7 +702,7 @@ class BacktestEngine:
profile_generation=(
cache_profile.generation if cache_profile is not None else "request"
),
source_generation=source_generation,
source_generation=cache_generation,
cancel_event=cancel_event,
)
self.assert_data_generation(asset_type, source_generation)
+108 -8
View File
@@ -8,6 +8,9 @@ import uuid
import weakref
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import date
from itertools import pairwise
from pathlib import Path
from typing import Any, BinaryIO
@@ -31,6 +34,58 @@ _HEARTBEAT_INTERVAL_S = 30.0
# 没有这层兜底时, 一次异常中断留下的 publishing 标记会永久驻留 —— 所有读取
# 持续 fail-closed, 而停止实时行情 (运行期唯一频繁的写入方) 反而移除了自愈路径。
CLAIM_STALE_SECONDS = 600.0
# A bounded prefix-version timeline: changes on D invalidate D and later windows
# (indicators may depend on all preceding history), never windows ending before D.
_MAX_RANGE_GENERATIONS = 128
@dataclass(frozen=True)
class EnrichedGenerationSnapshot:
generation: str
base_generation: str
ranges: tuple[tuple[date, str], ...] = ()
def through(self, end: date) -> str:
return next((token for day, token in reversed(self.ranges) if day <= end), self.base_generation)
def payload(self) -> dict[str, Any]:
return {
**_ready_payload(self.generation),
"base_generation": self.base_generation,
"range_generations": [[day.isoformat(), token] for day, token in self.ranges],
}
def changed(self, generation: str, start: date | None) -> EnrichedGenerationSnapshot:
if start is None:
return EnrichedGenerationSnapshot(generation, generation)
ranges = tuple((day, token) for day, token in self.ranges if day < start)
ranges += ((start, generation),)
base = self.base_generation
if len(ranges) > _MAX_RANGE_GENERATIONS:
base = ranges[-_MAX_RANGE_GENERATIONS - 1][1]
ranges = ranges[-_MAX_RANGE_GENERATIONS:]
return EnrichedGenerationSnapshot(generation, base, ranges)
def _snapshot(payload: dict[str, Any]) -> EnrichedGenerationSnapshot:
generation = payload["generation"]
# Older writers omit range metadata: conservatively invalidate every range.
if "range_generations" not in payload:
return EnrichedGenerationSnapshot(generation, generation)
try:
base = payload["base_generation"]
ranges = tuple((date.fromisoformat(day), token) for day, token in payload["range_generations"])
if (
not isinstance(base, str) or not base
or len(ranges) > _MAX_RANGE_GENERATIONS
or any(not isinstance(token, str) or not token for _, token in ranges)
or any(a[0] >= b[0] for a, b in pairwise(ranges))
or (ranges[-1][1] if ranges else base) != generation
):
raise ValueError("invalid range generations")
except (KeyError, TypeError, ValueError) as exc:
raise EnrichedGenerationUnavailableError("enriched range generation marker is invalid") from exc
return EnrichedGenerationSnapshot(generation, base, ranges)
def _marker_path(data_dir: Path, asset_type: str) -> Path:
@@ -235,6 +290,15 @@ def get_enriched_generation(
*,
initialize: bool = True,
) -> str:
return get_enriched_generation_snapshot(data_dir, asset_type, initialize=initialize).generation
def get_enriched_generation_snapshot(
data_dir: Path,
asset_type: str = "stock",
*,
initialize: bool = True,
) -> EnrichedGenerationSnapshot:
from app.services.daily_health import assert_usable
assert_usable(data_dir, asset_type, enriched_only=True)
@@ -246,7 +310,7 @@ def get_enriched_generation(
"enriched data generation marker is unavailable"
)
elif _is_ready_payload(payload):
return payload["generation"]
return _snapshot(payload)
elif not _orphaned_publishing_claim(payload):
# 发布仍在推进, 或为同进程异常遗留 (无法证明属主已死): 读取保持 fail-closed。
raise EnrichedGenerationUnavailableError(
@@ -258,9 +322,9 @@ def get_enriched_generation(
if payload is None:
generation = uuid.uuid4().hex
_write_marker(path, _ready_payload(generation))
return generation
return EnrichedGenerationSnapshot(generation, generation)
if _is_ready_payload(payload):
return payload["generation"]
return _snapshot(payload)
if not _orphaned_publishing_claim(payload):
raise EnrichedGenerationUnavailableError(
"enriched data is being published; retry after the update finishes"
@@ -271,7 +335,7 @@ def get_enriched_generation(
# 缓存全部失效, 避免把混合状态混入旧快照 —— 与写入方 recover 接管同语义。
generation = uuid.uuid4().hex
_write_marker(path, _ready_payload(generation))
return generation
return EnrichedGenerationSnapshot(generation, generation)
def recover_stale_publications(
@@ -358,6 +422,9 @@ class EnrichedPublication:
self._publishing = False
self._changed = False
self._base_generation: str | None = None
self._base_snapshot: EnrichedGenerationSnapshot | None = None
self._changed_from: date | None = None
self._range_known = True
self._publication_id = uuid.uuid4().hex
def _locked_claim(self, action):
@@ -392,6 +459,7 @@ class EnrichedPublication:
if not self._publishing:
raise RuntimeError("enriched publication has not started")
self._changed = True
self._range_known = False
def heartbeat(self) -> None:
"""发布进行中主动续期, 供长计算阶段调用。
@@ -445,7 +513,7 @@ class EnrichedPublication:
and current.get("publication_id") == self._publication_id
):
_write_marker(
path, _ready_payload(str(self._base_generation))
path, self._base_snapshot.payload()
)
break
except EnrichedGenerationUnavailableError:
@@ -465,8 +533,9 @@ class EnrichedPublication:
def _replace() -> None:
os.replace(temporary, out)
_fsync_directory(out.parent)
self._changed = True
self._record_changed_partition(df, out)
_fsync_directory(out.parent)
try:
df.write_parquet(temporary)
@@ -477,6 +546,24 @@ class EnrichedPublication:
finally:
temporary.unlink(missing_ok=True)
def _record_changed_partition(self, df: pl.DataFrame, out: Path) -> None:
directory = {"stock": "kline_daily_enriched", "etf": "kline_etf_enriched"}.get(self.asset_type)
try:
day = date.fromisoformat(out.parent.name.removeprefix("date="))
known = (
directory is not None
and out.parent.parent.resolve() == (self.data_dir / directory).resolve()
and "date" in df.columns and df["date"].dtype == pl.Date
and df["date"].null_count() == 0
and (df.is_empty() or (df["date"].min() == day == df["date"].max()))
)
except (TypeError, ValueError):
known = False
if not known:
self._range_known = False
elif self._changed_from is None or day < self._changed_from:
self._changed_from = day
def commit(self) -> str | None:
if not self._changed:
if self._publishing:
@@ -503,7 +590,10 @@ class EnrichedPublication:
"enriched publication ownership was lost"
)
generation = uuid.uuid4().hex
_write_marker(path, _ready_payload(generation))
snapshot = self._base_snapshot.changed(
generation, self._changed_from if self._range_known else None,
)
_write_marker(path, snapshot.payload())
self._publishing = False
# 待发布内容已全部落盘, 复位脏标记: 多日期分区写入复用同一发布对象,
# 无变化分区会再调 commit() (空提交) — 不复位会以过期脏标记命中
@@ -569,9 +659,19 @@ class EnrichedPublication:
"存在其他未完成的指标发布, 请先在数据页重建指标后再修补"
)
generation = None if current is None else current.get("generation")
if not isinstance(generation, str) or not generation:
if (
not isinstance(generation, str) or not generation
or (current is not None and not _is_ready_payload(current))
):
generation = uuid.uuid4().hex
self._base_generation = generation
self._base_snapshot = (
_snapshot(current) if current is not None and _is_ready_payload(current)
else EnrichedGenerationSnapshot(generation, generation)
)
self._changed_from = None
# Recovering a partial publication cannot retain any old range versions.
self._range_known = current is None or _is_ready_payload(current)
_write_marker(path, {
"state": "publishing",
"generation": generation,
+34
View File
@@ -1,15 +1,18 @@
"""扩展数据服务 — 配置管理 + 文件解析 + Parquet 存储。"""
from __future__ import annotations
import asyncio
import codecs
import copy
import json
import logging
import re
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Literal
import anyio
import polars as pl
from app.market_time import cn_now, cn_today
@@ -17,6 +20,37 @@ from app.services.fs_utils import atomic_write_parquet, atomic_write_text
logger = logging.getLogger(__name__)
# Preserve the serial read/merge/write order previously provided by the event
# loop, without blocking it or multiplying peak memory for simultaneous imports.
_EXT_IO_LIMITER = anyio.CapacityLimiter(1)
_EXT_IO_TASKS: set[asyncio.Task] = set()
def _ext_io_done(task: asyncio.Task) -> None:
_EXT_IO_TASKS.discard(task)
if not task.cancelled():
task.exception() # A disconnected request may no longer await its result.
async def run_ext_data_io(func, *args, **kwargs):
cancelled = threading.Event()
def run():
if not cancelled.is_set():
return func(*args, **kwargs)
# Raw asyncio cancellation otherwise releases AnyIO's limiter while its
# worker is still writing. Keep the limiter owned until the worker finishes;
# queued cancelled requests skip their work, running imports clean up in situ.
task = asyncio.create_task(anyio.to_thread.run_sync(run, limiter=_EXT_IO_LIMITER))
_EXT_IO_TASKS.add(task)
task.add_done_callback(_ext_io_done)
try:
return await asyncio.shield(task)
except asyncio.CancelledError:
cancelled.set()
raise
# ---------------------------------------------------------------------------
# 配置模型
# ---------------------------------------------------------------------------
+17 -16
View File
@@ -20,6 +20,7 @@ from app.services.ext_data import (
ExtConfigStore,
PullConfig,
rows_to_parquet,
run_ext_data_io,
)
logger = logging.getLogger(__name__)
@@ -470,8 +471,8 @@ async def fetch_and_ingest(
rows = await fetch_rows_for_date(config, day, data_dir)
if not rows:
raise ValueError("提取到的行数为 0")
n = rows_to_parquet(
rows, config, data_dir, snapshot_date=day,
n = await run_ext_data_io(
rows_to_parquet, rows, config, data_dir, snapshot_date=day,
keep_strategy_cache=keep_strategy_cache,
)
return n, day.isoformat()
@@ -530,7 +531,7 @@ async def _backfill_history(
from app.services.dragon_tiger import _local_trading_days
days = [d for d in _local_trading_days(data_dir) if start <= d <= end]
days = [d for d in await run_ext_data_io(_local_trading_days, data_dir) if start <= d <= end]
if not days:
raise ValueError("范围内无本地交易日 (需先同步日K以确定交易日历)")
@@ -549,7 +550,7 @@ async def _backfill_history(
if not rows:
empty += 1 # 该日无数据 (服务端未归档), 不是错误
else:
rows_written += rows_to_parquet(rows, config, data_dir, snapshot_date=d)
rows_written += await run_ext_data_io(rows_to_parquet, rows, config, data_dir, snapshot_date=d)
fetched += 1
except httpx.HTTPStatusError as e:
if _status_code(e) == 404:
@@ -574,7 +575,7 @@ async def _backfill_history(
if not rows:
empty += 1
else:
rows_written += rows_to_parquet(rows, config, data_dir, snapshot_date=d)
rows_written += await run_ext_data_io(rows_to_parquet, rows, config, data_dir, snapshot_date=d)
fetched += 1
except Exception as e2:
if _status_code(e2) == 404: # 退避重试后无该日快照 → 同样视为无数据
@@ -758,15 +759,15 @@ class PullScheduler:
fresh.pull.last_status = "success"
fresh.pull.last_message = f"{n} rows @ {d}"
fresh.pull.last_rows = n
store.upsert(fresh, keep_strategy_cache=True)
await run_ext_data_io(store.upsert, fresh, keep_strategy_cache=True)
logger.info("PullScheduler: %s success, %d rows", fresh.id, n)
except Exception as e:
fresh2 = store.get(fresh.id)
fresh2 = await run_ext_data_io(store.get, fresh.id)
if fresh2 and fresh2.pull:
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh2.pull.last_status = "error"
fresh2.pull.last_message = str(e)[:200]
store.upsert(fresh2, keep_strategy_cache=True)
await run_ext_data_io(store.upsert, fresh2, keep_strategy_cache=True)
logger.warning("PullScheduler: %s error: %s", fresh.id, e)
async def _run_daily_at(self, config_id: str, store: ExtConfigStore, hhmm: str) -> None:
@@ -781,7 +782,7 @@ class PullScheduler:
就是完整日程)。
"""
while self._running:
fresh = store.get(config_id)
fresh = await run_ext_data_io(store.get, config_id)
if (
not fresh or not fresh.pull or not fresh.pull.enabled
or fresh.pull.schedule_time != hhmm
@@ -798,10 +799,10 @@ class PullScheduler:
nxt = _next_scheduled_target(now, hhmm, fresh.pull)
# 预告下次运行时间 (UTC ISO, 与间隔制同口径), 供前端展示
nxt_iso = nxt.astimezone(UTC).isoformat()
latest = store.get(config_id)
latest = await run_ext_data_io(store.get, config_id)
if latest and latest.pull and latest.pull.next_run != nxt_iso:
latest.pull.next_run = nxt_iso
store.upsert(latest, keep_strategy_cache=True)
await run_ext_data_io(store.upsert, latest, keep_strategy_cache=True)
await asyncio.sleep(
min(_DAILY_POLL_SECONDS, max((nxt - now).total_seconds(), 1.0))
)
@@ -820,7 +821,7 @@ class PullScheduler:
chunk = min(_DAILY_POLL_SECONDS, remaining)
await asyncio.sleep(chunk)
remaining -= chunk
fresh = store.get(config_id)
fresh = await run_ext_data_io(store.get, config_id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
return
if fresh.pull.schedule_time or max(fresh.pull.schedule_minutes * 60, 60) != interval:
@@ -838,7 +839,7 @@ class PullScheduler:
while self._running:
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
fresh = await run_ext_data_io(store.get, config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
pull = fresh.pull
@@ -852,7 +853,7 @@ class PullScheduler:
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "skipped"
fresh.pull.last_message = "不在拉取时间窗口内"
store.upsert(fresh, keep_strategy_cache=True)
await run_ext_data_io(store.upsert, fresh, keep_strategy_cache=True)
logger.info("PullScheduler: %s skipped (outside time window)", config.id)
interval = max(pull.schedule_minutes * 60, 60)
await self._sleep_interval_reloading(config.id, store, interval)
@@ -865,12 +866,12 @@ class PullScheduler:
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
# 预告下次运行时间, 供前端展示
next_dt = datetime.now(UTC).timestamp() + interval
latest = store.get(config.id)
latest = await run_ext_data_io(store.get, config.id)
if latest and latest.pull:
latest.pull.next_run = datetime.fromtimestamp(
next_dt, tz=UTC
).isoformat()
store.upsert(latest, keep_strategy_cache=True)
await run_ext_data_io(store.upsert, latest, keep_strategy_cache=True)
await self._sleep_interval_reloading(config.id, store, interval)
if not self._running:
+18 -4
View File
@@ -28,11 +28,13 @@ import polars as pl
from app.config import settings
from app.enriched_generation import (
EnrichedGenerationSnapshot,
EnrichedGenerationUnavailableError,
EnrichedPublication,
bump_enriched_generation,
enriched_publication_incomplete,
get_enriched_generation,
get_enriched_generation_snapshot,
)
from app.market_time import cn_today
from app.parquet import scan_enriched_parquet
@@ -452,6 +454,7 @@ class KlineRepository:
self._enriched_history_cache: pl.DataFrame | None = None # ~100万行
self._enriched_history_start: date | None = None
self._enriched_history_generation: str | None = None
self._enriched_history_snapshot: tuple[pl.DataFrame, EnrichedGenerationSnapshot] | None = None
self._index_instruments_cache: pl.DataFrame | None = None
self._etf_enriched_cache: pl.DataFrame | None = None
self._etf_enriched_cache_date: date | None = None
@@ -656,6 +659,7 @@ class KlineRepository:
self._enriched_history_cache = None
self._enriched_history_start = None
self._enriched_history_generation = None
self._enriched_history_snapshot = None
self._live_agg_cache = None
self._live_agg_cache_date = None
self._live_agg_check_date = None
@@ -719,7 +723,8 @@ class KlineRepository:
"""
try:
started = time.perf_counter()
refresh_generation = self.get_matrix_data_generation("stock")
refresh_snapshot = self.get_enriched_generation_snapshot("stock")
refresh_generation = refresh_snapshot.generation
logger.info("enriched refresh start")
step = time.perf_counter()
@@ -799,6 +804,7 @@ class KlineRepository:
self._enriched_history_cache = df_full
self._enriched_history_start = df_full["date"].min()
self._enriched_history_generation = refresh_generation
self._enriched_history_snapshot = (df_full, refresh_snapshot)
logger.info("enriched 历史缓存: %d rows, %s ~ %s",
len(df_full), self._enriched_history_start, latest)
@@ -822,6 +828,7 @@ class KlineRepository:
how="diagonal_relaxed",
).sort(["symbol", "date"])
self._enriched_history_cache = df_full
self._enriched_history_snapshot = (df_full, refresh_snapshot)
self._enriched_cache = repaired_today
df_today = repaired_today
logger.info("enriched 缓存已计算: %d 只, 日期 %s (即时计算)", len(df_today), latest)
@@ -1416,10 +1423,14 @@ class KlineRepository:
data_dir = getattr(getattr(self, "store", None), "data_dir", None)
if data_dir is not None:
try:
current_generation = self.get_matrix_data_generation("stock")
current = self.get_enriched_generation_snapshot("stock")
except EnrichedGenerationUnavailableError:
return None
if self._enriched_history_generation != current_generation:
snapshot = self._enriched_history_snapshot
if snapshot is not None and snapshot[0] is cache:
if snapshot[1].through(end) != current.through(end):
return None
elif self._enriched_history_generation != current.generation:
return None
if cache is None or cache.is_empty() or "date" not in cache.columns:
return None
@@ -2228,6 +2239,9 @@ class KlineRepository:
"""Return the stable generation for managed enriched readers."""
return get_enriched_generation(self.store.data_dir, asset_type)
def get_enriched_generation_snapshot(self, asset_type: str = "stock") -> EnrichedGenerationSnapshot:
return get_enriched_generation_snapshot(self.store.data_dir, asset_type)
def _bump_matrix_data_generation(self, asset_type: str) -> str:
return bump_enriched_generation(self.store.data_dir, asset_type)
@@ -2466,7 +2480,7 @@ class KlineRepository:
"""只失效本次资产的运行时快照; 下次读取按新版本懒加载。"""
if asset_type == "stock":
for name in ("_enriched_cache", "_enriched_cache_date", "_enriched_history_cache",
"_enriched_history_start", "_enriched_history_generation", "_live_agg_cache",
"_enriched_history_start", "_enriched_history_generation", "_enriched_history_snapshot", "_live_agg_cache",
"_live_agg_cache_date", "_live_agg_check_date"):
setattr(self, name, None)
elif asset_type in {"etf", "index"}:
+145
View File
@@ -0,0 +1,145 @@
"""Isolated cache and event-loop benchmark; no network or user data.
Run from backend: uv run --frozen python -m scripts.benchmark_cache_async
To compare revisions, run this same file with PYTHONPATH pointing at each backend.
The 250 ms parser delay is deliberately synthetic: it measures event-loop
responsiveness, not CSV parser throughput. All data is generated in a tempdir.
"""
from __future__ import annotations
import asyncio
import io
import json
import logging
import os
import statistics
import tempfile
import time
from datetime import date, timedelta
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
def cache_benchmark(root: Path) -> dict:
import polars as pl
from app.backtest.engine import BacktestEngine
from app.tickflow.repository import DataStore, KlineRepository
days = [date(2026, 1, 1) + timedelta(days=i) for i in range(168)]
days = [d for d in days if d.weekday() < 5]
symbols = [f"{600000 + i}.SH" for i in range(1000)]
frames = [pl.DataFrame({
"symbol": symbols, "date": [day] * len(symbols),
"open": [10.0] * len(symbols), "high": [11.0] * len(symbols),
"low": [9.0] * len(symbols), "close": [10.0 + i / 100] * len(symbols),
"volume": [1000.0] * len(symbols),
}) for i, day in enumerate(days)]
history = pl.concat(frames)
repo = KlineRepository(DataStore(root))
try:
repo.append_enriched(history)
repo._enriched_history_cache = history
repo._enriched_history_start = days[0]
repo._enriched_history_generation = repo.get_matrix_data_generation()
if hasattr(repo, "get_enriched_generation_snapshot"):
repo._enriched_history_snapshot = (history, repo.get_enriched_generation_snapshot())
engine = BacktestEngine(repo)
columns = ["symbol", "date", "close"]
args = (None, days[0], days[-2], columns)
expected = history.filter(pl.col("date") <= days[-2]).select(columns).sort(["symbol", "date"])
assert engine.load_panel(*args).equals(expected)
reads, writes, hits = [], [], 0
for i in range(30):
tick = frames[-1].with_columns(pl.lit(12.0 + i / 100).alias("close"))
started = time.perf_counter()
repo.flush_live_enriched(tick)
writes.append((time.perf_counter() - started) * 1000)
started = time.perf_counter()
actual = engine.load_panel(*args)
reads.append((time.perf_counter() - started) * 1000)
assert actual.equals(expected)
cached = repo.get_enriched_range(days[0], days[-2], columns=columns)
if cached is not None:
assert cached.equals(expected)
hits += 1
return {
"symbols": len(symbols), "partitions": len(days), "rows": history.height,
"ticks": 30, "historical_cache_hits": hits,
"panel_computations": engine._cache.stats()["compute_count"],
"panel_read_median_ms": round(statistics.median(reads), 3),
"tick_write_median_ms": round(statistics.median(writes), 3),
"outputs_equal": True,
}
finally:
repo.db.close()
async def async_benchmark(root: Path) -> dict:
import polars as pl
from fastapi import UploadFile
from app.api import ext_data
from app.services.ext_data import ExtConfig, ExtField
config = ExtConfig(id="bench", label="bench", mode="snapshot", fields=[
ExtField("symbol"), ExtField("value", dtype="float"),
])
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(
repo=SimpleNamespace(store=SimpleNamespace(data_dir=root)),
)))
def parse(*args, **kwargs):
time.sleep(0.25)
return pl.DataFrame({"symbol": ["600000.SH"], "value": [1.5]})
maxima = []
for _ in range(5):
intervals = []
stop = asyncio.Event()
async def heartbeat(stop=stop, intervals=intervals):
while not stop.is_set():
started = time.perf_counter()
await asyncio.sleep(0.01)
intervals.append((time.perf_counter() - started) * 1000)
task = asyncio.create_task(heartbeat())
await asyncio.sleep(0.03)
try:
with (
patch.object(ext_data, "_store", lambda _: SimpleNamespace(get=lambda _: config)),
patch.object(ext_data.pl, "read_csv", parse),
patch.object(ext_data, "_refresh_views", lambda _: None),
):
file = UploadFile(io.BytesIO(b"symbol,value\n600000.SH,1.5\n"), filename="bench.csv")
result = await ext_data.upload_data(request, "bench", file, "2026-10-29")
assert result == {"status": "ok", "rows": 1, "date": "2026-10-29"}
await file.close()
await asyncio.sleep(0.03)
finally:
stop.set()
await task
maxima.append(max(intervals))
return {
"synthetic_parser_ms": 250, "heartbeat_period_ms": 10, "runs": 5,
"median_max_heartbeat_ms": round(statistics.median(maxima), 3),
"max_heartbeat_runs_ms": [round(value, 3) for value in maxima],
}
def main():
logging.basicConfig(level=logging.ERROR)
with tempfile.TemporaryDirectory(prefix="tsp-hotpaths-") as temporary:
root = Path(temporary)
os.environ["DATA_DIR"] = str(root / "settings")
os.environ["TICKFLOW_ENV_FILE"] = str(root / "absent.env")
print(json.dumps({
"cache": cache_benchmark(root / "cache"),
"async": asyncio.run(async_benchmark(root / "upload")),
}, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,197 @@
from datetime import date, timedelta
from types import SimpleNamespace
import polars as pl
import pytest
from app.api.watchlist import _added_base_closes
from app.backtest.engine import BacktestEngine
from app.enriched_generation import (
EnrichedGenerationUnavailableError,
EnrichedPublication,
bump_enriched_generation,
get_enriched_generation_snapshot,
)
from app.tickflow.repository import DataStore, KlineRepository
DAY = date(2026, 10, 29)
def frame(day=DAY, close=10.0):
return pl.DataFrame({
"symbol": ["600000.SH"], "date": [day],
"open": [close], "high": [close], "low": [close],
"close": [close], "volume": [1000.0],
})
@pytest.fixture
def repo(tmp_path):
result = KlineRepository(DataStore(tmp_path))
yield result
result.db.close()
def warm_history(repo):
history = pl.concat([frame(), frame(DAY + timedelta(days=1), 11.0)])
repo.append_enriched(history)
# Install a completed warm snapshot, without unrelated indicator computation.
repo._enriched_history_cache = history
repo._enriched_history_start = DAY
repo._enriched_cache_date = DAY + timedelta(days=1)
repo._enriched_history_generation = repo.get_matrix_data_generation()
if hasattr(repo, "get_enriched_generation_snapshot"):
repo._enriched_history_snapshot = (
history, repo.get_enriched_generation_snapshot("stock"),
)
return history
def test_live_tick_reuses_older_history_and_watchlist_baseline(repo):
warm_history(repo)
repo.flush_live_enriched(frame(DAY + timedelta(days=1), 12.0))
actual = repo.get_enriched_range(DAY, DAY)
assert actual is not None
assert actual["close"].to_list() == [10.0]
baseline = _added_base_closes(repo, {"600000.SH": DAY}, {"600000.SH"})
assert baseline.height == 1
# The warm snapshot contains yesterday's value for the modified day.
assert repo.get_enriched_range(DAY, DAY + timedelta(days=1)) is None
def test_repository_refresh_records_a_scoped_snapshot(repo):
history = pl.concat([frame(DAY + timedelta(days=i), 10.0 + i / 10) for i in range(5)])
repo.append_enriched(history)
assert repo.refresh_view("kline_enriched", repo._enriched_glob)
repo._refresh_enriched()
before = repo.get_enriched_range(DAY, DAY)
assert before is not None
repo.flush_live_enriched(frame(DAY + timedelta(days=4), 12.0))
after = repo.get_enriched_range(DAY, DAY)
assert after is not None
assert after.equals(before)
def test_history_revision_and_unknown_changes_invalidate(repo):
warm_history(repo)
repo.flush_live_enriched(frame(DAY, 9.0))
assert repo.get_enriched_range(DAY, DAY) is None
warm_history(repo)
bump_enriched_generation(repo.store.data_dir)
assert repo.get_enriched_range(DAY, DAY) is None
@pytest.mark.parametrize("asset_type", ["stock", "etf"])
def test_panel_reuses_history_across_ticks_but_reloads_revisions(repo, asset_type):
repo.flush_live_enriched_asset(asset_type, frame())
engine = BacktestEngine(repo)
# Exercise the real panel loader and its cache against persisted partitions.
args = (None, DAY, DAY, ["symbol", "date", "close"], asset_type)
first = engine.load_panel(*args)
for offset in (1, 1, 2):
repo.flush_live_enriched_asset(asset_type, frame(DAY + timedelta(days=offset), 12.0 + offset))
assert engine.load_panel(*args).equals(first)
assert engine._cache.stats()["compute_count"] == 1
repo.flush_live_enriched_asset(asset_type, frame(DAY, 9.0))
assert engine.load_panel(*args)["close"].to_list() == [9.0]
assert engine._cache.stats()["compute_count"] == 2
def test_partial_publication_never_serves_warm_history(repo):
warm_history(repo)
publication = EnrichedPublication(repo.store.data_dir)
out = repo.store.data_dir / "kline_daily_enriched/date=2026-10-30/part.parquet"
publication.write_parquet(frame(DAY + timedelta(days=1), 12.0), out)
assert repo.get_enriched_range(DAY, DAY) is None
with pytest.raises(EnrichedGenerationUnavailableError):
BacktestEngine(repo).load_panel(None, DAY, DAY)
publication.abandon()
def test_matrix_cache_respects_coverage_and_historical_corrections(repo, monkeypatch):
from app.backtest.engine import settings
monkeypatch.setattr(settings, "backtest_matrix_disk_cache_enabled", True)
repo.append_enriched(pl.concat([frame(), frame(DAY + timedelta(days=1), 11.0)]))
engine = BacktestEngine(repo)
plan = SimpleNamespace(
execution_backend="matrix_native", base_columns={"close"},
instrument_columns=set(), matrix_columns=set(),
)
def load(end=DAY):
return engine.load_market_data_matrix_for_backtest(None, DAY, DAY, plan, coverage_end=end)
assert load().cache_status == "built"
repo.flush_live_enriched(frame(DAY + timedelta(days=1), 12.0))
assert load().cache_status == "exact"
# A requested wider cache must also be invalidated by writes in its coverage.
load(DAY + timedelta(days=1))
repo.flush_live_enriched(frame(DAY, 9.0))
corrected = load()
assert corrected.cache_status == "built"
assert corrected.close[0, 0] == 9.0
def test_prefix_versions_survive_restart_noop_and_abandon(repo):
repo.flush_live_enriched(frame())
old = get_enriched_generation_snapshot(repo.store.data_dir)
repo.flush_live_enriched(frame(DAY + timedelta(days=1), 12.0))
current = get_enriched_generation_snapshot(repo.store.data_dir)
assert old.through(DAY) == current.through(DAY)
assert old.generation != current.generation
repo.flush_live_enriched(frame(DAY + timedelta(days=1), 12.0))
empty = EnrichedPublication(repo.store.data_dir)
empty.begin()
empty.abandon()
assert get_enriched_generation_snapshot(repo.store.data_dir) == current
restarted = KlineRepository(DataStore(repo.store.data_dir))
try:
assert restarted.get_enriched_generation_snapshot() == current
finally:
restarted.db.close()
def test_range_metadata_is_bounded_and_unknown_changes_reset_it(repo):
from app.enriched_generation import _MAX_RANGE_GENERATIONS
for offset in range(_MAX_RANGE_GENERATIONS + 2):
repo.flush_live_enriched(frame(DAY + timedelta(days=offset)))
snapshot = repo.get_enriched_generation_snapshot()
assert len(snapshot.ranges) == _MAX_RANGE_GENERATIONS
retained_day = DAY + timedelta(days=2)
token = snapshot.through(retained_day)
repo.flush_live_enriched(frame(DAY + timedelta(days=_MAX_RANGE_GENERATIONS + 3)))
assert repo.get_enriched_generation_snapshot().through(retained_day) == token
publication = EnrichedPublication(repo.store.data_dir)
publication.begin()
publication.mark_changed()
publication.commit()
assert repo.get_enriched_generation_snapshot().through(retained_day) != token
def test_correction_before_query_start_invalidates_indicator_dependencies(repo):
repo.append_enriched(pl.concat([frame(), frame(DAY + timedelta(days=1), 11.0)]))
before = repo.get_enriched_generation_snapshot()
repo.flush_live_enriched(frame(DAY, 9.0))
after = repo.get_enriched_generation_snapshot()
# A rolling feature on D+1 may depend on D even when D is outside the query.
assert before.through(DAY + timedelta(days=1)) != after.through(DAY + timedelta(days=1))
def test_legacy_writer_and_failed_recovery_cannot_reuse_scoped_versions(repo):
import json
warm_history(repo)
old = repo.get_enriched_generation_snapshot()
marker = repo.store.data_dir / ".matrix_generation_stock.json"
marker.write_text(json.dumps({"state": "ready", "generation": "legacy-writer"}))
assert repo.get_enriched_range(DAY, DAY) is None
assert repo.get_enriched_generation_snapshot().through(DAY) == "legacy-writer"
incomplete = EnrichedPublication(repo.store.data_dir)
incomplete.write_parquet(frame(), repo.store.data_dir / "kline_daily_enriched/date=2026-10-29/part.parquet")
incomplete.abandon()
recovery = EnrichedPublication(repo.store.data_dir, recover=True)
recovery.begin()
recovery.abandon()
assert repo.get_enriched_generation_snapshot().through(DAY) not in {old.through(DAY), "legacy-writer"}
@@ -0,0 +1,193 @@
import asyncio
import io
import threading
from datetime import date
from pathlib import Path
from types import SimpleNamespace
import polars as pl
import pytest
from fastapi import UploadFile
from app.api import ext_data
from app.services import ext_pull
from app.services.ext_data import ExtConfig, ExtField, PullConfig, run_ext_data_io
def require_responsive_loop():
loop = asyncio.get_running_loop()
def check():
acknowledged = threading.Event()
loop.call_soon_threadsafe(acknowledged.set)
assert acknowledged.wait(1), "synchronous work blocked the event loop"
return check
@pytest.mark.parametrize("endpoint", ["upload", "detect"])
async def test_file_parsing_leaves_loop_responsive(tmp_path, monkeypatch, endpoint):
config = ExtConfig(id="test", label="test", mode="snapshot", fields=[
ExtField("symbol"), ExtField("value", dtype="float"),
])
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(
repo=SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path)),
)))
check = require_responsive_loop()
original = pl.read_csv
def parse(*args, **kwargs):
check()
return original(*args, **kwargs)
monkeypatch.setattr(ext_data.pl, "read_csv", parse)
monkeypatch.setattr(ext_data, "_store", lambda _: SimpleNamespace(get=lambda _: config))
monkeypatch.setattr(ext_data, "_refresh_views", lambda _: check())
file = UploadFile(io.BytesIO(b"symbol,value\n600000.SH,1.5\n"), filename="test.csv")
if endpoint == "upload":
result = await ext_data.upload_data(request, "test", file, "2026-10-29")
assert result == {"status": "ok", "rows": 1, "date": "2026-10-29"}
saved = pl.read_parquet(next((tmp_path / "ext_data").rglob("*.parquet")))
assert saved["value"].to_list() == [1.5]
else:
result = await ext_data.detect_fields(request, file)
assert result["rows"] == 1
assert "symbol" in result["symbol_candidates"]
async def test_scheduled_ingest_leaves_loop_responsive(tmp_path, monkeypatch):
config = ExtConfig(id="test", label="test", mode="snapshot", fields=[
ExtField("symbol"), ExtField("value", dtype="float"),
], pull=PullConfig(url="https://example.test/data", enabled=True))
check = require_responsive_loop()
original = ext_pull.rows_to_parquet
async def fetch(*args):
return [{"symbol": "600000.SH", "value": 1.5}]
def write(*args, **kwargs):
check()
return original(*args, **kwargs)
monkeypatch.setattr(ext_pull, "fetch_rows_for_date", fetch)
monkeypatch.setattr(ext_pull, "rows_to_parquet", write)
assert await ext_pull.fetch_and_ingest(config, tmp_path, date(2026, 10, 29)) == (1, "2026-10-29")
async def test_cancelling_request_does_not_release_running_write_slot():
loop = asyncio.get_running_loop()
entered = asyncio.Event()
release = threading.Event()
finished = threading.Event()
second_entered = threading.Event()
def first():
loop.call_soon_threadsafe(entered.set)
try:
assert release.wait(2)
finally:
finished.set()
def second():
second_entered.set()
assert finished.is_set(), "cancelled writer was still modifying the partition"
task = asyncio.create_task(run_ext_data_io(first))
await asyncio.wait_for(entered.wait(), 1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
follower = asyncio.create_task(run_ext_data_io(second))
try:
await asyncio.sleep(0.03)
assert not second_entered.is_set()
finally:
release.set()
await asyncio.wait_for(follower, 2)
async def test_cancelled_queued_import_never_runs():
loop = asyncio.get_running_loop()
entered = asyncio.Event()
release = threading.Event()
calls = []
def first():
loop.call_soon_threadsafe(entered.set)
assert release.wait(2)
running = asyncio.create_task(run_ext_data_io(first))
await asyncio.wait_for(entered.wait(), 1)
queued = asyncio.create_task(run_ext_data_io(calls.append, "cancelled"))
try:
await asyncio.sleep(0)
queued.cancel()
with pytest.raises(asyncio.CancelledError):
await queued
finally:
release.set()
await running
await run_ext_data_io(calls.append, "finished")
assert calls == ["finished"]
@pytest.mark.parametrize("kind", ["backup", "strategy"])
async def test_archive_inspection_and_reload_leave_loop_responsive(monkeypatch, kind):
from app.api import config_backup, strategy
check = require_responsive_loop()
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(
strategy_engine=SimpleNamespace(reload=check),
)))
def inspect(data):
check()
assert data == b"archive"
return {"items": []}
def apply(data, **kwargs):
check()
assert data == b"archive"
return {"reload_hints": {"strategies": True}}
if kind == "backup":
monkeypatch.setattr(config_backup.config_backup, "inspect_archive", inspect)
monkeypatch.setattr(config_backup.config_backup, "apply_archive", apply)
assert await config_backup.inspect_backup(UploadFile(io.BytesIO(b"archive"))) == {"items": []}
result = await config_backup.apply_backup(request, UploadFile(io.BytesIO(b"archive")), False, "")
else:
monkeypatch.setattr(strategy._bundle_service(), "inspect_bundle", inspect)
monkeypatch.setattr(strategy._bundle_service(), "apply_bundle", apply)
assert await strategy.inspect_strategy_bundle(UploadFile(io.BytesIO(b"archive"))) == {"items": []}
result = await strategy.apply_strategy_bundle(request, UploadFile(io.BytesIO(b"archive")), False)
assert result["reload_hints"]["strategies"] is True
async def test_cancelled_parser_cleans_its_own_temporary_files(tmp_path, monkeypatch):
loop = asyncio.get_running_loop()
entered = asyncio.Event()
release = threading.Event()
paths = []
def parse(path, **kwargs):
paths.append(Path(path))
loop.call_soon_threadsafe(entered.set)
assert release.wait(2)
assert paths[0].exists(), "request cancellation deleted the worker's input"
return pl.DataFrame({"symbol": ["600000.SH"]})
monkeypatch.setattr(ext_data.pl, "read_csv", parse)
original_mkdtemp = ext_data.tempfile.mkdtemp
monkeypatch.setattr(ext_data.tempfile, "mkdtemp", lambda: original_mkdtemp(dir=tmp_path))
task = asyncio.create_task(ext_data.detect_fields(None, UploadFile(io.BytesIO(b"symbol\n600000.SH\n"), filename="x.csv")))
await asyncio.wait_for(entered.wait(), 1)
task.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await task
assert paths[0].exists()
finally:
release.set()
# Same limiter: this completes only after the parser and its finally block.
await run_ext_data_io(lambda: None)
assert not paths[0].parent.exists()
+2
View File
@@ -164,6 +164,8 @@ const extension: FrontendExtension = {
完整前端模板位于 [`frontend/src/custom/_template/extension.tsx.example`](../frontend/src/custom/_template/extension.tsx.example)。
较大的页面应让 `extension.tsx` 只保留路由、导航与轻量组件,使用 `React.lazy(() => import('./Page').then(m => ({ default: m.Page })))` 注册页面组件;路由宿主已有 `Suspense`。源码模块并发加载、按路径排序注册,冲突处理顺序不变。运行时页面插件清单与各脚本均有加载超时,单个脚本失败只禁用该扩展;契约仍为 v1。
### 3.4 前端禁止事项
- 不直接在多个组件中拼接后端 URL,统一使用 `frontend/src/lib/api.ts` 或未来公开客户端。
+3 -2
View File
@@ -1,9 +1,10 @@
# 前端扩展目录
每个子目录一个 `extension.tsx`,**单文件即完整扩展**(页面组件 + 路由 + 导航注册在同一个文件里),构建时自动发现挂载,无需修改核心路由和导航文件:
每个子目录一个 `extension.tsx` 注册清单,构建时自动发现挂载,无需修改核心路由和导航文件。清单保持轻量:路由与导航同步注册,页面实现放在同目录的 `Page.tsx`,使用 `React.lazy` 按访问加载:
```text
frontend/src/custom/<namespace>/extension.tsx
frontend/src/custom/<namespace>/Page.tsx
```
- **摘除**:删除目录即整体卸载该页面;**导入**:把目录复制进来重新构建即可。
@@ -11,7 +12,7 @@ frontend/src/custom/<namespace>/extension.tsx
## 目录现状
官方扩展页面全部以单文件扩展形式落在本目录(id 前缀 `official.`):
官方扩展页面落在本目录(id 前缀 `official.`),整目录可独立摘除或导入。旧的单文件扩展仍兼容,但其全部依赖会参与启动加载。
| 目录 | 页面 |
| --- | --- |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'
import { AssistantLauncher } from './AssistantLauncher'
const state = vi.hoisted(() => ({ open: false, load: vi.fn() }))
vi.mock('./store', () => ({
useAssistantStore: () => ({ open: state.open }),
setPageContext: vi.fn(), closeAssistant: vi.fn(), toggleAssistant: vi.fn(),
}))
vi.mock('./ui/AssistantFloatingButton', () => ({ AssistantFloatingButton: () => <button>助手</button> }))
vi.mock('./ui/AssistantDrawer', () => {
state.load()
return { AssistantDrawer: () => <span>{state.open ? '打开' : '关闭动画'}</span> }
})
it('首次打开才加载抽屉,关闭后保留组件以完成退出动画', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const container = document.createElement('div')
const root = createRoot(container)
const render = () => act(async () => root.render(<AssistantLauncher collapsed={false} pathname="/" />))
try {
await render()
expect(state.load).not.toHaveBeenCalled()
expect(container.textContent).toBe('助手')
state.open = true
await render()
expect(state.load).toHaveBeenCalledTimes(1)
expect(container.textContent).toContain('打开')
state.open = false
await render()
expect(container.textContent).toContain('关闭动画')
} finally {
act(() => root.unmount())
}
})
@@ -5,11 +5,12 @@
* (DOM 锚定); ③ 从页面右缘滑入的非模态面板。
* 另负责全局快捷键 ⌘K/Ctrl+K 与 Esc、按当前路由上报页面上下文。
*/
import { useEffect } from 'react'
import { lazy, Suspense, useEffect, useState } from 'react'
import type { FrontendSlotContextMap } from '@/extensions/types'
import { closeAssistant, setPageContext, toggleAssistant, useAssistantStore } from './store'
import { AssistantFloatingButton } from './ui/AssistantFloatingButton'
import { AssistantDrawer } from './ui/AssistantDrawer'
const AssistantDrawer = lazy(() => import('./ui/AssistantDrawer').then(m => ({ default: m.AssistantDrawer })))
// 插槽注册表把组件 props 定为所有插槽上下文的联合类型(保守契约);
// 本组件只在 layout.navigation.extra 渲染, 在此收窄到该插槽的上下文。
@@ -42,6 +43,11 @@ const PAGE_LABELS: Record<string, string> = {
export function AssistantLauncher(props: AnySlotContext) {
const { pathname } = props as NavigationContext
const { open } = useAssistantStore()
const [drawerLoaded, setDrawerLoaded] = useState(open)
useEffect(() => {
if (open) setDrawerLoaded(true)
}, [open])
useEffect(() => {
setPageContext({ page: PAGE_LABELS[pathname] ?? '' })
@@ -63,7 +69,8 @@ export function AssistantLauncher(props: AnySlotContext) {
return (
<>
<AssistantFloatingButton />
<AssistantDrawer />
{/* Keep the drawer mounted after first use to preserve its exit animation. */}
{(open || drawerLoaded) && <Suspense fallback={null}><AssistantDrawer /></Suspense>}
</>
)
}
@@ -0,0 +1,836 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Layers3,
RefreshCw,
Repeat,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { toNavItems, type NavItem } from '@/lib/listNav'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
import { SectorRotationCard } from '@/components/SectorRotationCard'
const KEYWORDS = ['concept', '概念', 'theme', '题材', '板块']
const CANDIDATE_FIELDS = ['concept', '概念', 'theme', '题材', '板块', 'concept_name', '概念名称']
const PAGE_LIMIT = 12000
const MAX_RENDERED_CONCEPTS = 120
const MAX_RENDERED_STOCKS = 160
/** 内置概念预设 id — 本页的数据来源与「未获取」引导按钮都锚定它 */
const PRESET_CONCEPT_ID = 'ext_gn_ths'
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface ConceptStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
function loadConfig(): AnalysisFieldConfig {
return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.conceptAnalysisConfig.set(c)
}
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
// 内置预设优先: 关键词打分会被「描述里互相提及对方」污染 —— 概念表描述含
// "概念/行业页", 行业表描述含"板块", 导致本页可能选中行业表 (拿不到数据,
// 且「未获取」引导按钮因 id 不匹配而不渲染)。预设存在时直接用它。
if (configs.some(c => c.id === PRESET_CONCEPT_ID)) return PRESET_CONCEPT_ID
let best = ''
let bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) {
bestScore = score
best = c.id
}
}
return best
}
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
function calcConceptStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): ConceptStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: ConceptStat, b: ConceptStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
export function ConceptAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => {
setPreviewSymbol(symbol)
setPreviewName(name ?? '')
setPreviewNavList(navList ?? [])
}, [])
const [showRps, setShowRps] = useState(false)
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
// 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择,
// 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。
const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId)
const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
// 内置概念预设 (ext_gn_ths) 无数据时的一键拉取: 走通用拉取端点
// (与「数据」页扩展表的「执行」同一路径, 配方由表的 pull 配置决定)
const queryClient = useQueryClient()
const fetchMutation = useMutation({
mutationFn: () => api.extDataPullRun(PRESET_CONCEPT_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.extData })
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_CONCEPT_ID, undefined, PAGE_LIMIT) })
},
})
// 是否处于「内置概念预设存在但无数据」状态 → 显示获取按钮
const needsConceptFetch =
!!activeConfig && activeConfig.id === PRESET_CONCEPT_ID &&
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const stats = useMemo(() => {
return resolved.groups
.map(g => calcConceptStat(g, marketMap))
.filter(s => s.count > 0)
}, [resolved.groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeConcept = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const conceptBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
if (!activeConfig) {
// 极端情况: 无任何概念配置。仍提供一键获取内置概念数据入口
return (
<>
<div className="flex h-full flex-col">
<PageHeader
title="概念板块"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<PresetFetchState
title="暂无概念数据"
hint="从同花顺获取概念分类数据后即可使用概念板块"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
</>
)
}
return (
<>
<PageHeader
title="概念板块"
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
<button
onClick={() => setShowRps(true)}
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
title="概念涨幅轮动矩阵"
>
<Repeat className="h-3.5 w-3.5" />涨幅RPS轮动分析
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(59,130,246,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<SectorRotationCard kind="concept" />
<HeroPanel leading={leading[0]} falling={falling[0]} activeConcept={activeConcept} conceptBreadth={conceptBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
activeSymbol={previewSymbol}
onStockClick={handleStockClick}
/>
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<ConceptRail
stats={filteredStats.slice(0, MAX_RENDERED_CONCEPTS)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<ConceptFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算概念强度...</div>
) : needsConceptFetch ? (
<PresetFetchState
title="未获取概念数据"
hint="内置概念数据源已就绪,点击下方按钮从同花顺获取概念分类数据"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
) : (
<EmptyState icon={Layers3} title="未匹配到概念数据" hint={resolved.hint || '请检查扩展数据是否包含概念/题材相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }}
navList={previewNavList}
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
/>
)}
<AnimatePresence>
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} />}
</AnimatePresence>
</>
)
}
function HeroPanel({
leading,
falling,
activeConcept,
conceptBreadth,
}: {
leading?: ConceptStat
falling?: ConceptStat
activeConcept?: ConceptStat | null
conceptBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强主线" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌板块"
value={<><span className="text-bull">{conceptBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{conceptBreadth.down}</span></>}
hint={<><span className="text-bull">上涨</span><span className="mx-1 text-muted">/</span><span className="text-bear">下跌</span>{conceptBreadth.flat ? <span className="text-muted"> · 平 {conceptBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeConcept?.key ?? '—'} hint={activeConcept ? fmtBigNum(activeConcept.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
leading: ConceptStat[]
falling: ConceptStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
title: string
items: ConceptStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore)
const leaders = sortedStocks.slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>涨
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>跌
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40">—</span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
function ConceptRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: ConceptStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">概念矩阵</h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索概念" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-accent/15 text-accent' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-blue-400/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}只</span>
<span className="text-bull">{item.upCount}涨</span>
<span className="text-bear">{item.downCount}跌</span>
<span className="ml-auto">强度 {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
function ConceptFocus({ stat, onStockClick, activeSymbol }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
const focusNav: NavItem[] = toNavItems(stocks)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-blue-400/10 px-2 py-0.5 text-[10px] text-blue-300">强度 {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} 只成分</span>
<span className={priceColorClass(stat.avgPct)}>平均 {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span>上涨占比 {(stat.upRate * 100).toFixed(0)}%</span>
<span>成交额 {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span>均换手 {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium">排名</th>
<th className="px-4 py-2 font-medium">股票</th>
<th className="px-4 py-2 font-medium">涨跌幅</th>
<th className="px-4 py-2 font-medium">换手率</th>
<th className="px-4 py-2 font-medium">成交额</th>
<th className="px-4 py-2 font-medium">流通市值</th>
<th className="px-4 py-2 font-medium">量比</th>
<th className="px-4 py-2 font-medium">龙头分</th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted">仅展示龙头分前 {MAX_RENDERED_STOCKS} 只,共 {stat.stocks.length} 只</div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无龙头候选</div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
本概念三龙头
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无评分拆解</div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground">主龙头评分拆解</span>
<span className="text-[11px] text-muted">涨幅 / 换手 / 成交 / 市值 / 量比 / 连板</span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
@@ -1,843 +1,9 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Layers3,
RefreshCw,
Repeat,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { toNavItems, type NavItem } from '@/lib/listNav'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
import { SectorRotationCard } from '@/components/SectorRotationCard'
import { lazy } from 'react'
import type { FrontendExtension } from '@/extensions/types'
import { IconConcept } from '@/components/BrandIcons'
const KEYWORDS = ['concept', '概念', 'theme', '题材', '板块']
const CANDIDATE_FIELDS = ['concept', '概念', 'theme', '题材', '板块', 'concept_name', '概念名称']
const PAGE_LIMIT = 12000
const MAX_RENDERED_CONCEPTS = 120
const MAX_RENDERED_STOCKS = 160
export const ConceptAnalysis = lazy(() => import('./Page').then(m => ({ default: m.ConceptAnalysis })))
/** 内置概念预设 id — 本页的数据来源与「未获取」引导按钮都锚定它 */
const PRESET_CONCEPT_ID = 'ext_gn_ths'
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface ConceptStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
function loadConfig(): AnalysisFieldConfig {
return storage.conceptAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.conceptAnalysisConfig.set(c)
}
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
// 内置预设优先: 关键词打分会被「描述里互相提及对方」污染 —— 概念表描述含
// "概念/行业页", 行业表描述含"板块", 导致本页可能选中行业表 (拿不到数据,
// 且「未获取」引导按钮因 id 不匹配而不渲染)。预设存在时直接用它。
if (configs.some(c => c.id === PRESET_CONCEPT_ID)) return PRESET_CONCEPT_ID
let best = ''
let bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) {
bestScore = score
best = c.id
}
}
return best
}
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
function calcConceptStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): ConceptStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: ConceptStat, b: ConceptStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
export function ConceptAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => {
setPreviewSymbol(symbol)
setPreviewName(name ?? '')
setPreviewNavList(navList ?? [])
}, [])
const [showRps, setShowRps] = useState(false)
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
// 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择,
// 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。
const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId)
const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
// 内置概念预设 (ext_gn_ths) 无数据时的一键拉取: 走通用拉取端点
// (与「数据」页扩展表的「执行」同一路径, 配方由表的 pull 配置决定)
const queryClient = useQueryClient()
const fetchMutation = useMutation({
mutationFn: () => api.extDataPullRun(PRESET_CONCEPT_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.extData })
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_CONCEPT_ID, undefined, PAGE_LIMIT) })
},
})
// 是否处于「内置概念预设存在但无数据」状态 → 显示获取按钮
const needsConceptFetch =
!!activeConfig && activeConfig.id === PRESET_CONCEPT_ID &&
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const stats = useMemo(() => {
return resolved.groups
.map(g => calcConceptStat(g, marketMap))
.filter(s => s.count > 0)
}, [resolved.groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeConcept = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const conceptBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
if (!activeConfig) {
// 极端情况: 无任何概念配置。仍提供一键获取内置概念数据入口
return (
<>
<div className="flex h-full flex-col">
<PageHeader
title="概念板块"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<PresetFetchState
title="暂无概念数据"
hint="从同花顺获取概念分类数据后即可使用概念板块"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
</>
)
}
return (
<>
<PageHeader
title="概念板块"
subtitle={`${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个概念 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
{/* RPS 轮动: 打开涨幅轮动矩阵对话框 */}
<button
onClick={() => setShowRps(true)}
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
title="概念涨幅轮动矩阵"
>
<Repeat className="h-3.5 w-3.5" />涨幅RPS轮动分析
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(59,130,246,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<SectorRotationCard kind="concept" />
<HeroPanel leading={leading[0]} falling={falling[0]} activeConcept={activeConcept} conceptBreadth={conceptBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
activeSymbol={previewSymbol}
onStockClick={handleStockClick}
/>
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<ConceptRail
stats={filteredStats.slice(0, MAX_RENDERED_CONCEPTS)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<ConceptFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算概念强度...</div>
) : needsConceptFetch ? (
<PresetFetchState
title="未获取概念数据"
hint="内置概念数据源已就绪,点击下方按钮从同花顺获取概念分类数据"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
) : (
<EmptyState icon={Layers3} title="未匹配到概念数据" hint={resolved.hint || '请检查扩展数据是否包含概念/题材相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }}
navList={previewNavList}
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
/>
)}
<AnimatePresence>
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} />}
</AnimatePresence>
</>
)
}
function HeroPanel({
leading,
falling,
activeConcept,
conceptBreadth,
}: {
leading?: ConceptStat
falling?: ConceptStat
activeConcept?: ConceptStat | null
conceptBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强主线" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌板块"
value={<><span className="text-bull">{conceptBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{conceptBreadth.down}</span></>}
hint={<><span className="text-bull">上涨</span><span className="mx-1 text-muted">/</span><span className="text-bear">下跌</span>{conceptBreadth.flat ? <span className="text-muted"> · 平 {conceptBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeConcept?.key ?? '—'} hint={activeConcept ? fmtBigNum(activeConcept.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
leading: ConceptStat[]
falling: ConceptStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
title: string
items: ConceptStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore)
const leaders = sortedStocks.slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>涨
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>跌
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40">—</span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
function ConceptRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: ConceptStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">概念矩阵</h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索概念" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-accent/15 text-accent' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-blue-400/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}只</span>
<span className="text-bull">{item.upCount}涨</span>
<span className="text-bear">{item.downCount}跌</span>
<span className="ml-auto">强度 {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
function ConceptFocus({ stat, onStockClick, activeSymbol }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
const focusNav: NavItem[] = toNavItems(stocks)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-blue-400/10 px-2 py-0.5 text-[10px] text-blue-300">强度 {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} 只成分</span>
<span className={priceColorClass(stat.avgPct)}>平均 {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span>上涨占比 {(stat.upRate * 100).toFixed(0)}%</span>
<span>成交额 {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span>均换手 {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium">排名</th>
<th className="px-4 py-2 font-medium">股票</th>
<th className="px-4 py-2 font-medium">涨跌幅</th>
<th className="px-4 py-2 font-medium">换手率</th>
<th className="px-4 py-2 font-medium">成交额</th>
<th className="px-4 py-2 font-medium">流通市值</th>
<th className="px-4 py-2 font-medium">量比</th>
<th className="px-4 py-2 font-medium">龙头分</th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted">仅展示龙头分前 {MAX_RENDERED_STOCKS} 只,共 {stat.stocks.length} 只</div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无龙头候选</div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
本概念三龙头
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无评分拆解</div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground">主龙头评分拆解</span>
<span className="text-[11px] text-muted">涨幅 / 换手 / 成交 / 市值 / 量比 / 连板</span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
// ── 扩展注册清单 — 本文件即完整扩展(组件+路由+导航), 单文件可整体摘除/导入 ──
const extension: FrontendExtension = {
id: 'official.concept-analysis',
apiVersion: 1,
+345
View File
@@ -0,0 +1,345 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { RefreshCw, Download, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink, ChartPie } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities, useCapabilityMatrix } from '@/lib/useSharedQueries'
import { routeCapUsable } from '@/lib/capability-labels'
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
import { LastStockChip } from '@/components/LastStockChip'
import { useLastStock } from '@/lib/useLastStock'
import { fmtBigNum } from '@/lib/format'
import { toast } from '@/components/Toast'
const TABLE_LABELS: Record<string, string> = {
metrics: '核心指标',
income: '利润表',
balance_sheet: '资产负债表',
cash_flow: '现金流量表',
shares: '股本表',
}
const TABLE_ICON: Record<string, typeof FileText> = {
metrics: Database,
income: FileText,
balance_sheet: FileText,
cash_flow: FileText,
shares: ChartPie,
}
export function Financials() {
const { data: caps } = useCapabilities()
const { data: matrix } = useCapabilityMatrix()
const { data: status, isLoading } = useFinancialStatus()
// 路由感知门控: 生效源当前能否提供财务数据 (含插件/自定义源);
// 矩阵未加载时回退 TickFlow 套餐视角 + 后端可用状态
const hasFinancial = routeCapUsable(matrix, 'financial')
?? (caps?.capabilities?.['financial'] != null || status?.available === true)
const syncMut = useFinancialSync()
// 同步进行中 = 服务端真值(status.syncing)或本地乐观态(请求已发出待确认)。
// 乐观窗口:点击后到 invalidate 触发的 refetch 返回之间,status.syncing 暂为 false,
// 用 syncMut.isPending 覆盖,让按钮立即置灰、避免重复点击。
// 后端 trigger() 返回时 syncing 已为 true,refetch 到达后 status.syncing 接管。
const syncing = (status?.syncing ?? false) || syncMut.isPending
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
const [syncStartedAt, setSyncStartedAt] = useState<number | null>(null)
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
const [syncSingleTable, setSyncSingleTable] = useState<string | null>(null)
// 同步自然结束(服务端 syncing 由 true→false):清空本次同步记录。
// 这是可靠的收尾时机 —— 不依赖 mutation 的 onSettled(它现在瞬间触发,会误清)。
useEffect(() => {
if (!syncing && syncStartedAt !== null) {
setSyncStartedAt(null)
setSyncSingleTable(null)
}
}, [syncing, syncStartedAt])
// 选中的个股(模糊搜索结果);null 时显示搜索引导
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
const pick = (symbol: string, name: string) => {
setSelected({ symbol, name })
rememberStock(symbol, name)
}
if (!hasFinancial) {
return (
<>
<PageHeader title="财务概况" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析" />
<div className="px-8 py-10">
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
<Lock className="h-6 w-6 text-warning" />
</div>
<h3 className="mt-4 text-[16px] leading-6 font-semibold text-foreground">财务数据不可用</h3>
<p className="mt-2 text-xs leading-relaxed text-secondary">
当前数据源未提供财务数据。配置提供财务数据的数据源后,此页自动显示财务数据面板。
</p>
<Link
to="/settings?tab=data-sources"
className="mt-4 inline-flex items-center gap-1.5 rounded-btn bg-accent/90 px-3.5 py-1.5 text-xs font-medium text-white hover:bg-accent transition-colors"
>
前往数据源配置
</Link>
{/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */}
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
关于数据源
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-secondary">
当前财务数据源需付费,后续会接入免费数据源。如你常用某个免费财务数据源,欢迎在 Issues 中多多推荐哈 ~
</p>
<a
href="https://github.com/shy3130/tickflow-stock-panel/issues"
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-accent hover:underline"
>
前往 Issues 推荐
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
</>
)
}
const handleSync = (table: string) => {
// 防重复点击:syncing 中不再触发(后端 trigger 也有 _is_syncing 兜底)
if (syncing) return
// 记录开始时间: 全量同步判断所有财务表, 单表同步只判断这一张
setSyncStartedAt(Date.now())
setSyncSingleTable(table === 'all' ? null : table)
syncMut.mutate(table, {
onSuccess: (r) => {
// 后端 trigger 立即返回 started 状态;若被防并发跳过(已有同步在进行),
// 给用户明确反馈,并清空本次误设的记录。
if (!r.synced?.started) {
if (r.synced?.reason === 'already running') {
toast('财务数据正在同步中,请稍候', 'success')
} else if (r.synced?.reason === 'no FINANCIAL capability') {
// 能力未就绪:通常发生在升级/刷新 Key 后调度器状态未同步 —— 提示用户检查 Key
toast('财务数据能力未就绪,请检查 API Key 或刷新页面后重试', 'error')
} else {
toast(`同步未能开始${r.synced?.reason ? `:${r.synced.reason}` : ''}`, 'error')
}
setSyncStartedAt(null)
setSyncSingleTable(null)
}
},
onError: () => {
// 请求失败:清空本次记录(request 已弹错误 toast)
setSyncStartedAt(null)
setSyncSingleTable(null)
},
})
}
const tables = status?.tables ?? {}
const available = status?.available ?? false
const lastSync = status?.last_sync ?? {}
// 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断
const isFullSync = syncing && syncStartedAt && !syncSingleTable // 全量同步
const isSingleSync = syncing && syncStartedAt && !!syncSingleTable // 单表同步
const TABLE_ORDER = ['metrics', 'income', 'balance_sheet', 'cash_flow', 'shares'] as const
const tableDoneThisRound = (key: string): boolean => {
if (!syncStartedAt || !syncing) return false
// 单表同步: 只判断这一张表是否完成
if (syncSingleTable && key !== syncSingleTable) return false
const ls = lastSync[key]
if (!ls) return false
return new Date(ls).getTime() >= syncStartedAt
}
// 当前正在同步的表:
// 全量同步 → 第一个未完成的; 单表同步 → 那张表(未完成时)
const currentSyncingTable = syncing && syncStartedAt
? (syncSingleTable
? (tableDoneThisRound(syncSingleTable) ? null : syncSingleTable)
: TABLE_ORDER.find(t => !tableDoneThisRound(t)) ?? null)
: null
const syncedCount = TABLE_ORDER.filter(t => tableDoneThisRound(t)).length
// 卡片三态: 仅全量同步时未轮到的表显示"等待"; 单表同步时其他表保持原样
const isWaitingTable = (key: string): boolean =>
!!isFullSync && !tableDoneThisRound(key) && currentSyncingTable !== key
return (
<>
<PageHeader
title="财务概况"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={pick} />
{syncing && (
<span className="text-xs text-accent/80 flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" />
{isFullSync
? `已同步 ${syncedCount}/${TABLE_ORDER.length} 张表…`
: isSingleSync
? `同步${TABLE_LABELS[syncSingleTable!] ?? syncSingleTable}…`
: '同步中…'}
</span>
)}
<button
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => handleSync('all')}
disabled={syncing}
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
{syncing ? '同步中…' : '全部同步'}
</button>
</div>
}
/>
<div className="px-3 sm:px-8 py-6 space-y-6 max-w-7xl">
{syncing && (
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
正在从财务数据源拉取数据,请稍候…
</div>
)}
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
{!isLoading && available && (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3">
{Object.entries(TABLE_LABELS).map(([key, label]) => {
const info = tables[key]
const TIcon = TABLE_ICON[key] ?? Database
const hasData = (info?.rows ?? 0) > 0
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
const doneThisRound = tableDoneThisRound(key)
const isThisSyncing = currentSyncingTable === key
const isWaiting = isWaitingTable(key)
const lsTime = lastSync[key]
return (
<div
key={key}
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
isThisSyncing
? 'border-accent/40 bg-accent/[0.04]'
: isWaiting
? 'border-border/50 bg-elevated/15'
: hasData
? 'border-border bg-surface'
: 'border-dashed border-border/60 bg-elevated/20'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{doneThisRound ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : isThisSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />
) : isWaiting ? (
<Hourglass className="h-3.5 w-3.5 text-muted/60" />
) : (
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
)}
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
<button
className="text-muted hover:text-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleSync(key)}
disabled={syncing}
title={syncing ? '正在同步…' : `更新${label}`}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Download className="h-3.5 w-3.5" />}
</button>
</div>
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
{fmtBigNum(info?.rows ?? 0)}
<span className="text-[10px] text-muted ml-1 font-normal">行</span>
</div>
<div className="text-[11px] text-muted mt-0.5">
{fmtBigNum(info?.symbols ?? 0)} 只标的
</div>
<div className="mt-auto pt-2 border-t border-border/40 text-[10px] text-muted flex items-center gap-1">
<Clock className="h-2.5 w-2.5 shrink-0" />
{lsTime
? new Date(lsTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '尚未同步'}
</div>
</div>
)
})}
</div>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted" />
</div>
) : !available ? (
<div className="rounded-card border border-dashed border-border bg-surface px-6 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-muted" />
<div className="mt-3 text-sm text-secondary">暂无财务数据</div>
<div className="mt-1 text-xs text-muted">点击右上角"全部同步"从当前数据源拉取</div>
</div>
) : (
<>
{/* 个股搜索区 */}
<div>
{selected ? (
// 已选个股:紧凑搜索条 + 清除按钮(便于换股)
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xl">
<StockFinancialSearch onSelect={pick} />
</div>
<button
onClick={() => setSelected(null)}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-secondary hover:text-foreground rounded-btn border border-border hover:bg-elevated transition-colors shrink-0"
title="清除选择"
>
<X className="h-3.5 w-3.5" />
清除
</button>
</div>
) : (
// 未选个股:醒目居中引导
<div className="flex flex-col items-center gap-3 py-8">
<div className="flex items-center gap-2 text-sm text-secondary">
<Search className="h-4 w-4 text-accent" />
<span>搜索个股查看详细财务数据</span>
</div>
<div className="w-full max-w-xl">
<StockFinancialSearch onSelect={pick} />
</div>
<div className="text-[11px] text-muted">支持股票代码或名称模糊匹配,如 600000 / 浦发</div>
</div>
)}
</div>
{/* 个股详情 / 空引导 */}
<div className="pb-4">
{selected ? (
<StockFinancialDetail symbol={selected.symbol} name={selected.name} />
) : (
<EmptyState
icon={Search}
title="未选择股票"
hint="在上方搜索框输入股票代码或名称,选择后即可查看该股的核心指标、财务报表与股本历史。"
/>
)}
</div>
{/* AI 历史分析报告 */}
{available && <ReportHistoryPanel />}
</>
)}
</div>
</>
)
}
+2 -345
View File
@@ -1,352 +1,9 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { RefreshCw, Download, Lock, Loader2, X, Search, FileText, Database, Clock, CheckCircle2, Hourglass, Lightbulb, ExternalLink, ChartPie } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { useCapabilities, useCapabilityMatrix } from '@/lib/useSharedQueries'
import { routeCapUsable } from '@/lib/capability-labels'
import { useFinancialStatus, useFinancialSync } from '@/lib/useFinancials'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockFinancialDetail } from '@/components/financials/StockFinancialDetail'
import { ReportHistoryPanel } from '@/components/financials/ReportHistoryPanel'
import { LastStockChip } from '@/components/LastStockChip'
import { useLastStock } from '@/lib/useLastStock'
import { fmtBigNum } from '@/lib/format'
import { toast } from '@/components/Toast'
import { lazy } from 'react'
import type { FrontendExtension } from '@/extensions/types'
import { IconFinancials } from '@/components/BrandIcons'
const TABLE_LABELS: Record<string, string> = {
metrics: '核心指标',
income: '利润表',
balance_sheet: '资产负债表',
cash_flow: '现金流量表',
shares: '股本表',
}
export const Financials = lazy(() => import('./Page').then(m => ({ default: m.Financials })))
const TABLE_ICON: Record<string, typeof FileText> = {
metrics: Database,
income: FileText,
balance_sheet: FileText,
cash_flow: FileText,
shares: ChartPie,
}
export function Financials() {
const { data: caps } = useCapabilities()
const { data: matrix } = useCapabilityMatrix()
const { data: status, isLoading } = useFinancialStatus()
// 路由感知门控: 生效源当前能否提供财务数据 (含插件/自定义源);
// 矩阵未加载时回退 TickFlow 套餐视角 + 后端可用状态
const hasFinancial = routeCapUsable(matrix, 'financial')
?? (caps?.capabilities?.['financial'] != null || status?.available === true)
const syncMut = useFinancialSync()
// 同步进行中 = 服务端真值(status.syncing)或本地乐观态(请求已发出待确认)。
// 乐观窗口:点击后到 invalidate 触发的 refetch 返回之间,status.syncing 暂为 false,
// 用 syncMut.isPending 覆盖,让按钮立即置灰、避免重复点击。
// 后端 trigger() 返回时 syncing 已为 true,refetch 到达后 status.syncing 接管。
const syncing = (status?.syncing ?? false) || syncMut.isPending
// 本次同步开始时间戳(ms): 用于判断每张表的 last_sync 是否属于本次同步
// (后端每张表完成即更新 last_sync, 前端轮询时对比时间戳得到精确进度)
const [syncStartedAt, setSyncStartedAt] = useState<number | null>(null)
// 单表同步时记录表名 (null = 全量同步), 用于区分卡片状态
const [syncSingleTable, setSyncSingleTable] = useState<string | null>(null)
// 同步自然结束(服务端 syncing 由 true→false):清空本次同步记录。
// 这是可靠的收尾时机 —— 不依赖 mutation 的 onSettled(它现在瞬间触发,会误清)。
useEffect(() => {
if (!syncing && syncStartedAt !== null) {
setSyncStartedAt(null)
setSyncSingleTable(null)
}
}, [syncing, syncStartedAt])
// 选中的个股(模糊搜索结果);null 时显示搜索引导
const [selected, setSelected] = useState<{ symbol: string; name: string } | null>(null)
const { last: lastStock, remember: rememberStock } = useLastStock('financials')
const pick = (symbol: string, name: string) => {
setSelected({ symbol, name })
rememberStock(symbol, name)
}
if (!hasFinancial) {
return (
<>
<PageHeader title="财务概况" subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析" />
<div className="px-8 py-10">
<div className="mx-auto max-w-md rounded-card border border-warning/30 bg-warning/[0.04] p-8 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-warning/10">
<Lock className="h-6 w-6 text-warning" />
</div>
<h3 className="mt-4 text-[16px] leading-6 font-semibold text-foreground">财务数据不可用</h3>
<p className="mt-2 text-xs leading-relaxed text-secondary">
当前数据源未提供财务数据。配置提供财务数据的数据源后,此页自动显示财务数据面板。
</p>
<Link
to="/settings?tab=data-sources"
className="mt-4 inline-flex items-center gap-1.5 rounded-btn bg-accent/90 px-3.5 py-1.5 text-xs font-medium text-white hover:bg-accent transition-colors"
>
前往数据源配置
</Link>
{/* 当前财务数据源(TickFlow)需付费,后续将接入免费数据源;期间欢迎在 issues 推荐免费源 */}
<div className="mt-5 rounded-btn border border-accent/25 bg-accent/[0.05] px-3.5 py-3 text-left">
<div className="flex items-center gap-1.5 text-xs font-medium text-accent">
<Lightbulb className="h-3.5 w-3.5 shrink-0" />
关于数据源
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-secondary">
当前财务数据源需付费,后续会接入免费数据源。如你常用某个免费财务数据源,欢迎在 Issues 中多多推荐哈 ~
</p>
<a
href="https://github.com/shy3130/tickflow-stock-panel/issues"
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-accent hover:underline"
>
前往 Issues 推荐
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
</>
)
}
const handleSync = (table: string) => {
// 防重复点击:syncing 中不再触发(后端 trigger 也有 _is_syncing 兜底)
if (syncing) return
// 记录开始时间: 全量同步判断所有财务表, 单表同步只判断这一张
setSyncStartedAt(Date.now())
setSyncSingleTable(table === 'all' ? null : table)
syncMut.mutate(table, {
onSuccess: (r) => {
// 后端 trigger 立即返回 started 状态;若被防并发跳过(已有同步在进行),
// 给用户明确反馈,并清空本次误设的记录。
if (!r.synced?.started) {
if (r.synced?.reason === 'already running') {
toast('财务数据正在同步中,请稍候', 'success')
} else if (r.synced?.reason === 'no FINANCIAL capability') {
// 能力未就绪:通常发生在升级/刷新 Key 后调度器状态未同步 —— 提示用户检查 Key
toast('财务数据能力未就绪,请检查 API Key 或刷新页面后重试', 'error')
} else {
toast(`同步未能开始${r.synced?.reason ? `:${r.synced.reason}` : ''}`, 'error')
}
setSyncStartedAt(null)
setSyncSingleTable(null)
}
},
onError: () => {
// 请求失败:清空本次记录(request 已弹错误 toast)
setSyncStartedAt(null)
setSyncSingleTable(null)
},
})
}
const tables = status?.tables ?? {}
const available = status?.available ?? false
const lastSync = status?.last_sync ?? {}
// 本次同步进度: 仅当 syncStartedAt 存在且 syncing 时, 按 last_sync 时间戳判断
const isFullSync = syncing && syncStartedAt && !syncSingleTable // 全量同步
const isSingleSync = syncing && syncStartedAt && !!syncSingleTable // 单表同步
const TABLE_ORDER = ['metrics', 'income', 'balance_sheet', 'cash_flow', 'shares'] as const
const tableDoneThisRound = (key: string): boolean => {
if (!syncStartedAt || !syncing) return false
// 单表同步: 只判断这一张表是否完成
if (syncSingleTable && key !== syncSingleTable) return false
const ls = lastSync[key]
if (!ls) return false
return new Date(ls).getTime() >= syncStartedAt
}
// 当前正在同步的表:
// 全量同步 → 第一个未完成的; 单表同步 → 那张表(未完成时)
const currentSyncingTable = syncing && syncStartedAt
? (syncSingleTable
? (tableDoneThisRound(syncSingleTable) ? null : syncSingleTable)
: TABLE_ORDER.find(t => !tableDoneThisRound(t)) ?? null)
: null
const syncedCount = TABLE_ORDER.filter(t => tableDoneThisRound(t)).length
// 卡片三态: 仅全量同步时未轮到的表显示"等待"; 单表同步时其他表保持原样
const isWaitingTable = (key: string): boolean =>
!!isFullSync && !tableDoneThisRound(key) && currentSyncingTable !== key
return (
<>
<PageHeader
title="财务概况"
subtitle="利润表 / 资负表 / 现金流 / 关键指标 / 股本 / AI分析"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={pick} />
{syncing && (
<span className="text-xs text-accent/80 flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" />
{isFullSync
? `已同步 ${syncedCount}/${TABLE_ORDER.length} 张表…`
: isSingleSync
? `同步${TABLE_LABELS[syncSingleTable!] ?? syncSingleTable}…`
: '同步中…'}
</span>
)}
<button
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-accent/25 to-accent/10 border border-accent/30 text-accent text-xs font-medium hover:from-accent/35 hover:to-accent/20 transition-all duration-150 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => handleSync('all')}
disabled={syncing}
title={syncing ? '正在同步,请稍候…' : '同步全部财务表'}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <RefreshCw className="h-3.5 w-3.5" />}
{syncing ? '同步中…' : '全部同步'}
</button>
</div>
}
/>
<div className="px-3 sm:px-8 py-6 space-y-6 max-w-7xl">
{syncing && (
<div className="flex items-center gap-2 rounded-card border border-accent/30 bg-accent/[0.06] px-3 py-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
正在从财务数据源拉取数据,请稍候…
</div>
)}
{/* 同步状态卡片 —— 始终显示,反映本地财务数据概况 */}
{!isLoading && available && (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 gap-3">
{Object.entries(TABLE_LABELS).map(([key, label]) => {
const info = tables[key]
const TIcon = TABLE_ICON[key] ?? Database
const hasData = (info?.rows ?? 0) > 0
// 本次同步三态: 完成 / 同步中 / 等待 (仅全量同步时未轮到的表才"等待")
const doneThisRound = tableDoneThisRound(key)
const isThisSyncing = currentSyncingTable === key
const isWaiting = isWaitingTable(key)
const lsTime = lastSync[key]
return (
<div
key={key}
className={`rounded-card border p-3.5 transition-colors flex flex-col ${
isThisSyncing
? 'border-accent/40 bg-accent/[0.04]'
: isWaiting
? 'border-border/50 bg-elevated/15'
: hasData
? 'border-border bg-surface'
: 'border-dashed border-border/60 bg-elevated/20'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{doneThisRound ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
) : isThisSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />
) : isWaiting ? (
<Hourglass className="h-3.5 w-3.5 text-muted/60" />
) : (
<TIcon className={`h-3.5 w-3.5 ${hasData ? 'text-accent' : 'text-muted'}`} />
)}
<span className="text-xs font-medium text-foreground">{label}</span>
</div>
<button
className="text-muted hover:text-accent transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleSync(key)}
disabled={syncing}
title={syncing ? '正在同步…' : `更新${label}`}
>
{syncing
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Download className="h-3.5 w-3.5" />}
</button>
</div>
<div className="mt-2 text-xl font-semibold tabular-nums text-foreground">
{fmtBigNum(info?.rows ?? 0)}
<span className="text-[10px] text-muted ml-1 font-normal">行</span>
</div>
<div className="text-[11px] text-muted mt-0.5">
{fmtBigNum(info?.symbols ?? 0)} 只标的
</div>
<div className="mt-auto pt-2 border-t border-border/40 text-[10px] text-muted flex items-center gap-1">
<Clock className="h-2.5 w-2.5 shrink-0" />
{lsTime
? new Date(lsTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '尚未同步'}
</div>
</div>
)
})}
</div>
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted" />
</div>
) : !available ? (
<div className="rounded-card border border-dashed border-border bg-surface px-6 py-14 text-center">
<Database className="mx-auto h-8 w-8 text-muted" />
<div className="mt-3 text-sm text-secondary">暂无财务数据</div>
<div className="mt-1 text-xs text-muted">点击右上角"全部同步"从当前数据源拉取</div>
</div>
) : (
<>
{/* 个股搜索区 */}
<div>
{selected ? (
// 已选个股:紧凑搜索条 + 清除按钮(便于换股)
<div className="flex items-center gap-3">
<div className="flex-1 max-w-xl">
<StockFinancialSearch onSelect={pick} />
</div>
<button
onClick={() => setSelected(null)}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-secondary hover:text-foreground rounded-btn border border-border hover:bg-elevated transition-colors shrink-0"
title="清除选择"
>
<X className="h-3.5 w-3.5" />
清除
</button>
</div>
) : (
// 未选个股:醒目居中引导
<div className="flex flex-col items-center gap-3 py-8">
<div className="flex items-center gap-2 text-sm text-secondary">
<Search className="h-4 w-4 text-accent" />
<span>搜索个股查看详细财务数据</span>
</div>
<div className="w-full max-w-xl">
<StockFinancialSearch onSelect={pick} />
</div>
<div className="text-[11px] text-muted">支持股票代码或名称模糊匹配,如 600000 / 浦发</div>
</div>
)}
</div>
{/* 个股详情 / 空引导 */}
<div className="pb-4">
{selected ? (
<StockFinancialDetail symbol={selected.symbol} name={selected.name} />
) : (
<EmptyState
icon={Search}
title="未选择股票"
hint="在上方搜索框输入股票代码或名称,选择后即可查看该股的核心指标、财务报表与股本历史。"
/>
)}
</div>
{/* AI 历史分析报告 */}
{available && <ReportHistoryPanel />}
</>
)}
</div>
</>
)
}
// ── 扩展注册清单 — 本文件即完整扩展(组件+路由+导航), 单文件可整体摘除/导入 ──
const extension: FrontendExtension = {
id: 'official.financials',
apiVersion: 1,
@@ -0,0 +1,905 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Layers3,
RefreshCw,
Repeat,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { toNavItems, type NavItem } from '@/lib/listNav'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
import { SectorRotationCard } from '@/components/SectorRotationCard'
const KEYWORDS = ['industry', '行业', 'sector', '申万', '中信']
const CANDIDATE_FIELDS = ['industry', '行业', 'sector', '申万', '中信', '行业名称', 'industry_name', 'sector_name']
const PAGE_LIMIT = 12000
const MAX_RENDERED_INDUSTRIES = 120
const MAX_RENDERED_STOCKS = 160
/** 内置行业预设 id — 本页的数据来源与「未获取」引导按钮都锚定它 */
const PRESET_INDUSTRY_ID = 'ext_hy_ths'
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
type IndustryLevel = 1 | 2 | 3
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface IndustryStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
// ===== 配置持久化 =====
function loadConfig(): AnalysisFieldConfig {
return storage.industryAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.industryAnalysisConfig.set(c)
}
// ===== 自动选取最佳数据源 =====
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
// 内置预设优先: 关键词打分会被「描述里互相提及对方」污染 —— 概念表描述含
// "概念/行业页", 行业表描述含"板块", 导致本页可能选中概念表 (拿不到数据,
// 且「未获取」引导按钮因 id 不匹配而不渲染)。预设存在时直接用它。
if (configs.some(c => c.id === PRESET_INDUSTRY_ID)) return PRESET_INDUSTRY_ID
let best = '', bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) { bestScore = score; best = c.id }
}
return best
}
// ===== 工具函数 =====
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
// ===== 龙头算法 =====
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
// ===== 行业统计计算 =====
function calcIndustryStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): IndustryStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: IndustryStat, b: IndustryStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
// ===== 行业层级分组 =====
function industryLevelName(key: string, level: IndustryLevel) {
const parts = key.split('-').map(s => s.trim()).filter(Boolean)
return parts[level - 1] || parts[parts.length - 1] || key
}
function groupByIndustryLevel(groups: DimensionGroup[], level: IndustryLevel): DimensionGroup[] {
const map = new Map<string, DimensionGroup>()
for (const group of groups) {
const key = industryLevelName(group.key, level)
const existing = map.get(key)
if (existing) {
existing.stocks.push(...group.stocks)
existing.count = existing.stocks.length
} else {
map.set(key, {
key,
count: group.stocks.length,
stocks: [...group.stocks],
metrics: { ...group.metrics },
})
}
}
return [...map.values()].sort((a, b) => b.count - a.count)
}
// ===== 主页面 =====
export function IndustryAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => {
setPreviewSymbol(symbol)
setPreviewName(name ?? '')
setPreviewNavList(navList ?? [])
}, [])
const [showRps, setShowRps] = useState(false)
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
// 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择,
// 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。
const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId)
const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
// 内置行业预设 (ext_hy_ths) 无数据时的一键拉取: 走通用拉取端点
// (与「数据」页扩展表的「执行」同一路径, 配方由表的 pull 配置决定)
const queryClient = useQueryClient()
const fetchMutation = useMutation({
mutationFn: () => api.extDataPullRun(PRESET_INDUSTRY_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.extData })
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_INDUSTRY_ID, undefined, PAGE_LIMIT) })
},
})
// 是否处于「内置行业预设存在但无数据」状态 → 显示获取按钮
const needsIndustryFetch =
!!activeConfig && activeConfig.id === PRESET_INDUSTRY_ID &&
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const industryLevel = fieldConfig.hierarchyLevel ?? 2
const groups = useMemo(() => groupByIndustryLevel(resolved.groups, industryLevel), [resolved.groups, industryLevel])
const stats = useMemo(() => {
return groups
.map(g => calcIndustryStat(g, marketMap))
.filter(s => s.count > 0)
}, [groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeIndustry = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const industryBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
// 热力图用的 quoteMap(兼容 DimensionHeatmap 组件接口)
const heatmapQuoteMap = useMemo(() => {
const map = new Map<string, { symbol: string; pct?: number; change_pct?: number; name?: string; [k: string]: unknown }>()
for (const [k, v] of marketMap) {
map.set(k, {
...v,
change_pct: v.change_pct ?? undefined,
name: v.name ?? undefined,
})
}
return map
}, [marketMap])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
if (!activeConfig) {
// 极端情况: 无任何行业配置。仍提供一键获取内置行业数据入口
return (
<>
<div className="flex h-full flex-col">
<PageHeader
title="行业板块"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<PresetFetchState
title="暂无行业数据"
hint="从同花顺获取行业分类数据后即可使用行业板块"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
</>
)
}
const industryLevelLabel = `${industryLevel}级行业`
return (
<>
<PageHeader
title="行业板块"
subtitle={`${industryLevelLabel} · ${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
{/* RPS 轮动: 打开行业涨幅轮动矩阵对话框 */}
<button
onClick={() => setShowRps(true)}
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
title="行业涨幅轮动矩阵"
>
<Repeat className="h-3.5 w-3.5" />涨幅RPS轮动分析
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(245,158,11,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<SectorRotationCard kind="industry" />
<HeroPanel leading={leading[0]} falling={falling[0]} activeIndustry={activeIndustry} industryBreadth={industryBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
activeSymbol={previewSymbol}
onStockClick={handleStockClick}
/>
{/* 热力图 */}
{groups.length > 0 && (
<DimensionHeatmap
groups={groups}
quoteMap={heatmapQuoteMap}
selectedKey={selectedKey}
onSelect={k => setSelectedKey(k)}
colorScheme="amber"
/>
)}
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<IndustryRail
stats={filteredStats.slice(0, MAX_RENDERED_INDUSTRIES)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<IndustryFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算行业强度...</div>
) : needsIndustryFetch ? (
<PresetFetchState
title="未获取行业数据"
hint="内置行业数据源已就绪,点击下方按钮从同花顺获取行业分类数据"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
) : (
<EmptyState icon={Layers3} title="未匹配到行业数据" hint={resolved.hint || '请检查扩展数据是否包含行业/板块相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }}
navList={previewNavList}
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
/>
)}
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} kind="industry" />}
</>
)
}
// ===== HeroPanel =====
function HeroPanel({
leading,
falling,
activeIndustry,
industryBreadth,
}: {
leading?: IndustryStat
falling?: IndustryStat
activeIndustry?: IndustryStat | null
industryBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强行业" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌行业"
value={<><span className="text-bull">{industryBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{industryBreadth.down}</span></>}
hint={<><span className="text-bull">上涨</span><span className="mx-1 text-muted">/</span><span className="text-bear">下跌</span>{industryBreadth.flat ? <span className="text-muted"> · 平 {industryBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeIndustry?.key ?? '—'} hint={activeIndustry ? fmtBigNum(activeIndustry.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
// ===== MarketPulse =====
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
leading: IndustryStat[]
falling: IndustryStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
title: string
items: IndustryStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore)
const leaders = sortedStocks.slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>涨
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>跌
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40">—</span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
// ===== IndustryRail(左侧列表面板) =====
function IndustryRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: IndustryStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">行业矩阵</h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索行业" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-amber-500/15 text-amber-400' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-amber-500/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}只</span>
<span className="text-bull">{item.upCount}涨</span>
<span className="text-bear">{item.downCount}跌</span>
<span className="ml-auto">强度 {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
// ===== IndustryFocus(右侧聚焦面板) =====
function IndustryFocus({ stat, onStockClick, activeSymbol }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
const focusNav: NavItem[] = toNavItems(stocks)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-400">强度 {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} 只成分</span>
<span className={priceColorClass(stat.avgPct)}>平均 {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span>上涨占比 {(stat.upRate * 100).toFixed(0)}%</span>
<span>成交额 {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span>均换手 {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium">排名</th>
<th className="px-4 py-2 font-medium">股票</th>
<th className="px-4 py-2 font-medium">涨跌幅</th>
<th className="px-4 py-2 font-medium">换手率</th>
<th className="px-4 py-2 font-medium">成交额</th>
<th className="px-4 py-2 font-medium">流通市值</th>
<th className="px-4 py-2 font-medium">量比</th>
<th className="px-4 py-2 font-medium">龙头分</th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted">仅展示龙头分前 {MAX_RENDERED_STOCKS} 只,共 {stat.stocks.length} 只</div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无龙头候选</div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
本行业三龙头
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无评分拆解</div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground">主龙头评分拆解</span>
<span className="text-[11px] text-muted">涨幅 / 换手 / 成交 / 市值 / 量比 / 连板</span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
@@ -1,912 +1,9 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
Activity,
Crown,
Layers3,
RefreshCw,
Repeat,
Search,
Settings2,
TrendingDown,
TrendingUp,
} from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { toNavItems, type NavItem } from '@/lib/listNav'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtBigNum, fmtPct, priceColorClass } from '@/lib/format'
import { cn } from '@/lib/cn'
import { resolveDimension, type DimensionGroup, type StockRow } from '@/lib/analysis-adapter'
import { SectorRotationCard } from '@/components/SectorRotationCard'
import { lazy } from 'react'
import type { FrontendExtension } from '@/extensions/types'
import { IconIndustry } from '@/components/BrandIcons'
const KEYWORDS = ['industry', '行业', 'sector', '申万', '中信']
const CANDIDATE_FIELDS = ['industry', '行业', 'sector', '申万', '中信', '行业名称', 'industry_name', 'sector_name']
const PAGE_LIMIT = 12000
const MAX_RENDERED_INDUSTRIES = 120
const MAX_RENDERED_STOCKS = 160
export const IndustryAnalysis = lazy(() => import('./Page').then(m => ({ default: m.IndustryAnalysis })))
/** 内置行业预设 id — 本页的数据来源与「未获取」引导按钮都锚定它 */
const PRESET_INDUSTRY_ID = 'ext_hy_ths'
type SortMode = 'heat' | 'avgPct' | 'leader' | 'amount' | 'down'
type IndustryLevel = 1 | 2 | 3
interface EnrichedStock extends MarketSnapshotRow {
leaderScore: number
leaderParts: {
momentum: number
turnover: number
amount: number
cap: number
volume: number
boards: number
}
}
interface IndustryStat {
key: string
stocks: EnrichedStock[]
count: number
avgPct: number | null
medianPct: number | null
upCount: number
downCount: number
flatCount: number
upRate: number
strongCount: number
weakCount: number
totalAmount: number
avgTurnover: number | null
avgVolRatio: number | null
leader: EnrichedStock | null
heatScore: number
riskScore: number
}
// ===== 配置持久化 =====
function loadConfig(): AnalysisFieldConfig {
return storage.industryAnalysisConfig.get({}) as AnalysisFieldConfig
}
function saveConfig(c: AnalysisFieldConfig) {
storage.industryAnalysisConfig.set(c)
}
// ===== 自动选取最佳数据源 =====
function pickBestConfig(
configs: { id: string; label: string; description?: string; fields: { name: string; label: string }[] }[],
): string {
// 内置预设优先: 关键词打分会被「描述里互相提及对方」污染 —— 概念表描述含
// "概念/行业页", 行业表描述含"板块", 导致本页可能选中概念表 (拿不到数据,
// 且「未获取」引导按钮因 id 不匹配而不渲染)。预设存在时直接用它。
if (configs.some(c => c.id === PRESET_INDUSTRY_ID)) return PRESET_INDUSTRY_ID
let best = '', bestScore = 0
for (const c of configs) {
const haystack = [c.id, c.label, c.description ?? '', ...c.fields.flatMap(f => [f.name, f.label])].join(' ').toLowerCase()
const score = KEYWORDS.reduce((n, k) => n + (haystack.includes(k) ? 1 : 0), 0)
if (score > bestScore) { bestScore = score; best = c.id }
}
return best
}
// ===== 工具函数 =====
function symbolKeys(symbol: unknown): string[] {
const raw = String(symbol ?? '').trim()
if (!raw) return []
const plain = raw.replace(/\.\w+$/, '')
return Array.from(new Set([raw, plain]))
}
function buildMarketMap(rows: MarketSnapshotRow[]) {
const map = new Map<string, MarketSnapshotRow>()
for (const r of rows) {
for (const key of symbolKeys(r.symbol)) map.set(key, r)
}
return map
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function num(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null
}
function avg(values: number[]) {
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null
}
function median(values: number[]) {
if (!values.length) return null
const sorted = [...values].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
// ===== 龙头算法 =====
function leaderScore(stock: MarketSnapshotRow) {
const pct = num(stock.change_pct) ?? 0
const turnover = num(stock.turnover_rate) ?? 0
const amount = num(stock.amount) ?? 0
const cap = num(stock.float_market_cap) ?? num(stock.market_cap) ?? 0
const volRatio = num(stock.vol_ratio_5d) ?? 1
const boards = num(stock.consecutive_limit_ups) ?? 0
const parts = {
momentum: clamp01((pct + 0.02) / 0.12),
turnover: clamp01(Math.log1p(Math.max(turnover, 0)) / Math.log1p(30)),
amount: clamp01(Math.log1p(Math.max(amount, 0)) / Math.log1p(20_000_000_000)),
cap: clamp01(Math.log1p(Math.max(cap, 0)) / Math.log1p(300_000_000_000)),
volume: clamp01((volRatio - 1) / 4),
boards: clamp01(boards / 5),
}
const score = (
parts.momentum * 0.35 +
parts.turnover * 0.22 +
parts.amount * 0.18 +
parts.cap * 0.15 +
parts.volume * 0.07 +
parts.boards * 0.03
) * 100
return { score, parts }
}
function enrichStock(stock: StockRow, marketMap: Map<string, MarketSnapshotRow>): EnrichedStock {
const market = symbolKeys(stock.symbol ?? stock.code).map(k => marketMap.get(k)).find(Boolean) ?? {}
const merged = { ...stock, ...market } as MarketSnapshotRow & StockRow
const ls = leaderScore(merged)
return {
...merged,
symbol: String(merged.symbol ?? stock.symbol ?? stock.code ?? ''),
name: merged.name ?? String(stock.name ?? stock['股票简称'] ?? ''),
leaderScore: ls.score,
leaderParts: ls.parts,
}
}
// ===== 行业统计计算 =====
function calcIndustryStat(group: DimensionGroup, marketMap: Map<string, MarketSnapshotRow>): IndustryStat {
const seen = new Set<string>()
const stocks = group.stocks
.map(s => enrichStock(s, marketMap))
.filter(s => {
const key = String(s.symbol ?? '')
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
const pctValues = stocks.map(s => num(s.change_pct)).filter((v): v is number => v != null)
const turnoverValues = stocks.map(s => num(s.turnover_rate)).filter((v): v is number => v != null)
const volValues = stocks.map(s => num(s.vol_ratio_5d)).filter((v): v is number => v != null)
const totalAmount = stocks.reduce((sum, s) => sum + (num(s.amount) ?? 0), 0)
const upCount = pctValues.filter(v => v > 0).length
const downCount = pctValues.filter(v => v < 0).length
const flatCount = Math.max(0, stocks.length - upCount - downCount)
const strongCount = pctValues.filter(v => v >= 0.05).length
const weakCount = pctValues.filter(v => v <= -0.05).length
const leader = stocks.length ? [...stocks].sort((a, b) => b.leaderScore - a.leaderScore)[0] : null
const avgPct = avg(pctValues)
const medianPct = median(pctValues)
const upRate = pctValues.length ? upCount / pctValues.length : 0
const amountScore = clamp01(Math.log1p(totalAmount) / Math.log1p(80_000_000_000))
const strongScore = stocks.length ? clamp01(strongCount / Math.max(1, stocks.length * 0.18)) : 0
const leaderPart = clamp01((leader?.leaderScore ?? 0) / 100)
const avgPart = clamp01(((avgPct ?? 0) + 0.02) / 0.09)
const upPart = clamp01((upRate - 0.35) / 0.55)
const heatScore = (avgPart * 0.38 + upPart * 0.2 + strongScore * 0.16 + amountScore * 0.12 + leaderPart * 0.14) * 100
const riskScore = (clamp01((-(avgPct ?? 0) + 0.01) / 0.08) * 0.55 + clamp01(weakCount / Math.max(1, stocks.length * 0.18)) * 0.45) * 100
return {
key: group.key,
stocks,
count: stocks.length,
avgPct,
medianPct,
upCount,
downCount,
flatCount,
upRate,
strongCount,
weakCount,
totalAmount,
avgTurnover: avg(turnoverValues),
avgVolRatio: avg(volValues),
leader,
heatScore,
riskScore,
}
}
function statSort(mode: SortMode) {
return (a: IndustryStat, b: IndustryStat) => {
switch (mode) {
case 'avgPct': return (b.avgPct ?? -Infinity) - (a.avgPct ?? -Infinity)
case 'leader': return (b.leader?.leaderScore ?? -Infinity) - (a.leader?.leaderScore ?? -Infinity)
case 'amount': return b.totalAmount - a.totalAmount
case 'down': return (a.avgPct ?? Infinity) - (b.avgPct ?? Infinity)
case 'heat':
default: return b.heatScore - a.heatScore
}
}
}
// ===== 行业层级分组 =====
function industryLevelName(key: string, level: IndustryLevel) {
const parts = key.split('-').map(s => s.trim()).filter(Boolean)
return parts[level - 1] || parts[parts.length - 1] || key
}
function groupByIndustryLevel(groups: DimensionGroup[], level: IndustryLevel): DimensionGroup[] {
const map = new Map<string, DimensionGroup>()
for (const group of groups) {
const key = industryLevelName(group.key, level)
const existing = map.get(key)
if (existing) {
existing.stocks.push(...group.stocks)
existing.count = existing.stocks.length
} else {
map.set(key, {
key,
count: group.stocks.length,
stocks: [...group.stocks],
metrics: { ...group.metrics },
})
}
}
return [...map.values()].sort((a, b) => b.count - a.count)
}
// ===== 主页面 =====
export function IndustryAnalysis() {
const [fieldConfig, setFieldConfig] = useState<AnalysisFieldConfig>(loadConfig)
const [showConfig, setShowConfig] = useState(false)
const [search, setSearch] = useState('')
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => {
setPreviewSymbol(symbol)
setPreviewName(name ?? '')
setPreviewNavList(navList ?? [])
}, [])
const [showRps, setShowRps] = useState(false)
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
const availableConfigs = configsQuery.data?.items ?? []
// 用户配置的 configId 可能已失效 (扩展数据被删除), 此时回退到自动选择,
// 避免用失效 ID 请求接口报错; 用户仍可点配置按钮重新选择。
const preferredConfigId = fieldConfig.configId || pickBestConfig(availableConfigs)
const preferredConfig = availableConfigs.find(c => c.id === preferredConfigId)
const activeConfigId = preferredConfig ? preferredConfigId : pickBestConfig(availableConfigs)
const activeConfig = availableConfigs.find(c => c.id === activeConfigId)
const rowsQuery = useQuery({
queryKey: QK.extDataRows(activeConfigId, undefined, PAGE_LIMIT),
queryFn: () => api.extDataRows(activeConfigId, { limit: PAGE_LIMIT }),
enabled: !!activeConfigId,
})
// 内置行业预设 (ext_hy_ths) 无数据时的一键拉取: 走通用拉取端点
// (与「数据」页扩展表的「执行」同一路径, 配方由表的 pull 配置决定)
const queryClient = useQueryClient()
const fetchMutation = useMutation({
mutationFn: () => api.extDataPullRun(PRESET_INDUSTRY_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.extData })
queryClient.invalidateQueries({ queryKey: QK.extDataRows(PRESET_INDUSTRY_ID, undefined, PAGE_LIMIT) })
},
})
// 是否处于「内置行业预设存在但无数据」状态 → 显示获取按钮
const needsIndustryFetch =
!!activeConfig && activeConfig.id === PRESET_INDUSTRY_ID &&
!rowsQuery.isLoading && (rowsQuery.data?.total ?? 0) === 0
const marketQuery = useQuery({
queryKey: QK.marketSnapshot,
queryFn: api.marketSnapshot,
staleTime: 60_000,
})
const marketMap = useMemo(() => buildMarketMap(marketQuery.data?.rows ?? []), [marketQuery.data?.rows])
const resolved = useMemo(
() => resolveDimension(rowsQuery.data, activeConfig, fieldConfig.dimensionField ? [fieldConfig.dimensionField, ...CANDIDATE_FIELDS] : CANDIDATE_FIELDS),
[rowsQuery.data, activeConfig, fieldConfig.dimensionField],
)
const industryLevel = fieldConfig.hierarchyLevel ?? 2
const groups = useMemo(() => groupByIndustryLevel(resolved.groups, industryLevel), [resolved.groups, industryLevel])
const stats = useMemo(() => {
return groups
.map(g => calcIndustryStat(g, marketMap))
.filter(s => s.count > 0)
}, [groups, marketMap])
const filteredStats = useMemo(() => {
const q = search.trim().toLowerCase()
const base = q ? stats.filter(s => s.key.toLowerCase().includes(q)) : stats
return [...base].sort(statSort(sortMode))
}, [stats, search, sortMode])
const selected = filteredStats.find(s => s.key === selectedKey) ?? filteredStats[0] ?? null
const leading = useMemo(() => [...stats].sort(statSort('heat')).slice(0, 10), [stats])
const falling = useMemo(() => [...stats].sort(statSort('down')).slice(0, 10), [stats])
const activeIndustry = useMemo(() => [...stats].sort(statSort('amount'))[0] ?? null, [stats])
const industryBreadth = useMemo(() => {
const priced = stats.filter(s => s.avgPct != null)
return {
up: priced.filter(s => (s.avgPct ?? 0) > 0).length,
down: priced.filter(s => (s.avgPct ?? 0) < 0).length,
flat: priced.filter(s => s.avgPct === 0).length,
}
}, [stats])
const totalSymbols = useMemo(() => {
const set = new Set<string>()
stats.forEach(s => s.stocks.forEach(st => { if (st.symbol) set.add(st.symbol) }))
return set.size
}, [stats])
// 热力图用的 quoteMap(兼容 DimensionHeatmap 组件接口)
const heatmapQuoteMap = useMemo(() => {
const map = new Map<string, { symbol: string; pct?: number; change_pct?: number; name?: string; [k: string]: unknown }>()
for (const [k, v] of marketMap) {
map.set(k, {
...v,
change_pct: v.change_pct ?? undefined,
name: v.name ?? undefined,
})
}
return map
}, [marketMap])
const handleSaveConfig = (c: AnalysisFieldConfig) => {
setFieldConfig(c)
saveConfig(c)
setSelectedKey(null)
}
if (configsQuery.isLoading) {
return <div className="flex h-full items-center justify-center"><RefreshCw className="h-5 w-5 animate-spin text-muted" /></div>
}
if (!activeConfig) {
// 极端情况: 无任何行业配置。仍提供一键获取内置行业数据入口
return (
<>
<div className="flex h-full flex-col">
<PageHeader
title="行业板块"
right={
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
}
/>
<PresetFetchState
title="暂无行业数据"
hint="从同花顺获取行业分类数据后即可使用行业板块"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
</>
)
}
const industryLevelLabel = `${industryLevel}级行业`
return (
<>
<PageHeader
title="行业板块"
subtitle={`${industryLevelLabel} · ${marketQuery.data?.as_of ?? rowsQuery.data?.date ?? '最新'} · ${stats.length} 个行业 · ${totalSymbols} 只标的`}
right={
<div className="flex items-center gap-1">
{/* RPS 轮动: 打开行业涨幅轮动矩阵对话框 */}
<button
onClick={() => setShowRps(true)}
className="inline-flex items-center gap-1 rounded-btn border border-amber-400/40 bg-amber-400/15 px-2.5 py-1.5 text-[11px] text-amber-400 font-medium transition-colors hover:bg-amber-400/25 hover:border-amber-400/60"
title="行业涨幅轮动矩阵"
>
<Repeat className="h-3.5 w-3.5" />涨幅RPS轮动分析
</button>
<button
onClick={() => { rowsQuery.refetch(); marketQuery.refetch() }}
disabled={rowsQuery.isFetching || marketQuery.isFetching}
className="p-1.5 text-muted hover:bg-surface disabled:opacity-50"
title="刷新"
>
<RefreshCw className={cn('h-4 w-4', (rowsQuery.isFetching || marketQuery.isFetching) && 'animate-spin')} />
</button>
<button onClick={() => setShowConfig(true)} className="p-1.5 text-muted hover:bg-surface hover:text-accent" title="配置数据源">
<Settings2 className="h-4 w-4" />
</button>
</div>
}
/>
<div className="min-h-full bg-[radial-gradient(circle_at_12%_0%,rgba(245,158,11,0.12),transparent_28%),radial-gradient(circle_at_85%_8%,rgba(244,63,94,0.08),transparent_28%)] px-6 py-5">
<div className="mx-auto max-w-[1440px] space-y-5">
<SectorRotationCard kind="industry" />
<HeroPanel leading={leading[0]} falling={falling[0]} activeIndustry={activeIndustry} industryBreadth={industryBreadth} />
<MarketPulse
leading={leading}
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
activeSymbol={previewSymbol}
onStockClick={handleStockClick}
/>
{/* 热力图 */}
{groups.length > 0 && (
<DimensionHeatmap
groups={groups}
quoteMap={heatmapQuoteMap}
selectedKey={selectedKey}
onSelect={k => setSelectedKey(k)}
colorScheme="amber"
/>
)}
{stats.length > 0 ? (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[18rem_1fr]">
<IndustryRail
stats={filteredStats.slice(0, MAX_RENDERED_INDUSTRIES)}
selectedKey={selected?.key ?? null}
search={search}
sortMode={sortMode}
onSearch={v => { setSearch(v); setSelectedKey(null) }}
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<IndustryFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">正在计算行业强度...</div>
) : needsIndustryFetch ? (
<PresetFetchState
title="未获取行业数据"
hint="内置行业数据源已就绪,点击下方按钮从同花顺获取行业分类数据"
isLoading={fetchMutation.isPending}
error={fetchMutation.error}
onFetch={() => fetchMutation.mutate()}
/>
) : (
<EmptyState icon={Layers3} title="未匹配到行业数据" hint={resolved.hint || '请检查扩展数据是否包含行业/板块相关字段'} />
)}
</div>
</div>
<AnimatePresence>
{showConfig && <AnalysisConfigDialog currentConfig={fieldConfig} onSave={handleSaveConfig} onClose={() => setShowConfig(false)} showHierarchyLevel />}
</AnimatePresence>
{previewSymbol && (
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }}
navList={previewNavList}
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
/>
)}
{showRps && <RpsRotationDialog onClose={() => setShowRps(false)} kind="industry" />}
</>
)
}
// ===== HeroPanel =====
function HeroPanel({
leading,
falling,
activeIndustry,
industryBreadth,
}: {
leading?: IndustryStat
falling?: IndustryStat
activeIndustry?: IndustryStat | null
industryBreadth: { up: number; down: number; flat: number }
}) {
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-5">
<HeroMetric icon={TrendingUp} label="最强行业" value={leading?.key ?? '—'} hint={leading?.avgPct != null ? <span className={priceColorClass(leading.avgPct)}>{fmtPct(leading.avgPct)}</span> : '等待行情'} tone="up" />
<HeroMetric icon={TrendingDown} label="最大风险" value={falling?.key ?? '—'} hint={falling?.avgPct != null ? <span className={priceColorClass(falling.avgPct)}>{fmtPct(falling.avgPct)}</span> : '等待行情'} tone="down" />
<HeroMetric
icon={Activity}
label="涨跌行业"
value={<><span className="text-bull">{industryBreadth.up}</span><span className="mx-1 text-muted">/</span><span className="text-bear">{industryBreadth.down}</span></>}
hint={<><span className="text-bull">上涨</span><span className="mx-1 text-muted">/</span><span className="text-bear">下跌</span>{industryBreadth.flat ? <span className="text-muted"> · 平 {industryBreadth.flat}</span> : null}</>}
tone="blue"
/>
<HeroMetric icon={Activity} label="资金活跃" value={activeIndustry?.key ?? '—'} hint={activeIndustry ? fmtBigNum(activeIndustry.totalAmount) : '等待行情'} tone="blue" />
<HeroMetric icon={Crown} label="龙头算法" value="6 因子" hint="强势 + 承接 + 容量" tone="gold" />
</div>
)
}
function HeroMetric({ icon: Icon, label, value, hint, tone }: {
icon: typeof TrendingUp
label: string
value: ReactNode
hint: ReactNode
tone: 'up' | 'down' | 'gold' | 'blue'
}) {
const toneClass = {
up: 'text-bull bg-bull/10',
down: 'text-bear bg-bear/10',
gold: 'text-amber-300 bg-amber-400/10',
blue: 'text-blue-300 bg-blue-400/10',
}[tone]
const valueClass = {
up: 'text-bull',
down: 'text-bear',
gold: 'text-amber-300',
blue: 'text-foreground',
}[tone]
return (
<div className="rounded-xl border border-border bg-surface px-3 py-2">
<div className="flex items-center justify-between text-[11px] text-muted">
<span>{label}</span>
<span className={cn('rounded-md p-1', toneClass)}><Icon className="h-3.5 w-3.5" /></span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', valueClass)}>{value}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">{hint}</div>
</div>
)
}
// ===== MarketPulse =====
function MarketPulse({
leading,
falling,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
leading: IndustryStat[]
falling: IndustryStat[]
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
return (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
</div>
)
}
function PulseList({
title,
items,
mode,
selectedKey,
onSelect,
onStockClick,
activeSymbol,
}: {
title: string
items: IndustryStat[]
mode: 'up' | 'down'
selectedKey: string | null
onSelect: (key: string) => void
onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void
activeSymbol: string | null
}) {
const toneText = mode === 'up' ? 'text-bull' : 'text-bear'
const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20'
const toneBg = mode === 'up' ? 'bg-bull/10' : 'bg-bear/10'
const toneHover = mode === 'up' ? 'hover:border-bull/35' : 'hover:border-bear/35'
return (
<div className={cn('rounded-xl border bg-base/35 p-2', toneBorder)}>
<div className="mb-1.5 flex items-center justify-between px-1">
<div className={cn('flex items-center gap-1.5 text-xs font-medium', toneText)}>
{mode === 'up' ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
{title}
</div>
<span className="rounded-full bg-elevated/60 px-2 py-0.5 text-[10px] text-muted">Top 10</span>
</div>
<div className="space-y-1">
{items.map((item, idx) => {
const active = selectedKey === item.key
const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore)
const leaders = sortedStocks.slice(0, 3)
const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0
const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0
const flatPct = Math.max(0, 100 - upPct - downPct)
return (
<button
key={item.key}
onClick={() => onSelect(item.key)}
className={cn(
'block w-full rounded-lg border border-transparent bg-surface/45 px-2 py-1.5 text-left transition-colors',
active ? cn('bg-blue-400/[0.08]', toneBorder) : cn('hover:bg-elevated/35', toneHover),
)}
>
<div className="grid gap-2 md:grid-cols-[minmax(0,0.9fr)_minmax(16rem,1.1fr)] md:items-center">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md font-mono text-[10px]', idx < 3 ? cn(toneBg, toneText) : 'bg-elevated/70 text-muted')}>{idx + 1}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs font-medium text-foreground">{item.key}</span>
<span className="shrink-0 text-[10px] text-muted">
<span className="text-bull">{item.upCount}</span>涨
<span className="mx-0.5 text-muted/40">/</span>
<span className="text-bear">{item.downCount}</span>跌
</span>
<span className={cn('ml-auto shrink-0 font-mono text-[10px] tabular-nums', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
</div>
</div>
<div className="ml-7 mt-1">
<div className="flex h-1 overflow-hidden rounded-full bg-elevated">
<div className="h-full bg-bull/70" style={{ width: `${upPct}%` }} />
{flatPct > 0 && <div className="h-full bg-muted/25" style={{ width: `${flatPct}%` }} />}
<div className="h-full bg-bear/70" style={{ width: `${downPct}%` }} />
</div>
</div>
</div>
<div className="grid min-w-0 grid-cols-3 gap-1">
{Array.from({ length: 3 }).map((_, i) => {
const stock = leaders[i]
return stock ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
<span className={cn('shrink-0 font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</span>
) : <span key={i} className="rounded-md bg-elevated/30 px-1.5 py-0.5 text-[10px] text-muted/40">—</span>
})}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
// ===== IndustryRail(左侧列表面板) =====
function IndustryRail({
stats,
selectedKey,
search,
sortMode,
onSearch,
onSort,
onSelect,
}: {
stats: IndustryStat[]
selectedKey: string | null
search: string
sortMode: SortMode
onSearch: (v: string) => void
onSort: (v: SortMode) => void
onSelect: (v: string) => void
}) {
return (
<section className="rounded-2xl border border-border bg-surface p-2.5">
<div className="px-1 pb-2.5">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">行业矩阵</h3>
<span className="text-[10px] text-muted">Top {stats.length}</span>
</div>
<div className="mt-2 relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input value={search} onChange={e => onSearch(e.target.value)} placeholder="搜索行业" className="h-8 w-full rounded-lg border border-border bg-base pl-8 pr-3 text-xs text-foreground outline-none focus:border-accent/50" />
</div>
<div className="mt-2 grid grid-cols-5 overflow-hidden rounded-lg border border-border text-[10px]">
{([
['heat', '强度'], ['avgPct', '涨幅'], ['leader', '龙头'], ['amount', '成交'], ['down', '跌幅'],
] as [SortMode, string][]).map(([key, label]) => (
<button key={key} onClick={() => onSort(key)} className={cn('py-1.5 transition-colors', sortMode === key ? 'bg-amber-500/15 text-amber-400' : 'bg-base text-muted hover:text-foreground')}>{label}</button>
))}
</div>
</div>
<div className="max-h-[620px] overflow-auto rounded-lg border border-border/50">
{stats.map(item => {
const active = selectedKey === item.key
return (
<button key={item.key} onClick={() => onSelect(item.key)} className={cn('w-full border-b border-border/50 px-2.5 py-2 text-left transition-colors last:border-b-0', active ? 'bg-amber-500/[0.08]' : 'hover:bg-elevated/40')}>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">{item.key}</span>
<span className={cn('font-mono text-xs', priceColorClass(item.avgPct))}>{item.avgPct != null ? fmtPct(item.avgPct) : '—'}</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted">
<span>{item.count}只</span>
<span className="text-bull">{item.upCount}涨</span>
<span className="text-bear">{item.downCount}跌</span>
<span className="ml-auto">强度 {item.heatScore.toFixed(0)}</span>
</div>
</button>
)
})}
</div>
</section>
)
}
// ===== IndustryFocus(右侧聚焦面板) =====
function IndustryFocus({ stat, onStockClick, activeSymbol }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) {
if (!stat) return null
const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS)
const topLeaders = stocks.slice(0, 3)
const focusNav: NavItem[] = toNavItems(stocks)
return (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<h3 className="truncate text-xl font-semibold text-foreground">{stat.key}</h3>
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-400">强度 {stat.heatScore.toFixed(0)}</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<span>{stat.count} 只成分</span>
<span className={priceColorClass(stat.avgPct)}>平均 {stat.avgPct != null ? fmtPct(stat.avgPct) : '—'}</span>
<span>上涨占比 {(stat.upRate * 100).toFixed(0)}%</span>
<span>成交额 {fmtBigNum(stat.totalAmount)}</span>
{stat.avgTurnover != null && <span>均换手 {stat.avgTurnover.toFixed(2)}%</span>}
</div>
</div>
<div className="grid grid-cols-5 gap-2 lg:w-[520px]">
<MiniStat label="均涨" value={stat.avgPct != null ? fmtPct(stat.avgPct) : '—'} cls={priceColorClass(stat.avgPct)} />
<MiniStat label="中位" value={stat.medianPct != null ? fmtPct(stat.medianPct) : '—'} cls={priceColorClass(stat.medianPct)} />
<MiniStat label="强势" value={`${stat.strongCount}`} cls="text-bull" />
<MiniStat label="弱势" value={`${stat.weakCount}`} cls="text-bear" />
<MiniStat label="量比" value={stat.avgVolRatio != null ? stat.avgVolRatio.toFixed(2) : '—'} cls="text-foreground" />
</div>
</div>
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
<ScoreExplain stock={topLeaders[0]} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-left text-xs">
<thead className="bg-elevated/60 text-[11px] text-muted">
<tr>
<th className="px-4 py-2 font-medium">排名</th>
<th className="px-4 py-2 font-medium">股票</th>
<th className="px-4 py-2 font-medium">涨跌幅</th>
<th className="px-4 py-2 font-medium">换手率</th>
<th className="px-4 py-2 font-medium">成交额</th>
<th className="px-4 py-2 font-medium">流通市值</th>
<th className="px-4 py-2 font-medium">量比</th>
<th className="px-4 py-2 font-medium">龙头分</th>
</tr>
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
<div className="font-mono text-[10px] text-muted">{s.symbol}</div>
</td>
<td className={cn('px-4 py-2 font-mono tabular-nums', priceColorClass(s.change_pct))}>{s.change_pct != null ? fmtPct(s.change_pct) : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.turnover_rate != null ? `${s.turnover_rate.toFixed(2)}%` : '—'}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.amount)}</td>
<td className="px-4 py-2 font-mono text-foreground">{fmtBigNum(s.float_market_cap ?? s.market_cap)}</td>
<td className="px-4 py-2 font-mono text-foreground">{s.vol_ratio_5d != null ? s.vol_ratio_5d.toFixed(2) : '—'}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<span className="w-9 font-mono text-amber-300">{s.leaderScore.toFixed(0)}</span>
<div className="h-1.5 w-16 rounded-full bg-elevated"><div className="h-full rounded-full bg-amber-300" style={{ width: `${Math.max(4, s.leaderScore)}%` }} /></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{stat.stocks.length > MAX_RENDERED_STOCKS && <div className="shrink-0 border-t border-border px-4 py-2 text-center text-[11px] text-muted">仅展示龙头分前 {MAX_RENDERED_STOCKS} 只,共 {stat.stocks.length} 只</div>}
</section>
)
}
function MiniStat({ label, value, cls }: { label: string; value: string; cls: string }) {
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) {
if (!stocks.length) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无龙头候选</div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-amber-300">
<Crown className="h-3.5 w-3.5" />
本行业三龙头
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
</div>
<div className="mt-2 truncate text-sm font-medium text-foreground">{stock.name || stock.symbol}</div>
<div className="mt-0.5 flex items-center justify-between text-[11px]">
<span className="font-mono text-muted">{stock.symbol}</span>
<span className={cn('font-mono', priceColorClass(stock.change_pct))}>{stock.change_pct != null ? fmtPct(stock.change_pct) : '—'}</span>
</div>
</div>
))}
</div>
</div>
)
}
function ScoreExplain({ stock }: { stock?: EnrichedStock }) {
if (!stock) return <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted">暂无评分拆解</div>
const parts = stock.leaderParts
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-foreground">主龙头评分拆解</span>
<span className="text-[11px] text-muted">涨幅 / 换手 / 成交 / 市值 / 量比 / 连板</span>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-3">
<Part label="动能" value={parts.momentum} cls="bg-rose-400" />
<Part label="换手" value={parts.turnover} cls="bg-orange-400" />
<Part label="成交" value={parts.amount} cls="bg-blue-400" />
<Part label="市值" value={parts.cap} cls="bg-cyan-400" />
<Part label="量比" value={parts.volume} cls="bg-purple-400" />
<Part label="连板" value={parts.boards} cls="bg-amber-300" />
</div>
</div>
)
}
function Part({ label, value, cls }: { label: string; value: number; cls: string }) {
return <div className="rounded-lg bg-base/35 px-2 py-1.5"><div className="mb-1 flex justify-between text-[10px] text-muted"><span>{label}</span><span>{Math.round(value * 100)}</span></div><div className="h-1 rounded-full bg-elevated"><div className={cn('h-full rounded-full', cls)} style={{ width: `${Math.max(3, value * 100)}%` }} /></div></div>
}
// ── 扩展注册清单 — 本文件即完整扩展(组件+路由+导航), 单文件可整体摘除/导入 ──
const extension: FrontendExtension = {
id: 'official.industry-analysis',
apiVersion: 1,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+417
View File
@@ -0,0 +1,417 @@
import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { ArrowUpRight, CalendarClock, Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { api, type Lot } from '@/lib/api'
import { fetchDailyBatchChunked } from '@/lib/klineBatchChunked'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { Modal } from '@/components/Modal'
import { DatePicker } from '@/components/DatePicker'
import { DateShortcuts } from '@/components/DateShortcuts'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { toNavItems } from '@/lib/listNav'
import { boardTag } from '@/components/stock-table/primitives'
const emptyDraft = (): Lot => ({
id: '',
symbol: '',
qty: 0,
cost_price: 0,
buy_date: '',
target_pct: 0,
stop_pct: 0,
remind_date: '',
lead_days: 1,
})
/** 剩余天数单元格: 到期日 − 今天, 可为负 = 已超期; 无到期日 → — */
function RemainingDays({ remind }: { remind?: string | null }) {
if (!remind) return <span className="text-muted/60">—</span>
const remindMs = new Date(`${remind}T00:00:00`).getTime()
const today = new Date()
today.setHours(0, 0, 0, 0)
const n = Math.floor((remindMs - today.getTime()) / 86400000)
if (n < 0) return <span className="font-mono text-warning">已超期{-n}天</span>
return <span className="font-mono text-secondary">{n}天</span>
}
/** 成本 vs 现价的盈亏% (纯价格比例, 无数量参与) */
function CostPnL({ close, cost }: { close?: number; cost: number }) {
if (close == null || !(cost > 0)) return <span className="text-muted/60">—</span>
const pnl = (close - cost) / cost
return <span className={cn('font-mono', priceColorClass(pnl))}>{fmtPct(pnl)}</span>
}
export function Lots() {
const qc = useQueryClient()
const navigate = useNavigate()
const [editing, setEditing] = useState<Lot | null>(null) // null=关闭
const [confirmId, setConfirmId] = useState<string | null>(null)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const lotsQuery = useQuery({ queryKey: QK.lots, queryFn: api.lotsList })
const lots = lotsQuery.data?.lots ?? []
const allSymbols = useMemo(() => Array.from(new Set(lots.map(l => l.symbol))), [lots])
const namesQuery = useQuery({
queryKey: QK.instrumentNames(allSymbols),
queryFn: () => api.instrumentNames(allSymbols),
enabled: allSymbols.length > 0,
staleTime: 300000,
})
const symbolNames = namesQuery.data?.names ?? {}
// 9999 哨兵: 未记买入日期的排最后
const sortedLots = useMemo(() => {
return [...lots].sort((a, b) => (a.buy_date ?? '9999-12-31').localeCompare(b.buy_date ?? '9999-12-31'))
}, [lots])
const lotsNavItems = useMemo(
() => toNavItems(sortedLots.map(l => ({ symbol: l.symbol, name: symbolNames[l.symbol] }))),
[sortedLots, symbolNames],
)
const dailyQuery = useQuery({
queryKey: QK.lotsKline(allSymbols.join(',')),
// 后端单次 symbols 上限 400, 分块拉取合并 (响应形状不变)
queryFn: () => fetchDailyBatchChunked(allSymbols, 5),
enabled: allSymbols.length > 0,
staleTime: 60000,
})
const lastPrices = useMemo(() => {
const m: Record<string, number> = {}
for (const [sym, rows] of Object.entries(dailyQuery.data?.data ?? {})) {
const last = rows[rows.length - 1]
if (last?.close != null) m[sym] = Number(last.close)
}
return m
}, [dailyQuery.data])
const del = useMutation({
mutationFn: api.lotDelete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.lots })
qc.invalidateQueries({ queryKey: QK.monitorRules })
setConfirmId(null)
},
})
// 删除: 第一次进确认态, 第二次真删, 3 秒后自动复位 (与监控中心一致)
const handleClickDelete = (id: string) => {
if (confirmId === id) {
if (resetTimer.current) clearTimeout(resetTimer.current)
setConfirmId(null)
del.mutate(id)
} else {
setConfirmId(id)
if (resetTimer.current) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setConfirmId(null), 3000)
}
}
return (
<div className="flex flex-col h-full">
<PageHeader title="持仓提醒" subtitle="记录个股 / ETF 买入批次, 自动生成止盈止损 / 到期监控规则" />
<div className="flex-1 min-h-0 px-5 py-4">
<div className="mx-auto max-w-5xl space-y-4">
<div className="flex items-center justify-between">
<div className="text-xs text-secondary">{lots.length} 个批次</div>
<button
onClick={() => setEditing(emptyDraft())}
className="inline-flex h-9 items-center gap-1.5 rounded-btn border border-accent/30 bg-accent/10 px-3 text-xs font-medium text-accent transition-colors hover:bg-accent/15 cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />新增批次
</button>
</div>
{lotsQuery.isLoading ? (
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center text-xs text-muted">加载中…</div>
) : lotsQuery.isError ? (
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center">
<div className="text-xs text-danger">批次加载失败</div>
<button
onClick={() => lotsQuery.refetch()}
className="mt-2 rounded-btn border border-border px-3 py-1 text-[11px] text-secondary hover:bg-elevated cursor-pointer"
>
重试
</button>
</div>
) : lots.length === 0 ? (
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center">
<div className="text-sm text-muted">还没有批次</div>
<div className="mt-1 text-[11px] text-muted/70">记录一笔买入后, 系统会按成本价 ± 止盈/止损% 生成价格监控; 填了到期日则自动生成到期提醒。这里只用于生成提醒, 不是持仓记账。</div>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border bg-surface/40 shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border/60 bg-surface/60 text-[10px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">标的</th>
<th className="px-2 py-2 font-medium text-right">数量(参考)</th>
<th className="px-2 py-2 font-medium text-right">成本价</th>
<th className="px-2 py-2 font-medium text-right">现价</th>
<th className="px-2 py-2 font-medium text-right">盈亏%</th>
<th className="px-2 py-2 font-medium text-right">止盈%</th>
<th className="px-2 py-2 font-medium text-right">止损%</th>
<th className="px-2 py-2 font-medium">买入日期</th>
<th className="px-2 py-2 font-medium text-right">剩余天数</th>
<th className="px-2 py-2 font-medium">到期提醒</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{sortedLots.map(lot => (
<tr key={lot.id} className="border-b border-border/40 last:border-0 hover:bg-elevated/40">
<td className="px-4 py-2.5">
<button
onClick={() => setPreviewSymbol(lot.symbol)}
title={`查看 ${lot.symbol} 日K`}
className="inline-flex items-center gap-1.5 min-w-0 hover:bg-elevated/50 rounded px-0.5 py-0.5 transition-colors cursor-pointer"
>
<span className="font-mono font-medium text-foreground">{lot.symbol}</span>
{(() => { const b = boardTag(lot.symbol); return b && <span className={`inline-flex items-center justify-center rounded px-1 text-[9px] font-bold leading-tight border ${b.color}`}>{b.label}</span> })()}
{symbolNames[lot.symbol] && <span className="text-secondary truncate max-w-28">{symbolNames[lot.symbol]}</span>}
</button>
</td>
<td className="px-2 py-2.5 text-right font-mono text-secondary">{lot.qty}</td>
<td className="px-2 py-2.5 text-right font-mono text-foreground">{lot.cost_price}</td>
<td className="px-2 py-2.5 text-right font-mono text-secondary">{lastPrices[lot.symbol] != null ? fmtPrice(lastPrices[lot.symbol]) : '—'}</td>
<td className="px-2 py-2.5 text-right"><CostPnL close={lastPrices[lot.symbol]} cost={lot.cost_price} /></td>
<td className="px-2 py-2.5 text-right font-mono text-bull">{lot.target_pct > 0 ? `${lot.target_pct}%` : '—'}</td>
<td className="px-2 py-2.5 text-right font-mono text-bear">{lot.stop_pct > 0 ? `${lot.stop_pct}%` : '—'}</td>
<td className="px-2 py-2.5 text-muted">{lot.buy_date || '—'}</td>
<td className="px-2 py-2.5 text-right"><RemainingDays remind={lot.remind_date} /></td>
<td className="px-2 py-2.5">
{lot.remind_date ? (
<span className="inline-flex items-center gap-1 text-rose-400">
<CalendarClock className="h-3 w-3" />
{lot.remind_date}
{lot.lead_days > 0 && <span className="text-muted">· 提前{lot.lead_days}天</span>}
</span>
) : <span className="text-muted/60">—</span>}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-0.5">
<button
onClick={() => setEditing(lot)}
title="编辑"
className="p-1.5 rounded-md text-secondary transition-all hover:bg-accent/10 hover:text-accent cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
{confirmId === lot.id ? (
<button
onClick={() => handleClickDelete(lot.id)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[9px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />确认
</button>
) : (
<button
onClick={() => handleClickDelete(lot.id)}
title="删除 (同步删除生成的监控规则)"
className="p-1.5 rounded-md text-secondary transition-all hover:bg-danger/10 hover:text-danger cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex items-center justify-center gap-1 text-[11px] text-muted">
生成的止盈止损 / 到期提醒规则已同步至监控中心
<button onClick={() => navigate('/monitor')} className="inline-flex items-center gap-0.5 text-accent hover:text-accent/80 cursor-pointer">
去查看 <ArrowUpRight className="h-3 w-3" />
</button>
</div>
</div>
</div>
{editing && <LotDialog lot={editing} onClose={() => setEditing(null)} />}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
navList={lotsNavItems}
onNavigate={(sym) => setPreviewSymbol(sym)}
onClose={() => setPreviewSymbol(null)}
/>
</div>
)
}
function LotDialog({ lot, onClose }: { lot: Lot; onClose: () => void }) {
const qc = useQueryClient()
const [draft, setDraft] = useState<Lot>(() => ({ ...lot }))
const [symbolQuery, setSymbolQuery] = useState('')
const [error, setError] = useState('')
// 数字字段用本地字符串承载 (可先清空再输入), 提交时才解析成数值;
// 否则受控 number + parseFloat 会在清空瞬间把值塞回 0, 导致「0 去不掉」。
const [nums, setNums] = useState<Record<string, string>>(() => {
const f = (v: number | undefined | null) => (v == null || v === 0 ? '' : String(v))
return {
qty: f(lot.qty), cost_price: f(lot.cost_price),
target_pct: f(lot.target_pct), stop_pct: f(lot.stop_pct),
lead_days: f(lot.lead_days),
}
})
const numField = (key: string) => ({
value: nums[key] ?? '',
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setNums(s => ({ ...s, [key]: e.target.value })),
})
const symbolSearch = useQuery({
queryKey: QK.instrumentSearch(symbolQuery, 'stock,etf'),
queryFn: () => api.instrumentSearch(symbolQuery, 20, 'stock,etf'),
enabled: symbolQuery.trim().length > 0,
})
const save = useMutation({
mutationFn: (vals: Partial<Lot>) => api.lotSave({ ...draft, ...vals, symbol: draft.symbol.trim() }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.lots })
qc.invalidateQueries({ queryKey: QK.monitorRules })
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
const parseNum = (s: string | undefined) => {
const n = parseFloat(s ?? '')
return Number.isFinite(n) ? n : 0
}
const submit = () => {
setError('')
if (!draft.symbol.trim()) return setError('请选择标的')
const vals: Partial<Lot> = {
qty: parseNum(nums.qty),
cost_price: parseNum(nums.cost_price),
target_pct: parseNum(nums.target_pct),
stop_pct: parseNum(nums.stop_pct),
lead_days: Math.floor(parseNum(nums.lead_days)),
}
if (!((vals.cost_price ?? 0) > 0)) return setError('成本价必须为正数')
if ((vals.qty ?? 0) < 0 || (vals.target_pct ?? 0) < 0 || (vals.stop_pct ?? 0) < 0 || (vals.lead_days ?? 0) < 0) {
return setError('数量 / 百分比 / 提前天数不能为负数')
}
if (!((vals.target_pct ?? 0) > 0 || (vals.stop_pct ?? 0) > 0 || draft.remind_date)) {
return setError('止盈% / 止损% / 到期日 至少设置一项')
}
save.mutate(vals)
}
return (
<Modal onClose={onClose} ariaLabel={lot.id ? '编辑批次' : '新增批次'} panelClassName="w-[92vw] max-w-md bg-surface border border-border rounded-card shadow-xl">
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
<span className="text-sm font-medium text-foreground">{lot.id ? '编辑批次' : '新增批次'}</span>
<span className="text-[10px] text-muted">保存后自动同步监控规则</span>
</div>
<div className="space-y-3 px-4 py-4">
{/* 标的 */}
<div className="space-y-1.5">
<span className="text-[11px] text-muted">标的</span>
{draft.symbol ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded bg-elevated px-2 py-1 font-mono text-[11px] text-secondary">
{draft.symbol}
<button onClick={() => setDraft(d => ({ ...d, symbol: '' }))} className="text-muted hover:text-danger cursor-pointer"><span className="text-[10px]">✕</span></button>
</span>
<span className="text-[10px] text-muted">点 ✕ 可重选</span>
</div>
) : (
<div className="relative">
<input
value={symbolQuery}
onChange={e => setSymbolQuery(e.target.value)}
placeholder="搜索代码或名称..."
autoFocus
className="h-9 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground focus:outline-none focus:border-accent/50"
/>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted" />
{symbolSearch.data && symbolSearch.data.results.length > 0 && (
<div className="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded border border-border bg-surface shadow-lg">
{symbolSearch.data.results.map(r => (
<button
key={r.symbol}
onClick={() => { setDraft(d => ({ ...d, symbol: r.symbol })); setSymbolQuery('') }}
className="block w-full px-2.5 py-1.5 text-left text-[11px] hover:bg-elevated cursor-pointer"
>
<span className="font-mono text-foreground/80">{r.symbol}</span>
<span className="ml-1.5 text-muted">{r.name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">数量 (参考)</span>
<input type="number" min={0} placeholder="0" {...numField('qty')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">成本价</span>
<input type="number" min={0} step="any" placeholder="0" {...numField('cost_price')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">止盈%</span>
<input type="number" min={0} step="any" placeholder="0" {...numField('target_pct')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">止损%</span>
<input type="number" min={0} step="any" placeholder="0" {...numField('stop_pct')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[11px] text-muted">买入日期 (可选)</span>
<DateShortcuts value={draft.buy_date ?? ''} onChange={v => setDraft(d => ({ ...d, buy_date: v || null }))} options={[{ label: '今天', days: 0 }]} />
<DatePicker value={draft.buy_date ?? ''} onChange={v => setDraft(d => ({ ...d, buy_date: v || null }))} placeholder="不记录" />
</div>
<div className="space-y-1.5">
<span className="text-[11px] text-muted">到期日 (可选)</span>
<DateShortcuts value={draft.remind_date ?? ''} onChange={v => setDraft(d => ({ ...d, remind_date: v || null }))} options={[{ label: '5天', days: 5 }, { label: '10天', days: 10 }, { label: '15天', days: 15 }]} base={draft.buy_date || undefined} />
<DatePicker value={draft.remind_date ?? ''} onChange={v => setDraft(d => ({ ...d, remind_date: v || null }))} placeholder="不提醒" />
</div>
</div>
{draft.remind_date && (
<label className="space-y-1.5">
<span className="text-[11px] text-muted">提前提醒天数</span>
<input type="number" min={0} placeholder="1" {...numField('lead_days')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
<span className="block text-[10px] text-muted">提醒仅在交易时段评估; 到期日若逢周末或长假, 请把提前天数调大些 (建议 ≥ 2, 长假更大)</span>
</label>
)}
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] text-danger">{error}</div>}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border/60 px-4 py-3">
<button onClick={onClose} className="h-9 rounded-btn border border-border px-3 text-xs text-secondary hover:bg-elevated cursor-pointer">取消</button>
<button
onClick={submit}
disabled={save.isPending}
className={cn('h-9 rounded-btn px-4 text-xs font-medium bg-accent/90 text-white hover:bg-accent cursor-pointer disabled:opacity-50')}
>
{save.isPending ? '保存中...' : '保存'}
</button>
</div>
</Modal>
)
}
+2 -417
View File
@@ -1,424 +1,9 @@
import { useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { ArrowUpRight, CalendarClock, Pencil, Plus, Search, Trash2 } from 'lucide-react'
import { api, type Lot } from '@/lib/api'
import { fetchDailyBatchChunked } from '@/lib/klineBatchChunked'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { PageHeader } from '@/components/PageHeader'
import { Modal } from '@/components/Modal'
import { DatePicker } from '@/components/DatePicker'
import { DateShortcuts } from '@/components/DateShortcuts'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { toNavItems } from '@/lib/listNav'
import { boardTag } from '@/components/stock-table/primitives'
import { lazy } from 'react'
import type { FrontendExtension } from '@/extensions/types'
import { IconLots } from '@/components/BrandIcons'
const emptyDraft = (): Lot => ({
id: '',
symbol: '',
qty: 0,
cost_price: 0,
buy_date: '',
target_pct: 0,
stop_pct: 0,
remind_date: '',
lead_days: 1,
})
export const Lots = lazy(() => import('./Page').then(m => ({ default: m.Lots })))
/** 剩余天数单元格: 到期日 − 今天, 可为负 = 已超期; 无到期日 → — */
function RemainingDays({ remind }: { remind?: string | null }) {
if (!remind) return <span className="text-muted/60">—</span>
const remindMs = new Date(`${remind}T00:00:00`).getTime()
const today = new Date()
today.setHours(0, 0, 0, 0)
const n = Math.floor((remindMs - today.getTime()) / 86400000)
if (n < 0) return <span className="font-mono text-warning">已超期{-n}天</span>
return <span className="font-mono text-secondary">{n}天</span>
}
/** 成本 vs 现价的盈亏% (纯价格比例, 无数量参与) */
function CostPnL({ close, cost }: { close?: number; cost: number }) {
if (close == null || !(cost > 0)) return <span className="text-muted/60">—</span>
const pnl = (close - cost) / cost
return <span className={cn('font-mono', priceColorClass(pnl))}>{fmtPct(pnl)}</span>
}
export function Lots() {
const qc = useQueryClient()
const navigate = useNavigate()
const [editing, setEditing] = useState<Lot | null>(null) // null=关闭
const [confirmId, setConfirmId] = useState<string | null>(null)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const lotsQuery = useQuery({ queryKey: QK.lots, queryFn: api.lotsList })
const lots = lotsQuery.data?.lots ?? []
const allSymbols = useMemo(() => Array.from(new Set(lots.map(l => l.symbol))), [lots])
const namesQuery = useQuery({
queryKey: QK.instrumentNames(allSymbols),
queryFn: () => api.instrumentNames(allSymbols),
enabled: allSymbols.length > 0,
staleTime: 300000,
})
const symbolNames = namesQuery.data?.names ?? {}
// 9999 哨兵: 未记买入日期的排最后
const sortedLots = useMemo(() => {
return [...lots].sort((a, b) => (a.buy_date ?? '9999-12-31').localeCompare(b.buy_date ?? '9999-12-31'))
}, [lots])
const lotsNavItems = useMemo(
() => toNavItems(sortedLots.map(l => ({ symbol: l.symbol, name: symbolNames[l.symbol] }))),
[sortedLots, symbolNames],
)
const dailyQuery = useQuery({
queryKey: QK.lotsKline(allSymbols.join(',')),
// 后端单次 symbols 上限 400, 分块拉取合并 (响应形状不变)
queryFn: () => fetchDailyBatchChunked(allSymbols, 5),
enabled: allSymbols.length > 0,
staleTime: 60000,
})
const lastPrices = useMemo(() => {
const m: Record<string, number> = {}
for (const [sym, rows] of Object.entries(dailyQuery.data?.data ?? {})) {
const last = rows[rows.length - 1]
if (last?.close != null) m[sym] = Number(last.close)
}
return m
}, [dailyQuery.data])
const del = useMutation({
mutationFn: api.lotDelete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.lots })
qc.invalidateQueries({ queryKey: QK.monitorRules })
setConfirmId(null)
},
})
// 删除: 第一次进确认态, 第二次真删, 3 秒后自动复位 (与监控中心一致)
const handleClickDelete = (id: string) => {
if (confirmId === id) {
if (resetTimer.current) clearTimeout(resetTimer.current)
setConfirmId(null)
del.mutate(id)
} else {
setConfirmId(id)
if (resetTimer.current) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setConfirmId(null), 3000)
}
}
return (
<div className="flex flex-col h-full">
<PageHeader title="持仓提醒" subtitle="记录个股 / ETF 买入批次, 自动生成止盈止损 / 到期监控规则" />
<div className="flex-1 min-h-0 px-5 py-4">
<div className="mx-auto max-w-5xl space-y-4">
<div className="flex items-center justify-between">
<div className="text-xs text-secondary">{lots.length} 个批次</div>
<button
onClick={() => setEditing(emptyDraft())}
className="inline-flex h-9 items-center gap-1.5 rounded-btn border border-accent/30 bg-accent/10 px-3 text-xs font-medium text-accent transition-colors hover:bg-accent/15 cursor-pointer"
>
<Plus className="h-3.5 w-3.5" />新增批次
</button>
</div>
{lotsQuery.isLoading ? (
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center text-xs text-muted">加载中…</div>
) : lotsQuery.isError ? (
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center">
<div className="text-xs text-danger">批次加载失败</div>
<button
onClick={() => lotsQuery.refetch()}
className="mt-2 rounded-btn border border-border px-3 py-1 text-[11px] text-secondary hover:bg-elevated cursor-pointer"
>
重试
</button>
</div>
) : lots.length === 0 ? (
<div className="rounded-xl border border-dashed border-border px-6 py-12 text-center">
<div className="text-sm text-muted">还没有批次</div>
<div className="mt-1 text-[11px] text-muted/70">记录一笔买入后, 系统会按成本价 ± 止盈/止损% 生成价格监控; 填了到期日则自动生成到期提醒。这里只用于生成提醒, 不是持仓记账。</div>
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border bg-surface/40 shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border/60 bg-surface/60 text-[10px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">标的</th>
<th className="px-2 py-2 font-medium text-right">数量(参考)</th>
<th className="px-2 py-2 font-medium text-right">成本价</th>
<th className="px-2 py-2 font-medium text-right">现价</th>
<th className="px-2 py-2 font-medium text-right">盈亏%</th>
<th className="px-2 py-2 font-medium text-right">止盈%</th>
<th className="px-2 py-2 font-medium text-right">止损%</th>
<th className="px-2 py-2 font-medium">买入日期</th>
<th className="px-2 py-2 font-medium text-right">剩余天数</th>
<th className="px-2 py-2 font-medium">到期提醒</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{sortedLots.map(lot => (
<tr key={lot.id} className="border-b border-border/40 last:border-0 hover:bg-elevated/40">
<td className="px-4 py-2.5">
<button
onClick={() => setPreviewSymbol(lot.symbol)}
title={`查看 ${lot.symbol} 日K`}
className="inline-flex items-center gap-1.5 min-w-0 hover:bg-elevated/50 rounded px-0.5 py-0.5 transition-colors cursor-pointer"
>
<span className="font-mono font-medium text-foreground">{lot.symbol}</span>
{(() => { const b = boardTag(lot.symbol); return b && <span className={`inline-flex items-center justify-center rounded px-1 text-[9px] font-bold leading-tight border ${b.color}`}>{b.label}</span> })()}
{symbolNames[lot.symbol] && <span className="text-secondary truncate max-w-28">{symbolNames[lot.symbol]}</span>}
</button>
</td>
<td className="px-2 py-2.5 text-right font-mono text-secondary">{lot.qty}</td>
<td className="px-2 py-2.5 text-right font-mono text-foreground">{lot.cost_price}</td>
<td className="px-2 py-2.5 text-right font-mono text-secondary">{lastPrices[lot.symbol] != null ? fmtPrice(lastPrices[lot.symbol]) : '—'}</td>
<td className="px-2 py-2.5 text-right"><CostPnL close={lastPrices[lot.symbol]} cost={lot.cost_price} /></td>
<td className="px-2 py-2.5 text-right font-mono text-bull">{lot.target_pct > 0 ? `${lot.target_pct}%` : '—'}</td>
<td className="px-2 py-2.5 text-right font-mono text-bear">{lot.stop_pct > 0 ? `${lot.stop_pct}%` : '—'}</td>
<td className="px-2 py-2.5 text-muted">{lot.buy_date || '—'}</td>
<td className="px-2 py-2.5 text-right"><RemainingDays remind={lot.remind_date} /></td>
<td className="px-2 py-2.5">
{lot.remind_date ? (
<span className="inline-flex items-center gap-1 text-rose-400">
<CalendarClock className="h-3 w-3" />
{lot.remind_date}
{lot.lead_days > 0 && <span className="text-muted">· 提前{lot.lead_days}天</span>}
</span>
) : <span className="text-muted/60">—</span>}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-0.5">
<button
onClick={() => setEditing(lot)}
title="编辑"
className="p-1.5 rounded-md text-secondary transition-all hover:bg-accent/10 hover:text-accent cursor-pointer"
>
<Pencil className="h-3.5 w-3.5" />
</button>
{confirmId === lot.id ? (
<button
onClick={() => handleClickDelete(lot.id)}
title="再次点击确认删除"
className="inline-flex items-center gap-1 rounded-md bg-danger/15 px-1.5 py-0.5 text-[9px] font-medium text-danger border border-danger/30 animate-pulse cursor-pointer"
>
<Trash2 className="h-2.5 w-2.5" />确认
</button>
) : (
<button
onClick={() => handleClickDelete(lot.id)}
title="删除 (同步删除生成的监控规则)"
className="p-1.5 rounded-md text-secondary transition-all hover:bg-danger/10 hover:text-danger cursor-pointer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex items-center justify-center gap-1 text-[11px] text-muted">
生成的止盈止损 / 到期提醒规则已同步至监控中心
<button onClick={() => navigate('/monitor')} className="inline-flex items-center gap-0.5 text-accent hover:text-accent/80 cursor-pointer">
去查看 <ArrowUpRight className="h-3 w-3" />
</button>
</div>
</div>
</div>
{editing && <LotDialog lot={editing} onClose={() => setEditing(null)} />}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
navList={lotsNavItems}
onNavigate={(sym) => setPreviewSymbol(sym)}
onClose={() => setPreviewSymbol(null)}
/>
</div>
)
}
function LotDialog({ lot, onClose }: { lot: Lot; onClose: () => void }) {
const qc = useQueryClient()
const [draft, setDraft] = useState<Lot>(() => ({ ...lot }))
const [symbolQuery, setSymbolQuery] = useState('')
const [error, setError] = useState('')
// 数字字段用本地字符串承载 (可先清空再输入), 提交时才解析成数值;
// 否则受控 number + parseFloat 会在清空瞬间把值塞回 0, 导致「0 去不掉」。
const [nums, setNums] = useState<Record<string, string>>(() => {
const f = (v: number | undefined | null) => (v == null || v === 0 ? '' : String(v))
return {
qty: f(lot.qty), cost_price: f(lot.cost_price),
target_pct: f(lot.target_pct), stop_pct: f(lot.stop_pct),
lead_days: f(lot.lead_days),
}
})
const numField = (key: string) => ({
value: nums[key] ?? '',
onChange: (e: React.ChangeEvent<HTMLInputElement>) => setNums(s => ({ ...s, [key]: e.target.value })),
})
const symbolSearch = useQuery({
queryKey: QK.instrumentSearch(symbolQuery, 'stock,etf'),
queryFn: () => api.instrumentSearch(symbolQuery, 20, 'stock,etf'),
enabled: symbolQuery.trim().length > 0,
})
const save = useMutation({
mutationFn: (vals: Partial<Lot>) => api.lotSave({ ...draft, ...vals, symbol: draft.symbol.trim() }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: QK.lots })
qc.invalidateQueries({ queryKey: QK.monitorRules })
onClose()
},
onError: err => setError(String((err as any)?.message ?? err)),
})
const parseNum = (s: string | undefined) => {
const n = parseFloat(s ?? '')
return Number.isFinite(n) ? n : 0
}
const submit = () => {
setError('')
if (!draft.symbol.trim()) return setError('请选择标的')
const vals: Partial<Lot> = {
qty: parseNum(nums.qty),
cost_price: parseNum(nums.cost_price),
target_pct: parseNum(nums.target_pct),
stop_pct: parseNum(nums.stop_pct),
lead_days: Math.floor(parseNum(nums.lead_days)),
}
if (!((vals.cost_price ?? 0) > 0)) return setError('成本价必须为正数')
if ((vals.qty ?? 0) < 0 || (vals.target_pct ?? 0) < 0 || (vals.stop_pct ?? 0) < 0 || (vals.lead_days ?? 0) < 0) {
return setError('数量 / 百分比 / 提前天数不能为负数')
}
if (!((vals.target_pct ?? 0) > 0 || (vals.stop_pct ?? 0) > 0 || draft.remind_date)) {
return setError('止盈% / 止损% / 到期日 至少设置一项')
}
save.mutate(vals)
}
return (
<Modal onClose={onClose} ariaLabel={lot.id ? '编辑批次' : '新增批次'} panelClassName="w-[92vw] max-w-md bg-surface border border-border rounded-card shadow-xl">
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
<span className="text-sm font-medium text-foreground">{lot.id ? '编辑批次' : '新增批次'}</span>
<span className="text-[10px] text-muted">保存后自动同步监控规则</span>
</div>
<div className="space-y-3 px-4 py-4">
{/* 标的 */}
<div className="space-y-1.5">
<span className="text-[11px] text-muted">标的</span>
{draft.symbol ? (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded bg-elevated px-2 py-1 font-mono text-[11px] text-secondary">
{draft.symbol}
<button onClick={() => setDraft(d => ({ ...d, symbol: '' }))} className="text-muted hover:text-danger cursor-pointer"><span className="text-[10px]">✕</span></button>
</span>
<span className="text-[10px] text-muted">点 ✕ 可重选</span>
</div>
) : (
<div className="relative">
<input
value={symbolQuery}
onChange={e => setSymbolQuery(e.target.value)}
placeholder="搜索代码或名称..."
autoFocus
className="h-9 w-full rounded-btn border border-border bg-base pl-8 pr-3 text-xs text-foreground focus:outline-none focus:border-accent/50"
/>
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted" />
{symbolSearch.data && symbolSearch.data.results.length > 0 && (
<div className="absolute z-10 mt-1 max-h-48 w-full overflow-auto rounded border border-border bg-surface shadow-lg">
{symbolSearch.data.results.map(r => (
<button
key={r.symbol}
onClick={() => { setDraft(d => ({ ...d, symbol: r.symbol })); setSymbolQuery('') }}
className="block w-full px-2.5 py-1.5 text-left text-[11px] hover:bg-elevated cursor-pointer"
>
<span className="font-mono text-foreground/80">{r.symbol}</span>
<span className="ml-1.5 text-muted">{r.name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="space-y-1.5">
<span className="text-[11px] text-muted">数量 (参考)</span>
<input type="number" min={0} placeholder="0" {...numField('qty')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">成本价</span>
<input type="number" min={0} step="any" placeholder="0" {...numField('cost_price')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">止盈%</span>
<input type="number" min={0} step="any" placeholder="0" {...numField('target_pct')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
<label className="space-y-1.5">
<span className="text-[11px] text-muted">止损%</span>
<input type="number" min={0} step="any" placeholder="0" {...numField('stop_pct')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[11px] text-muted">买入日期 (可选)</span>
<DateShortcuts value={draft.buy_date ?? ''} onChange={v => setDraft(d => ({ ...d, buy_date: v || null }))} options={[{ label: '今天', days: 0 }]} />
<DatePicker value={draft.buy_date ?? ''} onChange={v => setDraft(d => ({ ...d, buy_date: v || null }))} placeholder="不记录" />
</div>
<div className="space-y-1.5">
<span className="text-[11px] text-muted">到期日 (可选)</span>
<DateShortcuts value={draft.remind_date ?? ''} onChange={v => setDraft(d => ({ ...d, remind_date: v || null }))} options={[{ label: '5天', days: 5 }, { label: '10天', days: 10 }, { label: '15天', days: 15 }]} base={draft.buy_date || undefined} />
<DatePicker value={draft.remind_date ?? ''} onChange={v => setDraft(d => ({ ...d, remind_date: v || null }))} placeholder="不提醒" />
</div>
</div>
{draft.remind_date && (
<label className="space-y-1.5">
<span className="text-[11px] text-muted">提前提醒天数</span>
<input type="number" min={0} placeholder="1" {...numField('lead_days')} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
<span className="block text-[10px] text-muted">提醒仅在交易时段评估; 到期日若逢周末或长假, 请把提前天数调大些 (建议 ≥ 2, 长假更大)</span>
</label>
)}
{error && <div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] text-danger">{error}</div>}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border/60 px-4 py-3">
<button onClick={onClose} className="h-9 rounded-btn border border-border px-3 text-xs text-secondary hover:bg-elevated cursor-pointer">取消</button>
<button
onClick={submit}
disabled={save.isPending}
className={cn('h-9 rounded-btn px-4 text-xs font-medium bg-accent/90 text-white hover:bg-accent cursor-pointer disabled:opacity-50')}
>
{save.isPending ? '保存中...' : '保存'}
</button>
</div>
</Modal>
)
}
// ── 扩展注册清单 — 本文件即完整扩展(组件+路由+导航), 单文件可整体摘除/导入 ──
const extension: FrontendExtension = {
id: 'official.lots',
apiVersion: 1,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { DragonTigerCard } from './extension'
import { DragonTigerCard } from './Page'
import type { DragonTigerStockItem } from '@/lib/api'
const _items: DragonTigerStockItem[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Settings2, Trash2, Zap } from 'lucide-react'
import { api, type CustomSignal } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { type SignalKind } from '@/lib/signals'
import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog'
import { Skeleton } from '@/components/data/Skeleton'
import { PageHeader } from '@/components/PageHeader'
import { AnchorWrap } from '@/lib/useCardFlash'
import { ResourceTransferButton } from '@/components/ResourceTransferDialog'
const KIND_LABEL: Record<SignalKind, string> = { entry: '入场', exit: '出场', both: '出入通用' }
const KIND_CLASS: Record<SignalKind, string> = {
entry: 'bg-accent/10 text-accent',
exit: 'bg-warning/10 text-warning',
both: 'bg-muted/10 text-muted',
}
/** 信号库独立页: 全部信号均为用户定义 (csg_* / 迁移的 signal_*), 策略/回测/监控统一取用。 */
export function Signals() {
const [searchParams] = useSearchParams()
const highlight = searchParams.get('highlight') ?? ''
return (
<div className="flex flex-col h-full">
<PageHeader title="信号库" subtitle="条件信号定义, 供策略 / 回测 / 监控统一使用" right={<ResourceTransferButton kind="signal" />} />
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="mx-auto max-w-6xl">
<SignalsBody highlight={highlight} />
</div>
</div>
</div>
)
}
function SignalsBody({ highlight }: { highlight: string }) {
const qc = useQueryClient()
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList, staleTime: 60_000 })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, staleTime: 60_000 })
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CustomSignal | null>(null)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const resetDeleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const fields = options.data?.fields ?? []
const signals = list.data?.signals ?? []
const enabledCustomSignals = signals.filter(sig => sig.enabled).length
useEffect(() => () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
}, [])
const clearDeleteConfirm = () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = null
setConfirmingDeleteId(null)
}
const openNew = () => {
setEditing(null)
clearDeleteConfirm()
setShowForm(true)
}
const openEdit = (sig: CustomSignal) => {
setEditing(sig)
clearDeleteConfirm()
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const del = useMutation({
mutationFn: api.customSignalDelete,
onSuccess: () => {
clearDeleteConfirm()
qc.invalidateQueries({ queryKey: QK.customSignals })
},
})
const toggleEnabled = (sig: CustomSignal) => {
api.customSignalSave({ ...sig, enabled: !sig.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.customSignals }))
}
const handleDeleteClick = (sig: CustomSignal) => {
if (confirmingDeleteId === sig.id) {
clearDeleteConfirm()
del.mutate(sig.id)
return
}
setConfirmingDeleteId(sig.id)
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = setTimeout(() => setConfirmingDeleteId(null), 3000)
}
return (
<div className="space-y-4">
{/* 紧凑工具条: 统计 + 新建 */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="inline-flex h-7 items-center gap-1.5 rounded-btn border border-border bg-surface/80 px-2.5 text-[11px] font-medium text-secondary sm:text-xs">
条件信号
<span className="rounded bg-elevated px-1 py-px font-mono text-[10px] text-muted">{signals.length}</span>
<span className="text-muted">· {enabledCustomSignals} 个已启用</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] text-muted">信号保存为 <span className="font-mono text-secondary">csg_*</span> 列,条件字段可用行情指标、涨跌停判定价与全部注册因子</span>
<button
onClick={openNew}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-3 text-xs font-medium text-amber-400 transition-colors hover:bg-amber-400/10"
>
<Plus className="h-3.5 w-3.5" />
新建信号
</button>
</div>
</div>
<AnchorWrap highlight={highlight} anchor="signals">
<section className="rounded-card border border-border bg-surface p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{signals.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-medium text-foreground">{sig.name}</h3>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted">已停用</span>}
</div>
<p className="mt-1 truncate font-mono text-[11px] text-muted">{sig.column ?? `csg_${sig.id}`}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`cursor-pointer rounded p-1 ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
<Zap className="h-3.5 w-3.5" />
</button>
<button onClick={() => openEdit(sig)} className="cursor-pointer rounded p-1 text-muted hover:bg-accent/10 hover:text-accent" title="编辑">
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmingDeleteId === sig.id ? (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
title="再次点击确认删除"
className="inline-flex animate-pulse cursor-pointer items-center gap-1 rounded-md border border-danger/30 bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger disabled:opacity-50"
>
<Trash2 className="h-2.5 w-2.5" />确认
</button>
) : (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
className="cursor-pointer rounded p-1 text-muted hover:bg-danger/10 hover:text-danger disabled:opacity-50"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1">
{sig.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
<span className="w-6 text-right text-muted/50">{i === 0 ? '当' : '且'}</span>
<span className="font-mono text-foreground/80">{fieldWithDays(c.left, c.leftDays, fields)}</span>
<span className="font-mono text-muted">{c.op}</span>
<span className="font-mono text-foreground/80">
{typeof c.right === 'string' && c.right.startsWith('field:')
? fieldWithDays(c.right.slice(6), c.rightDays, fields)
: c.right}
</span>
</div>
))}
</div>
</div>
))}
{list.isLoading &&
Array.from({ length: 2 }).map((_, i) => (
<div key={`sk-${i}`} className="space-y-3 rounded-card border border-border bg-base p-4">
<Skeleton w="w-1/2" h="h-4" />
<Skeleton w="w-1/3" h="h-3" />
<Skeleton h="h-4" />
</div>
))}
{!list.isLoading && signals.length === 0 && (
<div className="rounded-card border border-dashed border-border px-5 py-10 text-center text-sm text-muted md:col-span-2">
信号库为空。可用「字段 + 运算符 + 值」组合条件创建,或从检验页因子行一键生成;也可让 AI 按描述生成。
</div>
)}
</div>
</section>
</AnchorWrap>
<CustomSignalDialog open={showForm} signal={editing} onClose={closeForm} />
</div>
)
}
function fieldLabel(key: string, fields: { key: string; label: string }[]): string {
return fields.find(f => f.key === key)?.label ?? key
}
/** 带偏移标注的字段显示: 收盘价(前1日) / MA20(最新省略) */
function fieldWithDays(key: string, days: number | undefined, fields: { key: string; label: string }[]): string {
const label = fieldLabel(key, fields)
return days && days > 0 ? `${label}(前${days}日)` : label
}
@@ -7,7 +7,7 @@ import { createRoot, type Root } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { Signals } from '@/custom/signals/extension'
import { Signals } from '@/custom/signals/Page'
import { ConditionEditor } from '@/components/signals/ConditionEditor'
import { ExtensionBoundary } from '@/extensions/ExtensionBoundary'
+2 -211
View File
@@ -1,218 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Settings2, Trash2, Zap } from 'lucide-react'
import { api, type CustomSignal } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { type SignalKind } from '@/lib/signals'
import { CustomSignalDialog } from '@/components/signals/CustomSignalDialog'
import { Skeleton } from '@/components/data/Skeleton'
import { PageHeader } from '@/components/PageHeader'
import { AnchorWrap } from '@/lib/useCardFlash'
import { lazy } from 'react'
import type { FrontendExtension } from '@/extensions/types'
import { IconSignals } from '@/components/BrandIcons'
import { ResourceTransferButton } from '@/components/ResourceTransferDialog'
const KIND_LABEL: Record<SignalKind, string> = { entry: '入场', exit: '出场', both: '出入通用' }
const KIND_CLASS: Record<SignalKind, string> = {
entry: 'bg-accent/10 text-accent',
exit: 'bg-warning/10 text-warning',
both: 'bg-muted/10 text-muted',
}
export const Signals = lazy(() => import('./Page').then(m => ({ default: m.Signals })))
/** 信号库独立页: 全部信号均为用户定义 (csg_* / 迁移的 signal_*), 策略/回测/监控统一取用。 */
export function Signals() {
const [searchParams] = useSearchParams()
const highlight = searchParams.get('highlight') ?? ''
return (
<div className="flex flex-col h-full">
<PageHeader title="信号库" subtitle="条件信号定义, 供策略 / 回测 / 监控统一使用" right={<ResourceTransferButton kind="signal" />} />
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="mx-auto max-w-6xl">
<SignalsBody highlight={highlight} />
</div>
</div>
</div>
)
}
function SignalsBody({ highlight }: { highlight: string }) {
const qc = useQueryClient()
const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList, staleTime: 60_000 })
const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions, staleTime: 60_000 })
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<CustomSignal | null>(null)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const resetDeleteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const fields = options.data?.fields ?? []
const signals = list.data?.signals ?? []
const enabledCustomSignals = signals.filter(sig => sig.enabled).length
useEffect(() => () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
}, [])
const clearDeleteConfirm = () => {
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = null
setConfirmingDeleteId(null)
}
const openNew = () => {
setEditing(null)
clearDeleteConfirm()
setShowForm(true)
}
const openEdit = (sig: CustomSignal) => {
setEditing(sig)
clearDeleteConfirm()
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const del = useMutation({
mutationFn: api.customSignalDelete,
onSuccess: () => {
clearDeleteConfirm()
qc.invalidateQueries({ queryKey: QK.customSignals })
},
})
const toggleEnabled = (sig: CustomSignal) => {
api.customSignalSave({ ...sig, enabled: !sig.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.customSignals }))
}
const handleDeleteClick = (sig: CustomSignal) => {
if (confirmingDeleteId === sig.id) {
clearDeleteConfirm()
del.mutate(sig.id)
return
}
setConfirmingDeleteId(sig.id)
if (resetDeleteTimer.current) clearTimeout(resetDeleteTimer.current)
resetDeleteTimer.current = setTimeout(() => setConfirmingDeleteId(null), 3000)
}
return (
<div className="space-y-4">
{/* 紧凑工具条: 统计 + 新建 */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="inline-flex h-7 items-center gap-1.5 rounded-btn border border-border bg-surface/80 px-2.5 text-[11px] font-medium text-secondary sm:text-xs">
条件信号
<span className="rounded bg-elevated px-1 py-px font-mono text-[10px] text-muted">{signals.length}</span>
<span className="text-muted">· {enabledCustomSignals} 个已启用</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[11px] text-muted">信号保存为 <span className="font-mono text-secondary">csg_*</span> 列,条件字段可用行情指标、涨跌停判定价与全部注册因子</span>
<button
onClick={openNew}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-3 text-xs font-medium text-amber-400 transition-colors hover:bg-amber-400/10"
>
<Plus className="h-3.5 w-3.5" />
新建信号
</button>
</div>
</div>
<AnchorWrap highlight={highlight} anchor="signals">
<section className="rounded-card border border-border bg-surface p-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{signals.map(sig => (
<div key={sig.id} className="rounded-card border border-border bg-base p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-medium text-foreground">{sig.name}</h3>
<span className={`rounded px-1.5 py-0.5 text-[10px] ${KIND_CLASS[sig.kind]}`}>
{KIND_LABEL[sig.kind]}
</span>
{!sig.enabled && <span className="rounded bg-muted/10 px-1.5 py-0.5 text-[10px] text-muted">已停用</span>}
</div>
<p className="mt-1 truncate font-mono text-[11px] text-muted">{sig.column ?? `csg_${sig.id}`}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button onClick={() => toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`cursor-pointer rounded p-1 ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
<Zap className="h-3.5 w-3.5" />
</button>
<button onClick={() => openEdit(sig)} className="cursor-pointer rounded p-1 text-muted hover:bg-accent/10 hover:text-accent" title="编辑">
<Settings2 className="h-3.5 w-3.5" />
</button>
{confirmingDeleteId === sig.id ? (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
title="再次点击确认删除"
className="inline-flex animate-pulse cursor-pointer items-center gap-1 rounded-md border border-danger/30 bg-danger/15 px-1.5 py-0.5 text-[10px] font-medium text-danger disabled:opacity-50"
>
<Trash2 className="h-2.5 w-2.5" />确认
</button>
) : (
<button
onClick={() => handleDeleteClick(sig)}
disabled={del.isPending}
className="cursor-pointer rounded p-1 text-muted hover:bg-danger/10 hover:text-danger disabled:opacity-50"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<div className="mt-3 space-y-1">
{sig.conditions.map((c, i) => (
<div key={i} className="flex items-center gap-1.5 text-[11px] text-secondary">
<span className="w-6 text-right text-muted/50">{i === 0 ? '当' : '且'}</span>
<span className="font-mono text-foreground/80">{fieldWithDays(c.left, c.leftDays, fields)}</span>
<span className="font-mono text-muted">{c.op}</span>
<span className="font-mono text-foreground/80">
{typeof c.right === 'string' && c.right.startsWith('field:')
? fieldWithDays(c.right.slice(6), c.rightDays, fields)
: c.right}
</span>
</div>
))}
</div>
</div>
))}
{list.isLoading &&
Array.from({ length: 2 }).map((_, i) => (
<div key={`sk-${i}`} className="space-y-3 rounded-card border border-border bg-base p-4">
<Skeleton w="w-1/2" h="h-4" />
<Skeleton w="w-1/3" h="h-3" />
<Skeleton h="h-4" />
</div>
))}
{!list.isLoading && signals.length === 0 && (
<div className="rounded-card border border-dashed border-border px-5 py-10 text-center text-sm text-muted md:col-span-2">
信号库为空。可用「字段 + 运算符 + 值」组合条件创建,或从检验页因子行一键生成;也可让 AI 按描述生成。
</div>
)}
</div>
</section>
</AnchorWrap>
<CustomSignalDialog open={showForm} signal={editing} onClose={closeForm} />
</div>
)
}
function fieldLabel(key: string, fields: { key: string; label: string }[]): string {
return fields.find(f => f.key === key)?.label ?? key
}
/** 带偏移标注的字段显示: 收盘价(前1日) / MA20(最新省略) */
function fieldWithDays(key: string, days: number | undefined, fields: { key: string; label: string }[]): string {
const label = fieldLabel(key, fields)
return days && days > 0 ? `${label}(前${days}日)` : label
}
// ── 扩展注册清单 — 本文件即完整扩展(组件+路由+导航), 单文件可整体摘除/导入 ──
const extension: FrontendExtension = {
id: 'official.signals',
apiVersion: 1,
+370
View File
@@ -0,0 +1,370 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink, Bell, AlertTriangle } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { LastStockChip } from '@/components/LastStockChip'
import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart'
import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog'
import { api } from '@/lib/api'
import { useLastStock } from '@/lib/useLastStock'
import { QK } from '@/lib/queryKeys'
import { toast } from '@/components/Toast'
import {
startAnalysis, findTodayReport, useHistoryReports,
deleteReport, openHistoryReport, loadHistory,
} from '@/lib/stockAnalysisStore'
/**
* 个股分析页 —— 日 K + 关键价位(压力/支撑/密集区/枢轴/前高前低)+ AI 四维分析。
*
* 与财务分析页的区别:
* - 以【行情 + 关键价位】为视觉主体(专用日 K 图表,不复用个股对话框图表)
* - AI 分析输出客观技术状态与风险提示(非买卖建议、非财务质量评级)
* - 报告胶囊用蓝色系,与财务分析(紫色)并存
*/
export function StockAnalysis() {
const [symbol, setSymbol] = useState<string>('')
const [name, setName] = useState<string>('')
const [checking, setChecking] = useState(false)
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [showPriceAlerts, setShowPriceAlerts] = useState(false)
const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
// 进入页面立即加载历史报告(供右侧常驻列表)。store 内部有 historyLoaded 去重, 重复调用安全。
useEffect(() => { loadHistory() }, [])
// 自动恢复上次选中的股票(切走再回来不丢)。useLastStock 的 last 来自 localStorage, 同步可用。
useEffect(() => {
if (!symbol && lastStock) {
setSymbol(lastStock.symbol)
setName(lastStock.name)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onSelect = (sym: string, nm: string) => {
setSymbol(sym)
setName(nm)
setConfirmReport(null)
setShowPriceAlerts(false)
rememberStock(sym, nm)
}
const handleAnalyze = async () => {
if (!symbol || checking) return
setChecking(true)
try {
// 当日已分析过 → 二次确认(查看今日报告 / 重新分析)
const today = await findTodayReport(symbol)
if (today) {
setConfirmReport({ id: today.id, created_at: today.created_at, focus: today.focus })
} else {
await doAnalysis()
}
} catch {
await doAnalysis()
} finally {
setChecking(false)
}
}
const doAnalysis = async () => {
const r = await startAnalysis(symbol, name)
if (r.error) toast(r.error, 'error')
}
return (
<>
<PageHeader
title="个股分析"
subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={onSelect} />
</div>
}
/>
<div className="w-full px-8 py-6 space-y-6">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="w-72">
<StockFinancialSearch onSelect={onSelect} assetTypes="stock,index" />
</div>
{symbol && (
<>
<button
onClick={() => setPreviewSymbol(symbol)}
title="查看个股日 K 详情"
className="group flex items-center gap-2 text-sm rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-elevated transition-colors"
>
<span className="text-foreground font-medium group-hover:text-sky-300 transition-colors">{name || symbol}</span>
<span className="text-[10px] font-mono text-muted">{symbol}</span>
<ExternalLink className="h-3 w-3 text-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
<button
onClick={handleAnalyze}
disabled={checking}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-sky-500/25 to-blue-500/15 border border-sky-400/30 text-sky-300 text-xs font-medium hover:from-sky-500/35 hover:to-blue-500/25 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
AI 个股分析
</button>
<button
onClick={() => setShowPriceAlerts(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-sky-400/25 bg-sky-400/[0.08] text-sky-300 text-xs font-medium hover:border-sky-400/40 hover:bg-sky-400/[0.12] transition-all"
title="设置价格点位提醒"
>
<Bell className="h-3.5 w-3.5" />
点位提醒
</button>
</>
)}
</div>
{/* 主体:左侧当前个股看板 + 右侧常驻历史报告 */}
<div className="grid grid-cols-[1fr_288px] gap-6 items-start">
<div className="min-w-0">
{!symbol ? (
<EmptyState
icon={LineChart}
title="选择一只股票开始分析"
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
/>
) : (
<StockAnalysisBoard symbol={symbol} />
)}
</div>
<HistorySidebar />
</div>
</div>
{/* 二次确认:已有历史报告 */}
{confirmReport && (
<ConfirmModal
report={confirmReport}
onView={() => { openHistoryReport(confirmReport.id); setConfirmReport(null) }}
onRedo={async () => { setConfirmReport(null); await doAnalysis() }}
onClose={() => setConfirmReport(null)}
/>
)}
{/* 个股日 K 详情对话框(点击名称/代码打开) */}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol === symbol ? name : undefined}
triggerInfo={null}
onClose={() => setPreviewSymbol(null)}
/>
{showPriceAlerts && symbol && (
<PriceAlertDialog
key={symbol}
symbol={symbol}
name={name}
onClose={() => setShowPriceAlerts(false)}
/>
)}
</>
)
}
// ===== 分析看板:日 K + 关键价位 =====
function StockAnalysisBoard({ symbol }: { symbol: string }) {
const kline = useQuery({
// QK 集中键 (start/end 空串 = 滚动最近窗口, 无日期边界)。收益: 键进集中
// 管理, 且 useQuoteStream 的焦点股增量合并对无边界键生效 — 此前私拼 3 元
// 键永远修补不到; 注意合并依赖 _focusSymbol (预览弹窗设置焦点), 看板单独
// 打开时仍靠 60s staleTime 重拉, 与其他日K入口的键 (带真实日期) 不共享。
queryKey: QK.kline(symbol, '', ''),
queryFn: () => api.klineDaily(symbol, 250),
enabled: !!symbol,
staleTime: 60_000,
placeholderData: (prev: any, prevQuery: any) =>
(prevQuery?.queryKey as readonly unknown[] | undefined)?.[1] === symbol ? prev : undefined,
})
const levelsQ = useQuery({
queryKey: QK.stockLevels(symbol),
queryFn: () => api.stockAnalysisLevels(symbol, 250),
enabled: !!symbol,
staleTime: 60_000,
})
if (kline.isLoading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
}
if (kline.isError) {
return (
<EmptyState
icon={AlertTriangle}
title="日 K 数据加载失败"
hint="请检查网络或数据源配置后重试。"
/>
)
}
const rows = kline.data?.rows ?? []
if (rows.length === 0) {
return <EmptyState icon={LineChart} title="暂无日 K 数据" hint="该标的尚未同步日 K,请先在数据页或自选页同步。" />
}
const levels = (levelsQ.data?.levels ?? {}) as Record<LevelType, PriceLevel[]>
// 涨跌色:最后一根 K 线收 vs 前一根收(无前日则按开收判断)
const last = rows[rows.length - 1]
const prev = rows[rows.length - 2]
const curClose = levelsQ.data?.close
const isUp = prev ? (last.close >= prev.close) : (last.close >= last.open)
return (
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<LineChart className="h-4 w-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-foreground">关键价位分析</span>
</div>
<div className="flex items-baseline gap-2 shrink-0">
<span className="text-[10px] text-muted">{rows.length} 个交易日</span>
<span className="text-[10px] text-muted/60">·</span>
<span className="text-[10px] text-muted">当前价</span>
<span className={`text-[16px] leading-6 font-mono font-bold ${isUp ? 'text-bull' : 'text-bear'}`}>
{curClose?.toFixed(2) ?? '—'}
</span>
</div>
</div>
</div>
<div className="p-3">
<AnalysisKChart
rows={rows}
levels={levels}
series={levelsQ.data?.series}
seriesDates={levelsQ.data?.dates}
defaultLevelTypes={['sr', 'pivot', 'keltner_s']}
height={480}
/>
</div>
</div>
)
}
// ===== 左侧常驻:历史报告侧栏(所有股票,按时间倒序平铺) =====
function HistorySidebar() {
const { reports, loaded } = useHistoryReports()
return (
<aside className="self-start sticky top-0">
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-3 py-2.5 border-b border-border/40 flex items-center gap-2">
<HistoryIcon className="h-3.5 w-3.5 text-sky-400 shrink-0" />
<span className="text-xs font-medium text-foreground">历史报告</span>
{loaded && reports.length > 0 && (
<span className="ml-auto text-[10px] text-muted">{reports.length}</span>
)}
</div>
{!loaded ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-4 w-4 animate-spin text-muted" />
</div>
) : reports.length === 0 ? (
<div className="px-3 py-10 text-center">
<p className="text-xs text-muted">还没有任何个股分析报告</p>
<p className="text-[10px] text-muted/60 mt-1">选一只股票,点「AI 个股分析」生成</p>
</div>
) : (
<div className="max-h-[calc(100vh-220px)] overflow-y-auto p-2 space-y-1.5">
{reports.map(r => (
<div
key={r.id}
className="group rounded-lg border border-border/40 bg-elevated/20 p-2.5 hover:border-border hover:bg-elevated/40 transition-colors"
>
<div className="flex items-center justify-between gap-2">
<button
onClick={() => openHistoryReport(r.id)}
className="flex-1 text-left min-w-0"
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted">
<span>{fmtRelative(r.created_at)}</span>
{r.close != null && <span className="font-mono">价 {r.close.toFixed(2)}</span>}
{r.focus && <span className="text-sky-300/70 truncate">关注: {r.focus}</span>}
</div>
{r.summary && (
<div className="mt-1 text-[11px] text-muted truncate">{r.summary}</div>
)}
</button>
<button
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
className="shrink-0 text-[10px] text-muted/60 hover:text-danger transition-colors px-1 py-0.5 opacity-0 group-hover:opacity-100"
title="删除"
>
删除
</button>
</div>
</div>
))}
</div>
)}
</div>
</aside>
)
}
// ===== 二次确认弹窗 =====
function ConfirmModal({ report, onView, onRedo, onClose }: {
report: { id: string; created_at: string; focus: string }
onView: () => void
onRedo: () => void
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div
className="w-full max-w-sm bg-surface border border-border rounded-2xl p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-2 mb-2">
<HistoryIcon className="h-4 w-4 text-sky-400" />
<span className="text-sm font-medium text-foreground">该个股已有分析报告</span>
</div>
<p className="text-xs text-secondary leading-relaxed mb-1">
最近一次报告生成于 <span className="text-foreground">{fmtRelative(report.created_at)}</span>。
</p>
{report.focus && <p className="text-xs text-muted mb-1">关注点: {report.focus}</p>}
<p className="text-xs text-muted mb-4">可直接查看历史,或重新生成一份新报告。</p>
<div className="flex gap-2">
<button onClick={onView}
className="flex-1 h-8 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
查看历史
</button>
<button onClick={onRedo}
className="flex-1 h-8 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs text-sky-300 hover:from-sky-500/30 transition-all">
重新分析
</button>
</div>
</div>
</div>
)
}
function fmtRelative(iso: string): string {
try {
const t = new Date(iso).getTime()
const diff = Date.now() - t
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
return new Date(iso).toLocaleDateString('zh-CN')
} catch { return iso }
}
@@ -1,377 +1,9 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Sparkles, LineChart, History as HistoryIcon, Loader2, ExternalLink, Bell, AlertTriangle } from 'lucide-react'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockFinancialSearch } from '@/components/financials/StockFinancialSearch'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { LastStockChip } from '@/components/LastStockChip'
import { AnalysisKChart, type PriceLevel, type LevelType } from '@/components/stock-analysis/AnalysisKChart'
import { PriceAlertDialog } from '@/components/stock-analysis/PriceAlertDialog'
import { api } from '@/lib/api'
import { useLastStock } from '@/lib/useLastStock'
import { QK } from '@/lib/queryKeys'
import { toast } from '@/components/Toast'
import {
startAnalysis, findTodayReport, useHistoryReports,
deleteReport, openHistoryReport, loadHistory,
} from '@/lib/stockAnalysisStore'
import { lazy } from 'react'
import type { FrontendExtension } from '@/extensions/types'
import { IconStockFocus } from '@/components/BrandIcons'
/**
* 个股分析页 —— 日 K + 关键价位(压力/支撑/密集区/枢轴/前高前低)+ AI 四维分析。
*
* 与财务分析页的区别:
* - 以【行情 + 关键价位】为视觉主体(专用日 K 图表,不复用个股对话框图表)
* - AI 分析输出客观技术状态与风险提示(非买卖建议、非财务质量评级)
* - 报告胶囊用蓝色系,与财务分析(紫色)并存
*/
export function StockAnalysis() {
const [symbol, setSymbol] = useState<string>('')
const [name, setName] = useState<string>('')
const [checking, setChecking] = useState(false)
const [confirmReport, setConfirmReport] = useState<{ id: string; created_at: string; focus: string } | null>(null)
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [showPriceAlerts, setShowPriceAlerts] = useState(false)
const { last: lastStock, remember: rememberStock } = useLastStock('stock-analysis')
export const StockAnalysis = lazy(() => import('./Page').then(m => ({ default: m.StockAnalysis })))
// 进入页面立即加载历史报告(供右侧常驻列表)。store 内部有 historyLoaded 去重, 重复调用安全。
useEffect(() => { loadHistory() }, [])
// 自动恢复上次选中的股票(切走再回来不丢)。useLastStock 的 last 来自 localStorage, 同步可用。
useEffect(() => {
if (!symbol && lastStock) {
setSymbol(lastStock.symbol)
setName(lastStock.name)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onSelect = (sym: string, nm: string) => {
setSymbol(sym)
setName(nm)
setConfirmReport(null)
setShowPriceAlerts(false)
rememberStock(sym, nm)
}
const handleAnalyze = async () => {
if (!symbol || checking) return
setChecking(true)
try {
// 当日已分析过 → 二次确认(查看今日报告 / 重新分析)
const today = await findTodayReport(symbol)
if (today) {
setConfirmReport({ id: today.id, created_at: today.created_at, focus: today.focus })
} else {
await doAnalysis()
}
} catch {
await doAnalysis()
} finally {
setChecking(false)
}
}
const doAnalysis = async () => {
const r = await startAnalysis(symbol, name)
if (r.error) toast(r.error, 'error')
}
return (
<>
<PageHeader
title="个股分析"
subtitle="日 K · 关键价位 · AI 四维分析(技术 / 基本面 / 财务 / 消息面)"
right={
<div className="flex items-center gap-2">
<LastStockChip stock={lastStock} onSelect={onSelect} />
</div>
}
/>
<div className="w-full px-8 py-6 space-y-6">
{/* 搜索栏 */}
<div className="flex items-center gap-3">
<div className="w-72">
<StockFinancialSearch onSelect={onSelect} assetTypes="stock,index" />
</div>
{symbol && (
<>
<button
onClick={() => setPreviewSymbol(symbol)}
title="查看个股日 K 详情"
className="group flex items-center gap-2 text-sm rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-elevated transition-colors"
>
<span className="text-foreground font-medium group-hover:text-sky-300 transition-colors">{name || symbol}</span>
<span className="text-[10px] font-mono text-muted">{symbol}</span>
<ExternalLink className="h-3 w-3 text-muted opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
<button
onClick={handleAnalyze}
disabled={checking}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-gradient-to-r from-sky-500/25 to-blue-500/15 border border-sky-400/30 text-sky-300 text-xs font-medium hover:from-sky-500/35 hover:to-blue-500/25 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{checking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
AI 个股分析
</button>
<button
onClick={() => setShowPriceAlerts(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn border border-sky-400/25 bg-sky-400/[0.08] text-sky-300 text-xs font-medium hover:border-sky-400/40 hover:bg-sky-400/[0.12] transition-all"
title="设置价格点位提醒"
>
<Bell className="h-3.5 w-3.5" />
点位提醒
</button>
</>
)}
</div>
{/* 主体:左侧当前个股看板 + 右侧常驻历史报告 */}
<div className="grid grid-cols-[1fr_288px] gap-6 items-start">
<div className="min-w-0">
{!symbol ? (
<EmptyState
icon={LineChart}
title="选择一只股票开始分析"
hint="搜索代码或名称,查看日 K 与关键价位,并可让 AI 进行技术面 / 基本面 / 财务面 / 消息面四维综合分析。"
/>
) : (
<StockAnalysisBoard symbol={symbol} />
)}
</div>
<HistorySidebar />
</div>
</div>
{/* 二次确认:已有历史报告 */}
{confirmReport && (
<ConfirmModal
report={confirmReport}
onView={() => { openHistoryReport(confirmReport.id); setConfirmReport(null) }}
onRedo={async () => { setConfirmReport(null); await doAnalysis() }}
onClose={() => setConfirmReport(null)}
/>
)}
{/* 个股日 K 详情对话框(点击名称/代码打开) */}
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol === symbol ? name : undefined}
triggerInfo={null}
onClose={() => setPreviewSymbol(null)}
/>
{showPriceAlerts && symbol && (
<PriceAlertDialog
key={symbol}
symbol={symbol}
name={name}
onClose={() => setShowPriceAlerts(false)}
/>
)}
</>
)
}
// ===== 分析看板:日 K + 关键价位 =====
function StockAnalysisBoard({ symbol }: { symbol: string }) {
const kline = useQuery({
// QK 集中键 (start/end 空串 = 滚动最近窗口, 无日期边界)。收益: 键进集中
// 管理, 且 useQuoteStream 的焦点股增量合并对无边界键生效 — 此前私拼 3 元
// 键永远修补不到; 注意合并依赖 _focusSymbol (预览弹窗设置焦点), 看板单独
// 打开时仍靠 60s staleTime 重拉, 与其他日K入口的键 (带真实日期) 不共享。
queryKey: QK.kline(symbol, '', ''),
queryFn: () => api.klineDaily(symbol, 250),
enabled: !!symbol,
staleTime: 60_000,
placeholderData: (prev: any, prevQuery: any) =>
(prevQuery?.queryKey as readonly unknown[] | undefined)?.[1] === symbol ? prev : undefined,
})
const levelsQ = useQuery({
queryKey: QK.stockLevels(symbol),
queryFn: () => api.stockAnalysisLevels(symbol, 250),
enabled: !!symbol,
staleTime: 60_000,
})
if (kline.isLoading) {
return <div className="flex items-center justify-center py-20"><Loader2 className="h-5 w-5 animate-spin text-muted" /></div>
}
if (kline.isError) {
return (
<EmptyState
icon={AlertTriangle}
title="日 K 数据加载失败"
hint="请检查网络或数据源配置后重试。"
/>
)
}
const rows = kline.data?.rows ?? []
if (rows.length === 0) {
return <EmptyState icon={LineChart} title="暂无日 K 数据" hint="该标的尚未同步日 K,请先在数据页或自选页同步。" />
}
const levels = (levelsQ.data?.levels ?? {}) as Record<LevelType, PriceLevel[]>
// 涨跌色:最后一根 K 线收 vs 前一根收(无前日则按开收判断)
const last = rows[rows.length - 1]
const prev = rows[rows.length - 2]
const curClose = levelsQ.data?.close
const isUp = prev ? (last.close >= prev.close) : (last.close >= last.open)
return (
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-4 py-3 border-b border-border/40">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<LineChart className="h-4 w-4 text-sky-400 shrink-0" />
<span className="text-sm font-medium text-foreground">关键价位分析</span>
</div>
<div className="flex items-baseline gap-2 shrink-0">
<span className="text-[10px] text-muted">{rows.length} 个交易日</span>
<span className="text-[10px] text-muted/60">·</span>
<span className="text-[10px] text-muted">当前价</span>
<span className={`text-[16px] leading-6 font-mono font-bold ${isUp ? 'text-bull' : 'text-bear'}`}>
{curClose?.toFixed(2) ?? '—'}
</span>
</div>
</div>
</div>
<div className="p-3">
<AnalysisKChart
rows={rows}
levels={levels}
series={levelsQ.data?.series}
seriesDates={levelsQ.data?.dates}
defaultLevelTypes={['sr', 'pivot', 'keltner_s']}
height={480}
/>
</div>
</div>
)
}
// ===== 左侧常驻:历史报告侧栏(所有股票,按时间倒序平铺) =====
function HistorySidebar() {
const { reports, loaded } = useHistoryReports()
return (
<aside className="self-start sticky top-0">
<div className="rounded-card border border-border/60 bg-surface/40 overflow-hidden">
<div className="px-3 py-2.5 border-b border-border/40 flex items-center gap-2">
<HistoryIcon className="h-3.5 w-3.5 text-sky-400 shrink-0" />
<span className="text-xs font-medium text-foreground">历史报告</span>
{loaded && reports.length > 0 && (
<span className="ml-auto text-[10px] text-muted">{reports.length}</span>
)}
</div>
{!loaded ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-4 w-4 animate-spin text-muted" />
</div>
) : reports.length === 0 ? (
<div className="px-3 py-10 text-center">
<p className="text-xs text-muted">还没有任何个股分析报告</p>
<p className="text-[10px] text-muted/60 mt-1">选一只股票,点「AI 个股分析」生成</p>
</div>
) : (
<div className="max-h-[calc(100vh-220px)] overflow-y-auto p-2 space-y-1.5">
{reports.map(r => (
<div
key={r.id}
className="group rounded-lg border border-border/40 bg-elevated/20 p-2.5 hover:border-border hover:bg-elevated/40 transition-colors"
>
<div className="flex items-center justify-between gap-2">
<button
onClick={() => openHistoryReport(r.id)}
className="flex-1 text-left min-w-0"
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-xs font-medium text-foreground truncate">{r.name || r.symbol}</span>
<span className="text-[10px] font-mono text-muted shrink-0">{r.symbol}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted">
<span>{fmtRelative(r.created_at)}</span>
{r.close != null && <span className="font-mono">价 {r.close.toFixed(2)}</span>}
{r.focus && <span className="text-sky-300/70 truncate">关注: {r.focus}</span>}
</div>
{r.summary && (
<div className="mt-1 text-[11px] text-muted truncate">{r.summary}</div>
)}
</button>
<button
onClick={() => { deleteReport(r.id); toast('已删除', 'success') }}
className="shrink-0 text-[10px] text-muted/60 hover:text-danger transition-colors px-1 py-0.5 opacity-0 group-hover:opacity-100"
title="删除"
>
删除
</button>
</div>
</div>
))}
</div>
)}
</div>
</aside>
)
}
// ===== 二次确认弹窗 =====
function ConfirmModal({ report, onView, onRedo, onClose }: {
report: { id: string; created_at: string; focus: string }
onView: () => void
onRedo: () => void
onClose: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" onClick={onClose}>
<div
className="w-full max-w-sm bg-surface border border-border rounded-2xl p-5 shadow-2xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center gap-2 mb-2">
<HistoryIcon className="h-4 w-4 text-sky-400" />
<span className="text-sm font-medium text-foreground">该个股已有分析报告</span>
</div>
<p className="text-xs text-secondary leading-relaxed mb-1">
最近一次报告生成于 <span className="text-foreground">{fmtRelative(report.created_at)}</span>。
</p>
{report.focus && <p className="text-xs text-muted mb-1">关注点: {report.focus}</p>}
<p className="text-xs text-muted mb-4">可直接查看历史,或重新生成一份新报告。</p>
<div className="flex gap-2">
<button onClick={onView}
className="flex-1 h-8 rounded-lg bg-elevated border border-border text-xs text-secondary hover:text-foreground transition-colors">
查看历史
</button>
<button onClick={onRedo}
className="flex-1 h-8 rounded-lg bg-gradient-to-r from-sky-500/20 to-blue-500/15 border border-sky-400/30 text-xs text-sky-300 hover:from-sky-500/30 transition-all">
重新分析
</button>
</div>
</div>
</div>
)
}
function fmtRelative(iso: string): string {
try {
const t = new Date(iso).getTime()
const diff = Date.now() - t
if (diff < 60_000) return '刚刚'
if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`
if (diff < 7 * 86400_000) return `${Math.floor(diff / 86400_000)} 天前`
return new Date(iso).toLocaleDateString('zh-CN')
} catch { return iso }
}
// ── 扩展注册清单 — 本文件即完整扩展(组件+路由+导航), 单文件可整体摘除/导入 ──
const extension: FrontendExtension = {
id: 'official.stock-analysis',
apiVersion: 1,
+72
View File
@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { act, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { afterEach, expect, it, vi } from 'vitest'
import type { FrontendExtensionModule } from './types'
const loads = vi.hoisted(() => ({ paper: vi.fn(), abnormal: vi.fn() }))
vi.mock('../custom/paper/Page', () => {
loads.paper()
return { Paper: () => <div>模拟盘页面</div> }
})
vi.mock('../custom/abnormal/Page', () => {
loads.abnormal()
return { AbnormalMoves: () => <div>异动页面</div> }
})
afterEach(() => { vi.resetModules() })
it('注册菜单不加载页面;直接访问扩展路由只加载该页面', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const registry = await import('./registry')
await registry.loadFrontendExtensions({
paper: () => import('../custom/paper/extension'),
abnormal: () => import('../custom/abnormal/extension'),
})
registry.finalizeFrontendExtensions(new Set(['/']))
expect(loads.paper).not.toHaveBeenCalled()
expect(loads.abnormal).not.toHaveBeenCalled()
expect(registry.getFrontendExtensionNavigation().map(n => n.label).sort()).toEqual(['异动监控', '模拟盘'].sort())
const container = document.createElement('div')
const root = createRoot(container)
try {
await act(async () => root.render(
<MemoryRouter initialEntries={['/paper']}>
<Suspense fallback={<span>加载中</span>}>
<Routes>{registry.getFrontendExtensionRoutes().map(route => {
const Page = route.component
return <Route key={route.id} path={route.path} element={<Page />} />
})}</Routes>
</Suspense>
</MemoryRouter>,
))
expect(container.textContent).toBe('模拟盘页面')
expect(loads.paper).toHaveBeenCalledTimes(1)
expect(loads.abnormal).not.toHaveBeenCalled()
} finally {
act(() => root.unmount())
}
})
it('并发加载仍按源顺序处理重复 ID,隔离同步抛错的模块', async () => {
const registry = await import('./registry')
let finish!: (m: FrontendExtensionModule) => void
const first = new Promise<FrontendExtensionModule>(resolve => { finish = resolve })
const second = vi.fn(async () => ({ default: { id: 'test.same', apiVersion: 1 as const } }))
const loading = registry.loadFrontendExtensions({
z: second,
a: () => first,
broken: () => { throw new Error('bad module') },
})
await Promise.resolve()
expect(second).toHaveBeenCalledTimes(1)
finish({ default: {
id: 'test.same', apiVersion: 1,
routes: [{ id: 'first', path: '/first', component: () => null }],
} })
await loading
registry.finalizeFrontendExtensions(new Set())
expect(registry.getFrontendExtensionRoutes().map(r => r.path)).toEqual(['/first'])
expect(registry.getFrontendExtensionLoadErrors().map(e => e.source)).toEqual(['broken', 'z'])
})
+8 -2
View File
@@ -91,10 +91,16 @@ function registerExtension(extension: FrontendExtension) {
export async function loadFrontendExtensions(
modules: Record<string, () => Promise<FrontendExtensionModule>>,
) {
for (const source of Object.keys(modules).sort()) {
const sources = Object.keys(modules).sort()
// Fetch concurrently, then register in source order so duplicate-ID winners
// and error ordering remain deterministic regardless of network timing.
const loaded = await Promise.allSettled(sources.map(source => Promise.resolve().then(modules[source])))
for (const [index, source] of sources.entries()) {
let extension: FrontendExtension | undefined
try {
extension = (await modules[source]()).default
const result = loaded[index]
if (result.status === 'rejected') throw result.reason
extension = result.value.default
if (!extension) throw new Error('模块必须 default export FrontendExtension')
registerExtension(extension)
} catch (error) {
+27 -1
View File
@@ -1,5 +1,5 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { loadRuntimePageExtensions, packToExtension, runtimeBundleUrl, type PagePackExtension, type RuntimePageMeta } from './runtime'
import type { FrontendExtensionModule } from './types'
@@ -42,6 +42,32 @@ describe('packToExtension: 契约校验与转换', () => {
})
describe('loadRuntimePageExtensions', () => {
it('页面脚本不响应时仍完成启动,隔离故障且不迟到注册', async () => {
vi.resetModules()
const registry = await import('./registry')
let finish!: (value: { default: PagePackExtension }) => void
const pending = new Promise<{ default: PagePackExtension }>(resolve => { finish = resolve })
try {
await Promise.race([
loadRuntimePageExtensions({
fetchList: async () => [{ id: 'slow', enabled: true }, { id: 'ok', enabled: true }],
importModule: url => url.includes('/slow/') ? pending : Promise.resolve({ default: pack() }),
loadInto: registry.loadFrontendExtensions,
timeoutMs: 10,
}),
new Promise((_, reject) => setTimeout(() => reject(new Error('启动被页面脚本阻塞')), 250)),
])
registry.finalizeFrontendExtensions(new Set())
expect(registry.getFrontendExtensionRoutes().map(r => r.path)).toEqual(['/demo-hello'])
expect(registry.getFrontendExtensionLoadErrors()).toEqual([
expect.objectContaining({ source: 'runtime:slow', error: 'timeout' }),
])
} finally {
finish({ default: pack({ id: 'late.page' }) })
}
await pending
expect(registry.getFrontendExtensionRoutes()).toHaveLength(1)
})
it('整包页面必须属于已就绪的配套后端,不能注册其他扩展 ID', async () => {
const rec = recorder()
await loadRuntimePageExtensions({
+1 -1
View File
@@ -168,7 +168,7 @@ export async function loadRuntimePageExtensions(opts: LoadRuntimeOptions = {}):
const url = runtimeBundleUrl(page.id)
loaders[`runtime:${page.id}`] = () => {
const impl = importModule ? importModule(url) : import(/* @vite-ignore */ url)
return impl.then(m => {
return withTimeout(impl, timeoutMs).then(m => {
const extension = packToExtension(m.default)
if (page.managed_by && extension.id !== page.managed_by) throw new Error('页面与配套后端的扩展 ID 不一致')
return { default: extension }