feat: AI市场分析功能

- 后端:AI分析服务、工具调用、定时任务
- 前端:报告列表、详情页、Mermaid图表支持
- 支持OpenAI API兼容模型
- 收盘后自动分析生成报告
This commit is contained in:
Sakurasan
2026-09-01 04:21:29 +08:00
parent d7d019c2c4
commit 912a224b6b
17 changed files with 2525 additions and 4 deletions
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import mermaid from "mermaid";
mermaid.initialize({
startOnLoad: false,
theme: "dark",
themeVariables: {
primaryColor: "#21262d",
primaryTextColor: "#e6edf3",
primaryBorderColor: "#30363d",
lineColor: "#58a6ff",
secondaryColor: "#161b22",
tertiaryColor: "#0d1117",
fontFamily: "inherit",
fontSize: "14px",
},
});
interface MermaidProps {
chart: string;
}
export function Mermaid({ chart }: MermaidProps) {
const ref = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (!ref.current) return;
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
mermaid
.render(id, chart)
.then(({ svg }) => setSvg(svg))
.catch((err) => setError(err.message || "图表渲染失败"));
}, [chart]);
if (error) {
return (
<pre className="bg-[#0d1117] border border-[#f85149]/30 rounded p-3 text-sm text-[#f85149] overflow-x-auto">
{error}
</pre>
);
}
return (
<div
ref={ref}
className="bg-[#0d1117] rounded p-3 overflow-x-auto my-2 flex justify-center [&>svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
+59
View File
@@ -0,0 +1,59 @@
// AI 分析报告 API 客户端
import { getApiBaseUrl } from "./api-client";
const API_BASE = getApiBaseUrl();
export interface AiReport {
id: number;
trade_date: string;
report_type: string;
title: string;
content: string;
summary: string | null;
toolsUsed: string[];
model: string;
tokens_used: number;
created_at: string;
}
export async function fetchAiReports(): Promise<AiReport[]> {
const resp = await fetch(`${API_BASE}/api/ai-analysis`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
const result = await resp.json();
return result.data || [];
}
export async function fetchAiReport(id: number): Promise<AiReport> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
const result = await resp.json();
return result.data;
}
export async function checkAiReport(tradeDate: string): Promise<boolean> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/check/${tradeDate}`);
if (!resp.ok) return false;
const result = await resp.json();
return result.data?.hasReport || false;
}
export async function triggerAiAnalysis(): Promise<{ id: number; tokens_used: number }> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/trigger`, { method: "POST" });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || "触发失败");
}
const result = await resp.json();
return result.data;
}
export async function regenerateAiReport(id: number): Promise<{ id: number; tokens_used: number }> {
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}/regenerate`, { method: "POST" });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || "重新生成失败");
}
const result = await resp.json();
return result.data;
}
Executable → Regular
+68
View File
@@ -14,10 +14,13 @@ 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 AiAnalysisRouteImport } from './routes/ai-analysis'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
import { Route as StockCodeRouteImport } from './routes/stock.$code'
import { Route as ShareCodeRouteImport } from './routes/share.$code'
import { Route as AiAnalysisIndexRouteImport } from './routes/ai-analysis._index'
import { Route as AiAnalysisIdRouteImport } from './routes/ai-analysis.$id'
const ThemesRoute = ThemesRouteImport.update({
id: '/themes',
@@ -44,6 +47,11 @@ const CoreStocksRoute = CoreStocksRouteImport.update({
path: '/core-stocks',
getParentRoute: () => rootRouteImport,
} as any)
const AiAnalysisRoute = AiAnalysisRouteImport.update({
id: '/ai-analysis',
path: '/ai-analysis',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -64,25 +72,38 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
path: '/share/$code',
getParentRoute: () => rootRouteImport,
} as any)
const AiAnalysisIndexRoute = AiAnalysisIndexRouteImport.update({
id: '/_index',
getParentRoute: () => AiAnalysisRoute,
} as any)
const AiAnalysisIdRoute = AiAnalysisIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => AiAnalysisRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRouteWithChildren
'/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute
'/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute
'/ai-analysis/$id': typeof AiAnalysisIdRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRouteWithChildren
'/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute
'/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute
'/ai-analysis/$id': typeof AiAnalysisIdRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
@@ -90,11 +111,14 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/ai-analysis': typeof AiAnalysisRouteWithChildren
'/core-stocks': typeof CoreStocksRoute
'/dashboard': typeof DashboardRoute
'/hot-map': typeof HotMapRoute
'/theme-history': typeof ThemeHistoryRoute
'/themes': typeof ThemesRoute
'/ai-analysis/$id': typeof AiAnalysisIdRoute
'/ai-analysis/_index': typeof AiAnalysisIndexRoute
'/share/$code': typeof ShareCodeRoute
'/stock/$code': typeof StockCodeRoute
'/theme/$code': typeof ThemeCodeRoute
@@ -103,33 +127,40 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/ai-analysis'
| '/core-stocks'
| '/dashboard'
| '/hot-map'
| '/theme-history'
| '/themes'
| '/ai-analysis/$id'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/ai-analysis'
| '/core-stocks'
| '/dashboard'
| '/hot-map'
| '/theme-history'
| '/themes'
| '/ai-analysis/$id'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
id:
| '__root__'
| '/'
| '/ai-analysis'
| '/core-stocks'
| '/dashboard'
| '/hot-map'
| '/theme-history'
| '/themes'
| '/ai-analysis/$id'
| '/ai-analysis/_index'
| '/share/$code'
| '/stock/$code'
| '/theme/$code'
@@ -137,6 +168,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AiAnalysisRoute: typeof AiAnalysisRouteWithChildren
CoreStocksRoute: typeof CoreStocksRoute
DashboardRoute: typeof DashboardRoute
HotMapRoute: typeof HotMapRoute
@@ -184,6 +216,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof CoreStocksRouteImport
parentRoute: typeof rootRouteImport
}
'/ai-analysis': {
id: '/ai-analysis'
path: '/ai-analysis'
fullPath: '/ai-analysis'
preLoaderRoute: typeof AiAnalysisRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -212,11 +251,40 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ShareCodeRouteImport
parentRoute: typeof rootRouteImport
}
'/ai-analysis/_index': {
id: '/ai-analysis/_index'
path: ''
fullPath: '/ai-analysis'
preLoaderRoute: typeof AiAnalysisIndexRouteImport
parentRoute: typeof AiAnalysisRoute
}
'/ai-analysis/$id': {
id: '/ai-analysis/$id'
path: '/$id'
fullPath: '/ai-analysis/$id'
preLoaderRoute: typeof AiAnalysisIdRouteImport
parentRoute: typeof AiAnalysisRoute
}
}
}
interface AiAnalysisRouteChildren {
AiAnalysisIdRoute: typeof AiAnalysisIdRoute
AiAnalysisIndexRoute: typeof AiAnalysisIndexRoute
}
const AiAnalysisRouteChildren: AiAnalysisRouteChildren = {
AiAnalysisIdRoute: AiAnalysisIdRoute,
AiAnalysisIndexRoute: AiAnalysisIndexRoute,
}
const AiAnalysisRouteWithChildren = AiAnalysisRoute._addFileChildren(
AiAnalysisRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AiAnalysisRoute: AiAnalysisRouteWithChildren,
CoreStocksRoute: CoreStocksRoute,
DashboardRoute: DashboardRoute,
HotMapRoute: HotMapRoute,
+138
View File
@@ -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>
);
}
+103
View File
@@ -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>
)}
</>
);
}
+29
View File
@@ -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>
);
}
+7 -1
View File
@@ -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>
+93
View File
@@ -146,3 +146,96 @@
font-family: var(--font-sans);
}
}
/* AI Report Content Styles */
.ai-report-content {
color: #c9d1d9;
line-height: 1.7;
}
.ai-report-content h1,
.ai-report-content h2,
.ai-report-content h3,
.ai-report-content h4 {
color: #e6edf3;
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.ai-report-content h1 { font-size: 1.5em; }
.ai-report-content h2 { font-size: 1.3em; }
.ai-report-content h3 { font-size: 1.15em; }
.ai-report-content p {
margin-bottom: 0.8em;
}
.ai-report-content strong {
color: #e6edf3;
font-weight: 600;
}
.ai-report-content ul,
.ai-report-content ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.ai-report-content li {
margin-bottom: 0.3em;
}
.ai-report-content blockquote {
border-left: 3px solid #30363d;
padding-left: 1em;
margin: 0.8em 0;
color: #8b949e;
}
.ai-report-content code {
background: #0d1117;
padding: 0.15em 0.4em;
border-radius: 4px;
font-size: 0.9em;
color: #79c0ff;
}
.ai-report-content pre {
background: #0d1117;
padding: 1em;
border-radius: 6px;
overflow-x: auto;
margin: 0.8em 0;
}
.ai-report-content pre code {
background: none;
padding: 0;
color: #c9d1d9;
}
.ai-report-content table {
width: 100%;
border-collapse: collapse;
margin: 0.8em 0;
}
.ai-report-content th,
.ai-report-content td {
border: 1px solid #30363d;
padding: 0.5em 0.8em;
text-align: left;
}
.ai-report-content th {
background: #21262d;
color: #e6edf3;
font-weight: 600;
}
.ai-report-content hr {
border: none;
border-top: 1px solid #30363d;
margin: 1.5em 0;
}