mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 02:16:13 +00:00
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:
@@ -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
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置模型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
@@ -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()
|
||||
@@ -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` 或未来公开客户端。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user