'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>({}); const fullAnalysisTriggeredRef = useRef(false); const isAnalysisRunningRef = useRef(false); const analysisFetchedRefs = useRef>({}); const stopRequestedRef = useRef(false); const abortControllerRef = useRef(null); const currentAnalysisTypeRef = useRef(null); const [manualRunKey, setManualRunKey] = useState(0); // 当前正在执行的分析任务 const [currentAnalysisTask, setCurrentAnalysisTask] = useState(null); // 计时器状态 const [startTime, setStartTime] = useState(null); const [elapsedSeconds, setElapsedSeconds] = useState(0); // 分析执行记录(用于执行详情) const [analysisRecords, setAnalysisRecords] = useState>([]); const [saving, setSaving] = useState(false) const [saveMsg, setSaveMsg] = useState(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 = {}; 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; const map: Record = {}; 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 (
报告页面
股票代码: {normalizedTsCode}
交易市场: {market}
公司名称: {isLoading ? ( 加载中... ) : financials?.name ? ( financials.name ) : ( - )}
{isChina && ( )} {isChina && (
任务状态 {startTime && (
总耗时: {formatElapsedTime(elapsedSeconds)}
)}
{allTasksCompleted && (
{saveMsg && {saveMsg}}
)} {currentAnalysisTask && analysisConfig && ( (() => { const analysisName = analysisConfig.analysis_modules[currentAnalysisTask]?.name || currentAnalysisTask; const modelName = analysisConfig.analysis_modules[currentAnalysisTask]?.model || 'AI'; return (
{analysisName}(来自 {modelName})
正在生成{analysisName}...
); })() )}
)}
{isChina && ( 股价图表 财务数据 {analysisTypes.map(type => ( {type === 'company_profile' ? '公司简介' : (analysisConfig?.analysis_modules[type]?.name || type)} ))} 执行详情

股价图表(来自 TradingView)

实时股价图表 - {normalizedTsCode}

财务数据(来自 Tushare)

{isLoading ? ( ) : error ? ( ) : ( )}
{isLoading ? '正在读取 Tushare 数据…' : error ? '读取失败' : '读取完成'}
{error &&

加载失败

} {isLoading && (
加载中
)} {financials && (
{(() => { 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

暂无可展示的数据

; } return ( 指标 {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 ( {label} ); })} {(() => { // 指定显示顺序(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 = ( 主要指标 {years.map((y) => ( ))} ); 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 ( {label || metricDisplayMap[key] || key} {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 -; } 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 ( {text}% ); } // ROE > 12% 高亮浅绿色背景 if (key === 'roe') { const highlight = typeof perc === 'number' && perc > 12; return ( {`${text}%`} ); } // ROIC > 12% 高亮浅绿色背景 if (key === 'roic') { const highlight = typeof perc === 'number' && perc > 12; return ( {`${text}%`} ); } return ( {`${text}%`} ); } 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 ( {isNeg ? {text} : text} ); } return ( {text} ); } })} ); }); // ========================= // 费用指标分组 // ========================= const feeHeaderRow = ( 费用指标 {years.map((y) => ( ))} ); 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 }) => ( {label} {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 -; } const rateText = numberFormatter.format(rate); const isNegative = rate < 0; return ( {isNegative ? {rateText}% : `${rateText}%`} ); })} )); // ========================= // 资产占比分组 // ========================= const assetHeaderRow = ( 资产占比 {years.map((y) => ( ))} ); const ratioCell = (value: number | null, y: string) => { if (value == null || !Number.isFinite(value)) { return -; } const text = numberFormatter.format(value); const isNegative = value < 0; return ( {isNegative ? {text}% : `${text}%`} ); }; 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 }) => ( {label} {years.map((y) => ratioCell(calc(y), y))} )); // ========================= // 周转能力分组 // ========================= const turnoverHeaderRow = ( 周转能力 {years.map((y) => ( ))} ); 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 }) => ( {label} {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 -; } const text = numberFormatter.format(value); if (key === 'arturn_days' && value > 90) { return ( {text} ); } return {text}; })} )); return [ summaryRow, ...rows, feeHeaderRow, ...feeRows, assetHeaderRow, ...assetRows, turnoverHeaderRow, ...turnoverRows, // ========================= // 人均效率分组 // ========================= ( 人均效率 {years.map((y) => ( ))} ), // 员工人数(整数千分位) ( 员工人数 {years.map((y) => { const v = getVal(series['employees'] as any, y); if (v == null || !Number.isFinite(v)) { return -; } return {integerFormatter.format(Math.round(v))}; })} ), // 人均创收 = 收入 / 员工人数(万元) ( 人均创收(万元) {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 -; } const val = (rev / emp) / 10000; return {numberFormatter.format(val)}; })} ), // 人均创利 = 净利润 / 员工人数(万元) ( 人均创利(万元) {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 -; } const val = (prof / emp) / 10000; return {numberFormatter.format(val)}; })} ), // 人均工资 = 支付给职工以及为职工支付的现金 / 员工人数(万元) ( 人均工资(万元) {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 -; } const val = (salaryPaid / emp) / 10000; return {numberFormatter.format(val)}; })} ), // ========================= // 市场表现分组 // ========================= ( 市场表现 {years.map((y) => ( ))} ), // 股价(收盘价) ( 股价 {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 -; return {numberFormatter.format(num)}; })} ), // 市值(按亿为单位显示:乘以10000并整数千分位) ( 市值(亿元) {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 -; const scaled = num / 10000; // 转为亿元 return {integerFormatter.format(Math.round(scaled))}; })} ), // PE ( PE {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 -; return {numberFormatter.format(num)}; })} ), // PB ( PB {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 -; return {numberFormatter.format(num)}; })} ), // 股东户数 ( 股东户数 {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 -; return {integerFormatter.format(Math.round(num))}; })} ), ]; })()}
); })()}
)}
{/* 动态生成各个分析的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 (

{analysisName}(来自 {modelName || 'AI'})

{!financials && (

请等待财务数据加载完成...

)} {financials && ( <>
{state.loading ? ( ) : state.error ? ( ) : state.content ? ( ) : null}
{state.loading ? `正在生成${analysisName}...` : state.error ? '生成失败' : state.content ? '生成完成' : '待开始'}
{/* 始终可见的"重新生成分析"按钮 */} {!state.loading && ( )}
{state.error && (

加载失败: {state.error}

)} {(state.loading || state.content) && (
{normalizedContent} {state.loading && ( 正在生成中... )}
)} )}
); })}

