feat: AI市场分析功能
- 后端:AI分析服务、工具调用、定时任务 - 前端:报告列表、详情页、Mermaid图表支持 - 支持OpenAI API兼容模型 - 收盘后自动分析生成报告
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import * as React from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, BrainCircuit, Clock, Loader2, AlertCircle, RefreshCw, Wrench } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Markdown from "react-markdown";
|
||||
import { Mermaid } from "../components/Mermaid";
|
||||
import { fetchAiReport, regenerateAiReport } from "../lib/ai-analysis-api";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis/$id")({
|
||||
component: AiReportDetailPage,
|
||||
});
|
||||
|
||||
function AiReportDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const reportId = Number(id);
|
||||
|
||||
const { data: report, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["ai-report", reportId],
|
||||
queryFn: () => fetchAiReport(reportId),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: () => regenerateAiReport(reportId),
|
||||
onSuccess: () => {
|
||||
toast.success("重新生成完成");
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-report", reportId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-reports"] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "重新生成失败");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/ai-analysis" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||
<BrainCircuit className="h-5 w-5 text-[#58a6ff]" />
|
||||
{report?.title || "AI 分析报告"}
|
||||
</h1>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
disabled={regenerateMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{regenerateMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[#58a6ff]" />
|
||||
<p className="text-sm text-[#8b949e]">加载中...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<AlertCircle className="h-6 w-6 text-[#f85149]" />
|
||||
<p className="text-sm text-[#8b949e]">报告加载失败</p>
|
||||
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
) : report ? (
|
||||
<>
|
||||
{/* Meta Info */}
|
||||
<Card className="bg-[#161b22] border-[#30363d] mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-[#8b949e]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{report.created_at}
|
||||
</span>
|
||||
<span>{report.model}</span>
|
||||
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
||||
{report.toolsUsed && report.toolsUsed.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
{report.toolsUsed.length} 个工具
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Content */}
|
||||
<Card className="bg-[#161b22] border-[#30363d]">
|
||||
<CardContent className="p-4 md:p-6 ai-report-content">
|
||||
<Markdown
|
||||
components={{
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
if (match && match[1] === "mermaid") {
|
||||
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{report.content}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-xs text-[#6e7681] text-center mt-6">
|
||||
本报告由 AI 生成,仅供参考,不构成投资建议
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { Clock, Zap, Loader2, AlertCircle, BrainCircuit } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchAiReports, triggerAiAnalysis } from "../lib/ai-analysis-api";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis/_index")({
|
||||
component: AiAnalysisIndex,
|
||||
});
|
||||
|
||||
function AiAnalysisIndex() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: reports, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["ai-reports"],
|
||||
queryFn: fetchAiReports,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: triggerAiAnalysis,
|
||||
onSuccess: () => {
|
||||
toast.success("AI 分析已开始,请稍候...");
|
||||
queryClient.invalidateQueries({ queryKey: ["ai-reports"] });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "触发失败");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end mb-6">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => triggerMutation.mutate()}
|
||||
disabled={triggerMutation.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{triggerMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
)}
|
||||
立即分析
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-[#58a6ff]" />
|
||||
<p className="text-sm text-[#8b949e]">加载中...</p>
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<AlertCircle className="h-6 w-6 text-[#f85149]" />
|
||||
<p className="text-sm text-[#8b949e]">数据加载失败</p>
|
||||
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
) : reports && reports.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{reports.map((report) => (
|
||||
<Card key={report.id} className="bg-[#161b22] border-[#30363d] hover:border-[#58a6ff]/50 transition-colors">
|
||||
<CardContent className="p-4 md:p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-[#e6edf3] mb-1">{report.title}</h3>
|
||||
{report.summary && (
|
||||
<p className="text-sm text-[#8b949e] line-clamp-2 mb-2">{report.summary}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[#8b949e]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{report.created_at}
|
||||
</span>
|
||||
<span>{report.model}</span>
|
||||
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/ai-analysis/$id" params={{ id: String(report.id) }} className="text-xs text-[#58a6ff] shrink-0 hover:underline">
|
||||
查看 →
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<BrainCircuit className="h-10 w-10 text-[#30363d]" />
|
||||
<p className="text-sm text-[#8b949e]">暂无分析报告</p>
|
||||
<p className="text-xs text-[#6e7681]">点击"立即分析"生成今日报告</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { Link, Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, BrainCircuit } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis")({
|
||||
component: AiAnalysisLayout,
|
||||
});
|
||||
|
||||
function AiAnalysisLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0d1117] text-[#e6edf3]">
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link to="/" className="text-[#8b949e] hover:text-[#e6edf3] transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||
<BrainCircuit className="h-5 w-5 text-[#58a6ff]" />
|
||||
AI 市场分析
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Child routes render here */}
|
||||
<Outlet />
|
||||
</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, BarChart3 } from "lucide-react";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network, BarChart3, BrainCircuit } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -230,6 +230,12 @@ function Index() {
|
||||
热点穿透
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/ai-analysis">
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||
<BrainCircuit className="h-3.5 w-3.5" />
|
||||
AI 分析
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user