258 lines
10 KiB
TypeScript
258 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { fetchAPI, postAPI } from "@/lib/api";
|
|
import type { LatestResult, SectorData, IndexOverview, DailyReviewResponse } from "@/lib/api";
|
|
import MarketTemp from "@/components/market-temp";
|
|
import StockCard from "@/components/stock-card";
|
|
import SectorHeatmap from "@/components/sector-heatmap";
|
|
import { useWebSocket } from "@/hooks/use-websocket";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { ThemeToggle } from "@/components/theme-toggle";
|
|
import { markdownToHtml } from "@/lib/markdown";
|
|
|
|
interface ScanStatus {
|
|
is_trading: boolean;
|
|
scan_mode: string;
|
|
description: string;
|
|
}
|
|
|
|
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 [llmEnabled, setLlmEnabled] = useState(false);
|
|
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
|
const [dailyReview, setDailyReview] = useState<string | null>(null);
|
|
const [generatingReview, setGeneratingReview] = useState(false);
|
|
|
|
const loadData = useCallback(async () => {
|
|
try {
|
|
const [latest, sectorData, status, health, overview, reviewData] = await Promise.all([
|
|
fetchAPI<LatestResult>("/api/recommendations/latest"),
|
|
fetchAPI<SectorData[]>("/api/sectors/hot?limit=8"),
|
|
fetchAPI<ScanStatus>("/api/recommendations/status"),
|
|
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
|
fetchAPI<IndexOverview[]>("/api/market/overview").catch(() => []),
|
|
fetchAPI<DailyReviewResponse>("/api/market/daily-review").catch(() => ({ reviews: [] })),
|
|
]);
|
|
setData(latest);
|
|
setSectors(sectorData);
|
|
setScanStatus(status);
|
|
setLlmEnabled(health.llm_enabled);
|
|
setIndices(overview);
|
|
if (reviewData.reviews?.length > 0) {
|
|
setDailyReview(reviewData.reviews[0].content);
|
|
}
|
|
} catch (e) {
|
|
console.error("加载数据失败:", e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
useWebSocket(
|
|
useCallback(() => {
|
|
loadData();
|
|
}, [loadData]),
|
|
["llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
|
);
|
|
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
setRefreshResult(null);
|
|
try {
|
|
const res = await postAPI<{
|
|
status: string;
|
|
count: number;
|
|
temperature: number;
|
|
scan_mode: string;
|
|
is_trading: boolean;
|
|
}>("/api/recommendations/refresh?scan_session=manual");
|
|
const modeLabel = res.scan_mode === "intraday" ? "盘中实时" : "盘后";
|
|
setRefreshResult(`${modeLabel}扫描完成,发现 ${res.count} 只股票`);
|
|
await loadData();
|
|
} catch (e) {
|
|
console.error("刷新失败:", e);
|
|
setRefreshResult("扫描失败,请重试");
|
|
} finally {
|
|
setRefreshing(false);
|
|
setTimeout(() => setRefreshResult(null), 5000);
|
|
}
|
|
};
|
|
|
|
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 md:hidden tracking-tight">Dragon AI Agent</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>
|
|
)}
|
|
|
|
{/* Market temp + Sector heatmap */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<MarketTemp data={data?.market_temperature ?? null} indices={indices} />
|
|
<SectorHeatmap sectors={sectors} />
|
|
</div>
|
|
|
|
{/* Daily Review */}
|
|
<div className="animate-fade-in-up delay-100">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
|
每日复盘
|
|
</h2>
|
|
{llmEnabled && (
|
|
<button
|
|
onClick={async () => {
|
|
setGeneratingReview(true);
|
|
setRefreshResult(null);
|
|
try {
|
|
const res = await postAPI<{ status: string; content?: string; message?: string }>("/api/market/generate-review");
|
|
if (res.status === "ok" && res.content) {
|
|
setDailyReview(res.content);
|
|
} else if (res.message) {
|
|
setRefreshResult(res.message);
|
|
}
|
|
} catch (e) {
|
|
console.error("生成复盘失败:", e);
|
|
setRefreshResult("生成复盘失败,请重试");
|
|
} finally {
|
|
setGeneratingReview(false);
|
|
setTimeout(() => setRefreshResult(null), 5000);
|
|
}
|
|
}}
|
|
disabled={generatingReview}
|
|
className="text-[10px] px-3 py-1.5 bg-surface-2 text-text-secondary rounded-lg hover:bg-surface-4 disabled:opacity-40 transition-all font-medium"
|
|
>
|
|
{generatingReview ? (
|
|
<span className="inline-flex items-center gap-1">
|
|
<span className="w-2.5 h-2.5 border border-text-muted/40 border-t-text-muted rounded-full animate-spin" />
|
|
生成中...
|
|
</span>
|
|
) : (
|
|
dailyReview ? "重新生成" : "生成复盘"
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{dailyReview ? (
|
|
<div className="glass-card-static p-5">
|
|
<div
|
|
className="text-xs text-text-secondary leading-relaxed prose prose-sm prose-invert max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_p]:text-text-secondary [&_p]:mb-2 [&_ul]:text-text-secondary [&_li]:mb-1"
|
|
dangerouslySetInnerHTML={{ __html: markdownToHtml(dailyReview) }}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="glass-card-static p-6 text-center">
|
|
<div className="text-text-muted text-sm mb-1">暂无复盘报告</div>
|
|
<div className="text-text-muted/50 text-xs">
|
|
{llmEnabled ? "点击「生成复盘」AI自动分析" : "配置LLM后自动生成"}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recommendations */}
|
|
<div className="animate-fade-in-up delay-150">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
|
今日推荐
|
|
{data?.recommendations?.length ? (
|
|
<span className="text-text-primary ml-1.5 font-mono tabular-nums">{data.recommendations.length}</span>
|
|
) : ""}
|
|
</h2>
|
|
<a href="/recommendations" className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1">
|
|
查看全部
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
|
|
{!data?.recommendations?.length ? (
|
|
<div className="glass-card-static p-10 text-center">
|
|
<div className="text-text-muted text-sm mb-1">暂无推荐</div>
|
|
<div className="text-text-muted/60 text-xs">
|
|
{user?.role === "admin" ? `点击 ${scanStatus?.is_trading ? "「盘中扫描」" : "「立即扫描」"} 开始分析` : "等待系统自动扫描更新"}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{data.recommendations.slice(0, 6).map((rec) => (
|
|
<StockCard key={rec.ts_code} rec={rec} showLLMLoading={llmEnabled} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|