507 lines
22 KiB
TypeScript
507 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback, useRef } from "react";
|
||
import { fetchAPI, postAPI } from "@/lib/api";
|
||
import type { LatestResult, SectorData, IndexOverview, OpsStatusResponse, StrategyBoard } from "@/lib/api";
|
||
import MarketTemp from "@/components/market-temp";
|
||
import SectorHeatmap from "@/components/sector-heatmap";
|
||
import { useWebSocket } from "@/hooks/use-websocket";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
import { ThemeToggle } from "@/components/theme-toggle";
|
||
|
||
interface ScanStatus {
|
||
is_trading: boolean;
|
||
scan_mode: string;
|
||
description: string;
|
||
}
|
||
|
||
const SCAN_TIMEOUT_MS = 120_000; // 扫描超时:120秒后自动刷新数据
|
||
|
||
export default function DashboardPage() {
|
||
const { user } = useAuth();
|
||
const [data, setData] = useState<LatestResult | null>(null);
|
||
const [sectors, setSectors] = useState<SectorData[]>([]);
|
||
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
||
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
||
const [strategyBoard, setStrategyBoard] = useState<StrategyBoard | null>(null);
|
||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||
const [opsRunning, setOpsRunning] = useState<string | null>(null);
|
||
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const [latest, sectorData, status, overview, board, ops] = await Promise.all([
|
||
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
||
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
|
||
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
||
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
|
||
fetchAPI<StrategyBoard>("/api/market/strategy-board").catch(() => null),
|
||
fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null),
|
||
]);
|
||
setData(latest);
|
||
setSectors(sectorData);
|
||
setScanStatus(status);
|
||
setIndices(overview);
|
||
setStrategyBoard(board);
|
||
setOpsStatus(ops);
|
||
} catch (e) {
|
||
console.error("加载数据失败:", e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// 清除扫描超时计时器
|
||
const clearScanTimeout = useCallback(() => {
|
||
if (scanTimeoutRef.current) {
|
||
clearTimeout(scanTimeoutRef.current);
|
||
scanTimeoutRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
useWebSocket(
|
||
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
||
clearScanTimeout();
|
||
if (msg.type === "scan_update") {
|
||
const modeLabel = msg.scan_mode === "intraday" ? "盘中实时" : "盘后";
|
||
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
|
||
setRefreshing(false);
|
||
loadData();
|
||
setTimeout(() => setRefreshResult(null), 5000);
|
||
} else if (msg.type === "scan_error") {
|
||
setRefreshResult("扫描失败,请重试");
|
||
setRefreshing(false);
|
||
setTimeout(() => setRefreshResult(null), 5000);
|
||
} else {
|
||
// 其他消息类型(如 llm_analysis_ready),刷新数据
|
||
loadData();
|
||
}
|
||
}, [loadData, clearScanTimeout]),
|
||
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
||
);
|
||
|
||
const handleRefresh = async () => {
|
||
setRefreshing(true);
|
||
setRefreshResult(null);
|
||
try {
|
||
const res = await postAPI<{
|
||
status: string;
|
||
message?: string;
|
||
is_trading: boolean;
|
||
}>("/api/recommendations/refresh?scan_session=manual");
|
||
|
||
if (res.status === "already_running") {
|
||
setRefreshResult(res.message || "扫描正在执行中,请稍候");
|
||
// 保持 refreshing,等待 WS 推送完成
|
||
} else if (res.status === "scanning") {
|
||
setRefreshResult("扫描已启动,完成后自动刷新...");
|
||
// 保持 refreshing,等待 WS 推送
|
||
}
|
||
|
||
// 设置超时:如果 120 秒内没收到 WebSocket 消息,自动停止加载状态并刷新数据
|
||
scanTimeoutRef.current = setTimeout(() => {
|
||
setRefreshResult("扫描超时,已自动刷新数据");
|
||
setRefreshing(false);
|
||
loadData();
|
||
setTimeout(() => setRefreshResult(null), 5000);
|
||
}, SCAN_TIMEOUT_MS);
|
||
} catch (e) {
|
||
console.error("触发扫描失败:", e);
|
||
setRefreshResult("触发扫描失败,请重试");
|
||
setRefreshing(false);
|
||
setTimeout(() => setRefreshResult(null), 5000);
|
||
}
|
||
};
|
||
|
||
const handleAdminAction = async (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => {
|
||
setOpsRunning(action);
|
||
setRefreshResult(null);
|
||
try {
|
||
if (action === "update_tracking") {
|
||
const result = await postAPI<{ tracked?: number; win_rate?: number; avg_return?: number }>("/api/recommendations/update-tracking");
|
||
setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`);
|
||
} else if (action === "generate_strategy_board") {
|
||
await postAPI("/api/market/generate-strategy-board");
|
||
setRefreshResult("策略板已生成");
|
||
} else {
|
||
await postAPI("/api/market/generate-strategy-iteration");
|
||
setRefreshResult("策略复盘已生成");
|
||
}
|
||
await loadData();
|
||
setTimeout(() => setRefreshResult(null), 5000);
|
||
} catch (error) {
|
||
console.error("管理员操作失败:", error);
|
||
setRefreshResult("管理员操作失败,请重试");
|
||
setTimeout(() => setRefreshResult(null), 5000);
|
||
} finally {
|
||
setOpsRunning(null);
|
||
}
|
||
};
|
||
|
||
// 清理超时计时器
|
||
useEffect(() => {
|
||
return () => clearScanTimeout();
|
||
}, [clearScanTimeout]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-5">
|
||
<div className="h-32 glass-card-static animate-shimmer" />
|
||
<div className="h-48 glass-card-static animate-shimmer" />
|
||
<div className="h-48 glass-card-static animate-shimmer" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
|
||
{/* Header bar */}
|
||
<div className="flex items-center justify-between animate-fade-in-up">
|
||
<div>
|
||
<h1 className="text-lg font-bold tracking-tight">今日作战</h1>
|
||
{scanStatus && (
|
||
<p className="text-xs text-text-muted mt-1">
|
||
{scanStatus.is_trading ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-pulse" />
|
||
<span className="text-emerald-400/80">交易中</span>
|
||
<span className="text-text-muted/40">·</span>
|
||
实时行情
|
||
</span>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<span className="w-1.5 h-1.5 bg-text-muted/40 rounded-full" />
|
||
已收盘
|
||
<span className="text-text-muted/40">·</span>
|
||
Tushare 日级数据
|
||
</span>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<ThemeToggle />
|
||
{user?.role === "admin" && (
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={refreshing}
|
||
className="text-xs px-4 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium"
|
||
>
|
||
{refreshing ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||
分析中...
|
||
</span>
|
||
) : scanStatus?.is_trading ? (
|
||
"盘中扫描"
|
||
) : (
|
||
"立即扫描"
|
||
)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scan result toast */}
|
||
{refreshResult && (
|
||
<div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400 animate-fade-in-up flex items-center gap-2">
|
||
<span className="w-1 h-1 rounded-full bg-amber-400" />
|
||
{refreshResult}
|
||
</div>
|
||
)}
|
||
|
||
<MissionControl
|
||
board={strategyBoard}
|
||
recommendations={data?.recommendations ?? []}
|
||
sectors={sectors}
|
||
strategyProfile={data?.strategy_profile ?? null}
|
||
/>
|
||
|
||
{opsStatus && (
|
||
user?.role === "admin" ? (
|
||
<OpsPanel
|
||
opsStatus={opsStatus}
|
||
isAdmin={true}
|
||
refreshing={refreshing}
|
||
onRefresh={handleRefresh}
|
||
opsRunning={opsRunning}
|
||
onAction={handleAdminAction}
|
||
/>
|
||
) : null
|
||
)}
|
||
|
||
<div className="animate-fade-in-up delay-100">
|
||
<EvidenceHeader title="决策证据" />
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-3">
|
||
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
|
||
<SectorHeatmap sectors={sectors} />
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OpsPanel({
|
||
opsStatus,
|
||
isAdmin,
|
||
refreshing,
|
||
onRefresh,
|
||
opsRunning,
|
||
onAction,
|
||
}: {
|
||
opsStatus: OpsStatusResponse;
|
||
isAdmin: boolean;
|
||
refreshing: boolean;
|
||
onRefresh: () => void;
|
||
opsRunning: string | null;
|
||
onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void;
|
||
}) {
|
||
return (
|
||
<div className="glass-card-static p-4 animate-fade-in-up">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div>
|
||
<div className="text-[10px] uppercase tracking-[0.22em] text-cyan-400 font-semibold">Admin Ops</div>
|
||
<div className="text-xs text-text-muted mt-2">
|
||
{opsStatus.data_freshness.message}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-text-muted">
|
||
<span>市场 {opsStatus.data_freshness.market_trade_date || "暂无"}</span>
|
||
<span>板块 {opsStatus.data_freshness.sector_trade_date || "暂无"}</span>
|
||
<span>跟踪 {opsStatus.data_freshness.tracking_trade_date || "暂无"}</span>
|
||
<span>{opsStatus.scan_running ? "扫描中" : "空闲"}</span>
|
||
</div>
|
||
</div>
|
||
{isAdmin ? (
|
||
<div className="flex flex-wrap gap-2 mt-3">
|
||
<button
|
||
onClick={onRefresh}
|
||
disabled={refreshing || !!opsRunning}
|
||
className="text-xs px-3 py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all border border-amber-500/10"
|
||
>
|
||
{refreshing ? "扫描中..." : "立即扫描"}
|
||
</button>
|
||
<button
|
||
onClick={() => onAction("update_tracking")}
|
||
disabled={refreshing || !!opsRunning}
|
||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||
>
|
||
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
|
||
</button>
|
||
<button
|
||
onClick={() => onAction("generate_strategy_board")}
|
||
disabled={refreshing || !!opsRunning}
|
||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||
>
|
||
{opsRunning === "generate_strategy_board" ? "生成中..." : "生成策略板"}
|
||
</button>
|
||
<button
|
||
onClick={() => onAction("generate_strategy_iteration")}
|
||
disabled={refreshing || !!opsRunning}
|
||
className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors disabled:opacity-40"
|
||
>
|
||
{opsRunning === "generate_strategy_iteration" ? "生成中..." : "生成策略复盘"}
|
||
</button>
|
||
<a href="/strategy" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-cyan-400 transition-colors">
|
||
查看策略复盘
|
||
</a>
|
||
<a href="/recommendations" className="text-xs px-3 py-2 rounded-xl border border-border-subtle bg-surface-1 text-text-secondary hover:text-amber-400 transition-colors">
|
||
查看推荐闭环
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EvidenceHeader({ title }: { title: string }) {
|
||
return (
|
||
<div>
|
||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||
{title}
|
||
</h2>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MissionControl({
|
||
board,
|
||
recommendations,
|
||
sectors,
|
||
strategyProfile,
|
||
}: {
|
||
board: StrategyBoard | null;
|
||
recommendations: LatestResult["recommendations"];
|
||
sectors: SectorData[];
|
||
strategyProfile: LatestResult["strategy_profile"];
|
||
}) {
|
||
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
|
||
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
|
||
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
|
||
const topSectors = sectors.slice(0, 3);
|
||
const risks = board?.avoid_rules?.length ? board.avoid_rules : ["等待市场状态和推荐结果更新后生成风险约束。"];
|
||
const primaryQueue = (actionable.length ? actionable : watch).slice(0, 3);
|
||
const laneTitle = actionable.length ? "优先执行" : watch.length ? "候选观察" : "等待信号";
|
||
const strategyName = strategyProfile?.name ?? board?.recommended_mode ?? "待定";
|
||
const strategyHint = strategyProfile?.description ?? "系统判断今天更适合采用的出手方式";
|
||
const riskText = risks.slice(0, 2).join(" / ");
|
||
|
||
return (
|
||
<div className="glass-card-static px-4 py-4 md:px-5 md:py-5 overflow-hidden relative animate-fade-in-up">
|
||
<div className="absolute right-[-100px] top-[-120px] w-72 h-72 rounded-full bg-amber-500/[0.04] blur-3xl pointer-events-none" />
|
||
<div className="relative grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_300px] gap-4">
|
||
<div className="min-w-0 space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">今日结论</span>
|
||
{board?.generated_by === "rules+llm" && (
|
||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-cyan-500/15 bg-cyan-500/10 text-cyan-400">AI增强</span>
|
||
)}
|
||
</div>
|
||
<a href="/recommendations" className="text-[11px] text-text-muted hover:text-amber-400 transition-colors">
|
||
推荐池
|
||
</a>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_220px] gap-3 items-start">
|
||
<div className="min-w-0">
|
||
<h2 className="text-lg md:text-[1.2rem] font-bold tracking-tight truncate">
|
||
{board?.market_regime ?? "等待市场状态"}
|
||
</h2>
|
||
<p className="text-[12px] text-text-secondary leading-relaxed mt-1.5 max-w-3xl line-clamp-2">
|
||
{board?.summary ?? "系统尚未生成今日作战结论。触发扫描后,将基于市场温度、板块主线、推荐生命周期和策略复盘生成操作框架。"}
|
||
</p>
|
||
</div>
|
||
<div className="grid grid-cols-3 lg:grid-cols-1 gap-1.5">
|
||
<CommandMetric label="可操作" value={actionable.length} description="盯盘" />
|
||
<CommandMetric label="关注" value={watch.length} description="等确认" />
|
||
<CommandMetric label="观察" value={observe.length} description="不追" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-2">
|
||
<CompactDecision label="今日打法" value={strategyName} extra={strategyHint} />
|
||
<CompactDecision label="仓位建议" value={board?.position_suggestion ?? "等待判断"} extra="今天建议的进攻上限" />
|
||
<CompactDecision label="市场倾向" value={board?.action_bias ?? "等待确认"} extra={`风险 ${board?.risk_level ?? "-"}`} />
|
||
<CompactDecision label="主线板块" value={topSectors.length ? topSectors.map((sector) => sector.sector_name).join(" / ") : "暂无"} extra={riskText || "暂无风险约束"} />
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{topSectors.length ? (
|
||
topSectors.map((sector) => (
|
||
<span key={sector.sector_code} className="inline-flex items-center gap-1.5 rounded-full bg-surface-1/80 border border-border-subtle px-2.5 py-1 text-[11px]">
|
||
<span className="font-medium text-text-primary">{sector.sector_name}</span>
|
||
<span className={`font-mono tabular-nums ${sector.pct_change >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||
{sector.pct_change > 0 ? "+" : ""}{sector.pct_change.toFixed(2)}%
|
||
</span>
|
||
</span>
|
||
))
|
||
) : (
|
||
<span className="text-xs text-text-muted">暂无主线板块</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-surface-1/70 border border-border-subtle p-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<SectionTitle title={laneTitle} />
|
||
<span className="text-[10px] text-text-muted">{primaryQueue.length}只</span>
|
||
</div>
|
||
<div className="mt-2.5 space-y-1.5">
|
||
{primaryQueue.length ? (
|
||
primaryQueue.map((rec) => (
|
||
<CompactMissionStock key={`mission-${rec.ts_code}`} rec={rec} />
|
||
))
|
||
) : (
|
||
<div className="rounded-lg bg-surface-2/60 border border-border-subtle p-3 text-center">
|
||
<div className="text-xs text-text-muted">暂无执行标的</div>
|
||
<div className="text-[11px] text-text-muted/50 mt-0.5">等待扫描生成可操作或重点关注列表</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<a
|
||
href="/strategy"
|
||
className="inline-flex items-center justify-center mt-3 w-full rounded-xl border border-border-subtle bg-surface-2/60 px-3 py-2 text-[11px] text-text-muted hover:text-amber-400 hover:border-amber-500/20 transition-colors"
|
||
>
|
||
查看策略复盘
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompactDecision({ label, value, extra }: { label: string; value: string; extra: string }) {
|
||
return (
|
||
<div className="rounded-lg bg-surface-2 px-3 py-2">
|
||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||
<div className="text-sm font-semibold text-text-primary mt-1 truncate">{value}</div>
|
||
{extra ? <div className="text-[10px] text-text-muted mt-1 truncate">{extra}</div> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CompactMissionStock({ rec }: { rec: LatestResult["recommendations"][number] }) {
|
||
const actionStyle: Record<string, string> = {
|
||
"可操作": "bg-red-500/15 text-red-400 border-red-500/20",
|
||
"重点关注": "bg-amber-500/15 text-amber-400 border-amber-500/20",
|
||
"观察": "bg-surface-3 text-text-muted border-border-default",
|
||
};
|
||
|
||
return (
|
||
<a
|
||
href={`/stock/${rec.ts_code}`}
|
||
className="block rounded-lg bg-surface-2/60 border border-border-subtle px-3 py-2 hover:border-amber-500/20 transition-colors"
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<span className="text-sm font-semibold truncate">{rec.name}</span>
|
||
<span className={`shrink-0 text-[10px] px-1.5 py-0.5 rounded-md border ${actionStyle[rec.action_plan ?? "观察"] ?? actionStyle["观察"]}`}>
|
||
{rec.action_plan ?? "观察"}
|
||
</span>
|
||
</div>
|
||
<div className="text-[10px] text-text-muted font-mono tabular-nums mt-0.5 truncate">
|
||
{rec.ts_code} · {rec.sector}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right">
|
||
<div className="text-base font-bold font-mono tabular-nums text-text-primary">{rec.score}</div>
|
||
<div className="text-[10px] text-text-muted">参考</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-1.5 text-[11px] text-text-secondary leading-relaxed line-clamp-1">
|
||
{rec.trigger_condition ?? rec.reasons?.[0] ?? "等待触发条件确认"}
|
||
</div>
|
||
</a>
|
||
);
|
||
}
|
||
|
||
function CommandMetric({ label, value, description }: { label: string; value: number; description: string }) {
|
||
return (
|
||
<div className="rounded-lg bg-surface-1/70 border border-border-subtle px-2.5 py-2">
|
||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">{label}</div>
|
||
<div className="flex items-baseline justify-between gap-2 mt-0.5">
|
||
<div className="text-lg font-bold font-mono tabular-nums text-text-primary">{value}</div>
|
||
<div className="text-[10px] text-text-muted">{description}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionTitle({ title }: { title: string }) {
|
||
return (
|
||
<div className="text-[10px] text-text-muted uppercase tracking-wider font-semibold">
|
||
{title}
|
||
</div>
|
||
);
|
||
}
|