mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat: 概念涨幅轮动 AI 分析
- 新增 concept_rotation_analyzer: 从涨幅排名矩阵预计算轮动信号 (主线/新晋/退潮/机构vs游资), 结合大盘背景生成分析报告 - rps API 加 /rotation-analyze 流式端点 (NDJSON) - 轮动对话框: AI 占位替换为流式 Markdown 报告区 + 自动滚动 - 删除搜索框, 新增选中概念排名行 (前10红/后10绿)
This commit is contained in:
@@ -6,8 +6,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import rps_rotation
|
||||
from app.services.concept_rotation_analyzer import analyze_rotation_stream
|
||||
|
||||
router = APIRouter(prefix="/api/rps", tags=["rps"])
|
||||
|
||||
@@ -25,3 +28,40 @@ def get_rotation(
|
||||
concept_count: 去重概念总数
|
||||
"""
|
||||
return rps_rotation.build_rps_rotation(request.app.state.repo, days)
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 概念轮动分析请求。"""
|
||||
days: int = 12 # 分析最近 N 个交易日
|
||||
focus: str = "" # 用户追加的关注点
|
||||
|
||||
|
||||
@router.post("/rotation-analyze")
|
||||
async def analyze_rotation(request: Request, req: AnalyzeRequest):
|
||||
"""AI 概念轮动分析 — NDJSON 流式返回。
|
||||
|
||||
装配轮动矩阵信号 + 大盘背景 → 分析提示词 → 流式调用 LLM →
|
||||
逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
|
||||
|
||||
协议:
|
||||
{"type":"meta","days","summary"}
|
||||
{"type":"delta","content":"..."}
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
quote_service = getattr(request.app.state, "quote_service", None)
|
||||
depth_service = getattr(request.app.state, "depth_service", None)
|
||||
days = max(7, min(30, req.days))
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_rotation_stream(
|
||||
repo, days, req.focus, quote_service, depth_service,
|
||||
):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""AI 概念轮动分析 — 从概念涨幅排名矩阵提炼主线/新晋/退潮信号。
|
||||
|
||||
数据来源:
|
||||
- rps_rotation.build_rps_rotation: 概念涨幅排名矩阵 (N 日 × ~387 概念)
|
||||
- market_overview_builder.build_market_overview: 大盘背景 (指数/情绪/涨停)
|
||||
|
||||
架构 (复刻 market_recap):
|
||||
预计算轮动信号 → 拼装 prompt → stream_ai_text 流式调用 → NDJSON 协议输出
|
||||
协议事件: meta(摘要) / delta(文本片段) / error / done
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# System Prompt — 轮动策略师人格 + 固定章节模板
|
||||
# ================================================================
|
||||
|
||||
_SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的资深策略师,拥有 12 年一线实战经验,擅长从概念板块的**涨幅排名矩阵**中识别主力资金脉络,区分机构主导的持续性主线与游资驱动的脉冲式轮动,产出可直接指导题材跟踪与节奏把握的轮动分析。
|
||||
|
||||
## 输出规范
|
||||
|
||||
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
|
||||
|
||||
### 1. 🎯 主线研判(2-3 句)
|
||||
点名当前最核心的 1-2 条主线题材(连续多日霸榜的强势概念),用一句话概括其逻辑(政策/产业/业绩/事件驱动),并判断是**主升期/加速期/扩散期/见顶期**。结尾用【主线强度:强 / 中 / 弱】定性。
|
||||
|
||||
### 2. 🆕 新晋强势
|
||||
列出排名快速跃升的概念(从榜单中后段冲进前列的),逐个给出:
|
||||
- 概念名 + 近 N 日排名变化(如 `45→20→8`)
|
||||
- 涨幅加速度(连日递增 = 趋势加强)
|
||||
- 可能的驱动逻辑(从板块属性推断,不要编造具体消息)
|
||||
- 判断是**主力切入**还是**消息脉冲**
|
||||
|
||||
### 3. 📉 退潮预警
|
||||
列出从高位明显滑落的概念(连续排名下滑或涨幅骤降),逐个给出:
|
||||
- 概念名 + 排名下滑轨迹
|
||||
- 退潮性质(高位分歧/资金撤离/补跌)
|
||||
- 是否扩散风险
|
||||
|
||||
### 4. 🏛️ 机构主线 vs 🎰 游资轮动
|
||||
基于排名稳定性区分两类资金行为:
|
||||
- **机构主线**:排名标准差小、长期稳居前列的概念 → 持续性判断、是否可作底仓方向
|
||||
- **游资轮动**:排名剧烈波动、脉冲式冲高的概念 → 短线节奏提示、追高风险
|
||||
给出当前市场**整体轮动节奏**(快轮动/慢轮动/主线聚焦)的判断。
|
||||
|
||||
### 5. 🌐 结合大盘
|
||||
结合提供的大盘数据(指数涨跌/情绪/涨停数),判断:
|
||||
- 当前大盘环境对题材轮动是助力还是阻力
|
||||
- 情绪温度与轮动节奏的匹配度(如情绪冰点但题材活跃 = 抱团;情绪火热但轮动快 = 末段)
|
||||
|
||||
### 6. 🎯 操作建议
|
||||
- **跟踪方向**:主线延续 + 新晋确认的概念
|
||||
- **规避方向**:明确退潮 + 高位脉冲的概念
|
||||
- **节奏提示**:当前适合追高 / 低吸 / 观望,及切换信号(如"主线概念连续 2 日跌出前 10 则确认退潮")
|
||||
|
||||
### 7. ⚠️ 风险提示
|
||||
列出需要盯的风险(如主线断层、情绪与轮动背离、成交萎缩)。末尾附一行:
|
||||
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
|
||||
|
||||
## 分析准则(务必遵守)
|
||||
|
||||
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤。不要写"我先看...""基于上述数据我认为"——直接给结论。
|
||||
1. **数据说话**:每个判断引用具体排名/涨幅数值,严禁空泛套话("强势"必须改成"连续 4 日稳居前 5,均涨 +4.2%")。
|
||||
2. **诚实中立**:数据不支持的结论就直言"信号不足,暂无法判断",不要硬凑。
|
||||
3. **区分资金性质**:这是本分析的核心价值——机构 vs 游资的判断必须基于排名稳定性(标准差),不要凭感觉。
|
||||
4. **不重复数字**:正文负责解读信号含义,不要照抄罗列已提供的全部原始数据。
|
||||
5. **简明实战**:总字数 1000-1800 字,重在可执行。
|
||||
6. **客观推断**:若无明确消息,从量价异动推断可能逻辑并给结论,不要标注"[推断]"或编造具体新闻。
|
||||
|
||||
现在请基于下方概念轮动数据进行分析。"""
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 预计算: 把排名矩阵转成结构化轮动信号
|
||||
# ================================================================
|
||||
|
||||
# 每类信号最多取多少个概念喂给 AI (控制 token)
|
||||
_TOP_N = 8
|
||||
|
||||
|
||||
def _compute_rotation_signals(dates: list[str], columns: dict) -> dict:
|
||||
"""从概念涨幅排名矩阵计算轮动信号。
|
||||
|
||||
Args:
|
||||
dates: 日期列表 (最新在最前, 与 columns key 一致)
|
||||
columns: {日期: [[概念, 涨幅], ...]} 每列各自降序
|
||||
|
||||
Returns:
|
||||
{
|
||||
"persistent_leaders": [...], # 连续多日稳居前列 (主线)
|
||||
"rising": [...], # 排名快速跃升 (新晋)
|
||||
"fading": [...], # 从高位滑落 (退潮)
|
||||
"institutional": [...], # 排名稳定 (机构特征)
|
||||
"hot_money": [...], # 排名波动大 (游资特征)
|
||||
}
|
||||
每项含: concept, ranks (按 dates 顺序), pcts, avg_rank, rank_std
|
||||
ranks 时间方向: ranks[0] = 最早日, ranks[-1] = 最新日 (已反转, 左老右新)
|
||||
"""
|
||||
if not dates or not columns:
|
||||
return {}
|
||||
|
||||
# 按时间正序 (左老右新) 处理
|
||||
dates_asc = list(reversed(dates))
|
||||
|
||||
# 收集每个概念在各日期的 (排名, 涨幅)。排名 = 该日在列中的索引 + 1。
|
||||
concept_data: dict[str, list[tuple[int, float]]] = {}
|
||||
for d in dates_asc:
|
||||
col = columns.get(d) or []
|
||||
for idx, (name, pct) in enumerate(col):
|
||||
concept_data.setdefault(name, []).append((idx + 1, pct))
|
||||
|
||||
n_dates = len(dates_asc)
|
||||
|
||||
def _stats(ranks_pcts: list[tuple[int, float]]) -> dict:
|
||||
ranks = [r for r, _ in ranks_pcts]
|
||||
pcts = [p for _, p in ranks_pcts]
|
||||
avg = sum(ranks) / len(ranks) if ranks else 0
|
||||
var = sum((r - avg) ** 2 for r in ranks) / len(ranks) if ranks else 0
|
||||
return {
|
||||
"ranks": ranks,
|
||||
"pcts": [round(p, 4) for p in pcts],
|
||||
"avg_rank": round(avg, 1),
|
||||
"rank_std": round(math.sqrt(var), 1),
|
||||
}
|
||||
|
||||
persistent: list[dict] = []
|
||||
rising: list[dict] = []
|
||||
fading: list[dict] = []
|
||||
institutional: list[dict] = []
|
||||
hot_money: list[dict] = []
|
||||
|
||||
for concept, rp in concept_data.items():
|
||||
# 缺失日补 (大排名, 0 涨幅) 保持时间轴对齐
|
||||
if len(rp) < n_dates:
|
||||
rp = rp + [(999, 0.0)] * (n_dates - len(rp))
|
||||
s = _stats(rp)
|
||||
s["concept"] = concept
|
||||
|
||||
ranks = s["ranks"]
|
||||
latest_rank = ranks[-1]
|
||||
earliest_rank = ranks[0]
|
||||
# 最近 3 日 (不足则全部) 均排名, 判断近期强度
|
||||
recent = ranks[-min(3, len(ranks)):]
|
||||
recent_avg = sum(recent) / len(recent)
|
||||
|
||||
# 主线: 近期稳居前 10
|
||||
if recent_avg <= 10 and latest_rank <= 10:
|
||||
persistent.append(s)
|
||||
|
||||
# 新晋: 早期排名靠后(>30), 最新冲进前 20, 跃升幅度大
|
||||
jump = earliest_rank - latest_rank
|
||||
if earliest_rank > 30 and latest_rank <= 20 and jump >= 20:
|
||||
rising.append(s)
|
||||
|
||||
# 退潮: 早期排名靠前(<=10), 最新滑落到 30 外
|
||||
drop = latest_rank - earliest_rank
|
||||
if earliest_rank <= 10 and latest_rank > 30 and drop >= 20:
|
||||
fading.append(s)
|
||||
|
||||
# 机构: 排名标准差小且平均排名靠前 (稳定强势)
|
||||
if s["rank_std"] <= 5 and s["avg_rank"] <= 20:
|
||||
institutional.append(s)
|
||||
|
||||
# 游资: 排名标准差大 (波动剧烈)
|
||||
if s["rank_std"] >= 20:
|
||||
hot_money.append(s)
|
||||
|
||||
# 排序: 主线按近期排名升序; 新晋按跃升幅度降序; 退潮按跌幅降序
|
||||
persistent.sort(key=lambda x: x["avg_rank"])
|
||||
rising.sort(key=lambda x: x["ranks"][0] - x["ranks"][-1], reverse=True)
|
||||
fading.sort(key=lambda x: x["ranks"][-1] - x["ranks"][0], reverse=True)
|
||||
institutional.sort(key=lambda x: (x["rank_std"], x["avg_rank"]))
|
||||
hot_money.sort(key=lambda x: x["rank_std"], reverse=True)
|
||||
|
||||
return {
|
||||
"persistent_leaders": persistent[:_TOP_N],
|
||||
"rising": rising[:_TOP_N],
|
||||
"fading": fading[:_TOP_N],
|
||||
"institutional": institutional[:_TOP_N],
|
||||
"hot_money": hot_money[:_TOP_N],
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Prompt 构建
|
||||
# ================================================================
|
||||
|
||||
def _fmt_pct(v) -> str:
|
||||
if v is None:
|
||||
return "—"
|
||||
return f"{v*100:+.2f}%"
|
||||
|
||||
|
||||
def _build_market_block(overview: dict) -> str:
|
||||
"""大盘背景精简块 (复用 market_overview 已算好的字段)。"""
|
||||
indices = overview.get("indices") or []
|
||||
emo = overview.get("emotion") or {}
|
||||
lim = overview.get("limit") or {}
|
||||
amt = overview.get("amount") or {}
|
||||
|
||||
idx_lines = []
|
||||
for idx in indices[:4]:
|
||||
name = idx.get("name") or idx.get("symbol") or "?"
|
||||
chg = idx.get("change_pct")
|
||||
idx_lines.append(f"{name} {_fmt_pct(chg)}")
|
||||
idx_str = " / ".join(idx_lines) or "指数缺失"
|
||||
|
||||
total_amount = (amt.get("total") or 0) / 1e8 # 元 → 亿
|
||||
|
||||
return (
|
||||
f"- 指数: {idx_str}\n"
|
||||
f"- 情绪: {emo.get('score', 50)} ({emo.get('label', '—')})\n"
|
||||
f"- 涨停/炸板/跌停: {lim.get('limit_up', 0)} / {lim.get('broken', 0)} / {lim.get('limit_down', 0)}"
|
||||
f" (最高连板 {lim.get('max_boards', 0)})\n"
|
||||
f"- 两市成交额: {total_amount:.0f} 亿元"
|
||||
)
|
||||
|
||||
|
||||
def _build_signal_block(title: str, items: list[dict]) -> str:
|
||||
"""轮动信号块: 把预计算的概念信号转成紧凑文本。"""
|
||||
if not items:
|
||||
return f"### {title}\n(本类无明显信号)"
|
||||
lines = [f"### {title}"]
|
||||
for it in items:
|
||||
ranks_str = "→".join(str(r) if r < 999 else "—" for r in it["ranks"])
|
||||
avg_pct = sum(it["pcts"]) / len(it["pcts"]) if it["pcts"] else 0
|
||||
lines.append(
|
||||
f"- {it['concept']}: 排名 {ranks_str} | 均排名 {it['avg_rank']} "
|
||||
f"| 排名波动σ {it['rank_std']} | 区间均涨 {_fmt_pct(avg_pct)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str], focus: str) -> str:
|
||||
"""组装 user 消息: 大盘背景 + 轮动信号 + focus。"""
|
||||
dates_asc = list(reversed(dates))
|
||||
date_range = f"{dates_asc[0]} ~ {dates_asc[-1]}" if dates_asc else "—"
|
||||
|
||||
parts = [
|
||||
f"# 概念涨幅轮动数据 (最近 {days} 个交易日: {date_range})",
|
||||
"",
|
||||
"## 大盘背景",
|
||||
_build_market_block(overview),
|
||||
"",
|
||||
"## 轮动信号 (排名时间方向: 左→右 = 旧→新, 排名越小越强)",
|
||||
"",
|
||||
_build_signal_block("🎯 主线 (连续霸榜)", signals.get("persistent_leaders", [])),
|
||||
"",
|
||||
_build_signal_block("🆕 新晋强势 (排名跃升)", signals.get("rising", [])),
|
||||
"",
|
||||
_build_signal_block("📉 退潮预警 (高位滑落)", signals.get("fading", [])),
|
||||
"",
|
||||
_build_signal_block("🏛️ 机构特征 (排名稳定)", signals.get("institutional", [])),
|
||||
"",
|
||||
_build_signal_block("🎰 游资特征 (排名波动大)", signals.get("hot_money", [])),
|
||||
]
|
||||
|
||||
if focus.strip():
|
||||
parts.extend(["", f"## 用户关注点\n{focus.strip()}"])
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _build_summary(signals: dict) -> str:
|
||||
"""meta 事件的摘要 (前端可立即展示)。"""
|
||||
leaders = signals.get("persistent_leaders", [])
|
||||
rising = signals.get("rising", [])
|
||||
fading = signals.get("fading", [])
|
||||
leader_names = "、".join(it["concept"] for it in leaders[:3]) or "暂无明确主线"
|
||||
return f"主线: {leader_names} | 新晋 {len(rising)} | 退潮 {len(fading)}"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 流式主入口
|
||||
# ================================================================
|
||||
|
||||
async def analyze_rotation_stream(
|
||||
repo,
|
||||
days: int = 12,
|
||||
focus: str = "",
|
||||
quote_service=None,
|
||||
depth_service=None,
|
||||
) -> AsyncIterator[str]:
|
||||
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
|
||||
|
||||
Args:
|
||||
repo: KlineRepository (必填)。
|
||||
days: 分析最近 N 个交易日 (7-30)。
|
||||
focus: 用户追加的关注点。
|
||||
quote_service / depth_service: 可选, 大盘背景装配依赖。
|
||||
"""
|
||||
from app.services.rps_rotation import build_rps_rotation
|
||||
from app.services.market_overview_builder import build_market_overview
|
||||
|
||||
# 1. 取轮动矩阵
|
||||
rotation = build_rps_rotation(repo, days)
|
||||
dates = rotation.get("dates") or []
|
||||
columns = rotation.get("columns") or {}
|
||||
|
||||
if not dates or not columns:
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": "暂无概念轮动数据,请先在「概念分析」页获取概念数据源",
|
||||
}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
# 2. 预计算轮动信号
|
||||
signals = _compute_rotation_signals(dates, columns)
|
||||
|
||||
# 3. 大盘背景 (失败不阻断, 降级为空)
|
||||
try:
|
||||
overview = build_market_overview(repo, quote_service, depth_service)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
|
||||
overview = {}
|
||||
|
||||
# 4. meta 事件
|
||||
yield json.dumps({
|
||||
"type": "meta",
|
||||
"days": days,
|
||||
"summary": _build_summary(signals),
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 5. 构建 prompt + 流式调用 LLM
|
||||
try:
|
||||
from app.services.ai_provider import stream_ai_text, ai_configured
|
||||
|
||||
if not ai_configured():
|
||||
yield json.dumps({
|
||||
"type": "error",
|
||||
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
|
||||
}, ensure_ascii=False)
|
||||
return
|
||||
|
||||
user_prompt = _build_user_prompt(signals, overview, days, dates, focus)
|
||||
async for delta in stream_ai_text(
|
||||
[
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
temperature=0.5,
|
||||
max_tokens=4000,
|
||||
):
|
||||
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("AI concept rotation analyze failed: %s", e)
|
||||
yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False)
|
||||
|
||||
yield json.dumps({"type": "done"}, ensure_ascii=False)
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Repeat, Sparkles, ArrowDownUp, Search } from 'lucide-react'
|
||||
import { X, Repeat, Sparkles, ArrowDownUp, RefreshCw, AlertCircle } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { fmtPct } from '@/lib/format'
|
||||
import { MarkdownRenderer } from '@/components/financials/MarkdownRenderer'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
@@ -35,11 +36,44 @@ function shortDate(s: string): string {
|
||||
return `${Number(m[2])}/${m[3]}`
|
||||
}
|
||||
|
||||
// 排名 → 前景色(A 股语义: 红=强, 绿=弱)。前 10 红, 后 10 绿, 中间默认强调色。
|
||||
// total 兜底: 概念总数未知时只判前 10, 不判后 10。
|
||||
function rankColorClass(rank: number, total: number): string {
|
||||
if (rank <= 10) return 'text-bull'
|
||||
if (total > 20 && rank > total - 10) return 'text-bear'
|
||||
return 'text-accent'
|
||||
}
|
||||
|
||||
export function RpsRotationDialog({ onClose }: Props) {
|
||||
const [days, setDays] = useState(DEFAULT_DAYS)
|
||||
const [reversed, setReversed] = useState(false) // false=高→低, true=低→高
|
||||
const [selected, setSelected] = useState<string | null>(null) // 点中的概念名, 高亮追踪
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// ---- AI 轮动分析状态 (组件内, 不建全局 store: 切页即关对话框) ----
|
||||
const [analysis, setAnalysis] = useState('') // 累积的 Markdown 报告
|
||||
const [analyzing, setAnalyzing] = useState(false) // 生成中
|
||||
const [analysisError, setAnalysisError] = useState('') // 错误信息
|
||||
const [analysisMeta, setAnalysisMeta] = useState<{ summary?: string } | null>(null)
|
||||
const [focus, setFocus] = useState('') // 用户追加的关注点
|
||||
|
||||
const runAnalysis = useCallback(async (daysParam: number, focusParam: string) => {
|
||||
setAnalyzing(true)
|
||||
setAnalysis('')
|
||||
setAnalysisError('')
|
||||
setAnalysisMeta(null)
|
||||
try {
|
||||
for await (const ev of api.rotationAnalyzeStream(daysParam, focusParam)) {
|
||||
if (ev.type === 'meta') setAnalysisMeta({ summary: ev.summary })
|
||||
else if (ev.type === 'delta') setAnalysis(a => a + (ev.content ?? ''))
|
||||
else if (ev.type === 'error') setAnalysisError(ev.message ?? '未知错误')
|
||||
// done: 无操作
|
||||
}
|
||||
} catch (e) {
|
||||
setAnalysisError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setAnalyzing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 数据请求: React Query 缓存, 同 days 5 分钟内重开秒开
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -68,8 +102,17 @@ export function RpsRotationDialog({ onClose }: Props) {
|
||||
// 监听滚动容器 scrollTop, 只渲染 [firstIdx, lastIdx] 范围内的行。
|
||||
// 387 行只画可视的 ~25 行 + overscan, DOM 恒定 ~30 行 × N 列, 滚动 60fps。
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// AI 报告区滚动容器: 流式生成时自动滚到底部
|
||||
const analysisRef = useRef<HTMLDivElement>(null)
|
||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 25 })
|
||||
|
||||
// 流式生成中: analysis 每次追加都把报告区滚到底部, 跟踪最新文字
|
||||
useEffect(() => {
|
||||
if (!analyzing) return
|
||||
const el = analysisRef.current
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}, [analysis, analyzing])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
@@ -92,25 +135,19 @@ export function RpsRotationDialog({ onClose }: Props) {
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
// 搜索命中: 找出该概念在(未翻转的)每列中的排名, 用于跳转高亮
|
||||
// 仅在有搜索词时计算, 避免每次渲染都遍历
|
||||
const searchMatch = useMemo(() => {
|
||||
const q = search.trim()
|
||||
if (!q || rowCount === 0) return null
|
||||
// 在最新日期列里找第一个含搜索词的概念, 返回它的显示行号(考虑翻转)
|
||||
const latest = dates[0]
|
||||
const col = columns[latest] ?? []
|
||||
const rawIdx = col.findIndex(([name]) => name.includes(q))
|
||||
if (rawIdx < 0) return null
|
||||
return reversed ? rowCount - 1 - rawIdx : rawIdx
|
||||
}, [search, columns, dates, reversed, rowCount])
|
||||
|
||||
// 搜索命中时自动滚到该行
|
||||
useEffect(() => {
|
||||
if (searchMatch == null) return
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTo({ top: searchMatch * ROW_HEIGHT - el.clientHeight / 2, behavior: 'smooth' })
|
||||
}, [searchMatch])
|
||||
// 选中概念的追踪行: 找出它在每个日期列的(排名, 涨幅)。
|
||||
// 每列已按涨幅降序排好, 故排名 = 该概念在数组里的索引 + 1。
|
||||
// 未入选该日(概念当天无数据)显示空, 便于横向看排名变化。
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!selected) return null
|
||||
const cells: ({ rank: number; pct: number } | null)[] = []
|
||||
for (const d of dates) {
|
||||
const col = columns[d] ?? []
|
||||
const idx = col.findIndex(([name]) => name === selected)
|
||||
cells.push(idx >= 0 ? { rank: idx + 1, pct: col[idx][1] } : null)
|
||||
}
|
||||
return cells
|
||||
}, [selected, dates, columns])
|
||||
|
||||
const renderRows = useMemo(() => {
|
||||
const rows: JSX.Element[] = []
|
||||
@@ -196,18 +233,67 @@ export function RpsRotationDialog({ onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 上半区: AI 分析占位 */}
|
||||
<div className="shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-1.5 px-4 py-1.5 bg-elevated/30">
|
||||
<Sparkles className="h-3.5 w-3.5 text-accent/60" />
|
||||
<span className="text-[11px] text-muted">AI 轮动分析</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 text-center">
|
||||
<div className="inline-flex items-center gap-1.5 text-[11px] text-muted/60">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>AI 轮动分析功能开发中,敬请期待</span>
|
||||
{/* 上半区: AI 轮动分析 */}
|
||||
<div className="shrink-0 border-b border-border flex flex-col max-h-[42%]">
|
||||
{/* 标题栏: 标题 + meta 摘要 + focus 输入 + 触发按钮 */}
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-elevated/30 shrink-0">
|
||||
<Sparkles className={cn('h-3.5 w-3.5 text-accent/60', analyzing && 'animate-pulse')} />
|
||||
<span className="text-[11px] text-muted shrink-0">AI 轮动分析</span>
|
||||
{analysisMeta?.summary && (
|
||||
<span className="text-[11px] text-accent/80 truncate">{analysisMeta.summary}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<input
|
||||
type="text"
|
||||
value={focus}
|
||||
onChange={e => setFocus(e.target.value)}
|
||||
placeholder="关注点(可选)"
|
||||
disabled={analyzing}
|
||||
className="w-28 px-2 py-0.5 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={() => runAnalysis(days, focus)}
|
||||
disabled={analyzing}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] transition-colors cursor-pointer border',
|
||||
analyzing
|
||||
? 'opacity-60 cursor-not-allowed border-border text-muted'
|
||||
: 'bg-accent/10 text-accent border-accent/30 hover:bg-accent/20',
|
||||
)}
|
||||
>
|
||||
{analyzing
|
||||
? <><RefreshCw className="h-3 w-3 animate-spin" />分析中</>
|
||||
: analysis
|
||||
? <><RefreshCw className="h-3 w-3" />重新分析</>
|
||||
: <><Sparkles className="h-3 w-3" />生成分析</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 报告内容区: 四态渲染 */}
|
||||
<div ref={analysisRef} className="flex-1 min-h-0 overflow-auto">
|
||||
{analysisError ? (
|
||||
<div className="flex items-center gap-2 px-4 py-4 text-[11px] text-danger">
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{analysisError}</span>
|
||||
<button
|
||||
onClick={() => runAnalysis(days, focus)}
|
||||
className="ml-auto text-accent hover:underline shrink-0"
|
||||
>重试</button>
|
||||
</div>
|
||||
) : analysis || analyzing ? (
|
||||
<div className="px-4 py-2.5 text-[12px] leading-relaxed">
|
||||
<MarkdownRenderer content={analysis} />
|
||||
{analyzing && (
|
||||
<span className="inline-block w-1.5 h-3.5 bg-accent animate-pulse align-middle ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-4 text-center text-[11px] text-muted/60">
|
||||
点击「生成分析」,AI 将从主线研判 / 新晋强势 / 退潮预警 / 机构vs游资 等角度分析最近 {days} 天的概念轮动
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
@@ -238,16 +324,6 @@ export function RpsRotationDialog({ onClose }: Props) {
|
||||
<ArrowDownUp className="h-3 w-3" />
|
||||
{reversed ? '低→高' : '高→低'}
|
||||
</button>
|
||||
<div className="relative flex-1 max-w-[220px] ml-auto">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="搜索概念定位…"
|
||||
className="w-full pl-7 pr-2 py-1 text-[11px] bg-elevated/50 border border-border rounded-btn text-foreground placeholder:text-muted/50 focus:outline-none focus:border-accent/40"
|
||||
/>
|
||||
</div>
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => setSelected(null)}
|
||||
@@ -295,6 +371,46 @@ export function RpsRotationDialog({ onClose }: Props) {
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* 选中概念追踪行: 在日期表头下方单独一行, 横向展示它在各日的排名+涨幅 */}
|
||||
<AnimatePresence>
|
||||
{selected && selectedRow && (
|
||||
<motion.tr
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="border-b border-accent/20 bg-accent/5"
|
||||
>
|
||||
<td className="sticky left-0 z-30 bg-surface px-2 py-1 text-center border-r border-border/40">
|
||||
<span className="text-[10px] text-accent truncate block max-w-[44px]" title={selected}>
|
||||
{selected}
|
||||
</span>
|
||||
</td>
|
||||
{selectedRow.map((cell, i) => (
|
||||
<td key={i} className="px-2 py-1 text-center whitespace-nowrap align-middle">
|
||||
{cell ? (
|
||||
<div className="flex flex-col items-center gap-0.5 leading-tight">
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium tabular-nums',
|
||||
rankColorClass(cell.rank, conceptCount),
|
||||
)}>
|
||||
#{cell.rank}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-[10px] tabular-nums',
|
||||
cell.pct > 0 ? 'text-bull' : cell.pct < 0 ? 'text-bear' : 'text-muted',
|
||||
)}>
|
||||
{fmtPct(cell.pct)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</motion.tr>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* 顶部占位: 把滚动位置撑起来 */}
|
||||
|
||||
Reference in New Issue
Block a user