From 4fef6bf35b3bd787f334e3093ccaadbeeb2445ec Mon Sep 17 00:00:00 2001 From: "Lv, Qi" Date: Wed, 19 Nov 2025 06:51:46 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=20ReportPage=20?= =?UTF-8?q?=E4=B8=BA=E7=BB=84=E4=BB=B6=E5=92=8C=20Hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将庞大的 page.tsx 拆分为多个独立组件 (components/) - 提取业务逻辑到 Hooks (hooks/) - 提取工具函数到 utils.ts - 优化代码结构和可维护性 --- .../[symbol]/components/AnalysisContent.tsx | 115 ++ .../[symbol]/components/ExecutionDetails.tsx | 147 ++ .../[symbol]/components/FinancialTable.tsx | 569 ++++++ .../[symbol]/components/ReportHeader.tsx | 146 ++ .../report/[symbol]/components/StockChart.tsx | 57 + .../report/[symbol]/components/TaskStatus.tsx | 77 + .../[symbol]/hooks/useAnalysisRunner.ts | 414 +++++ .../report/[symbol]/hooks/useReportData.ts | 65 + frontend/src/app/report/[symbol]/page.tsx | 1599 ++--------------- frontend/src/app/report/[symbol]/utils.ts | 64 + 10 files changed, 1758 insertions(+), 1495 deletions(-) create mode 100644 frontend/src/app/report/[symbol]/components/AnalysisContent.tsx create mode 100644 frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx create mode 100644 frontend/src/app/report/[symbol]/components/FinancialTable.tsx create mode 100644 frontend/src/app/report/[symbol]/components/ReportHeader.tsx create mode 100644 frontend/src/app/report/[symbol]/components/StockChart.tsx create mode 100644 frontend/src/app/report/[symbol]/components/TaskStatus.tsx create mode 100644 frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts create mode 100644 frontend/src/app/report/[symbol]/hooks/useReportData.ts create mode 100644 frontend/src/app/report/[symbol]/utils.ts diff --git a/frontend/src/app/report/[symbol]/components/AnalysisContent.tsx b/frontend/src/app/report/[symbol]/components/AnalysisContent.tsx new file mode 100644 index 0000000..5cbae12 --- /dev/null +++ b/frontend/src/app/report/[symbol]/components/AnalysisContent.tsx @@ -0,0 +1,115 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Spinner } from '@/components/ui/spinner'; +import { Button } from '@/components/ui/button'; +import { CheckCircle, XCircle, RotateCw } from 'lucide-react'; +import { normalizeMarkdown, removeTitleFromContent } from '../utils'; + +interface AnalysisContentProps { + analysisType: string; + state: { + content: string; + loading: boolean; + error: string | null; + }; + financials: any; + analysisConfig: any; + retryAnalysis: (type: string) => void; + currentAnalysisTask: string | null; +} + +export function AnalysisContent({ + analysisType, + state, + financials, + analysisConfig, + retryAnalysis, + currentAnalysisTask, +}: AnalysisContentProps) { + const analysisName = analysisType === 'company_profile' + ? '公司简介' + : (analysisConfig?.analysis_modules?.[analysisType]?.name || analysisType); + const modelName = analysisConfig?.analysis_modules?.[analysisType]?.model; + + // Process content + const contentWithoutTitle = removeTitleFromContent(state.content, analysisName); + const normalizedContent = normalizeMarkdown(contentWithoutTitle); + + 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 && ( + + + 正在生成中... + + )} +
+
+
+ )} + + )} +
+ ); +} + diff --git a/frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx b/frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx new file mode 100644 index 0000000..ce0921d --- /dev/null +++ b/frontend/src/app/report/[symbol]/components/ExecutionDetails.tsx @@ -0,0 +1,147 @@ +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { Button } from '@/components/ui/button'; +import { CheckCircle, XCircle, RotateCw } from 'lucide-react'; +import { formatMs } from '../utils'; + +interface AnalysisRecord { + 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; +} + +interface ExecutionDetailsProps { + financials: any; + isLoading: boolean; + error: any; + analysisRecords: AnalysisRecord[]; + currentAnalysisTask: string | null; + totalElapsedMs: number; + retryAnalysis: (type: string) => void; +} + +export function ExecutionDetails({ + financials, + isLoading, + error, + analysisRecords, + currentAnalysisTask, + totalElapsedMs, + retryAnalysis, +}: ExecutionDetailsProps) { + return ( +
+

执行详情

+ + {/* 执行概况卡片 */} + {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 as any[]).filter((s: any) => s?.status === 'done').length}/{(financials.meta.steps as any[]).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)}
+ + )} +
+
+
+
+ )} +
+ ); +} + diff --git a/frontend/src/app/report/[symbol]/components/FinancialTable.tsx b/frontend/src/app/report/[symbol]/components/FinancialTable.tsx new file mode 100644 index 0000000..ebf595e --- /dev/null +++ b/frontend/src/app/report/[symbol]/components/FinancialTable.tsx @@ -0,0 +1,569 @@ +import { useMemo } from 'react'; +import { Spinner } from '@/components/ui/spinner'; +import { CheckCircle, XCircle } from 'lucide-react'; +import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'; +import { formatReportPeriod } from '@/lib/financial-utils'; +import { numberFormatter, integerFormatter } from '../utils'; + +interface FinancialTableProps { + financials: any; + isLoading: boolean; + error: any; + financialConfig: any; +} + +export function FinancialTable({ financials, isLoading, error, financialConfig }: FinancialTableProps) { + // 创建 tushareParam 到 displayText 的映射 + const metricDisplayMap = useMemo(() => { + if (!financialConfig?.api_groups) return {}; + + const map: Record = {}; + const groups = Object.values((financialConfig as any).api_groups || {}) as any[][]; + groups.forEach((metrics) => { + (metrics || []).forEach((metric: any) => { + 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 = {}; + const entries = Object.entries((financialConfig as any).api_groups || {}) as [string, any[]][]; + entries.forEach(([groupName, metrics]) => { + (metrics || []).forEach((metric: any) => { + if (metric.tushareParam) { + map[metric.tushareParam] = groupName; + } + }); + }); + return map; + }, [financialConfig]); + + return ( +
+

