astock-agent/frontend/src/app/diagnose/page.tsx
2026-04-16 14:16:02 +08:00

356 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}