Fundamental_Analysis/frontend/src/app/report/[symbol]/page.tsx
xucheng ff7dc0c95a feat(backend): introduce DataManager and multi-provider; analysis orchestration; streaming endpoints; remove legacy tushare_client; enhance logging
feat(frontend): integrate Prisma and reports API/pages

chore(config): add data_sources.yaml; update analysis-config.json

docs: add 2025-11-03 dev log; update user guide

scripts: enhance dev.sh; add tushare_legacy_client

deps: update backend and frontend dependencies
2025-11-03 21:48:08 +08:00

1574 lines
78 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 { useParams, useSearchParams } from 'next/navigation';
import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis } from '@/hooks/useApi';
import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button';
import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table';
import { useEffect, useState, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { TradingViewWidget } from '@/components/TradingViewWidget';
import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
import { useMemo } from 'react';
export default function ReportPage() {
const params = useParams();
const searchParams = useSearchParams();
const symbol = params.symbol as string;
const market = (searchParams.get('market') || '').toLowerCase();
const isChina = market === 'china' || market === 'cn';
// 规范化中国市场 ts_code若为6位数字或无后缀自动推断交易所
const normalizedTsCode = (() => {
if (!isChina) return symbol;
if (!symbol) return symbol;
if (symbol.includes('.')) return symbol.toUpperCase();
const onlyDigits = symbol.replace(/\D/g, '');
if (onlyDigits.length === 6) {
const first = onlyDigits[0];
if (first === '6') return `${onlyDigits}.SH`;
if (first === '0' || first === '3') return `${onlyDigits}.SZ`;
}
return symbol.toUpperCase();
})();
const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10);
const { data: financialConfig } = useFinancialConfig();
const { data: analysisConfig } = useAnalysisConfig();
// 分析类型列表(按顺序)
const analysisTypes = useMemo(() => {
if (!analysisConfig?.analysis_modules) return [];
return Object.keys(analysisConfig.analysis_modules);
}, [analysisConfig]);
// 分析状态管理
const [analysisStates, setAnalysisStates] = useState<Record<string, {
content: string;
loading: boolean;
error: string | null;
elapsed_ms?: number;
tokens?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}>>({});
const fullAnalysisTriggeredRef = useRef<boolean>(false);
const isAnalysisRunningRef = useRef<boolean>(false);
const analysisFetchedRefs = useRef<Record<string, boolean>>({});
const stopRequestedRef = useRef<boolean>(false);
const abortControllerRef = useRef<AbortController | null>(null);
const currentAnalysisTypeRef = useRef<string | null>(null);
const [manualRunKey, setManualRunKey] = useState(0);
// 当前正在执行的分析任务
const [currentAnalysisTask, setCurrentAnalysisTask] = useState<string | null>(null);
// 计时器状态
const [startTime, setStartTime] = useState<number | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
// 分析执行记录(用于执行详情)
const [analysisRecords, setAnalysisRecords] = useState<Array<{
type: string;
name: string;
status: 'pending' | 'running' | 'done' | 'error';
start_ts?: string;
end_ts?: string;
duration_ms?: number;
tokens?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
error?: string;
}>>([]);
const [saving, setSaving] = useState(false)
const [saveMsg, setSaveMsg] = useState<string | null>(null)
const saveReport = async () => {
try {
setSaving(true)
setSaveMsg(null)
const content = {
market,
normalizedSymbol: normalizedTsCode,
financialsMeta: financials?.meta || null,
// 同步保存财务数据(用于报告详情页展示)
financials: financials
? {
ts_code: financials.ts_code,
name: (financials as any).name,
series: financials.series,
meta: financials.meta,
}
: null,
analyses: Object.fromEntries(
Object.entries(analysisStates).map(([k, v]) => [k, { content: v.content, error: v.error, elapsed_ms: v.elapsed_ms, tokens: v.tokens }])
)
}
const resp = await fetch('/api/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol: normalizedTsCode, content })
})
if (!resp.ok) {
const t = await resp.json().catch(() => ({}))
throw new Error(t?.error || `HTTP ${resp.status}`)
}
const data = await resp.json()
setSaveMsg('保存成功')
return data
} catch (e) {
setSaveMsg(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const runFullAnalysis = async () => {
if (!isChina || !financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
return;
}
// 初始化/重置状态,准备顺序执行
stopRequestedRef.current = false;
abortControllerRef.current?.abort();
abortControllerRef.current = null;
analysisFetchedRefs.current = {};
setCurrentAnalysisTask('开始全量分析...');
setStartTime(Date.now());
setElapsedSeconds(0);
const initialStates: typeof analysisStates = {};
const initialRecords: typeof analysisRecords = [];
const analysisModuleKeys = Object.keys(analysisConfig.analysis_modules);
for (const type of analysisModuleKeys) {
initialStates[type] = { content: '', loading: false, error: null };
initialRecords.push({
type,
name: analysisConfig.analysis_modules[type]?.name || type,
status: 'pending',
});
}
setAnalysisStates(initialStates);
setAnalysisRecords(initialRecords);
// 触发顺序执行
setManualRunKey((k) => k + 1);
};
useEffect(() => {
if (financials && !fullAnalysisTriggeredRef.current) {
fullAnalysisTriggeredRef.current = true;
runFullAnalysis();
}
}, [financials]);
// 计算完成比例
const completionProgress = useMemo(() => {
const totalTasks = analysisRecords.length;
if (totalTasks === 0) return 0;
const completedTasks = analysisRecords.filter(r => r.status === 'done' || r.status === 'error').length;
return (completedTasks / totalTasks) * 100;
}, [analysisRecords]);
// 格式化耗时显示
const formatElapsedTime = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
// 以 ms 为输入的格式化:>1000ms 显示为秒
const formatMs = (ms?: number | null): string => {
const v = typeof ms === 'number' ? ms : 0;
if (v >= 1000) {
const s = v / 1000;
return `${s.toFixed(2)} s`;
}
return `${v} ms`;
};
// 总耗时ms财务 + 所有分析任务
const totalElapsedMs = useMemo(() => {
const finMs = financials?.meta?.elapsed_ms || 0;
const analysesMs = analysisRecords.reduce((sum, r) => sum + (r.duration_ms || 0), 0);
return finMs + analysesMs;
}, [financials?.meta?.elapsed_ms, analysisRecords]);
// 创建 tushareParam 到 displayText 的映射
const metricDisplayMap = useMemo(() => {
if (!financialConfig?.api_groups) return {};
const map: Record<string, string> = {};
Object.values(financialConfig.api_groups).forEach(metrics => {
metrics.forEach(metric => {
if (metric.tushareParam && metric.displayText) {
map[metric.tushareParam] = metric.displayText;
}
});
});
return map;
}, [financialConfig]);
const metricGroupMap = useMemo(() => {
if (!financialConfig?.api_groups) return {} as Record<string, string>;
const map: Record<string, string> = {};
Object.entries(financialConfig.api_groups).forEach(([groupName, metrics]) => {
metrics.forEach((metric) => {
if (metric.tushareParam) {
map[metric.tushareParam] = groupName;
}
});
});
return map;
}, [financialConfig]);
const numberFormatter = useMemo(() => new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}), []);
const integerFormatter = useMemo(() => new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}), []);
const normalizeMarkdown = useMemo(() => {
return (content: string): string => {
if (!content) return content;
const lines = content.split(/\r?\n/);
const out: string[] = [];
for (let i = 0; i < lines.length; i += 1) {
let line = lines[i];
line = line.replace(/^(\s*)(\d+)[]\s*/u, '$1$2. ');
const onlyIndexMatch = line.match(/^\s*(\d+)\.[\s\u3000]*$/u);
if (onlyIndexMatch) {
const next = lines[i + 1] ?? '';
out.push(`${onlyIndexMatch[1]}. ${next}`);
i += 1;
continue;
}
out.push(line);
}
let text = out.join('\n');
text = text.replace(/([^\n])\n(\s*\d+\.\s)/g, (_m, a, b) => `${a}\n\n${b}`);
return text;
};
}, []);
const hasRunningTask = useMemo(() => {
if (currentAnalysisTask !== null) return true;
if (analysisRecords.some(r => r.status === 'running')) return true;
return false;
}, [currentAnalysisTask, analysisRecords]);
// 全部任务是否完成(无运行中任务,且所有分析记录为 done 或 error
const allTasksCompleted = useMemo(() => {
if (analysisRecords.length === 0) return false;
const allDoneOrErrored = analysisRecords.every(r => r.status === 'done' || r.status === 'error');
return allDoneOrErrored && !hasRunningTask && currentAnalysisTask === null;
}, [analysisRecords, hasRunningTask, currentAnalysisTask]);
// 所有任务完成时,停止计时器
useEffect(() => {
if (allTasksCompleted) {
setStartTime(null);
}
}, [allTasksCompleted]);
useEffect(() => {
if (!startTime) return;
const interval = setInterval(() => {
const now = Date.now();
const elapsed = Math.floor((now - startTime) / 1000);
setElapsedSeconds(elapsed);
}, 1000);
return () => clearInterval(interval);
}, [startTime]);
const retryAnalysis = async (analysisType: string) => {
if (!isChina || !financials || !analysisConfig?.analysis_modules) {
return;
}
analysisFetchedRefs.current[analysisType] = false;
setAnalysisStates(prev => ({
...prev,
[analysisType]: { content: '', loading: true, error: null }
}));
setAnalysisRecords(prev => prev.filter(record => record.type !== analysisType));
const analysisName =
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
const startTime = new Date().toISOString();
setCurrentAnalysisTask(analysisType);
setAnalysisRecords(prev => [...prev, {
type: analysisType,
name: analysisName,
status: 'running',
start_ts: startTime
}]);
try {
const startedMsLocal = Date.now();
const response = await fetch(
`/api/financials/china/${normalizedTsCode}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let aggregate = '';
if (reader) {
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
aggregate += chunk;
const snapshot = aggregate;
setAnalysisStates(prev => ({
...prev,
[analysisType]: {
...prev[analysisType],
content: snapshot,
loading: true,
error: null,
}
}));
}
}
const endTime = new Date().toISOString();
const elapsedMs = Date.now() - startedMsLocal;
setAnalysisStates(prev => ({
...prev,
[analysisType]: {
...prev[analysisType],
content: aggregate,
loading: false,
error: null,
elapsed_ms: elapsedMs,
}
}));
setAnalysisRecords(prev => prev.map(record =>
record.type === analysisType
? {
...record,
status: 'done',
end_ts: endTime,
duration_ms: elapsedMs,
}
: record
));
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '加载失败';
const endTime = new Date().toISOString();
setAnalysisStates(prev => ({
...prev,
[analysisType]: {
content: '',
loading: false,
error: errorMessage
}
}));
setAnalysisRecords(prev => prev.map(record =>
record.type === analysisType
? {
...record,
status: 'error',
end_ts: endTime,
error: errorMessage
}
: record
));
} finally {
setCurrentAnalysisTask(null);
analysisFetchedRefs.current[analysisType] = true;
}
};
useEffect(() => {
if (!isChina || isLoading || error || !financials || !analysisConfig?.analysis_modules || analysisTypes.length === 0) {
return;
}
if (isAnalysisRunningRef.current) {
return;
}
const runAnalysesSequentially = async () => {
if (isAnalysisRunningRef.current) {
return;
}
isAnalysisRunningRef.current = true;
try {
if (!stopRequestedRef.current && !startTime) {
setStartTime(Date.now());
}
for (let i = 0; i < analysisTypes.length; i++) {
const analysisType = analysisTypes[i];
if (stopRequestedRef.current) {
break;
}
if (analysisFetchedRefs.current[analysisType]) {
continue;
}
if (!analysisFetchedRefs.current || !analysisConfig?.analysis_modules) {
console.error("分析配置或refs未初始化无法进行分析。");
continue;
}
currentAnalysisTypeRef.current = analysisType;
const analysisName =
analysisConfig.analysis_modules[analysisType]?.name || analysisType;
const startTime = new Date().toISOString();
setCurrentAnalysisTask(analysisType);
setAnalysisRecords(prev => {
const next = [...prev];
const idx = next.findIndex(r => r.type === analysisType);
const updated = {
type: analysisType,
name: analysisName,
status: 'running' as const,
start_ts: startTime
};
if (idx >= 0) {
next[idx] = { ...next[idx], ...updated };
} else {
next.push(updated);
}
return next;
});
setAnalysisStates(prev => ({
...prev,
[analysisType]: { content: '', loading: true, error: null }
}));
try {
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const startedMsLocal = Date.now();
const response = await fetch(
`/api/financials/china/${normalizedTsCode}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`,
{ signal: abortControllerRef.current.signal }
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let aggregate = '';
if (reader) {
// 持续读取并追加到内容
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
aggregate += chunk;
const snapshot = aggregate;
setAnalysisStates(prev => ({
...prev,
[analysisType]: {
...prev[analysisType],
content: snapshot,
loading: true,
error: null,
}
}));
}
}
const endTime = new Date().toISOString();
const elapsedMs = Date.now() - startedMsLocal;
setAnalysisStates(prev => ({
...prev,
[analysisType]: {
...prev[analysisType],
content: aggregate,
loading: false,
error: null,
elapsed_ms: elapsedMs,
}
}));
setAnalysisRecords(prev => prev.map(record =>
record.type === analysisType
? {
...record,
status: 'done',
end_ts: endTime,
duration_ms: elapsedMs,
}
: record
));
} catch (err) {
if (err && typeof err === 'object' && (err as any).name === 'AbortError') {
setAnalysisStates(prev => ({
...prev,
[analysisType]: { content: '', loading: false, error: null }
}));
setAnalysisRecords(prev => prev.map(record =>
record.type === analysisType
? { ...record, status: 'pending', start_ts: undefined }
: record
));
analysisFetchedRefs.current[analysisType] = false;
break;
}
const errorMessage = err instanceof Error ? err.message : '加载失败';
const endTime = new Date().toISOString();
setAnalysisStates(prev => ({
...prev,
[analysisType]: {
content: '',
loading: false,
error: errorMessage
}
}));
setAnalysisRecords(prev => prev.map(record =>
record.type === analysisType
? {
...record,
status: 'error',
end_ts: endTime,
error: errorMessage
}
: record
));
} finally {
setCurrentAnalysisTask(null);
currentAnalysisTypeRef.current = null;
analysisFetchedRefs.current[analysisType] = true;
}
}
} finally {
isAnalysisRunningRef.current = false;
}
};
runAnalysesSequentially();
}, [isChina, isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]);
const stopAll = () => {
stopRequestedRef.current = true;
abortControllerRef.current?.abort();
abortControllerRef.current = null;
isAnalysisRunningRef.current = false;
if (currentAnalysisTypeRef.current) {
analysisFetchedRefs.current[currentAnalysisTypeRef.current] = false;
}
setCurrentAnalysisTask(null);
setStartTime(null);
};
const continuePending = () => {
if (isAnalysisRunningRef.current) return;
stopRequestedRef.current = false;
setStartTime((prev) => (prev == null ? Date.now() - elapsedSeconds * 1000 : prev));
setManualRunKey((k) => k + 1);
};
return (
<div className="space-y-4">
<div className="flex items-stretch justify-between gap-4">
<Card className="flex-1">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">{normalizedTsCode}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">{market}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">
{isLoading ? (
<span className="flex items-center gap-1">
<Spinner className="size-3" />
<span className="text-muted-foreground">...</span>
</span>
) : financials?.name ? (
financials.name
) : (
<span className="text-muted-foreground">-</span>
)}
</span>
</div>
</CardContent>
</Card>
{isChina && (
<Card className="w-40 flex-shrink-0">
<CardContent className="flex flex-col gap-2">
<Button onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}>
{isAnalysisRunningRef.current ? '正在分析…' : '开始分析'}
</Button>
<Button variant="destructive" onClick={stopAll} disabled={!hasRunningTask}>
</Button>
<Button variant="outline" onClick={continuePending} disabled={isAnalysisRunningRef.current}>
</Button>
</CardContent>
</Card>
)}
{isChina && (
<Card className="w-80">
<CardHeader className="flex flex-col space-y-2 pb-2">
<div className="flex flex-row items-center justify-between w-full">
<CardTitle className="text-xl"></CardTitle>
{startTime && (
<div className="text-sm font-medium text-muted-foreground ml-auto">
: {formatElapsedTime(elapsedSeconds)}
</div>
)}
</div>
<div className="w-full bg-muted rounded-full h-2 mt-1">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${completionProgress}%` }}
/>
</div>
{allTasksCompleted && (
<div className="pt-2">
<Button onClick={saveReport} disabled={saving} variant="outline">
{saving ? '保存中...' : '保存报告'}
</Button>
{saveMsg && <span className="ml-2 text-xs text-muted-foreground">{saveMsg}</span>}
</div>
)}
</CardHeader>
<CardContent className="space-y-2">
{currentAnalysisTask && analysisConfig && (
(() => {
const analysisName = analysisConfig.analysis_modules[currentAnalysisTask]?.name || currentAnalysisTask;
const modelName = analysisConfig.analysis_modules[currentAnalysisTask]?.model || 'AI';
return (
<div className="flex items-center gap-2 text-sm">
<Spinner className="size-4" />
<div>
<div className="font-medium">{analysisName} {modelName}</div>
<div className="text-xs text-muted-foreground">{analysisName}...</div>
</div>
</div>
);
})()
)}
</CardContent>
</Card>
)}
</div>
{isChina && (
<Tabs defaultValue="chart" className="mt-4">
<TabsList className="flex-wrap">
<TabsTrigger value="chart"></TabsTrigger>
<TabsTrigger value="financial"></TabsTrigger>
{analysisTypes.map(type => (
<TabsTrigger key={type} value={type}>
{type === 'company_profile' ? '公司简介' : (analysisConfig?.analysis_modules[type]?.name || type)}
</TabsTrigger>
))}
<TabsTrigger value="execution"></TabsTrigger>
</TabsList>
<TabsContent value="chart" className="space-y-4">
<h2 className="text-lg font-medium"> TradingView</h2>
<div className="flex items-center gap-3 text-sm mb-4">
<CheckCircle className="size-4 text-green-600" />
<div className="text-muted-foreground">
- {normalizedTsCode}
</div>
</div>
<TradingViewWidget
symbol={normalizedTsCode}
market={market}
height={500}
/>
</TabsContent>
<TabsContent value="financial" className="space-y-4">
<h2 className="text-lg font-medium"> Tushare</h2>
<div className="flex items-center gap-3 text-sm">
{isLoading ? (
<Spinner className="size-4" />
) : error ? (
<XCircle className="size-4 text-red-500" />
) : (
<CheckCircle className="size-4 text-green-600" />
)}
<div className="text-muted-foreground">
{isLoading
? '正在读取 Tushare 数据…'
: error
? '读取失败'
: '读取完成'}
</div>
</div>
{error && <p className="text-red-500"></p>}
{isLoading && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"></span>
<Spinner className="size-4" />
</div>
)}
{financials && (
<div className="overflow-x-auto">
{(() => {
const series = financials?.series ?? {};
const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }>;
const currentYearStr = String(new Date().getFullYear());
const years = Array
.from(new Set(allPoints.map((p) => p?.year).filter(Boolean) as string[]))
.sort((a, b) => Number(b) - Number(a)); // 最新年份在左
const getQuarter = (month: number | null | undefined) => {
if (month == null) return null;
return Math.floor((month - 1) / 3) + 1;
};
if (years.length === 0) {
return <p className="text-sm text-muted-foreground"></p>;
}
return (
<Table className="min-w-full text-sm">
<TableHeader>
<TableRow>
<TableHead className="text-left p-2"></TableHead>
{years.map((y) => {
const isCurrent = y === currentYearStr;
const yearData = allPoints.find(p => p.year === y);
const quarter = yearData?.month ? getQuarter(yearData.month) : null;
const label = isCurrent && quarter ? `${y} Q${quarter}` : y;
return (
<TableHead key={y} className="text-right p-2">{label}</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{(() => {
// 指定显示顺序tushareParam
const ORDER: Array<{ key: string; label?: string; kind?: 'computed' }> = [
{ key: 'roe' },
{ key: 'roa' },
{ key: 'roic' },
{ key: 'grossprofit_margin' },
{ key: 'netprofit_margin' },
{ key: 'revenue' },
{ key: 'tr_yoy' },
{ key: 'n_income' },
{ key: 'dt_netprofit_yoy' },
{ key: 'n_cashflow_act' },
{ key: 'c_pay_acq_const_fiolta' },
{ key: '__free_cash_flow', label: '自由现金流', kind: 'computed' },
{ key: 'cash_div_tax', label: '分红' },
{ key: 'buyback', label: '回购' },
{ key: 'total_assets' },
{ key: 'total_hldr_eqy_exc_min_int' },
{ key: 'goodwill' },
];
// 在表格顶部插入"主要指标"行
const summaryRow = (
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => (
<TableCell key={y} className="p-2"></TableCell>
))}
</TableRow>
);
const PERCENT_KEYS = new Set([
'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy'
]);
const rows = ORDER.map(({ key, label, kind }) => {
// 自由现金流为计算项:经营现金流 - 资本开支
const isComputed = kind === 'computed' && key === '__free_cash_flow';
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
const operating = series['n_cashflow_act'] as Array<{ year?: string; value?: number | null }> | undefined;
const capex = series['c_pay_acq_const_fiolta'] as Array<{ year?: string; value?: number | null }> | undefined;
return (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">
{label || metricDisplayMap[key] || key}
</TableCell>
{years.map((y) => {
let v: number | null | undefined = undefined;
if (isComputed) {
const op = operating?.find(p => p?.year === y)?.value ?? null;
const cp = capex?.find(p => p?.year === y)?.value ?? null;
if (op == null || cp == null) {
v = null;
} else {
v = (typeof op === 'number' ? op : Number(op)) - (typeof cp === 'number' ? cp : Number(cp));
}
} else {
v = points?.find(p => p?.year === y)?.value ?? null;
}
const groupName = metricGroupMap[key];
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (rawNum == null || Number.isNaN(rawNum)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
if (PERCENT_KEYS.has(key)) {
const perc = Math.abs(rawNum) <= 1 ? rawNum * 100 : rawNum;
const text = Number.isFinite(perc) ? numberFormatter.format(perc) : '-';
const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy';
if (isGrowthRow) {
const isNeg = typeof perc === 'number' && perc < 0;
return (
<TableCell key={y} className="text-right p-2">
<span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span>
</TableCell>
);
}
// ROE > 12% 高亮浅绿色背景
if (key === 'roe') {
const highlight = typeof perc === 'number' && perc > 12;
return (
<TableCell key={y} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
);
}
// ROIC > 12% 高亮浅绿色背景
if (key === 'roic') {
const highlight = typeof perc === 'number' && perc > 12;
return (
<TableCell key={y} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
);
}
return (
<TableCell key={y} className="text-right p-2">{`${text}%`}</TableCell>
);
} else {
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow';
const scaled = key === 'total_mv'
? rawNum / 10000
: (isFinGroup || isComputed ? rawNum / 1e8 : rawNum);
const formatter = key === 'total_mv' ? integerFormatter : numberFormatter;
const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-';
if (key === '__free_cash_flow') {
const isNeg = typeof scaled === 'number' && scaled < 0;
return (
<TableCell key={y} className="text-right p-2">
{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}
</TableCell>
);
}
return (
<TableCell key={y} className="text-right p-2">{text}</TableCell>
);
}
})}
</TableRow>
);
});
// =========================
// 费用指标分组
// =========================
const feeHeaderRow = (
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => (
<TableCell key={y} className="p-2"></TableCell>
))}
</TableRow>
);
const getVal = (arr: Array<{ year?: string; value?: number | null }> | undefined, y: string) => {
const v = arr?.find(p => p?.year === y)?.value;
return typeof v === 'number' ? v : (v == null ? null : Number(v));
};
// 销售费用率 = sell_exp / revenue
const feeRows = [
{ key: '__sell_rate', label: '销售费用率', num: series['sell_exp'] as any, den: series['revenue'] as any },
{ key: '__admin_rate', label: '管理费用率', num: series['admin_exp'] as any, den: series['revenue'] as any },
{ key: '__rd_rate', label: '研发费用率', num: series['rd_exp'] as any, den: series['revenue'] as any },
{ key: '__other_fee_rate', label: '其他费用率', num: undefined, den: series['revenue'] as any },
// 所得税率:若有 tax_to_ebt用该指标否则按 income_tax / 税前利润 计算(暂无字段,留空)
{ key: '__tax_rate', label: '所得税率', num: series['tax_to_ebt'] as any, den: undefined },
// 折旧费用占比 = 折旧费用 / 收入
{ key: '__depr_ratio', label: '折旧费用占比', num: series['depr_fa_coga_dpba'] as any, den: series['revenue'] as any },
].map(({ key, label, num, den }) => (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => {
const numerator = getVal(num, y);
const denominator = getVal(den, y);
let rate: number | null = null;
if (key === '__tax_rate') {
// tax_to_ebt 有些接口为比例0-1有些可能已是百分数
if (numerator == null || Number.isNaN(numerator)) {
rate = null;
} else if (Math.abs(numerator) <= 1) {
rate = numerator * 100;
} else {
rate = numerator; // 认为已是百分比
}
} else if (key === '__other_fee_rate') {
// 其他费用率 = 毛利率 - 净利润率 - 销售费用率 - 管理费用率 - 研发费用率
const gpRaw = getVal(series['grossprofit_margin'] as any, y);
const npRaw = getVal(series['netprofit_margin'] as any, y);
const rev = getVal(series['revenue'] as any, y);
const sell = getVal(series['sell_exp'] as any, y);
const admin = getVal(series['admin_exp'] as any, y);
const rd = getVal(series['rd_exp'] as any, y);
if (
gpRaw == null || npRaw == null || rev == null || rev === 0 ||
sell == null || admin == null || rd == null
) {
rate = null;
} else {
// 将毛利率、净利润率标准化为百分比
const gp = Math.abs(gpRaw) <= 1 ? gpRaw * 100 : gpRaw;
const np = Math.abs(npRaw) <= 1 ? npRaw * 100 : npRaw;
const sellRate = (sell / rev) * 100;
const adminRate = (admin / rev) * 100;
const rdRate = (rd / rev) * 100;
rate = gp - np - sellRate - adminRate - rdRate;
}
} else {
if (numerator == null || denominator == null || denominator === 0) {
rate = null;
} else {
rate = (numerator / denominator) * 100;
}
}
if (rate == null || !Number.isFinite(rate)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const rateText = numberFormatter.format(rate);
const isNegative = rate < 0;
return (
<TableCell key={y} className="text-right p-2">
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`}
</TableCell>
);
})}
</TableRow>
));
// =========================
// 资产占比分组
// =========================
const assetHeaderRow = (
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => (
<TableCell key={y} className="p-2"></TableCell>
))}
</TableRow>
);
const ratioCell = (value: number | null, y: string) => {
if (value == null || !Number.isFinite(value)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const text = numberFormatter.format(value);
const isNegative = value < 0;
return (
<TableCell key={y} className="text-right p-2">
{isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`}
</TableCell>
);
};
const assetRows = [
{
key: '__money_cap_ratio', label: '现金占比', calc: (y: string) => {
const num = getVal(series['money_cap'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__inventories_ratio', label: '库存占比', calc: (y: string) => {
const num = getVal(series['inventories'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__ar_ratio', label: '应收款占比', calc: (y: string) => {
const num = getVal(series['accounts_receiv_bill'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__prepay_ratio', label: '预付款占比', calc: (y: string) => {
const num = getVal(series['prepayment'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__fix_assets_ratio', label: '固定资产占比', calc: (y: string) => {
const num = getVal(series['fix_assets'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__lt_invest_ratio', label: '长期投资占比', calc: (y: string) => {
const num = getVal(series['lt_eqt_invest'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__goodwill_ratio', label: '商誉占比', calc: (y: string) => {
const num = getVal(series['goodwill'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__other_assets_ratio', label: '其他资产占比', calc: (y: string) => {
const total = getVal(series['total_assets'] as any, y);
if (total == null || total === 0) return null;
const parts = [
getVal(series['money_cap'] as any, y),
getVal(series['inventories'] as any, y),
getVal(series['accounts_receiv_bill'] as any, y),
getVal(series['prepayment'] as any, y),
getVal(series['fix_assets'] as any, y),
getVal(series['lt_eqt_invest'] as any, y),
getVal(series['goodwill'] as any, y),
].map(v => (typeof v === 'number' && Number.isFinite(v) ? v : 0));
const sumKnown = parts.reduce((acc: number, v: number) => acc + v, 0);
return ((total - sumKnown) / total) * 100;
}
},
{
key: '__ap_ratio', label: '应付款占比', calc: (y: string) => {
const num = getVal(series['accounts_pay'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__adv_ratio', label: '预收款占比', calc: (y: string) => {
const adv = getVal(series['adv_receipts'] as any, y) || 0;
const contractLiab = getVal(series['contract_liab'] as any, y) || 0;
const num = adv + contractLiab;
const den = getVal(series['total_assets'] as any, y);
return den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__st_borr_ratio', label: '短期借款占比', calc: (y: string) => {
const num = getVal(series['st_borr'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__lt_borr_ratio', label: '长期借款占比', calc: (y: string) => {
const num = getVal(series['lt_borr'] as any, y);
const den = getVal(series['total_assets'] as any, y);
return num == null || den == null || den === 0 ? null : (num / den) * 100;
}
},
{
key: '__operating_assets_ratio', label: '运营资产占比', calc: (y: string) => {
const total = getVal(series['total_assets'] as any, y);
if (total == null || total === 0) return null;
const inv = getVal(series['inventories'] as any, y) || 0;
const ar = getVal(series['accounts_receiv_bill'] as any, y) || 0;
const pre = getVal(series['prepayment'] as any, y) || 0;
const ap = getVal(series['accounts_pay'] as any, y) || 0;
const adv = getVal(series['adv_receipts'] as any, y) || 0;
const contractLiab = getVal(series['contract_liab'] as any, y) || 0;
const operating = inv + ar + pre - ap - adv - contractLiab;
return (operating / total) * 100;
}
},
{
key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (y: string) => {
const total = getVal(series['total_assets'] as any, y);
if (total == null || total === 0) return null;
const st = getVal(series['st_borr'] as any, y) || 0;
const lt = getVal(series['lt_borr'] as any, y) || 0;
return ((st + lt) / total) * 100;
}
},
].map(({ key, label, calc }) => (
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => ratioCell(calc(y), y))}
</TableRow>
));
// =========================
// 周转能力分组
// =========================
const turnoverHeaderRow = (
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => (
<TableCell key={y} className="p-2"></TableCell>
))}
</TableRow>
);
const turnoverItems: Array<{ key: string; label: string }> = [
{ key: 'invturn_days', label: '存货周转天数' },
{ key: 'arturn_days', label: '应收款周转天数' },
{ key: 'payturn_days', label: '应付款周转天数' },
{ key: 'fa_turn', label: '固定资产周转率' },
{ key: 'assets_turn', label: '总资产周转率' },
];
const getYearNumber = (ys: string) => {
const n = Number(ys);
return Number.isFinite(n) ? n : null;
};
const getPoint = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => {
return arr?.find(p => p?.year === year)?.value ?? null;
};
const getAvg = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => {
const curr = getPoint(arr, year);
const yNum = getYearNumber(year);
const prevYear = yNum != null ? String(yNum - 1) : null;
const prev = prevYear ? getPoint(arr, prevYear) : null;
const c = typeof curr === 'number' ? curr : (curr == null ? null : Number(curr));
const p = typeof prev === 'number' ? prev : (prev == null ? null : Number(prev));
if (c == null) return null;
if (p == null) return c;
return (c + p) / 2;
};
const getMarginRatio = (year: string) => {
const gmRaw = getPoint(series['grossprofit_margin'] as any, year);
if (gmRaw == null) return null;
const gmNum = typeof gmRaw === 'number' ? gmRaw : Number(gmRaw);
if (!Number.isFinite(gmNum)) return null;
// 支持 0-1 或 百分数
return Math.abs(gmNum) <= 1 ? gmNum : gmNum / 100;
};
const getRevenue = (year: string) => {
const rev = getPoint(series['revenue'] as any, year);
const r = typeof rev === 'number' ? rev : (rev == null ? null : Number(rev));
return r;
};
const getCOGS = (year: string) => {
const rev = getRevenue(year);
const gm = getMarginRatio(year);
if (rev == null || gm == null) return null;
const cogs = rev * (1 - gm);
return Number.isFinite(cogs) ? cogs : null;
};
const turnoverRows = turnoverItems.map(({ key, label }) => (
<TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => {
let value: number | null = null;
if (key === 'payturn_days') {
const avgAP = getAvg(series['accounts_pay'] as any, y);
const cogs = getCOGS(y);
value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs;
} else {
// 直接显示原值从API读取invturn_days、arturn_days、fa_turn、assets_turn
const arr = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
const v = arr?.find(p => p?.year === y)?.value ?? null;
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
value = num == null || Number.isNaN(num) ? null : num;
}
if (value == null || !Number.isFinite(value)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const text = numberFormatter.format(value);
if (key === 'arturn_days' && value > 90) {
return (
<TableCell key={y} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell>
);
}
return <TableCell key={y} className="text-right p-2">{text}</TableCell>;
})}
</TableRow>
));
return [
summaryRow,
...rows,
feeHeaderRow,
...feeRows,
assetHeaderRow,
...assetRows,
turnoverHeaderRow,
...turnoverRows,
// =========================
// 人均效率分组
// =========================
(
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => (
<TableCell key={y} className="p-2"></TableCell>
))}
</TableRow>
),
// 员工人数(整数千分位)
(
<TableRow key="__employees_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => {
const v = getVal(series['employees'] as any, y);
if (v == null || !Number.isFinite(v)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
})}
</TableRow>
),
// 人均创收 = 收入 / 员工人数(万元)
(
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => {
const rev = getVal(series['revenue'] as any, y);
const emp = getVal(series['employees'] as any, y);
if (rev == null || emp == null || emp === 0) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const val = (rev / emp) / 10000;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})}
</TableRow>
),
// 人均创利 = 净利润 / 员工人数(万元)
(
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => {
const prof = getVal(series['n_income'] as any, y);
const emp = getVal(series['employees'] as any, y);
if (prof == null || emp == null || emp === 0) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const val = (prof / emp) / 10000;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})}
</TableRow>
),
// 人均工资 = 支付给职工以及为职工支付的现金 / 员工人数(万元)
(
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => {
const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, y);
const emp = getVal(series['employees'] as any, y);
if (salaryPaid == null || emp == null || emp === 0) {
return <TableCell key={y} className="text-right p-2">-</TableCell>;
}
const val = (salaryPaid / emp) / 10000;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})}
</TableRow>
),
// =========================
// 市场表现分组
// =========================
(
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => (
<TableCell key={y} className="p-2"></TableCell>
))}
</TableRow>
),
// 股价(收盘价)
(
<TableRow key="__price_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => {
const arr = series['close'] as Array<{ year?: string; value?: number | null }> | undefined;
const v = arr?.find(p => p?.year === y)?.value ?? null;
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
})}
</TableRow>
),
// 市值按亿为单位显示乘以10000并整数千分位
(
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">亿</TableCell>
{years.map((y) => {
const arr = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined;
const v = arr?.find(p => p?.year === y)?.value ?? null;
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
const scaled = num / 10000; // 转为亿元
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>;
})}
</TableRow>
),
// PE
(
<TableRow key="__pe_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PE</TableCell>
{years.map((y) => {
const arr = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined;
const v = arr?.find(p => p?.year === y)?.value ?? null;
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
})}
</TableRow>
),
// PB
(
<TableRow key="__pb_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
{years.map((y) => {
const arr = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined;
const v = arr?.find(p => p?.year === y)?.value ?? null;
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>;
})}
</TableRow>
),
// 股东户数
(
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => {
const arr = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined;
const v = arr?.find(p => p?.year === y)?.value ?? null;
const num = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>;
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell>;
})}
</TableRow>
),
];
})()}
</TableBody>
</Table>
);
})()}
</div>
)}
</TabsContent>
{/* 动态生成各个分析的TabsContent */}
{analysisTypes.map(analysisType => {
const state = analysisStates[analysisType] || { content: '', loading: false, error: null };
const normalizedContent = normalizeMarkdown(state.content);
const analysisName = analysisType === 'company_profile'
? '公司简介'
: (analysisConfig?.analysis_modules[analysisType]?.name || analysisType);
const modelName = analysisConfig?.analysis_modules[analysisType]?.model;
return (
<TabsContent key={analysisType} value={analysisType} className="space-y-4">
<h2 className="text-lg font-medium">{analysisName} {modelName || 'AI'}</h2>
{!financials && (
<p className="text-sm text-muted-foreground">...</p>
)}
{financials && (
<>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 text-sm">
{state.loading ? (
<Spinner className="size-4" />
) : state.error ? (
<XCircle className="size-4 text-red-500" />
) : state.content ? (
<CheckCircle className="size-4 text-green-600" />
) : null}
<div className="text-muted-foreground">
{state.loading
? `正在生成${analysisName}...`
: state.error
? '生成失败'
: state.content
? '生成完成'
: '待开始'}
</div>
</div>
{/* 始终可见的"重新生成分析"按钮 */}
{!state.loading && (
<Button
variant="ghost"
size="sm"
onClick={() => retryAnalysis(analysisType)}
disabled={currentAnalysisTask !== null}
>
<RotateCw className="size-4" />
</Button>
)}
</div>
{state.error && (
<p className="text-red-500">: {state.error}</p>
)}
{(state.loading || state.content) && (
<div className="space-y-4">
<div className="border rounded-lg p-6 bg-card">
<article className="markdown-body" style={{
boxSizing: 'border-box',
minWidth: '200px',
maxWidth: '980px',
margin: '0 auto',
padding: '0'
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
>
{normalizedContent}
</ReactMarkdown>
{state.loading && (
<span className="inline-flex items-center gap-2 mt-2 text-muted-foreground">
<Spinner className="size-3" />
<span className="text-sm">...</span>
</span>
)}
</article>
</div>
</div>
)}
</>
)}
</TabsContent>
);
})}
<TabsContent value="execution" className="space-y-4">
<h2 className="text-lg font-medium"></h2>
{/* 执行概况卡片 */}
{financials && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 财务数据状态 */}
<div>
<div className="flex items-center gap-2 mb-2">
{isLoading ? (
<Spinner className="size-4" />
) : error ? (
<XCircle className="size-4 text-red-500" />
) : (
<CheckCircle className="size-4 text-green-600" />
)}
<span className="font-medium"></span>
</div>
{financials?.meta && (
<div className="ml-6 text-sm text-muted-foreground space-y-1">
<div>: {formatMs(financials.meta.elapsed_ms)}</div>
<div>API调用: {financials.meta.api_calls_total} </div>
<div>: {financials.meta.started_at}</div>
{financials.meta.finished_at && (
<div>: {financials.meta.finished_at}</div>
)}
</div>
)}
</div>
{/* 分析任务状态 */}
{analysisRecords.length > 0 && (
<div className="pt-3 border-t">
<div className="font-medium mb-2"></div>
<div className="ml-6 text-sm text-muted-foreground space-y-2">
{analysisRecords.map((record, idx) => (
<div key={idx} className="space-y-1">
<div className="flex items-center gap-2">
{record.status === 'running' && <Spinner className="size-3" />}
{record.status === 'done' && <CheckCircle className="size-3 text-green-600" />}
{record.status === 'error' && <XCircle className="size-3 text-red-500" />}
<span className="font-medium">{record.name}</span>
<span className="text-xs text-muted-foreground">
{record.status === 'running' ? '运行中' : record.status === 'done' ? '已完成' : record.status === 'error' ? '失败' : '待继续'}
</span>
{record.status === 'error' && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
retryAnalysis(record.type);
}}
disabled={currentAnalysisTask !== null}
>
<RotateCw className="size-3" />
</Button>
)}
</div>
{record.duration_ms !== undefined && (
<div className="ml-5">: {formatMs(record.duration_ms)}</div>
)}
{record.tokens && (
<div className="ml-5">
Token: {record.tokens.total_tokens}
(Prompt: {record.tokens.prompt_tokens},
Completion: {record.tokens.completion_tokens})
</div>
)}
{record.error && (
<div className="ml-5 text-red-500">: {record.error}</div>
)}
</div>
))}
</div>
</div>
)}
{/* 总体统计 */}
<div className="pt-3 border-t">
<div className="font-medium mb-2"></div>
<div className="ml-6 text-sm text-muted-foreground space-y-1">
<div>: {formatMs(totalElapsedMs)}</div>
{financials?.meta?.steps && (
<div>: {financials.meta.steps.filter(s => s.status === 'done').length}/{financials.meta.steps.length}</div>
)}
{analysisRecords.length > 0 && (
<>
<div>: {analysisRecords.filter(r => r.status === 'done').length}/{analysisRecords.length} </div>
<div>Token消耗: {analysisRecords.reduce((sum, r) => sum + (r.tokens?.total_tokens || 0), 0)}</div>
</>
)}
</div>
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
)}
</div>
);
}