财务数据

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

加载失败

} + + {isLoading && ( +
+ 加载中 + +
+ )} + + {financials && ( +
+ {(() => { + const series = financials?.series ?? {}; + // 统一 period:优先 p.period;若仅有 year 则映射到 `${year}1231` + const toPeriod = (p: any): string | null => { + if (!p) return null; + if (p.period) return String(p.period); + if (p.year) return `${p.year}1231`; + return null; + }; + + const displayedKeys = [ + 'roe', 'roa', 'roic', 'grossprofit_margin', 'netprofit_margin', 'revenue', 'tr_yoy', 'n_income', + 'dt_netprofit_yoy', 'n_cashflow_act', 'c_pay_acq_const_fiolta', '__free_cash_flow', + 'dividend_amount', 'repurchase_amount', 'total_assets', 'total_hldr_eqy_exc_min_int', 'goodwill', + '__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio', + '__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', '__fix_assets_ratio', + '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', '__ap_ratio', '__adv_ratio', + '__st_borr_ratio', '__lt_borr_ratio', '__operating_assets_ratio', '__interest_bearing_debt_ratio', + 'invturn_days', 'arturn_days', 'payturn_days', 'fa_turn', 'assets_turn', + 'employees', '__rev_per_emp', '__profit_per_emp', '__salary_per_emp', + 'close', 'total_mv', 'pe', 'pb', 'holder_num' + ]; + + const displayedSeries = Object.entries(series) + .filter(([key]) => displayedKeys.includes(key)) + .map(([, value]) => value); + + const allPeriods = Array.from( + new Set( + (displayedSeries.flat() as any[]) + .map((p) => toPeriod(p)) + .filter((v): v is string => Boolean(v)) + ) + ).sort((a, b) => b.localeCompare(a)); // 最新在左(按 YYYYMMDD 排序) + + if (allPeriods.length === 0) { + return

暂无可展示的数据

; + } + const periods = allPeriods.slice(0, 10); + + const getValueByPeriod = (points: any[] | undefined, period: string): number | null => { + if (!points) return null; + const hit = points.find((pp) => toPeriod(pp) === period); + const v = hit?.value; + if (v == null) return null; + const num = typeof v === 'number' ? v : Number(v); + return Number.isFinite(num) ? num : null; + }; + return ( + + + + 指标 + {periods.map((p) => ( + {formatReportPeriod(p)} + ))} + + + + {(() => { + // 指定显示顺序(tushareParam) + const ORDER: Array<{ key: string; label?: string }> = [ + { 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: '自由现金流' }, + { key: 'dividend_amount', label: '分红' }, + { key: 'repurchase_amount', label: '回购' }, + { key: 'total_assets' }, + { key: 'total_hldr_eqy_exc_min_int' }, + { key: 'goodwill' }, + ]; + + // 在表格顶部插入"主要指标"行 + const summaryRow = ( + + 主要指标 + {periods.map((p) => ( + + ))} + + ); + + const PERCENT_KEYS = new Set([ + 'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy', + // Add all calculated percentage rows + '__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio', + '__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', + '__fix_assets_ratio', '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', + '__ap_ratio', '__adv_ratio', '__st_borr_ratio', '__lt_borr_ratio', + '__operating_assets_ratio', '__interest_bearing_debt_ratio' + ]); + const rows = ORDER.map(({ key, label }) => { + const points = series[key] as any[] | undefined; + + return ( + + + {label || metricDisplayMap[key] || key} + + {periods.map((p) => { + const v = getValueByPeriod(points, p); + + 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 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? 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; + const isHighGrowth = typeof perc === 'number' && perc > 30; + + let content = `${text}%`; + if (key === 'dt_netprofit_yoy' && typeof perc === 'number' && perc > 1000) { + content = `${(perc / 100).toFixed(1)}x`; + } + + let tableCellClassName = 'text-right p-2'; + let spanClassName = 'italic'; + + if (isNeg) { + tableCellClassName += ' bg-red-100'; + spanClassName += ' text-red-600'; + } else if (isHighGrowth) { + tableCellClassName += ' bg-green-100'; + spanClassName += ' text-green-800 font-bold'; + } else { + spanClassName += ' text-blue-600'; + } + + return ( + + {content} + + ); + } + const isHighlighted = (key === 'roe' && typeof perc === 'number' && perc > 12.5) || + (key === 'grossprofit_margin' && typeof perc === 'number' && perc > 35) || + (key === 'netprofit_margin' && typeof perc === 'number' && perc > 15); + + if (isHighlighted) { + return ( + + {`${text}%`} + + ); + } + return ( + {`${text}%`} + ); + } else { + const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'; + const scaled = key === 'total_mv' + ? rawNum / 10000 + : (isFinGroup || key === '__free_cash_flow' || key === 'repurchase_amount' ? 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 = ( + + 费用指标 + {periods.map((p) => ( + + ))} + + ); + + const feeRows = [ + { key: '__sell_rate', label: '销售费用率' }, + { key: '__admin_rate', label: '管理费用率' }, + { key: '__rd_rate', label: '研发费用率' }, + { key: '__other_fee_rate', label: '其他费用率' }, + { key: '__tax_rate', label: '所得税率' }, + { key: '__depr_ratio', label: '折旧费用占比' }, + ].map(({ key, label }) => ( + + {label} + {periods.map((p) => { + const points = series[key] as any[] | undefined; + const v = getValueByPeriod(points, p); + + if (v == null || !Number.isFinite(v)) { + return -; + } + const rateText = numberFormatter.format(v); + const isNegative = v < 0; + return ( + + {isNegative ? {rateText}% : `${rateText}%`} + + ); + })} + + )); + + // ========================= + // 资产占比分组 + // ========================= + const assetHeaderRow = ( + + 资产占比 + {periods.map((p) => ( + + ))} + + ); + + const ratioCell = (value: number | null, keyStr: string) => { + if (value == null || !Number.isFinite(value)) { + return -; + } + const text = numberFormatter.format(value); + const isNegative = value < 0; + const isHighRatio = value > 30; + + let cellClassName = "text-right p-2"; + if (isHighRatio) { + cellClassName += " bg-red-100"; + } else if (isNegative) { + cellClassName += " bg-red-100"; + } + + return ( + + {isNegative ? {text}% : `${text}%`} + + ); + }; + + const assetRows = [ + { key: '__money_cap_ratio', label: '现金占比' }, + { key: '__inventories_ratio', label: '库存占比' }, + { key: '__ar_ratio', label: '应收款占比' }, + { key: '__prepay_ratio', label: '预付款占比' }, + { key: '__fix_assets_ratio', label: '固定资产占比' }, + { key: '__lt_invest_ratio', label: '长期投资占比' }, + { key: '__goodwill_ratio', label: '商誉占比' }, + { key: '__other_assets_ratio', label: '其他资产占比' }, + { key: '__ap_ratio', label: '应付款占比' }, + { key: '__adv_ratio', label: '预收款占比' }, + { key: '__st_borr_ratio', label: '短期借款占比' }, + { key: '__lt_borr_ratio', label: '长期借款占比' }, + { key: '__operating_assets_ratio', label: '运营资产占比' }, + { key: '__interest_bearing_debt_ratio', label: '有息负债率' }, + ].map(({ key, label }) => ( + + {label} + {periods.map((p) => { + const points = series[key] as any[] | undefined; + const v = getValueByPeriod(points, p); + return ratioCell(v, p); + })} + + )); + + // ========================= + // 周转能力分组 + // ========================= + const turnoverHeaderRow = ( + + 周转能力 + {periods.map((p) => ( + + ))} + + ); + + 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 turnoverRows = turnoverItems.map(({ key, label }) => ( + + {label} + {periods.map((p) => { + const points = series[key] as any[] | undefined; + const v = getValueByPeriod(points, p); + const value = typeof v === 'number' ? v : (v == null ? null : Number(v)); + + 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, + // ========================= + // 人均效率分组 + // ========================= + ( + + 人均效率 + {periods.map((p) => ( + + ))} + + ), + // 员工人数(整数千分位) + ( + + 员工人数 + {periods.map((p) => { + const points = series['employees'] as any[] | undefined; + const v = getValueByPeriod(points, p); + if (v == null) { + return -; + } + return {integerFormatter.format(Math.round(v))}; + })} + + ), + // 人均创收 = 收入 / 员工人数(万元) + ( + + 人均创收(万元) + {periods.map((p) => { + const points = series['__rev_per_emp'] as any[] | undefined; + const val = getValueByPeriod(points, p); + if (val == null) { + return -; + } + return {numberFormatter.format(val)}; + })} + + ), + // 人均创利 = 净利润 / 员工人数(万元) + ( + + 人均创利(万元) + {periods.map((p) => { + const points = series['__profit_per_emp'] as any[] | undefined; + const val = getValueByPeriod(points, p); + if (val == null) { + return -; + } + return {numberFormatter.format(val)}; + })} + + ), + // 人均工资 = 支付给职工以及为职工支付的现金 / 员工人数(万元) + ( + + 人均工资(万元) + {periods.map((p) => { + const points = series['__salary_per_emp'] as any[] | undefined; + const val = getValueByPeriod(points, p); + if (val == null) { + return -; + } + return {numberFormatter.format(val)}; + })} + + ), + // ========================= + // 市场表现分组 + // ========================= + ( + + 市场表现 + {periods.map((p) => ( + + ))} + + ), + // 股价(收盘价) + ( + + 股价 + {periods.map((p) => { + const points = series['close'] as any[] | undefined; + const v = getValueByPeriod(points, p); + if (v == null) { + return -; + } + return {numberFormatter.format(v)}; + })} + + ), + // 市值(按亿为单位显示:乘以10000并整数千分位) + ( + + 市值(亿元) + {periods.map((p) => { + const points = series['total_mv'] as any[] | undefined; + const v = getValueByPeriod(points, p); + if (v == null) { + return -; + } + const scaled = v / 10000; // 转为亿元 + return {integerFormatter.format(Math.round(scaled))}; + })} + + ), + // PE + ( + + PE + {periods.map((p) => { + const points = series['pe'] as any[] | undefined; + const v = getValueByPeriod(points, p); + if (v == null) { + return -; + } + return {numberFormatter.format(v)}; + })} + + ), + // PB + ( + + PB + {periods.map((p) => { + const points = series['pb'] as any[] | undefined; + const v = getValueByPeriod(points, p); + if (v == null) { + return -; + } + return {numberFormatter.format(v)}; + })} + + ), + // 股东户数 + ( + + 股东户数 + {periods.map((p) => { + const points = series['holder_num'] as any[] | undefined; + const v = getValueByPeriod(points, p); + if (v == null) { + return -; + } + return {integerFormatter.format(Math.round(v))}; + })} + + ), + ]; + })()} + +
+ ); + })()} +
+ )} +
+ ); +} + diff --git a/frontend/src/app/report/[symbol]/components/ReportHeader.tsx b/frontend/src/app/report/[symbol]/components/ReportHeader.tsx new file mode 100644 index 0000000..0d47226 --- /dev/null +++ b/frontend/src/app/report/[symbol]/components/ReportHeader.tsx @@ -0,0 +1,146 @@ +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Spinner } from '@/components/ui/spinner'; + +interface ReportHeaderProps { + unifiedSymbol: string; + displayMarket: string; + isLoading: boolean; + financials: any; + snapshot: any; + snapshotLoading: boolean; + triggering: boolean; + hasRunningTask: boolean; + isAnalysisRunning: boolean; + onStartAnalysis: () => void; + onStopAnalysis: () => void; + onContinueAnalysis: () => void; +} + +export function ReportHeader({ + unifiedSymbol, + displayMarket, + isLoading, + financials, + snapshot, + snapshotLoading, + triggering, + hasRunningTask, + isAnalysisRunning, + onStartAnalysis, + onStopAnalysis, + onContinueAnalysis, +}: ReportHeaderProps) { + return ( + <> + + + 报告页面 + + +
+ 股票代码: + {unifiedSymbol} +
+
+ 交易市场: + {displayMarket} +
+
+ 公司名称: + + {isLoading ? ( + + + 加载中... + + ) : financials?.name ? ( + financials.name + ) : ( + - + )} + +
+
+
+ + + + 昨日快照 + + +
+ + + + + + +
+
+
+ + + + + + + + + + ); +} + +function SnapshotItem({ label, value, loading }: { label: string; value?: string; loading: boolean }) { + return ( +
+ {label}: + + {loading ? ( + + {label === '日期' && } + {label === '日期' ? '加载中...' : '-'} + + ) : value ? ( + value + ) : ( + - + )} + +
+ ); +} + diff --git a/frontend/src/app/report/[symbol]/components/StockChart.tsx b/frontend/src/app/report/[symbol]/components/StockChart.tsx new file mode 100644 index 0000000..e805e2f --- /dev/null +++ b/frontend/src/app/report/[symbol]/components/StockChart.tsx @@ -0,0 +1,57 @@ +import { CheckCircle } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { TradingViewWidget } from '@/components/TradingViewWidget'; + +interface StockChartProps { + unifiedSymbol: string; + marketParam: string; + realtime: any; + realtimeLoading: boolean; + realtimeError: any; +} + +export function StockChart({ + unifiedSymbol, + marketParam, + realtime, + realtimeLoading, + realtimeError, +}: StockChartProps) { + return ( +
+

股价图表(来自 TradingView)

+
+
+ +
+ 实时股价图表 - {unifiedSymbol} +
+
+
+ {realtimeLoading ? ( + 正在获取实时报价… + ) : realtimeError ? ( + 实时报价不可用 + ) : (() => { + const priceRaw = realtime?.price; + const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(priceRaw); + const tsRaw = realtime?.ts; + const tsDate = tsRaw == null ? null : new Date(typeof tsRaw === 'number' ? tsRaw : String(tsRaw)); + const tsText = tsDate && !isNaN(tsDate.getTime()) ? `(${tsDate.toLocaleString()})` : ''; + if (Number.isFinite(priceNum)) { + return 价格 {priceNum.toLocaleString()} {tsText}; + } + return 暂无最新报价; + })()} +
+
+ + +
+ ); +} + diff --git a/frontend/src/app/report/[symbol]/components/TaskStatus.tsx b/frontend/src/app/report/[symbol]/components/TaskStatus.tsx new file mode 100644 index 0000000..1a0ab8a --- /dev/null +++ b/frontend/src/app/report/[symbol]/components/TaskStatus.tsx @@ -0,0 +1,77 @@ +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { formatElapsedTime } from '../utils'; + +interface TaskStatusProps { + requestId: string | null; + taskProgress: any; + startTime: number | null; + elapsedSeconds: number; + completionProgress: number; + currentAnalysisTask: string | null; + analysisConfig: any; +} + +export function TaskStatus({ + requestId, + taskProgress, + startTime, + elapsedSeconds, + completionProgress, + currentAnalysisTask, + analysisConfig, +}: TaskStatusProps) { + return ( + <> + + + 任务进度(新架构) + + + {requestId ? ( +
{JSON.stringify(taskProgress || {}, null, 2)}
+ ) : ( +
未触发任务
+ )} +
+
+ + + +
+ 任务状态 + {startTime && ( +
+ 总耗时: {formatElapsedTime(elapsedSeconds)} +
+ )} +
+
+
+
+ + + {currentAnalysisTask && analysisConfig && ( + (() => { + const analysisName = analysisConfig.analysis_modules?.[currentAnalysisTask]?.name || currentAnalysisTask; + const modelName = analysisConfig.analysis_modules?.[currentAnalysisTask]?.model || 'AI'; + return ( +
+ +
+
{analysisName}(来自 {modelName})
+
正在生成{analysisName}...
+
+
+ ); + })() + )} +
+ + + ); +} + diff --git a/frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts b/frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts new file mode 100644 index 0000000..b5e69fa --- /dev/null +++ b/frontend/src/app/report/[symbol]/hooks/useAnalysisRunner.ts @@ -0,0 +1,414 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; +import { useDataRequest, useTaskProgress } from '@/hooks/useApi'; + +interface AnalysisState { + content: string; + loading: boolean; + error: string | null; + elapsed_ms?: number; +} + +interface AnalysisRecord { + 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; +} + +export function useAnalysisRunner( + financials: any, + analysisConfig: any, + normalizedMarket: string, + unifiedSymbol: string, + isLoading: boolean, + error: any +) { + // 分析类型列表 + 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 { trigger: triggerAnalysisRequest, isMutating: triggering } = useDataRequest(); + const [requestId, setRequestId] = useState(null); + const { progress: taskProgress } = useTaskProgress(requestId); + + // 计算完成比例 + 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]); + + // 总耗时(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]); + + const hasRunningTask = useMemo(() => { + if (currentAnalysisTask !== null) return true; + if (analysisRecords.some(r => r.status === 'running')) return true; + return false; + }, [currentAnalysisTask, analysisRecords]); + + // 全部任务是否完成 + 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 (!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 startTimeISO = new Date().toISOString(); + setCurrentAnalysisTask(analysisType); + setAnalysisRecords(prev => [...prev, { + type: analysisType, + name: analysisName, + status: 'running', + start_ts: startTimeISO + }]); + + try { + const startedMsLocal = Date.now(); + const response = await fetch( + `/api/financials/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}` + ); + 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 (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 startTimeISO = new Date().toISOString(); + setCurrentAnalysisTask(analysisType); + setAnalysisRecords(prev => { + const next = [...prev]; + const idx = next.findIndex(r => r.type === analysisType); + const updated: AnalysisRecord = { + type: analysisType, + name: analysisName, + status: 'running' as const, + start_ts: startTimeISO + }; + 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/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`, + { 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(); + }, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedMarket, unifiedSymbol, startTime, 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); + }; + + const triggerAnalysis = async () => { + const reqId = await triggerAnalysisRequest(unifiedSymbol, normalizedMarket || ''); + if (reqId) setRequestId(reqId); + }; + + return { + analysisTypes, + analysisStates, + analysisRecords, + currentAnalysisTask, + triggerAnalysis, + triggering, + requestId, + setRequestId, + taskProgress, + startTime, + elapsedSeconds, + completionProgress, + totalElapsedMs, + stopAll, + continuePending, + retryAnalysis, + hasRunningTask, + isAnalysisRunning: isAnalysisRunningRef.current, // 注意:这里返回的是 ref.current,可能不是响应式的。通常需要 state。 + // 但原代码中 isAnalysisRunningRef 仅用于内部逻辑控制,没有直接用于 UI 展示(除了 disabled 状态,但这通常依赖 state 变化触发重渲染)。 + // 在原 UI 中 `disabled={isAnalysisRunningRef.current}` 可能会有问题,如果仅仅 Ref 变了组件不一定会刷新。 + // 不过原代码就是这样写的。 + // 我建议可以用 hasRunningTask 来作为替代。 + }; +} + diff --git a/frontend/src/app/report/[symbol]/hooks/useReportData.ts b/frontend/src/app/report/[symbol]/hooks/useReportData.ts new file mode 100644 index 0000000..63d2b57 --- /dev/null +++ b/frontend/src/app/report/[symbol]/hooks/useReportData.ts @@ -0,0 +1,65 @@ +import { useParams, useSearchParams } from 'next/navigation'; +import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisTemplateSets, useSnapshot, useRealtimeQuote } from '@/hooks/useApi'; + +export function useReportData() { + const params = useParams(); + const searchParams = useSearchParams(); + const symbol = params.symbol as string; + const marketParam = (searchParams.get('market') || '').toLowerCase(); + const normalizedMarket = (() => { + if (marketParam === 'usa') return 'us'; + if (marketParam === 'china') return 'cn'; + if (marketParam === 'hkex') return 'hk'; + if (marketParam === 'jpn') return 'jp'; + return marketParam; + })(); + + const displayMarket = marketParam === 'china' ? '中国' : marketParam; + + const isChina = normalizedMarket === '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 chinaFin = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10); + const otherFin = useFinancials(!isChina ? normalizedMarket : undefined, !isChina ? symbol : undefined, 10); + const financials = (chinaFin.data ?? otherFin.data) as any; + const error = chinaFin.error ?? otherFin.error; + const isLoading = chinaFin.isLoading || otherFin.isLoading; + const unifiedSymbol = isChina ? normalizedTsCode : symbol; + const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useSnapshot(normalizedMarket, unifiedSymbol); + const { data: realtime, error: realtimeError, isLoading: realtimeLoading } = useRealtimeQuote(normalizedMarket, unifiedSymbol, { maxAgeSeconds: 30, refreshIntervalMs: 5000 }); + const { data: financialConfig } = useFinancialConfig(); + const { data: templateSets } = useAnalysisTemplateSets(); + + return { + symbol, + unifiedSymbol, + displayMarket, + normalizedMarket, + marketParam, + financials, + isLoading, + error, + snapshot, + snapshotLoading, + snapshotError, + realtime, + realtimeLoading, + realtimeError, + financialConfig: financialConfig as any, + templateSets + }; +} + diff --git a/frontend/src/app/report/[symbol]/page.tsx b/frontend/src/app/report/[symbol]/page.tsx index db982da..666b085 100644 --- a/frontend/src/app/report/[symbol]/page.tsx +++ b/frontend/src/app/report/[symbol]/page.tsx @@ -1,761 +1,78 @@ 'use client'; -import { useParams, useSearchParams } from 'next/navigation'; -import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisConfig, useSnapshot, useRealtimeQuote, useDataRequest, useTaskProgress } 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, useCallback } from 'react'; -import { formatReportPeriod } from '@/lib/financial-utils'; +import { useReportData } from './hooks/useReportData'; +import { useAnalysisRunner } from './hooks/useAnalysisRunner'; +import { ReportHeader } from './components/ReportHeader'; +import { TaskStatus } from './components/TaskStatus'; +import { StockChart } from './components/StockChart'; +import { FinancialTable } from './components/FinancialTable'; +import { AnalysisContent } from './components/AnalysisContent'; +import { ExecutionDetails } from './components/ExecutionDetails'; export default function ReportPage() { - const params = useParams(); - const searchParams = useSearchParams(); - const symbol = params.symbol as string; - const marketParam = (searchParams.get('market') || '').toLowerCase(); - const normalizedMarket = (() => { - if (marketParam === 'usa') return 'us'; - if (marketParam === 'china') return 'cn'; - if (marketParam === 'hkex') return 'hk'; - if (marketParam === 'jpn') return 'jp'; - return marketParam; - })(); + const { + unifiedSymbol, + displayMarket, + normalizedMarket, + marketParam, + financials, + isLoading, + error, + snapshot, + snapshotLoading, + realtime, + realtimeLoading, + realtimeError, + financialConfig, + } = useReportData(); - const displayMarket = marketParam === 'china' ? '中国' : marketParam; - - const isChina = normalizedMarket === '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 chinaFin = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10); - const otherFin = useFinancials(!isChina ? normalizedMarket : undefined, !isChina ? symbol : undefined, 10); - const financials = (chinaFin.data ?? otherFin.data) as any; - const error = chinaFin.error ?? otherFin.error; - const isLoading = chinaFin.isLoading || otherFin.isLoading; - const unifiedSymbol = isChina ? normalizedTsCode : symbol; - const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useSnapshot(normalizedMarket, unifiedSymbol); - const { data: realtime, error: realtimeError, isLoading: realtimeLoading } = useRealtimeQuote(normalizedMarket, unifiedSymbol, { maxAgeSeconds: 30, refreshIntervalMs: 5000 }); - 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 { trigger: triggerAnalysis, isMutating: triggering } = useDataRequest(); - const [requestId, setRequestId] = useState(null); - const { progress: taskProgress } = useTaskProgress(requestId); - - - const runFullAnalysis = useCallback(async () => { - if (!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); - }, [analysisConfig?.analysis_modules]); - - useEffect(() => { - if (analysisConfig?.analysis_modules && !fullAnalysisTriggeredRef.current) { - fullAnalysisTriggeredRef.current = true; - runFullAnalysis(); - } - }, [analysisConfig?.analysis_modules, runFullAnalysis]); - - // 计算完成比例 - 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 = {}; - const groups = Object.values((financialConfig as Partial).api_groups || {}) as import('@/types').FinancialMetricConfig[][]; - 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 = {}; - const entries = Object.entries((financialConfig as Partial).api_groups || {}) as [string, import('@/types').FinancialMetricConfig[]][]; - entries.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 removeTitleFromContent = useMemo(() => { - return (content: string, title: string): string => { - if (!content || !title) { - return content; - } - const lines = content.split('\n'); - // Trim and remove markdown from first line - const firstLine = (lines[0] || '').trim().replace(/^(#+\s*|\*\*|__)/, '').replace(/(\*\*|__)$/, '').trim(); - if (firstLine === title) { - return lines.slice(1).join('\n').trim(); - } - return content; - }; - }, []); - - 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 (!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/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}` - ); - 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 (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/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`, - { 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(); - }, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, normalizedMarket, unifiedSymbol, startTime, 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); - }; + const { + analysisTypes, + analysisStates, + analysisRecords, + currentAnalysisTask, + triggerAnalysis, + triggering, + requestId, + taskProgress, + startTime, + elapsedSeconds, + completionProgress, + totalElapsedMs, + stopAll, + continuePending, + retryAnalysis, + hasRunningTask, + isAnalysisRunning, + } = useAnalysisRunner(financials, financialConfig, normalizedMarket, unifiedSymbol, isLoading, error); return (
- - - 报告页面 - - -
- 股票代码: - {unifiedSymbol} -
-
- 交易市场: - {displayMarket} -
-
- 公司名称: - - {isLoading ? ( - - - 加载中... - - ) : financials?.name ? ( - financials.name - ) : ( - - - )} - -
- -
-
- - - 昨日快照 - - -
-
- 日期: - - {snapshotLoading ? ( - 加载中... - ) : snapshot?.trade_date ? ( - `${snapshot.trade_date.slice(0,4)}-${snapshot.trade_date.slice(4,6)}-${snapshot.trade_date.slice(6,8)}` - ) : ( - - - )} - -
- -
- PB: - - {snapshotLoading ? ( - - - ) : snapshot?.pb != null ? ( - `${Number(snapshot.pb).toFixed(2)}` - ) : ( - - - )} - -
- -
- 股价: - - {snapshotLoading ? ( - - - ) : snapshot?.close != null ? ( - `${Number(snapshot.close).toFixed(2)}` - ) : ( - - - )} - -
- -
- PE: - - {snapshotLoading ? ( - - - ) : snapshot?.pe != null ? ( - `${Number(snapshot.pe).toFixed(2)}` - ) : ( - - - )} - -
- -
- 市值: - - {snapshotLoading ? ( - - - ) : snapshot?.total_mv != null ? ( - `${Math.round((snapshot.total_mv as number) / 10000).toLocaleString('zh-CN')} 亿元` - ) : ( - - - )} - -
- -
- 股息率: - - {snapshotLoading ? ( - - - ) : snapshot?.dv_ratio != null ? ( - `${Number(snapshot.dv_ratio).toFixed(2)}%` - ) : ( - - - )} - -
-
-
-
- - - - - - - - - - 任务进度(新架构) - - - {requestId ? ( -
{JSON.stringify(taskProgress || {}, null, 2)}
- ) : ( -
未触发任务
- )} -
-
- - -
- 任务状态 - {startTime && ( -
- 总耗时: {formatElapsedTime(elapsedSeconds)} -
- )} -
-
-
-
- - - {currentAnalysisTask && analysisConfig && ( - (() => { - const analysisName = analysisConfig.analysis_modules[currentAnalysisTask]?.name || currentAnalysisTask; - const modelName = analysisConfig.analysis_modules[currentAnalysisTask]?.model || 'AI'; - return ( -
- -
-
{analysisName}(来自 {modelName})
-
正在生成{analysisName}...
-
-
- ); - })() - )} -
- + +
@@ -764,764 +81,56 @@ export default function ReportPage() { 财务数据 {analysisTypes.map(type => ( - {type === 'company_profile' ? '公司简介' : (analysisConfig?.analysis_modules[type]?.name || type)} + {type === 'company_profile' ? '公司简介' : (financialConfig?.analysis_modules?.[type]?.name || type)} ))} 执行详情 - -

股价图表(来自 TradingView)

-
-
- -
- 实时股价图表 - {unifiedSymbol} -
-
-
- {realtimeLoading ? ( - 正在获取实时报价… - ) : realtimeError ? ( - 实时报价不可用 - ) : (() => { - const priceRaw = (realtime as any)?.price; - const priceNum = typeof priceRaw === 'number' ? priceRaw : Number(priceRaw); - const tsRaw = (realtime as any)?.ts; - const tsDate = tsRaw == null ? null : new Date(typeof tsRaw === 'number' ? tsRaw : String(tsRaw)); - const tsText = tsDate && !isNaN(tsDate.getTime()) ? `(${tsDate.toLocaleString()})` : ''; - if (Number.isFinite(priceNum)) { - return 价格 {priceNum.toLocaleString()} {tsText}; - } - return 暂无最新报价; - })()} -
-
- - +
- -

财务数据

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

加载失败

} - - {isLoading && ( -
- 加载中 - -
- )} - - - - {financials && ( -
- {(() => { - const series = financials?.series ?? {}; - // 统一 period:优先 p.period;若仅有 year 则映射到 `${year}1231` - const toPeriod = (p: any): string | null => { - if (!p) return null; - if (p.period) return String(p.period); - if (p.year) return `${p.year}1231`; - return null; - }; - - const displayedKeys = [ - 'roe', 'roa', 'roic', 'grossprofit_margin', 'netprofit_margin', 'revenue', 'tr_yoy', 'n_income', - 'dt_netprofit_yoy', 'n_cashflow_act', 'c_pay_acq_const_fiolta', '__free_cash_flow', - 'dividend_amount', 'repurchase_amount', 'total_assets', 'total_hldr_eqy_exc_min_int', 'goodwill', - '__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio', - '__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', '__fix_assets_ratio', - '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', '__ap_ratio', '__adv_ratio', - '__st_borr_ratio', '__lt_borr_ratio', '__operating_assets_ratio', '__interest_bearing_debt_ratio', - 'invturn_days', 'arturn_days', 'payturn_days', 'fa_turn', 'assets_turn', - 'employees', '__rev_per_emp', '__profit_per_emp', '__salary_per_emp', - 'close', 'total_mv', 'pe', 'pb', 'holder_num' - ]; - - const displayedSeries = Object.entries(series) - .filter(([key]) => displayedKeys.includes(key)) - .map(([, value]) => value); - - const allPeriods = Array.from( - new Set( - (displayedSeries.flat() as any[]) - .map((p) => toPeriod(p)) - .filter((v): v is string => Boolean(v)) - ) - ).sort((a, b) => b.localeCompare(a)); // 最新在左(按 YYYYMMDD 排序) - - if (allPeriods.length === 0) { - return

暂无可展示的数据

; - } - const periods = allPeriods.slice(0, 10); - - const getValueByPeriod = (points: any[] | undefined, period: string): number | null => { - if (!points) return null; - const hit = points.find((pp) => toPeriod(pp) === period); - const v = hit?.value; - if (v == null) return null; - const num = typeof v === 'number' ? v : Number(v); - return Number.isFinite(num) ? num : null; - }; - return ( - - - - 指标 - {periods.map((p) => ( - {formatReportPeriod(p)} - ))} - - - - {(() => { - // 指定显示顺序(tushareParam) - const ORDER: Array<{ key: string; label?: string }> = [ - { 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: '自由现金流' }, - { key: 'dividend_amount', label: '分红' }, - { key: 'repurchase_amount', label: '回购' }, - { key: 'total_assets' }, - { key: 'total_hldr_eqy_exc_min_int' }, - { key: 'goodwill' }, - ]; - - // 在表格顶部插入"主要指标"行 - const summaryRow = ( - - 主要指标 - {periods.map((p) => ( - - ))} - - ); - - const PERCENT_KEYS = new Set([ - 'roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy', - // Add all calculated percentage rows - '__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio', - '__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', - '__fix_assets_ratio', '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', - '__ap_ratio', '__adv_ratio', '__st_borr_ratio', '__lt_borr_ratio', - '__operating_assets_ratio', '__interest_bearing_debt_ratio' - ]); - const rows = ORDER.map(({ key, label }) => { - const points = series[key] as any[] | undefined; - - return ( - - - {label || metricDisplayMap[key] || key} - - {periods.map((p) => { - const v = getValueByPeriod(points, p); - - 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 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? 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; - const isHighGrowth = typeof perc === 'number' && perc > 30; - - let content = `${text}%`; - if (key === 'dt_netprofit_yoy' && typeof perc === 'number' && perc > 1000) { - content = `${(perc / 100).toFixed(1)}x`; - } - - let tableCellClassName = 'text-right p-2'; - let spanClassName = 'italic'; - - if (isNeg) { - tableCellClassName += ' bg-red-100'; - spanClassName += ' text-red-600'; - } else if (isHighGrowth) { - tableCellClassName += ' bg-green-100'; - spanClassName += ' text-green-800 font-bold'; - } else { - spanClassName += ' text-blue-600'; - } - - return ( - - {content} - - ); - } - const isHighlighted = (key === 'roe' && typeof perc === 'number' && perc > 12.5) || - (key === 'grossprofit_margin' && typeof perc === 'number' && perc > 35) || - (key === 'netprofit_margin' && typeof perc === 'number' && perc > 15); - - if (isHighlighted) { - return ( - - {`${text}%`} - - ); - } - return ( - {`${text}%`} - ); - } else { - const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'; - const scaled = key === 'total_mv' - ? rawNum / 10000 - : (isFinGroup || key === '__free_cash_flow' || key === 'repurchase_amount' ? 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 = ( - - 费用指标 - {periods.map((p) => ( - - ))} - - ); - - const feeRows = [ - { key: '__sell_rate', label: '销售费用率' }, - { key: '__admin_rate', label: '管理费用率' }, - { key: '__rd_rate', label: '研发费用率' }, - { key: '__other_fee_rate', label: '其他费用率' }, - { key: '__tax_rate', label: '所得税率' }, - { key: '__depr_ratio', label: '折旧费用占比' }, - ].map(({ key, label }) => ( - - {label} - {periods.map((p) => { - const points = series[key] as any[] | undefined; - const v = getValueByPeriod(points, p); - - if (v == null || !Number.isFinite(v)) { - return -; - } - const rateText = numberFormatter.format(v); - const isNegative = v < 0; - return ( - - {isNegative ? {rateText}% : `${rateText}%`} - - ); - })} - - )); - - // ========================= - // 资产占比分组 - // ========================= - const assetHeaderRow = ( - - 资产占比 - {periods.map((p) => ( - - ))} - - ); - - const ratioCell = (value: number | null, keyStr: string) => { - if (value == null || !Number.isFinite(value)) { - return -; - } - const text = numberFormatter.format(value); - const isNegative = value < 0; - const isHighRatio = value > 30; - - let cellClassName = "text-right p-2"; - if (isHighRatio) { - cellClassName += " bg-red-100"; - } else if (isNegative) { - cellClassName += " bg-red-100"; - } - - return ( - - {isNegative ? {text}% : `${text}%`} - - ); - }; - - const assetRows = [ - { key: '__money_cap_ratio', label: '现金占比' }, - { key: '__inventories_ratio', label: '库存占比' }, - { key: '__ar_ratio', label: '应收款占比' }, - { key: '__prepay_ratio', label: '预付款占比' }, - { key: '__fix_assets_ratio', label: '固定资产占比' }, - { key: '__lt_invest_ratio', label: '长期投资占比' }, - { key: '__goodwill_ratio', label: '商誉占比' }, - { key: '__other_assets_ratio', label: '其他资产占比' }, - { key: '__ap_ratio', label: '应付款占比' }, - { key: '__adv_ratio', label: '预收款占比' }, - { key: '__st_borr_ratio', label: '短期借款占比' }, - { key: '__lt_borr_ratio', label: '长期借款占比' }, - { key: '__operating_assets_ratio', label: '运营资产占比' }, - { key: '__interest_bearing_debt_ratio', label: '有息负债率' }, - ].map(({ key, label }) => ( - - {label} - {periods.map((p) => { - const points = series[key] as any[] | undefined; - const v = getValueByPeriod(points, p); - return ratioCell(v, p); - })} - - )); - - // ========================= - // 周转能力分组 - // ========================= - const turnoverHeaderRow = ( - - 周转能力 - {periods.map((p) => ( - - ))} - - ); - - 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 turnoverRows = turnoverItems.map(({ key, label }) => ( - - {label} - {periods.map((p) => { - const points = series[key] as any[] | undefined; - const v = getValueByPeriod(points, p); - const value = typeof v === 'number' ? v : (v == null ? null : Number(v)); - - 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, - // ========================= - // 人均效率分组 - // ========================= - ( - - 人均效率 - {periods.map((p) => ( - - ))} - - ), - // 员工人数(整数千分位) - ( - - 员工人数 - {periods.map((p) => { - const points = series['employees'] as any[] | undefined; - const v = getValueByPeriod(points, p); - if (v == null) { - return -; - } - return {integerFormatter.format(Math.round(v))}; - })} - - ), - // 人均创收 = 收入 / 员工人数(万元) - ( - - 人均创收(万元) - {periods.map((p) => { - const points = series['__rev_per_emp'] as any[] | undefined; - const val = getValueByPeriod(points, p); - if (val == null) { - return -; - } - return {numberFormatter.format(val)}; - })} - - ), - // 人均创利 = 净利润 / 员工人数(万元) - ( - - 人均创利(万元) - {periods.map((p) => { - const points = series['__profit_per_emp'] as any[] | undefined; - const val = getValueByPeriod(points, p); - if (val == null) { - return -; - } - return {numberFormatter.format(val)}; - })} - - ), - // 人均工资 = 支付给职工以及为职工支付的现金 / 员工人数(万元) - ( - - 人均工资(万元) - {periods.map((p) => { - const points = series['__salary_per_emp'] as any[] | undefined; - const val = getValueByPeriod(points, p); - if (val == null) { - return -; - } - return {numberFormatter.format(val)}; - })} - - ), - // ========================= - // 市场表现分组 - // ========================= - ( - - 市场表现 - {periods.map((p) => ( - - ))} - - ), - // 股价(收盘价) - ( - - 股价 - {periods.map((p) => { - const points = series['close'] as any[] | undefined; - const v = getValueByPeriod(points, p); - if (v == null) { - return -; - } - return {numberFormatter.format(v)}; - })} - - ), - // 市值(按亿为单位显示:乘以10000并整数千分位) - ( - - 市值(亿元) - {periods.map((p) => { - const points = series['total_mv'] as any[] | undefined; - const v = getValueByPeriod(points, p); - if (v == null) { - return -; - } - const scaled = v / 10000; // 转为亿元 - return {integerFormatter.format(Math.round(scaled))}; - })} - - ), - // PE - ( - - PE - {periods.map((p) => { - const points = series['pe'] as any[] | undefined; - const v = getValueByPeriod(points, p); - if (v == null) { - return -; - } - return {numberFormatter.format(v)}; - })} - - ), - // PB - ( - - PB - {periods.map((p) => { - const points = series['pb'] as any[] | undefined; - const v = getValueByPeriod(points, p); - if (v == null) { - return -; - } - return {numberFormatter.format(v)}; - })} - - ), - // 股东户数 - ( - - 股东户数 - {periods.map((p) => { - const points = series['holder_num'] as any[] | undefined; - const v = getValueByPeriod(points, p); - if (v == null) { - return -; - } - return {integerFormatter.format(Math.round(v))}; - })} - - ), - ]; - })()} - -
- ); - })()} -
- )} + + + + {analysisTypes.map(analysisType => ( + + + + ))} - - - {/* 动态生成各个分析的TabsContent */} - {analysisTypes.map(analysisType => { - const state = analysisStates[analysisType] || { content: '', loading: false, error: null }; - const analysisName = analysisType === 'company_profile' - ? '公司简介' - : (analysisConfig?.analysis_modules[analysisType]?.name || analysisType); - const contentWithoutTitle = removeTitleFromContent(state.content, analysisName); - const normalizedContent = normalizeMarkdown(contentWithoutTitle); - 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 as any[]).filter((s: any) => s?.status === 'done').length}/{(financials.meta.steps as any[]).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)}
- - )} -
-
-
-
- )} + + -
+
); } diff --git a/frontend/src/app/report/[symbol]/utils.ts b/frontend/src/app/report/[symbol]/utils.ts new file mode 100644 index 0000000..46c862d --- /dev/null +++ b/frontend/src/app/report/[symbol]/utils.ts @@ -0,0 +1,64 @@ +export 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`; +}; + +export 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`; +}; + +export const numberFormatter = new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +export const integerFormatter = new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const normalizeMarkdown = (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; +}; + +export const removeTitleFromContent = (content: string, title: string): string => { + if (!content || !title) { + return content; + } + const lines = content.split('\n'); + // Trim and remove markdown from first line + const firstLine = (lines[0] || '').trim().replace(/^(#+\s*|\*\*|__)/, '').replace(/(\*\*|__)$/, '').trim(); + if (firstLine === title) { + return lines.slice(1).join('\n').trim(); + } + return content; +}; +