356 lines
14 KiB
TypeScript
356 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect, useCallback } from "react";
|
||
import { useTheme } from "next-themes";
|
||
import { useSearchParams } from "next/navigation";
|
||
import { fetchAPI, type DiagnosisResult } from "@/lib/api";
|
||
import { markdownToHtml } from "@/lib/markdown";
|
||
import { ErrorBoundary } from "@/components/error-boundary";
|
||
|
||
interface SearchResult {
|
||
ts_code: string;
|
||
name: string;
|
||
industry: string;
|
||
}
|
||
|
||
export default function DiagnosePage() {
|
||
const { theme } = useTheme();
|
||
const searchParams = useSearchParams();
|
||
const codeParam = searchParams.get("code");
|
||
const [input, setInput] = useState("");
|
||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||
const [showSearch, setShowSearch] = useState(false);
|
||
const [loading, setLoading] = useState(false);
|
||
const [streamingContent, setStreamingContent] = useState("");
|
||
const [result, setResult] = useState<DiagnosisResult | null>(null);
|
||
const [cachedResult, setCachedResult] = useState<string | null>(null);
|
||
const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
function handleClick(e: MouseEvent) {
|
||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||
setShowSearch(false);
|
||
}
|
||
}
|
||
document.addEventListener("mousedown", handleClick);
|
||
return () => document.removeEventListener("mousedown", handleClick);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!codeParam) return;
|
||
setInput(codeParam);
|
||
runDiagnosis(codeParam);
|
||
}, [codeParam]);
|
||
|
||
const searchStock = useCallback(async (keyword: string) => {
|
||
if (!keyword.trim() || keyword.length < 1) {
|
||
setSearchResults([]);
|
||
setShowSearch(false);
|
||
return;
|
||
}
|
||
try {
|
||
const data = await fetchAPI<SearchResult[]>(`/api/stocks/search?keyword=${encodeURIComponent(keyword)}`);
|
||
setSearchResults(data);
|
||
setShowSearch(data.length > 0);
|
||
} catch {
|
||
setSearchResults([]);
|
||
}
|
||
}, []);
|
||
|
||
const handleInputChange = (value: string) => {
|
||
setInput(value);
|
||
clearTimeout(searchTimer.current);
|
||
searchTimer.current = setTimeout(() => searchStock(value), 300);
|
||
};
|
||
|
||
const selectStock = (stock: SearchResult) => {
|
||
setInput(`${stock.name} (${stock.ts_code})`);
|
||
setShowSearch(false);
|
||
setSearchResults([]);
|
||
};
|
||
|
||
const runDiagnosis = async (tsCode?: string) => {
|
||
let code = tsCode;
|
||
if (!code) {
|
||
const match = input.match(/\((\d{6}\.[A-Z]{2})\)/);
|
||
if (match) {
|
||
code = match[1];
|
||
} else if (/^\d{6}$/.test(input.trim())) {
|
||
code = `${input.trim()}.SH`;
|
||
} else if (/^\d{6}\.[A-Z]{2}$/.test(input.trim())) {
|
||
code = input.trim();
|
||
}
|
||
}
|
||
|
||
if (!code) return;
|
||
|
||
setLoading(true);
|
||
setStreamingContent("");
|
||
setResult(null);
|
||
setCachedResult(null);
|
||
|
||
try {
|
||
const token = localStorage.getItem("auth_token");
|
||
const headers: Record<string, string> = {};
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
const res = await fetch(`/api/stocks/${code}/diagnose`, {
|
||
method: "POST",
|
||
headers,
|
||
});
|
||
|
||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||
if (!res.body) throw new Error("No response body");
|
||
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
let fullContent = "";
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split("\n");
|
||
buffer = lines.pop() || "";
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith("data: ")) {
|
||
const data = line.slice(6).trim();
|
||
try {
|
||
const parsed = JSON.parse(data);
|
||
|
||
if (parsed.cached && parsed.diagnosis) {
|
||
setCachedResult(parsed.diagnosis);
|
||
fullContent = parsed.diagnosis;
|
||
} else if (parsed.token) {
|
||
fullContent += parsed.token;
|
||
setStreamingContent(fullContent);
|
||
} else if (parsed.error) {
|
||
setResult({ status: "error", message: parsed.error });
|
||
} else if (parsed.done) {
|
||
if (fullContent) {
|
||
setResult({ status: "ok", ts_code: parsed.ts_code || code, diagnosis: fullContent });
|
||
const name = input.split(" (")[0] || parsed.ts_code || code;
|
||
setHistory((prev) => {
|
||
const filtered = prev.filter((h) => h.ts_code !== (parsed.ts_code || code));
|
||
return [{ ts_code: parsed.ts_code || code, name }, ...filtered].slice(0, 10);
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore malformed lines
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
setResult({ status: "error", message: "诊断失败,请检查股票代码后重试" });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter" && !showSearch) {
|
||
e.preventDefault();
|
||
runDiagnosis();
|
||
}
|
||
};
|
||
|
||
const displayContent = cachedResult || result?.diagnosis || streamingContent;
|
||
|
||
return (
|
||
<ErrorBoundary>
|
||
<div className="max-w-3xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10">
|
||
{/* Header */}
|
||
<div className="mb-6 animate-fade-in-up">
|
||
<div className="flex items-center gap-3 mb-1">
|
||
<div className="w-8 h-8 rounded-xl bg-gradient-to-br from-amber-500/25 to-amber-600/15 flex items-center justify-center border border-amber-500/15">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="text-amber-400">
|
||
<path d="M9 11l3 3L22 4" />
|
||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<h1 className="text-lg font-bold tracking-tight">AI 诊断</h1>
|
||
<p className="text-xs text-text-muted">
|
||
输入任意股票代码,AI 综合技术面、资金面进行全面分析
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search Input */}
|
||
<div ref={wrapperRef} className="relative z-30 mb-6 animate-fade-in-up">
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={input}
|
||
onChange={(e) => handleInputChange(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onFocus={() => searchResults.length > 0 && setShowSearch(true)}
|
||
placeholder="输入股票名称或代码,如 600683 或 京投发展"
|
||
className="w-full bg-surface-2 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-1 focus:ring-amber-400/30 placeholder-text-muted/40 border border-border-subtle transition-all duration-200"
|
||
disabled={loading}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => runDiagnosis()}
|
||
disabled={loading || !input.trim()}
|
||
className="px-6 py-3 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-30 transition-all duration-200 border border-amber-500/10 shrink-0"
|
||
>
|
||
{loading ? (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<span className="w-3.5 h-3.5 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||
分析中
|
||
</span>
|
||
) : (
|
||
"开始诊断"
|
||
)}
|
||
</button>
|
||
</div>
|
||
{showSearch && searchResults.length > 0 && (
|
||
<div className="absolute top-full left-0 right-0 mt-1 bg-bg-secondary border border-border-subtle rounded-xl shadow-lg z-20 overflow-hidden">
|
||
{searchResults.map((stock) => (
|
||
<button
|
||
key={stock.ts_code}
|
||
onClick={() => selectStock(stock)}
|
||
className="w-full flex items-center justify-between px-4 py-2.5 text-sm hover:bg-surface-3 transition-colors text-left"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-text-primary font-medium">{stock.name}</span>
|
||
<span className="text-text-muted text-xs">{stock.ts_code}</span>
|
||
</div>
|
||
{stock.industry && (
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-2 text-text-muted">
|
||
{stock.industry}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* History */}
|
||
{history.length > 0 && !displayContent && (
|
||
<div className="mb-6 animate-fade-in-up">
|
||
<div className="text-[10px] text-text-muted/50 mb-2 uppercase tracking-wider">最近诊断</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{history.map((h) => (
|
||
<button
|
||
key={h.ts_code}
|
||
onClick={() => {
|
||
setInput(`${h.name} (${h.ts_code})`);
|
||
runDiagnosis(h.ts_code);
|
||
}}
|
||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||
>
|
||
{h.name}
|
||
<span className="text-text-muted/50 ml-1">{h.ts_code}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Streaming / Loading State */}
|
||
{loading && !displayContent && (
|
||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||
<div className="w-8 h-8 border-2 border-amber-400/30 border-t-amber-400 rounded-full animate-spin mx-auto mb-3" />
|
||
<div className="text-sm text-text-secondary mb-1">正在分析中...</div>
|
||
<div className="text-xs text-text-muted/50">收集行情数据、技术指标、资金流向并生成分析报告</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Streaming content */}
|
||
{loading && displayContent && (
|
||
<div className="glass-card-static p-5 animate-fade-in-up">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
||
<span className="text-xs text-text-muted">AI 正在分析...</span>
|
||
</div>
|
||
<div className="text-sm text-text-secondary leading-relaxed whitespace-pre-line">
|
||
{displayContent}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Final Result */}
|
||
{!loading && displayContent && (
|
||
<div className="animate-fade-in-up">
|
||
<div className="glass-card-static p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm font-semibold text-text-primary">{result?.ts_code || codeParam}</span>
|
||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-emerald-400 border border-emerald-500/15">
|
||
{cachedResult ? "缓存" : "分析完成"}
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={() => runDiagnosis(result?.ts_code || codeParam || undefined)}
|
||
className="text-xs text-text-muted hover:text-amber-400 transition-colors flex items-center gap-1"
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M1 4v6h6" />
|
||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
||
</svg>
|
||
重新诊断
|
||
</button>
|
||
</div>
|
||
<div
|
||
className={`text-sm text-text-secondary leading-relaxed prose prose-sm max-w-none [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-amber-400 [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:first:mt-0 [&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-text-primary [&_h3]:mt-3 [&_h3]:mb-1.5 [&_p]:text-text-secondary [&_p]:mb-2.5 [&_p]:leading-relaxed [&_ul]:text-text-secondary [&_ul]:mb-2.5 [&_li]:mb-1 [&_strong]:text-text-primary ${theme !== "light" ? "prose-invert" : ""}`}
|
||
dangerouslySetInnerHTML={{ __html: markdownToHtml(displayContent) }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{result?.status === "error" && !loading && !displayContent && (
|
||
<div className="glass-card-static p-8 text-center animate-fade-in-up">
|
||
<div className="text-sm text-red-400 mb-2">诊断失败</div>
|
||
<div className="text-xs text-text-muted">{result.message || "未知错误"}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Empty state */}
|
||
{!result && !loading && !displayContent && history.length === 0 && (
|
||
<div className="glass-card-static p-10 text-center animate-fade-in-up">
|
||
<div className="w-12 h-12 rounded-2xl bg-surface-2 flex items-center justify-center mx-auto mb-4">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-text-muted/40">
|
||
<path d="M9 11l3 3L22 4" />
|
||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||
</svg>
|
||
</div>
|
||
<div className="text-sm text-text-muted mb-2">输入股票代码开始 AI 诊断</div>
|
||
<div className="text-xs text-text-muted/50 mb-4">
|
||
支持股票代码(如 600683)或名称(如 京投发展)
|
||
</div>
|
||
<div className="flex flex-wrap justify-center gap-2">
|
||
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => (
|
||
<button
|
||
key={name}
|
||
onClick={() => {
|
||
setInput(name);
|
||
searchStock(name);
|
||
}}
|
||
className="text-xs px-3 py-1.5 bg-surface-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-all border border-border-subtle"
|
||
>
|
||
{name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ErrorBoundary>
|
||
);
|
||
} |