feat: AI分析第三档——海外期货章节、报告分卡片渲染、红绿着色、期号;LLM改流式+分段生成绕开网关120s超时

This commit is contained in:
Sakurasan
2026-09-02 13:17:49 +08:00
parent 311b9f0d7e
commit 1d46ad7a08
8 changed files with 358 additions and 120 deletions
+109 -35
View File
@@ -4,6 +4,7 @@ import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide
import { useQuery } from "@tanstack/react-query";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Mermaid } from "../components/Mermaid";
import { fetchAiLatestReport } from "../lib/ai-analysis-api";
import { Card, CardContent } from "../components/ui/card";
@@ -12,6 +13,51 @@ export const Route = createFileRoute("/ai-analysis")({
component: AiAnalysisPage,
});
/** A股惯例红涨绿跌:只给带显式 +/- 号的数字着色(+2.3% / -1.2亿 / +56万...),无符号数字语义不明保持默认 */
const SIGNED_NUM_RE = /([+-]\d+(?:\.\d+)?(?:%|亿|万亿|万)?)/g;
function colorizeText(text: string): React.ReactNode[] {
return text.split(SIGNED_NUM_RE).map((part, i) => {
if (part.startsWith("+") || part.startsWith("-")) {
const val = parseFloat(part);
if (!Number.isNaN(val) && val !== 0) {
if (val > 0) return <span key={i} className="text-red-500">{part}</span>;
return <span key={i} className="text-green-600">{part}</span>;
}
}
return <React.Fragment key={i}>{part}</React.Fragment>;
});
}
function colorizeChildren(children: React.ReactNode): React.ReactNode {
return React.Children.map(children, (child) => {
if (typeof child === "string") return colorizeText(child);
if (React.isValidElement(child)) {
const kids = (child.props as { children?: React.ReactNode }).children;
if (kids != null) {
return React.cloneElement(
child as React.ReactElement<{ children?: React.ReactNode }>,
{},
colorizeChildren(kids),
);
}
}
return child;
});
}
/** 按 "## " 二级标题拆分报告为多张卡片;首段(# 标题 + 定调引用块)单独一张 */
function splitReport(content: string): { intro: string; sections: { title: string; body: string }[] } {
const parts = content.split(/\n(?=## )/);
const sections = parts.slice(1).map((p) => {
const nl = p.indexOf("\n");
const title = (nl === -1 ? p : p.slice(0, nl)).replace(/^##\s*/, "").trim();
const body = nl === -1 ? "" : p.slice(nl + 1).trim();
return { title, body };
});
return { intro: parts[0].trim(), sections };
}
function AiAnalysisPage() {
const { data: report, isLoading, isError } = useQuery({
queryKey: ["ai-report-latest"],
@@ -19,6 +65,30 @@ function AiAnalysisPage() {
retry: false,
});
const mdComponents: Components = {
table({ children, ...props }) {
return (
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
<table {...props}>{children}</table>
</div>
);
},
td({ children, ...props }) {
return <td {...props}>{colorizeChildren(children)}</td>;
},
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>
);
},
};
return (
<div className="min-h-screen bg-background text-foreground">
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6">
@@ -30,6 +100,9 @@ function AiAnalysisPage() {
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2">
<Calendar className="h-5 w-5 text-primary" />
AI 收盘分析
{report?.issue_number ? (
<span className="text-xs font-normal text-muted-foreground">总第 {report.issue_number} 期</span>
) : null}
</h1>
</div>
@@ -81,44 +154,45 @@ function AiAnalysisPage() {
</div>
)}
{/* Report Content */}
<Card className="bg-card border-border">
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
<Markdown
remarkPlugins={[remarkGfm]}
components={{
table({ children, ...props }) {
return (
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
<table {...props}>{children}</table>
</div>
);
},
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-muted-foreground/60 text-center mt-4 sm:mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议
</p>
{/* 报告正文:按 ## 章节分卡片渲染 */}
{(() => {
const { intro, sections } = splitReport(report.content);
return (
<div className="space-y-3 sm:space-y-4">
{intro && (
<Card className="bg-card border-border">
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{intro}
</Markdown>
</CardContent>
</Card>
)}
{sections.map((sec) => (
<Card key={sec.title} className="bg-card border-border overflow-hidden">
<div className="px-3 sm:px-4 md:px-6 pt-3 sm:pt-4 pb-2 sm:pb-3 border-b border-border/60">
<h2 className="text-base sm:text-lg font-semibold">{sec.title}</h2>
</div>
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
<Markdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{sec.body}
</Markdown>
</CardContent>
</Card>
))}
</div>
);
})()}
</>
)}
{/* Disclaimer */}
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
本报告由 AI 生成,仅供参考,不构成投资建议
</p>
</div>
</div>
);
}
export default AiAnalysisPage;