执行详情

{/* 执行概况卡片 */} {financials && ( 执行概况 {/* 财务数据状态 */}
{isLoading ? ( ) : error ? ( ) : ( )} 财务数据
{financials?.meta && (
耗时: {formatMs(financials.meta.elapsed_ms)}
API调用: {financials.meta.api_calls_total} 次
开始时间: {financials.meta.started_at}
{financials.meta.finished_at && (
结束时间: {financials.meta.finished_at}
)}
)}
{/* 分析任务状态 */} {analysisRecords.length > 0 && (
分析任务
{analysisRecords.map((record, idx) => (
{record.status === 'running' && } {record.status === 'done' && } {record.status === 'error' && } {record.name} {record.status === 'running' ? '运行中' : record.status === 'done' ? '已完成' : record.status === 'error' ? '失败' : '待继续'} {record.status === 'error' && ( )}
{record.duration_ms !== undefined && (
耗时: {formatMs(record.duration_ms)}
)} {record.tokens && (
Token: {record.tokens.total_tokens} (Prompt: {record.tokens.prompt_tokens}, Completion: {record.tokens.completion_tokens})
)} {record.error && (
错误: {record.error}
)}
))}
)} {/* 总体统计 */}
总体统计
总耗时: {formatMs(totalElapsedMs)}
{financials?.meta?.steps && (
财务数据完成步骤: {financials.meta.steps.filter(s => s.status === 'done').length}/{financials.meta.steps.length}
)} {analysisRecords.length > 0 && ( <>
分析任务: {analysisRecords.filter(r => r.status === 'done').length}/{analysisRecords.length} 已完成
总Token消耗: {analysisRecords.reduce((sum, r) => sum + (r.tokens?.total_tokens || 0), 0)}
)}
)}
)}
); }