"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([]); const [showSearch, setShowSearch] = useState(false); const [loading, setLoading] = useState(false); const [streamingContent, setStreamingContent] = useState(""); const [result, setResult] = useState(null); const [cachedResult, setCachedResult] = useState(null); const [history, setHistory] = useState<{ ts_code: string; name: string }[]>([]); const inputRef = useRef(null); const searchTimer = useRef>(); const wrapperRef = useRef(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(`/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 = {}; 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 (
{/* Header */}

AI 诊断

输入任意股票代码,AI 综合技术面、资金面进行全面分析

{/* Search Input */}
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} />
{showSearch && searchResults.length > 0 && (
{searchResults.map((stock) => ( ))}
)}
{/* History */} {history.length > 0 && !displayContent && (
最近诊断
{history.map((h) => ( ))}
)} {/* Streaming / Loading State */} {loading && !displayContent && (
正在分析中...
收集行情数据、技术指标、资金流向并生成分析报告
)} {/* Streaming content */} {loading && displayContent && (
AI 正在分析...
{displayContent}
)} {/* Final Result */} {!loading && displayContent && (
{result?.ts_code || codeParam} {cachedResult ? "缓存" : "分析完成"}
)} {/* Error */} {result?.status === "error" && !loading && !displayContent && (
诊断失败
{result.message || "未知错误"}
)} {/* Empty state */} {!result && !loading && !displayContent && history.length === 0 && (
输入股票代码开始 AI 诊断
支持股票代码(如 600683)或名称(如 京投发展)
{["贵州茅台", "宁德时代", "比亚迪"].map((name) => ( ))}
)}
); }