feat: 市场看板 - 聚合同花顺SDK全量数据的A股实时看板
- 新增 /dashboard 页面:暗色主题,指数行情/市场温度/涨跌分布/行业强度/概念热度/事件情报 - 后端聚合接口 /api/market-dashboard,30秒缓存 - 利用SDK接口:指数行情、全市场快照、涨停/跌停/炸板池、连板天梯、热门股、飙升榜、龙虎榜、异动分析、集合竞价基准、行业/概念目录 - 市场温度评分:6因子加权(涨跌比/中位涨跌/强弱比/涨停活跃度/炸板惩罚/竞价信号) - 首页添加市场看板导航入口
This commit is contained in:
@@ -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 ThemeHistoryRouteImport } from './routes/theme-history'
|
||||
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 IndexRouteImport } from './routes/index'
|
||||
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||
@@ -33,6 +34,11 @@ const HotMapRoute = HotMapRouteImport.update({
|
||||
path: '/hot-map',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DashboardRoute = DashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CoreStocksRoute = CoreStocksRouteImport.update({
|
||||
id: '/core-stocks',
|
||||
path: '/core-stocks',
|
||||
@@ -62,6 +68,7 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
@@ -72,6 +79,7 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
@@ -83,6 +91,7 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
@@ -95,6 +104,7 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
@@ -105,6 +115,7 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
@@ -115,6 +126,7 @@ export interface FileRouteTypes {
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
@@ -126,6 +138,7 @@ export interface FileRouteTypes {
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
CoreStocksRoute: typeof CoreStocksRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
HotMapRoute: typeof HotMapRoute
|
||||
ThemeHistoryRoute: typeof ThemeHistoryRoute
|
||||
ThemesRoute: typeof ThemesRoute
|
||||
@@ -157,6 +170,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof HotMapRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dashboard': {
|
||||
id: '/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/dashboard'
|
||||
preLoaderRoute: typeof DashboardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/core-stocks': {
|
||||
id: '/core-stocks'
|
||||
path: '/core-stocks'
|
||||
@@ -198,6 +218,7 @@ declare module '@tanstack/react-router' {
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
CoreStocksRoute: CoreStocksRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
HotMapRoute: HotMapRoute,
|
||||
ThemeHistoryRoute: ThemeHistoryRoute,
|
||||
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 { Label } from "@/components/ui/label";
|
||||
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";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -212,6 +212,12 @@ function Index() {
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
||||
<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">
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
|
||||
Reference in New Issue
Block a user