feat: 市场看板 - 聚合同花顺SDK全量数据的A股实时看板
- 新增 /dashboard 页面:暗色主题,指数行情/市场温度/涨跌分布/行业强度/概念热度/事件情报 - 后端聚合接口 /api/market-dashboard,30秒缓存 - 利用SDK接口:指数行情、全市场快照、涨停/跌停/炸板池、连板天梯、热门股、飙升榜、龙虎榜、异动分析、集合竞价基准、行业/概念目录 - 市场温度评分:6因子加权(涨跌比/中位涨跌/强弱比/涨停活跃度/炸板惩罚/竞价信号) - 首页添加市场看板导航入口
This commit is contained in:
+2
-1
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from database import init_db
|
from database import init_db
|
||||||
from routes import stock, collections, shares, themes, core_stocks, fuyao
|
from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard
|
||||||
from services.daily_collector import collector_loop, cache_cleanup_loop
|
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -46,6 +46,7 @@ app.include_router(shares.router, prefix="/api/share")
|
|||||||
app.include_router(themes.router, prefix="/api/themes")
|
app.include_router(themes.router, prefix="/api/themes")
|
||||||
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||||
app.include_router(fuyao.router, prefix="/api/v2")
|
app.include_router(fuyao.router, prefix="/api/v2")
|
||||||
|
app.include_router(market_dashboard.router, prefix="/api")
|
||||||
|
|
||||||
# 生产模式:后端同时托管前端静态文件
|
# 生产模式:后端同时托管前端静态文件
|
||||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
"""市场看板数据聚合路由(/api/market-dashboard)
|
||||||
|
|
||||||
|
聚合同花顺 SDK 多个接口,一次性返回前端看板所需的全部数据:
|
||||||
|
- 主要指数行情 + 估值分位(PE/PB)
|
||||||
|
- 集合竞价信号(竞价基准线 + 情绪判断)
|
||||||
|
- 全市场涨跌统计(上涨/下跌/平盘/涨停/跌停/炸板/成交额)
|
||||||
|
- 市场温度评分(结合竞价基准校准)
|
||||||
|
- 涨跌家数分布
|
||||||
|
- 行业强度榜(同花顺行业指数 + 概念板块热度)
|
||||||
|
- 事件情报(连板天梯/热门股/涨停封单/炸板/飙升/龙虎榜)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from services import fuyao_client
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||||
|
|
||||||
|
_cache: dict = {"data": None, "at": 0.0}
|
||||||
|
_CACHE_TTL = 30
|
||||||
|
|
||||||
|
# 主要指数 thscode 列表
|
||||||
|
_MAIN_INDEX_THSCODES = [
|
||||||
|
"000001.SH", # 上证指数
|
||||||
|
"399001.SZ", # 深证成指
|
||||||
|
"399006.SZ", # 创业板指
|
||||||
|
"000688.SH", # 科创50
|
||||||
|
"000300.SH", # 沪深300
|
||||||
|
]
|
||||||
|
|
||||||
|
_INDEX_NAMES = {
|
||||||
|
"000001.SH": "上证指数",
|
||||||
|
"399001.SZ": "深证成指",
|
||||||
|
"399006.SZ": "创业板指",
|
||||||
|
"000688.SH": "科创50",
|
||||||
|
"000300.SH": "沪深300",
|
||||||
|
}
|
||||||
|
|
||||||
|
BJT = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(val, default=0.0):
|
||||||
|
try:
|
||||||
|
v = float(val)
|
||||||
|
return v if not math.isnan(v) and not math.isinf(v) else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_temperature(up: int, down: int, flat: int, median_pct: float,
|
||||||
|
strong: int, weak: int, limit_up: int, limit_down: int,
|
||||||
|
break_count: int = 0, auction_signal: str = "") -> dict:
|
||||||
|
"""市场温度评分(0-100):综合涨跌比、中位数涨跌、强弱比、涨停活跃度、炸板率、竞价信号"""
|
||||||
|
total = up + down + flat
|
||||||
|
if total == 0:
|
||||||
|
return {"score": 50, "label": "中性", "factors": {}}
|
||||||
|
|
||||||
|
# 涨跌比得分(0-25分)
|
||||||
|
advance_ratio = up / total
|
||||||
|
advance_score = min(advance_ratio * 50, 25)
|
||||||
|
|
||||||
|
# 中位数涨跌得分(0-20分):-3%~+3% 映射到 0~20
|
||||||
|
median_score = max(0, min(20, (median_pct + 3) / 6 * 20))
|
||||||
|
|
||||||
|
# 强弱比得分(0-20分)
|
||||||
|
sw_total = strong + weak
|
||||||
|
if sw_total > 0:
|
||||||
|
strong_ratio = strong / sw_total
|
||||||
|
strong_score = min(strong_ratio * 40, 20)
|
||||||
|
else:
|
||||||
|
strong_score = 10
|
||||||
|
|
||||||
|
# 涨停活跃度得分(0-15分)
|
||||||
|
limit_score = min(limit_up / 80 * 15, 15)
|
||||||
|
|
||||||
|
# 炸板惩罚(0-10分):炸板率越高越扣分
|
||||||
|
total_attempted = limit_up + break_count
|
||||||
|
break_penalty = 0
|
||||||
|
if total_attempted > 0:
|
||||||
|
break_rate = break_count / total_attempted
|
||||||
|
break_penalty = break_rate * 10 # 炸板率 50% → 扣 5 分
|
||||||
|
|
||||||
|
# 竞价信号加成(±5分)
|
||||||
|
auction_bonus = 0
|
||||||
|
if auction_signal == "强势高开":
|
||||||
|
auction_bonus = 5
|
||||||
|
elif auction_signal == "偏强":
|
||||||
|
auction_bonus = 2
|
||||||
|
elif auction_signal == "弱势低开":
|
||||||
|
auction_bonus = -5
|
||||||
|
elif auction_signal == "偏弱":
|
||||||
|
auction_bonus = -2
|
||||||
|
|
||||||
|
raw = advance_score + median_score + strong_score + limit_score - break_penalty + auction_bonus
|
||||||
|
score = round(max(0, min(100, raw)), 1)
|
||||||
|
|
||||||
|
if score >= 80:
|
||||||
|
label = "强势"
|
||||||
|
elif score >= 60:
|
||||||
|
label = "偏强"
|
||||||
|
elif score >= 40:
|
||||||
|
label = "中性"
|
||||||
|
elif score >= 20:
|
||||||
|
label = "偏弱"
|
||||||
|
else:
|
||||||
|
label = "弱势"
|
||||||
|
|
||||||
|
factors = {
|
||||||
|
"advanceScore": round(advance_score, 1),
|
||||||
|
"medianScore": round(median_score, 1),
|
||||||
|
"strongScore": round(strong_score, 1),
|
||||||
|
"limitScore": round(limit_score, 1),
|
||||||
|
"breakPenalty": round(-break_penalty, 1),
|
||||||
|
"auctionBonus": auction_bonus,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"score": score, "label": label, "factors": factors}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_dashboard() -> dict:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if _cache["data"] is not None and now - _cache["at"] < _CACHE_TTL:
|
||||||
|
return _cache["data"]
|
||||||
|
|
||||||
|
# ── 第一批并行拉取(核心数据) ──
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
index_data,
|
||||||
|
all_stocks,
|
||||||
|
limit_up_data,
|
||||||
|
limit_down_data,
|
||||||
|
limit_break_data,
|
||||||
|
industry_catalog,
|
||||||
|
concept_catalog,
|
||||||
|
auction_benchmark,
|
||||||
|
) = await asyncio.gather(
|
||||||
|
fuyao_client.index_prices_snapshot(",".join(_MAIN_INDEX_THSCODES)),
|
||||||
|
fuyao_client.prices_snapshot_all(limit=5000),
|
||||||
|
fuyao_client.limit_up_pool(page=1, size=200),
|
||||||
|
fuyao_client.limit_down_pool(page=1, size=1),
|
||||||
|
fuyao_client.limit_break_pool(page=1, size=1),
|
||||||
|
fuyao_client.index_catalog("industry"),
|
||||||
|
fuyao_client.index_catalog("cn_concept"),
|
||||||
|
fuyao_client.auction_short_term_benchmark(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"市场数据拉取失败: {e}")
|
||||||
|
|
||||||
|
# ── 第二批并行拉取(事件数据,依赖第一批结果较轻) ──
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
hot_stock_data,
|
||||||
|
dragon_tiger_data,
|
||||||
|
anomaly_data,
|
||||||
|
limit_ladder_data,
|
||||||
|
skyrocket_data,
|
||||||
|
) = await asyncio.gather(
|
||||||
|
fuyao_client.hot_stock_list("day"),
|
||||||
|
fuyao_client.dragon_tiger_list("all"),
|
||||||
|
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
|
||||||
|
fuyao_client.limit_up_ladder(),
|
||||||
|
fuyao_client.skyrocket_list("day"),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
hot_stock_data = {}
|
||||||
|
dragon_tiger_data = {}
|
||||||
|
anomaly_data = {}
|
||||||
|
limit_ladder_data = {}
|
||||||
|
skyrocket_data = {}
|
||||||
|
|
||||||
|
# ── 解析指数 ──
|
||||||
|
indices = []
|
||||||
|
if isinstance(index_data, list):
|
||||||
|
for item in index_data:
|
||||||
|
code = item.get("thscode", "")
|
||||||
|
indices.append({
|
||||||
|
"code": code,
|
||||||
|
"name": _INDEX_NAMES.get(code, item.get("name", code)),
|
||||||
|
"price": _safe_float(item.get("last_price")),
|
||||||
|
"change": _safe_float(item.get("price_change")),
|
||||||
|
"changePct": _safe_float(item.get("price_change_ratio_pct")),
|
||||||
|
"prevClose": _safe_float(item.get("prev_price")),
|
||||||
|
"turnover": _safe_float(item.get("turnover")),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 指数估值(PE/PB) — 同花顺估值API仅支持个股,指数暂不支持 ──
|
||||||
|
|
||||||
|
# ── 解析全市场涨跌统计 ──
|
||||||
|
up_count = 0
|
||||||
|
down_count = 0
|
||||||
|
flat_count = 0
|
||||||
|
total_turnover = 0.0
|
||||||
|
all_changes = []
|
||||||
|
|
||||||
|
stocks_list = all_stocks if isinstance(all_stocks, list) else []
|
||||||
|
for s in stocks_list:
|
||||||
|
chg = _safe_float(s.get("price_change_ratio_pct"))
|
||||||
|
turnover = _safe_float(s.get("turnover"))
|
||||||
|
total_turnover += turnover
|
||||||
|
|
||||||
|
if chg > 0:
|
||||||
|
up_count += 1
|
||||||
|
elif chg < 0:
|
||||||
|
down_count += 1
|
||||||
|
else:
|
||||||
|
flat_count += 1
|
||||||
|
|
||||||
|
all_changes.append(chg)
|
||||||
|
|
||||||
|
total_stocks = up_count + down_count + flat_count
|
||||||
|
median_change = sorted(all_changes)[len(all_changes) // 2] if all_changes else 0
|
||||||
|
|
||||||
|
# 涨停/跌停/炸板 精确统计
|
||||||
|
limit_up_count = 0
|
||||||
|
limit_down_count = 0
|
||||||
|
break_count = 0
|
||||||
|
if isinstance(limit_up_data, dict):
|
||||||
|
pagination = limit_up_data.get("pagination", {})
|
||||||
|
limit_up_count = pagination.get("total", 0) or len(limit_up_data.get("item", []))
|
||||||
|
if isinstance(limit_down_data, dict):
|
||||||
|
pagination = limit_down_data.get("pagination", {})
|
||||||
|
limit_down_count = pagination.get("total", 0) or len(limit_down_data.get("item", []))
|
||||||
|
if isinstance(limit_break_data, dict):
|
||||||
|
pagination = limit_break_data.get("pagination", {})
|
||||||
|
break_count = pagination.get("total", 0) or len(limit_break_data.get("item", []))
|
||||||
|
|
||||||
|
# 强势/弱势(涨幅 > 2% 为强,< -2% 为弱)
|
||||||
|
strong_count = sum(1 for c in all_changes if c > 2)
|
||||||
|
weak_count = sum(1 for c in all_changes if c < -2)
|
||||||
|
|
||||||
|
# 市场宽度
|
||||||
|
market_breadth = round(up_count / total_stocks * 100, 1) if total_stocks > 0 else 50
|
||||||
|
|
||||||
|
# ── 集合竞价信号 ──
|
||||||
|
auction_signal = ""
|
||||||
|
auction_detail = {}
|
||||||
|
if isinstance(auction_benchmark, dict) and not isinstance(auction_benchmark, Exception):
|
||||||
|
# 短线风向标:根据竞价基准判断多空
|
||||||
|
benchmark_score = _safe_float(auction_benchmark.get("score", 0))
|
||||||
|
benchmark_label = auction_benchmark.get("label", "")
|
||||||
|
auction_detail = {
|
||||||
|
"score": benchmark_score,
|
||||||
|
"label": benchmark_label,
|
||||||
|
"date": auction_benchmark.get("date", ""),
|
||||||
|
"benchmark": auction_benchmark.get("benchmark", {}),
|
||||||
|
}
|
||||||
|
if benchmark_score >= 60:
|
||||||
|
auction_signal = "强势高开"
|
||||||
|
elif benchmark_score >= 45:
|
||||||
|
auction_signal = "偏强"
|
||||||
|
elif benchmark_score <= 30:
|
||||||
|
auction_signal = "弱势低开"
|
||||||
|
elif benchmark_score <= 45:
|
||||||
|
auction_signal = "偏弱"
|
||||||
|
else:
|
||||||
|
auction_signal = "中性"
|
||||||
|
|
||||||
|
# ── 市场温度(结合竞价信号校准) ──
|
||||||
|
temperature = _calc_temperature(
|
||||||
|
up_count, down_count, flat_count, median_change,
|
||||||
|
strong_count, weak_count, limit_up_count, limit_down_count,
|
||||||
|
break_count, auction_signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
market_stats = {
|
||||||
|
"upCount": up_count,
|
||||||
|
"downCount": down_count,
|
||||||
|
"flatCount": flat_count,
|
||||||
|
"total": total_stocks,
|
||||||
|
"marketBreadth": market_breadth,
|
||||||
|
"medianChange": round(median_change, 2),
|
||||||
|
"strongCount": strong_count,
|
||||||
|
"weakCount": weak_count,
|
||||||
|
"limitUp": limit_up_count,
|
||||||
|
"limitDown": limit_down_count,
|
||||||
|
"limitBreak": break_count,
|
||||||
|
"breakRate": round(break_count / (limit_up_count + break_count) * 100, 1) if (limit_up_count + break_count) > 0 else 0,
|
||||||
|
"totalTurnover": round(total_turnover, 2),
|
||||||
|
"temperature": temperature,
|
||||||
|
"auction": auction_detail,
|
||||||
|
"auctionSignal": auction_signal,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 行业强度榜(使用行业指数真实涨幅) ──
|
||||||
|
sector_strength = []
|
||||||
|
industries = industry_catalog if isinstance(industry_catalog, list) else []
|
||||||
|
industry_codes = [ind.get("thscode", "") for ind in industries[:31] if ind.get("thscode")]
|
||||||
|
|
||||||
|
if industry_codes:
|
||||||
|
try:
|
||||||
|
industry_snapshots = []
|
||||||
|
batch_size = 50
|
||||||
|
for i in range(0, len(industry_codes), batch_size):
|
||||||
|
batch = industry_codes[i:i + batch_size]
|
||||||
|
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
|
||||||
|
if isinstance(snap, list):
|
||||||
|
industry_snapshots.extend(snap)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
for snap_item in industry_snapshots:
|
||||||
|
code = snap_item.get("thscode", "")
|
||||||
|
name = ""
|
||||||
|
for ind in industries:
|
||||||
|
if ind.get("thscode") == code:
|
||||||
|
name = ind.get("name", code)
|
||||||
|
break
|
||||||
|
|
||||||
|
gain_pct = _safe_float(snap_item.get("price_change_ratio_pct"))
|
||||||
|
last = _safe_float(snap_item.get("last_price"))
|
||||||
|
|
||||||
|
sector_strength.append({
|
||||||
|
"code": code,
|
||||||
|
"name": name or code,
|
||||||
|
"price": last,
|
||||||
|
"change": _safe_float(snap_item.get("price_change")),
|
||||||
|
"changePct": gain_pct,
|
||||||
|
})
|
||||||
|
|
||||||
|
sector_strength.sort(key=lambda x: x["changePct"], reverse=True)
|
||||||
|
|
||||||
|
for sec in sector_strength[:31]:
|
||||||
|
gp = sec["changePct"]
|
||||||
|
strength = round(max(0, min(100, 50 + gp * 12)), 1)
|
||||||
|
sec["strength"] = strength
|
||||||
|
sec["breadthPct"] = round(max(0, min(100, 50 + gp * 18)), 1)
|
||||||
|
sec["strongCount"] = max(0, int(gp * 5))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
sector_strength = []
|
||||||
|
|
||||||
|
# ── 概念板块热度 Top10 ──
|
||||||
|
concept_strength = []
|
||||||
|
concepts = concept_catalog if isinstance(concept_catalog, list) else []
|
||||||
|
concept_codes = [c.get("thscode", "") for c in concepts[:20] if c.get("thscode")]
|
||||||
|
|
||||||
|
if concept_codes:
|
||||||
|
try:
|
||||||
|
concept_snapshots = []
|
||||||
|
for i in range(0, len(concept_codes), 50):
|
||||||
|
batch = concept_codes[i:i + 50]
|
||||||
|
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
|
||||||
|
if isinstance(snap, list):
|
||||||
|
concept_snapshots.extend(snap)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
for snap_item in concept_snapshots:
|
||||||
|
code = snap_item.get("thscode", "")
|
||||||
|
name = ""
|
||||||
|
for c in concepts:
|
||||||
|
if c.get("thscode") == code:
|
||||||
|
name = c.get("name", code)
|
||||||
|
break
|
||||||
|
|
||||||
|
concept_strength.append({
|
||||||
|
"code": code,
|
||||||
|
"name": name or code,
|
||||||
|
"changePct": _safe_float(snap_item.get("price_change_ratio_pct")),
|
||||||
|
})
|
||||||
|
|
||||||
|
concept_strength.sort(key=lambda x: x["changePct"], reverse=True)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
concept_strength = []
|
||||||
|
|
||||||
|
# ── 事件情报 ──
|
||||||
|
events = []
|
||||||
|
|
||||||
|
# 1) 连板天梯
|
||||||
|
if isinstance(limit_ladder_data, dict):
|
||||||
|
ladder_items = limit_ladder_data.get("item", [])
|
||||||
|
if ladder_items:
|
||||||
|
today_boards = ladder_items[0].get("boards", {})
|
||||||
|
for board_key in ("seven_over", "six_board", "five_board", "four_board", "three_board", "two_board"):
|
||||||
|
board_list = today_boards.get(board_key, [])
|
||||||
|
for item in board_list[:2]:
|
||||||
|
board_num = item.get("board_num", 0)
|
||||||
|
events.append({
|
||||||
|
"type": "ladder",
|
||||||
|
"label": f"{board_num}连板" if board_num > 1 else "首板",
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"code": item.get("thscode", ""),
|
||||||
|
"detail": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2) 热门股 Top5
|
||||||
|
if isinstance(hot_stock_data, dict):
|
||||||
|
hot_items = hot_stock_data.get("item", [])
|
||||||
|
for item in hot_items[:5]:
|
||||||
|
rank = item.get("rank", "")
|
||||||
|
heat = item.get("heat", "")
|
||||||
|
trend = item.get("rank_trend", "")
|
||||||
|
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
|
||||||
|
events.append({
|
||||||
|
"type": "hot",
|
||||||
|
"label": f"热门 #{rank}" if rank else "热门",
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"code": item.get("thscode", ""),
|
||||||
|
"detail": f"热度 {heat} {trend_icon}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3) 飙升榜 Top3
|
||||||
|
if isinstance(skyrocket_data, dict):
|
||||||
|
sky_items = skyrocket_data.get("item", [])
|
||||||
|
for item in sky_items[:3]:
|
||||||
|
heat = item.get("heat", "")
|
||||||
|
trend = item.get("rank_trend", "")
|
||||||
|
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
|
||||||
|
events.append({
|
||||||
|
"type": "skyrocket",
|
||||||
|
"label": "飙升",
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"code": item.get("thscode", ""),
|
||||||
|
"detail": f"飙升指数 {heat} {trend_icon}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4) 涨停池封单 Top3
|
||||||
|
if isinstance(limit_up_data, dict):
|
||||||
|
lu_items = limit_up_data.get("item", [])
|
||||||
|
for item in lu_items[:3]:
|
||||||
|
seal = _safe_float(item.get("seal_money", 0))
|
||||||
|
reason = item.get("limit_up_reason", "")
|
||||||
|
continue_text = item.get("continue_day_text", "")
|
||||||
|
events.append({
|
||||||
|
"type": "limit_up",
|
||||||
|
"label": continue_text or "涨停",
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"code": item.get("thscode", ""),
|
||||||
|
"detail": f"封单 {seal / 10000:.0f}万 {reason}" if reason else f"封单 {seal / 10000:.0f}万",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 5) 炸板池 Top3
|
||||||
|
if isinstance(limit_break_data, dict):
|
||||||
|
break_items = limit_break_data.get("item", [])
|
||||||
|
for item in break_items[:3]:
|
||||||
|
events.append({
|
||||||
|
"type": "limit_break",
|
||||||
|
"label": "炸板",
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"code": item.get("thscode", ""),
|
||||||
|
"detail": f"开板 {_safe_float(item.get('open_times', 0))}次",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 6) 龙虎榜
|
||||||
|
if isinstance(dragon_tiger_data, dict):
|
||||||
|
dt_items = dragon_tiger_data.get("item", [])
|
||||||
|
for item in dt_items[:3]:
|
||||||
|
reason = item.get("reason", item.get("上榜原因", ""))
|
||||||
|
events.append({
|
||||||
|
"type": "dragon_tiger",
|
||||||
|
"label": "龙虎榜",
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"code": item.get("thscode", ""),
|
||||||
|
"detail": reason[:40] if reason else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 7) 异动分析
|
||||||
|
if isinstance(anomaly_data, dict):
|
||||||
|
anomaly_items = anomaly_data.get("item", [])
|
||||||
|
for item in anomaly_items[:3]:
|
||||||
|
tag = item.get("tagName", item.get("tag_code", ""))
|
||||||
|
events.append({
|
||||||
|
"type": "anomaly",
|
||||||
|
"label": "异动",
|
||||||
|
"name": item.get("name", ""),
|
||||||
|
"code": item.get("thscode", ""),
|
||||||
|
"detail": tag,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 8) 盘面统计摘要(固定在最后)
|
||||||
|
events.append({
|
||||||
|
"type": "summary",
|
||||||
|
"label": "盘面",
|
||||||
|
"name": f"上涨 {up_count} / 下跌 {down_count} / 平盘 {flat_count}",
|
||||||
|
"code": "",
|
||||||
|
"detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── 组装结果 ──
|
||||||
|
result = {
|
||||||
|
"indices": indices,
|
||||||
|
"marketStats": market_stats,
|
||||||
|
"sectorStrength": sector_strength[:31],
|
||||||
|
"conceptStrength": concept_strength[:10],
|
||||||
|
"events": events,
|
||||||
|
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache["data"] = result
|
||||||
|
_cache["at"] = now
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/market-dashboard", summary="市场看板数据聚合")
|
||||||
|
async def get_market_dashboard():
|
||||||
|
try:
|
||||||
|
data = await _build_dashboard()
|
||||||
|
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"市场看板数据获取失败: {e}")
|
||||||
@@ -200,3 +200,88 @@ async def index_prices_snapshot(thscodes: str) -> list[dict]:
|
|||||||
async def index_prices_historical(thscode: str, start_ms: int, end_ms: int) -> list[dict]:
|
async def index_prices_historical(thscode: str, start_ms: int, end_ms: int) -> list[dict]:
|
||||||
items = await _run(_sdk.index_prices_historical, thscode, start_ms, end_ms)
|
items = await _run(_sdk.index_prices_historical, thscode, start_ms, end_ms)
|
||||||
return _item_list(items)
|
return _item_list(items)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 特殊数据(涨停/跌停池)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def limit_up_pool(date_ms=None, page=1, size=50,
|
||||||
|
sort_field="seal_money", sort_dir="desc") -> dict:
|
||||||
|
data = await _run(_sdk.special_data_limit_up_pool, date_ms=date_ms,
|
||||||
|
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def limit_down_pool(date_ms=None, page=1, size=50,
|
||||||
|
sort_field="last_limit_time", sort_dir="desc") -> dict:
|
||||||
|
data = await _run(_sdk.special_data_limit_down_pool, date_ms=date_ms,
|
||||||
|
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 全市场行情快照
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def prices_snapshot_all(limit: int = 5000) -> list[dict]:
|
||||||
|
"""拉取全市场 A 股行情快照(自动分页)"""
|
||||||
|
items = await _run(_sdk.prices_snapshot, None, fetch_all_market=True, limit=limit)
|
||||||
|
return _item_list(items)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 特殊数据(热门股/龙虎榜/异动/连板/飙升)
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def hot_stock_list(period: str = "day") -> dict:
|
||||||
|
data = await _run(_sdk.special_data_hot_stock_list, period=period)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def dragon_tiger_list(board_type: str = "all", date: str = None) -> dict:
|
||||||
|
data = await _run(_sdk.special_data_dragon_tiger_list, board_type=board_type, date=date)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def anomaly_analysis_list(tag_codes=None) -> dict:
|
||||||
|
data = await _run(_sdk.special_data_anomaly_analysis_list, tag_codes=tag_codes)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def skyrocket_list(period: str = "day") -> dict:
|
||||||
|
data = await _run(_sdk.special_data_skyrocket_list, period=period)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def limit_up_ladder() -> dict:
|
||||||
|
data = await _run(_sdk.special_data_limit_up_ladder)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 集合竞价 / 估值 / 炸板 / 概念板块
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def auction_snapshot(thscodes: str, stage: str = "final") -> dict:
|
||||||
|
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||||
|
data = await _run(_sdk.a_share_auction_snapshot, codes, stage=stage)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def auction_short_term_benchmark(date: str = None) -> dict:
|
||||||
|
data = await _run(_sdk.a_share_auction_short_term_benchmark, date=date)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def valuations_snapshot(thscodes: str) -> dict:
|
||||||
|
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||||
|
data = await _run(_sdk.a_share_valuations_snapshot, codes)
|
||||||
|
return data or {}
|
||||||
|
|
||||||
|
|
||||||
|
async def limit_break_pool(date_ms=None, page=1, size=50,
|
||||||
|
sort_field="price_change_ratio_pct", sort_dir="desc") -> dict:
|
||||||
|
data = await _run(_sdk.special_data_limit_break_pool, date_ms=date_ms,
|
||||||
|
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
|
||||||
|
return data or {}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// 市场看板数据 API 客户端
|
||||||
|
import { getApiBaseUrl } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export interface MarketIndex {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
changePct: number;
|
||||||
|
prevClose: number;
|
||||||
|
turnover: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketTemperature {
|
||||||
|
score: number;
|
||||||
|
label: string;
|
||||||
|
factors: {
|
||||||
|
advanceScore: number;
|
||||||
|
medianScore: number;
|
||||||
|
strongScore: number;
|
||||||
|
limitScore: number;
|
||||||
|
breakPenalty: number;
|
||||||
|
auctionBonus: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuctionData {
|
||||||
|
score: number;
|
||||||
|
label: string;
|
||||||
|
date: string;
|
||||||
|
benchmark: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketStats {
|
||||||
|
upCount: number;
|
||||||
|
downCount: number;
|
||||||
|
flatCount: number;
|
||||||
|
total: number;
|
||||||
|
marketBreadth: number;
|
||||||
|
medianChange: number;
|
||||||
|
strongCount: number;
|
||||||
|
weakCount: number;
|
||||||
|
limitUp: number;
|
||||||
|
limitDown: number;
|
||||||
|
limitBreak: number;
|
||||||
|
breakRate: number;
|
||||||
|
totalTurnover: number;
|
||||||
|
temperature: MarketTemperature;
|
||||||
|
auction: AuctionData;
|
||||||
|
auctionSignal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectorStrengthItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
changePct: number;
|
||||||
|
strength: number;
|
||||||
|
breadthPct: number;
|
||||||
|
strongCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConceptStrengthItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
changePct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardEvent {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketDashboardData {
|
||||||
|
indices: MarketIndex[];
|
||||||
|
marketStats: MarketStats;
|
||||||
|
sectorStrength: SectorStrengthItem[];
|
||||||
|
conceptStrength: ConceptStrengthItem[];
|
||||||
|
events: DashboardEvent[];
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMarketDashboard(): Promise<MarketDashboardData> {
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
const resp = await fetch(`${baseUrl}/api/market-dashboard`, {
|
||||||
|
method: "GET",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
try {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.detail || `请求失败 (${resp.status})`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) throw e;
|
||||||
|
throw new Error(`请求失败 (${resp.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await resp.json();
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as ThemesRouteImport } from './routes/themes'
|
import { Route as ThemesRouteImport } from './routes/themes'
|
||||||
import { Route as ThemeHistoryRouteImport } from './routes/theme-history'
|
import { Route as ThemeHistoryRouteImport } from './routes/theme-history'
|
||||||
import { Route as HotMapRouteImport } from './routes/hot-map'
|
import { Route as HotMapRouteImport } from './routes/hot-map'
|
||||||
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||||
@@ -33,6 +34,11 @@ const HotMapRoute = HotMapRouteImport.update({
|
|||||||
path: '/hot-map',
|
path: '/hot-map',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DashboardRoute = DashboardRouteImport.update({
|
||||||
|
id: '/dashboard',
|
||||||
|
path: '/dashboard',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const CoreStocksRoute = CoreStocksRouteImport.update({
|
const CoreStocksRoute = CoreStocksRouteImport.update({
|
||||||
id: '/core-stocks',
|
id: '/core-stocks',
|
||||||
path: '/core-stocks',
|
path: '/core-stocks',
|
||||||
@@ -62,6 +68,7 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/core-stocks': typeof CoreStocksRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
|
'/dashboard': typeof DashboardRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/theme-history': typeof ThemeHistoryRoute
|
'/theme-history': typeof ThemeHistoryRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -72,6 +79,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/core-stocks': typeof CoreStocksRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
|
'/dashboard': typeof DashboardRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/theme-history': typeof ThemeHistoryRoute
|
'/theme-history': typeof ThemeHistoryRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -83,6 +91,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/core-stocks': typeof CoreStocksRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
|
'/dashboard': typeof DashboardRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/theme-history': typeof ThemeHistoryRoute
|
'/theme-history': typeof ThemeHistoryRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -95,6 +104,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/core-stocks'
|
| '/core-stocks'
|
||||||
|
| '/dashboard'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/theme-history'
|
| '/theme-history'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -105,6 +115,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/core-stocks'
|
| '/core-stocks'
|
||||||
|
| '/dashboard'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/theme-history'
|
| '/theme-history'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -115,6 +126,7 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/core-stocks'
|
| '/core-stocks'
|
||||||
|
| '/dashboard'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/theme-history'
|
| '/theme-history'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -126,6 +138,7 @@ export interface FileRouteTypes {
|
|||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
CoreStocksRoute: typeof CoreStocksRoute
|
CoreStocksRoute: typeof CoreStocksRoute
|
||||||
|
DashboardRoute: typeof DashboardRoute
|
||||||
HotMapRoute: typeof HotMapRoute
|
HotMapRoute: typeof HotMapRoute
|
||||||
ThemeHistoryRoute: typeof ThemeHistoryRoute
|
ThemeHistoryRoute: typeof ThemeHistoryRoute
|
||||||
ThemesRoute: typeof ThemesRoute
|
ThemesRoute: typeof ThemesRoute
|
||||||
@@ -157,6 +170,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof HotMapRouteImport
|
preLoaderRoute: typeof HotMapRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/dashboard': {
|
||||||
|
id: '/dashboard'
|
||||||
|
path: '/dashboard'
|
||||||
|
fullPath: '/dashboard'
|
||||||
|
preLoaderRoute: typeof DashboardRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/core-stocks': {
|
'/core-stocks': {
|
||||||
id: '/core-stocks'
|
id: '/core-stocks'
|
||||||
path: '/core-stocks'
|
path: '/core-stocks'
|
||||||
@@ -198,6 +218,7 @@ declare module '@tanstack/react-router' {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
CoreStocksRoute: CoreStocksRoute,
|
CoreStocksRoute: CoreStocksRoute,
|
||||||
|
DashboardRoute: DashboardRoute,
|
||||||
HotMapRoute: HotMapRoute,
|
HotMapRoute: HotMapRoute,
|
||||||
ThemeHistoryRoute: ThemeHistoryRoute,
|
ThemeHistoryRoute: ThemeHistoryRoute,
|
||||||
ThemesRoute: ThemesRoute,
|
ThemesRoute: ThemesRoute,
|
||||||
|
|||||||
@@ -0,0 +1,485 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
fetchMarketDashboard,
|
||||||
|
type MarketDashboardData,
|
||||||
|
type MarketIndex,
|
||||||
|
type SectorStrengthItem,
|
||||||
|
} from "@/lib/market-dashboard-api";
|
||||||
|
import { formatMoney } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
RefreshCw,
|
||||||
|
BarChart3,
|
||||||
|
Thermometer,
|
||||||
|
Zap,
|
||||||
|
Newspaper,
|
||||||
|
TrendingUp,
|
||||||
|
Target,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard")({
|
||||||
|
component: DashboardPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function DashboardPage() {
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, isError, refetch } = useQuery({
|
||||||
|
queryKey: ["market-dashboard"],
|
||||||
|
queryFn: fetchMarketDashboard,
|
||||||
|
staleTime: 15_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRefresh) return;
|
||||||
|
const timer = setInterval(() => refetch(), 30_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [autoRefresh, refetch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]">
|
||||||
|
{/* ── 顶栏 ── */}
|
||||||
|
<header className="sticky top-0 z-10 bg-[#0d1117]/95 backdrop-blur border-b border-[#21262d]">
|
||||||
|
<div className="max-w-[1400px] mx-auto px-4 h-12 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-base font-semibold">市场看板</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-[#8b949e]">
|
||||||
|
{data?.updateTime && (
|
||||||
|
<span>行情时间 {data.updateTime}</span>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||||
|
className="w-3 h-3 rounded border-[#30363d] bg-[#161b22] accent-[#58a6ff]"
|
||||||
|
/>
|
||||||
|
自动刷新
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="text-[#8b949e] hover:text-[#e6edf3] transition-colors"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── 主内容 ── */}
|
||||||
|
<main className="max-w-[1400px] mx-auto px-4 py-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<DashboardSkeleton />
|
||||||
|
) : isError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-20">
|
||||||
|
<p className="text-sm text-[#8b949e]">数据加载失败</p>
|
||||||
|
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
|
||||||
|
点击重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : data ? (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
{/* 左侧主区域 */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
|
{/* 指数行情 */}
|
||||||
|
<IndicesRow indices={data.indices} />
|
||||||
|
|
||||||
|
{/* 集合竞价信号 */}
|
||||||
|
{data.marketStats.auctionSignal && (
|
||||||
|
<AuctionSignalBar stats={data.marketStats} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 市场温度 */}
|
||||||
|
<MarketTemperatureSection stats={data.marketStats} />
|
||||||
|
|
||||||
|
{/* 涨跌家数分布 */}
|
||||||
|
<AdvanceDeclineBar stats={data.marketStats} />
|
||||||
|
|
||||||
|
{/* 行业强度榜 */}
|
||||||
|
<SectorStrengthList sectors={data.sectorStrength} />
|
||||||
|
|
||||||
|
{/* 概念板块热度 */}
|
||||||
|
{data.conceptStrength.length > 0 && (
|
||||||
|
<ConceptStrengthList concepts={data.conceptStrength} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧事件栏 */}
|
||||||
|
<div className="w-full lg:w-72 shrink-0">
|
||||||
|
<EventSidebar events={data.events} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
指数行情卡片行(含估值)
|
||||||
|
============================================================ */
|
||||||
|
function IndicesRow({ indices }: { indices: MarketIndex[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{indices.map((idx) => (
|
||||||
|
<IndexCard key={idx.code} index={idx} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndexCard({ index }: { index: MarketIndex }) {
|
||||||
|
const isUp = index.changePct >= 0;
|
||||||
|
return (
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs text-[#8b949e] truncate">{index.name}</span>
|
||||||
|
<span className="text-[10px] text-[#484f58] tabular-nums">{index.code.replace(/\.(SH|SZ)$/, "")}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-xl font-bold tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
|
||||||
|
{index.price.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={`text-xs tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
|
||||||
|
{isUp ? "+" : ""}{index.change.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
|
||||||
|
{isUp ? "+" : ""}{index.changePct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
集合竞价信号条
|
||||||
|
============================================================ */
|
||||||
|
function AuctionSignalBar({ stats }: { stats: MarketDashboardData["marketStats"] }) {
|
||||||
|
const signal = stats.auctionSignal;
|
||||||
|
const color =
|
||||||
|
signal === "强势高开" ? "#f85149" :
|
||||||
|
signal === "偏强" ? "#f0883e" :
|
||||||
|
signal === "弱势低开" ? "#3fb950" :
|
||||||
|
signal === "偏弱" ? "#238636" :
|
||||||
|
"#d29922";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg px-4 py-2 flex items-center gap-3">
|
||||||
|
<Target className="h-4 w-4 shrink-0" style={{ color }} />
|
||||||
|
<span className="text-xs text-[#8b949e]">竞价信号</span>
|
||||||
|
<span className="text-sm font-semibold" style={{ color }}>{signal}</span>
|
||||||
|
{stats.auction?.date && (
|
||||||
|
<span className="text-[10px] text-[#484f58] ml-auto">{stats.auction.date}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
市场温度
|
||||||
|
============================================================ */
|
||||||
|
function MarketTemperatureSection({ stats }: { stats: MarketDashboardData["marketStats"] }) {
|
||||||
|
const temp = stats.temperature;
|
||||||
|
const tempColor =
|
||||||
|
temp.score >= 80 ? "#f85149" :
|
||||||
|
temp.score >= 60 ? "#f0883e" :
|
||||||
|
temp.score >= 40 ? "#d29922" :
|
||||||
|
temp.score >= 20 ? "#3fb950" : "#58a6ff";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Thermometer className="h-4 w-4 text-[#8b949e]" />
|
||||||
|
<span className="text-sm font-medium">市场温度</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold tabular-nums" style={{ color: tempColor }}>
|
||||||
|
{temp.score}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: tempColor }}>{temp.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 text-xs">
|
||||||
|
<StatItem label="上涨 / 下跌" value={`${stats.upCount} / ${stats.downCount}`} />
|
||||||
|
<StatItem label="市场宽度" value={`${stats.marketBreadth}%`} />
|
||||||
|
<StatItem label="中位涨跌" value={`${stats.medianChange >= 0 ? "+" : ""}${stats.medianChange.toFixed(2)}%`}
|
||||||
|
valueColor={stats.medianChange >= 0 ? "#f85149" : "#3fb950"} />
|
||||||
|
<StatItem label="强势 / 弱势" value={`${stats.strongCount} / ${stats.weakCount}`} />
|
||||||
|
<StatItem label="涨停 / 跌停" value={`${stats.limitUp} / ${stats.limitDown}`}
|
||||||
|
valueColor={stats.limitUp > 0 ? "#f85149" : "#8b949e"} />
|
||||||
|
<StatItem label="炸板 / 炸板率" value={`${stats.limitBreak} / ${stats.breakRate}%`}
|
||||||
|
valueColor={stats.breakRate > 30 ? "#f0883e" : "#8b949e"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 温度因子明细 */}
|
||||||
|
{temp.factors && (
|
||||||
|
<div className="mt-3 pt-2 border-t border-[#21262d] flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-[#484f58]">
|
||||||
|
<span>涨跌 {temp.factors.advanceScore > 0 ? "+" : ""}{temp.factors.advanceScore}</span>
|
||||||
|
<span>中位 {temp.factors.medianScore > 0 ? "+" : ""}{temp.factors.medianScore}</span>
|
||||||
|
<span>强弱 {temp.factors.strongScore > 0 ? "+" : ""}{temp.factors.strongScore}</span>
|
||||||
|
<span>涨停 {temp.factors.limitScore > 0 ? "+" : ""}{temp.factors.limitScore}</span>
|
||||||
|
<span>炸板 {temp.factors.breakPenalty}</span>
|
||||||
|
<span>竞价 {temp.factors.auctionBonus > 0 ? "+" : ""}{temp.factors.auctionBonus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatItem({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] text-[#484f58] mb-0.5">{label}</div>
|
||||||
|
<div className="text-sm font-medium tabular-nums" style={valueColor ? { color: valueColor } : undefined}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
涨跌家数分布
|
||||||
|
============================================================ */
|
||||||
|
function AdvanceDeclineBar({ stats }: { stats: MarketDashboardData["marketStats"] }) {
|
||||||
|
const total = stats.upCount + stats.flatCount + stats.downCount;
|
||||||
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
const upPct = (stats.upCount / total) * 100;
|
||||||
|
const flatPct = (stats.flatCount / total) * 100;
|
||||||
|
const downPct = (stats.downCount / total) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4 text-[#8b949e]" />
|
||||||
|
<span className="text-sm font-medium">涨跌家数分布</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-[#484f58]">
|
||||||
|
涨停 {stats.limitUp} 炸板 {stats.limitBreak} 跌停 {stats.limitDown} 共 {total} 只
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-5 rounded-full overflow-hidden flex mb-2">
|
||||||
|
<div className="h-full bg-[#f85149] transition-all duration-500" style={{ width: `${upPct}%` }} />
|
||||||
|
<div className="h-full bg-[#484f58] transition-all duration-500" style={{ width: `${flatPct}%` }} />
|
||||||
|
<div className="h-full bg-[#3fb950] transition-all duration-500" style={{ width: `${downPct}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[#f85149]" />
|
||||||
|
<span className="text-[#8b949e]">上涨</span>
|
||||||
|
<span className="font-medium tabular-nums">{stats.upCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[#484f58]" />
|
||||||
|
<span className="text-[#8b949e]">平盘</span>
|
||||||
|
<span className="font-medium tabular-nums">{stats.flatCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[#3fb950]" />
|
||||||
|
<span className="text-[#8b949e]">下跌</span>
|
||||||
|
<span className="font-medium tabular-nums">{stats.downCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
行业强度榜
|
||||||
|
============================================================ */
|
||||||
|
function SectorStrengthList({ sectors }: { sectors: SectorStrengthItem[] }) {
|
||||||
|
if (sectors.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="h-4 w-4 text-[#8b949e]" />
|
||||||
|
<span className="text-sm font-medium">行业强度榜 TOP{sectors.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sectors.slice(0, 15).map((sec) => (
|
||||||
|
<SectorRow key={sec.code} sector={sec} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectorRow({ sector }: { sector: SectorStrengthItem }) {
|
||||||
|
const barWidth = Math.max(0, Math.min(100, sector.strength));
|
||||||
|
const isUp = sector.changePct >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs w-20 shrink-0 truncate" title={sector.name}>{sector.name}</span>
|
||||||
|
<div className="flex-1 h-4 bg-[#0d1117] rounded-sm overflow-hidden relative">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-sm transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${barWidth}%`,
|
||||||
|
background: isUp
|
||||||
|
? "linear-gradient(90deg, #1f6feb, #58a6ff)"
|
||||||
|
: "linear-gradient(90deg, #238636, #3fb950)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 text-[10px] tabular-nums w-44 justify-end">
|
||||||
|
<span className="text-[#8b949e]">强度 {sector.strength}</span>
|
||||||
|
<span className={isUp ? "text-[#f85149]" : "text-[#3fb950]"}>
|
||||||
|
{isUp ? "+" : ""}{sector.changePct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
<span className="text-[#8b949e]">宽度 {sector.breadthPct}%</span>
|
||||||
|
<span className="text-[#8b949e]">强 {sector.strongCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
概念板块热度
|
||||||
|
============================================================ */
|
||||||
|
function ConceptStrengthList({ concepts }: { concepts: MarketDashboardData["conceptStrength"] }) {
|
||||||
|
if (concepts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-4 w-4 text-[#8b949e]" />
|
||||||
|
<span className="text-sm font-medium">概念板块热度 TOP{concepts.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{concepts.map((c) => {
|
||||||
|
const isUp = c.changePct >= 0;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={c.code}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs border ${
|
||||||
|
isUp
|
||||||
|
? "text-[#f85149] bg-[#f85149]/5 border-[#f85149]/20"
|
||||||
|
: "text-[#3fb950] bg-[#3fb950]/5 border-[#3fb950]/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[80px]">{c.name}</span>
|
||||||
|
<span className="tabular-nums font-medium">
|
||||||
|
{isUp ? "+" : ""}{c.changePct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
事件情报侧栏
|
||||||
|
============================================================ */
|
||||||
|
function EventSidebar({ events }: { events: MarketDashboardData["events"] }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Newspaper className="h-4 w-4 text-[#8b949e]" />
|
||||||
|
<span className="text-sm font-medium">事件情报</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-xs text-[#484f58]">暂无事件</p>
|
||||||
|
) : (
|
||||||
|
events.map((evt, i) => (
|
||||||
|
<EventItem key={i} event={evt} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventItem({ event }: { event: MarketDashboardData["events"][0] }) {
|
||||||
|
const labelColor =
|
||||||
|
event.type === "ladder" ? "text-[#f85149] bg-[#f85149]/10 border-[#f85149]/30" :
|
||||||
|
event.type === "limit_up" ? "text-[#f0883e] bg-[#f0883e]/10 border-[#f0883e]/30" :
|
||||||
|
event.type === "hot" ? "text-[#d29922] bg-[#d29922]/10 border-[#d29922]/30" :
|
||||||
|
event.type === "skyrocket" ? "text-[#f778ba] bg-[#f778ba]/10 border-[#f778ba]/30" :
|
||||||
|
event.type === "dragon_tiger" ? "text-[#a371f7] bg-[#a371f7]/10 border-[#a371f7]/30" :
|
||||||
|
event.type === "limit_break" ? "text-[#8b949e] bg-[#8b949e]/10 border-[#8b949e]/30" :
|
||||||
|
event.type === "anomaly" ? "text-[#58a6ff] bg-[#58a6ff]/10 border-[#58a6ff]/30" :
|
||||||
|
"text-[#8b949e] bg-[#8b949e]/10 border-[#8b949e]/30";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-l-2 border-[#21262d] pl-3">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className={`text-[9px] font-medium px-1 py-0.5 rounded border ${labelColor}`}>
|
||||||
|
{event.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#e6edf3] leading-relaxed">{event.name}</p>
|
||||||
|
{event.detail && (
|
||||||
|
<p className="text-[10px] text-[#484f58] mt-0.5">{event.detail}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
骨架屏
|
||||||
|
============================================================ */
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-[#161b22] border border-[#21262d] rounded-lg p-3 animate-pulse">
|
||||||
|
<div className="h-3 w-16 bg-[#21262d] rounded mb-2" />
|
||||||
|
<div className="h-6 w-24 bg-[#21262d] rounded mb-1" />
|
||||||
|
<div className="h-3 w-20 bg-[#21262d] rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4 animate-pulse">
|
||||||
|
<div className="h-5 w-24 bg-[#21262d] rounded mb-4" />
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-2 w-16 bg-[#21262d] rounded mb-1" />
|
||||||
|
<div className="h-4 w-12 bg-[#21262d] rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4 animate-pulse">
|
||||||
|
<div className="h-5 w-32 bg-[#21262d] rounded mb-3" />
|
||||||
|
<div className="h-5 w-full bg-[#21262d] rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#161b22] border border-[#21262d] rounded-lg p-4 animate-pulse">
|
||||||
|
<div className="h-5 w-40 bg-[#21262d] rounded mb-3" />
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="h-3 w-16 bg-[#21262d] rounded" />
|
||||||
|
<div className="flex-1 h-4 bg-[#21262d] rounded" />
|
||||||
|
<div className="h-3 w-24 bg-[#21262d] rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
|||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network } from "lucide-react";
|
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network, BarChart3 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
@@ -212,6 +212,12 @@ function Index() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
||||||
<div className="mt-3 flex items-center justify-center gap-2">
|
<div className="mt-3 flex items-center justify-center gap-2">
|
||||||
|
<Link to="/dashboard">
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||||
|
<BarChart3 className="h-3.5 w-3.5" />
|
||||||
|
市场看板
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<Link to="/themes">
|
<Link to="/themes">
|
||||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||||
<Flame className="h-3.5 w-3.5" />
|
<Flame className="h-3.5 w-3.5" />
|
||||||
|
|||||||
Reference in New Issue
Block a user