import { headers } from 'next/headers' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table' import { formatReportPeriod } from '@/lib/financial-utils' type Report = { id: string symbol: string content: any createdAt: string } export default async function ReportDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const h = await headers() const host = h.get('x-forwarded-host') || h.get('host') || 'localhost:3000' const proto = h.get('x-forwarded-proto') || 'http' const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}` const resp = await fetch(`${base}/api/reports/${encodeURIComponent(id)}`, { cache: 'no-store' }) if (!resp.ok) { return
未找到报告
} const raw = await resp.json() let parsedContent: any = {} try { // 后端 content 可能为 JSON 字符串,也可能为已解析对象 parsedContent = typeof raw?.content === 'string' ? JSON.parse(raw.content) : (raw?.content ?? {}) } catch { parsedContent = {} } const data: Report | null = raw ? { id: String(raw.id), symbol: String(raw.symbol ?? ''), content: parsedContent, createdAt: String(raw.createdAt ?? raw.generated_at ?? new Date().toISOString()), } : null if (!data) { return
未找到报告
} const content = (data.content ?? {}) as any const analyses = (content?.analyses ?? {}) as Record // 规范化显示顺序(与生成报告时一致的中文 Tabs 次序) const ordered = [ { id: 'financial', label: '财务数据' }, { id: 'company_profile', label: '公司简介' }, { id: 'fundamentals', label: '基本面分析' }, { id: 'bullish', label: '看涨分析' }, { id: 'bearish', label: '看跌分析' }, { id: 'market', label: '市场分析' }, { id: 'news', label: '新闻分析' }, { id: 'trading', label: '交易分析' }, { id: 'insiders_institutions', label: '内部人及机构动向分析' }, { id: 'final_conclusion', label: '最终结论' }, { id: 'meta', label: '元数据' }, ] as const // 每个规范化 id 对应的候选后端 key(兼容不同命名) const candidateKeys: Record = { company_profile: ['company_profile'], fundamentals: ['fundamental_analysis', 'fundamentals_analysis', 'basic_analysis', 'basics_analysis'], bullish: ['bullish_analysis', 'bullish_case', 'bull_case'], bearish: ['bearish_analysis', 'bearish_case', 'bear_case'], market: ['market_analysis'], news: ['news_analysis'], trading: ['trading_analysis'], insiders_institutions: ['insider_institutional', 'insiders_institutions_analysis', 'insider_institution_analysis', 'insider_analysis'], final_conclusion: ['final_conclusion', 'conclusion', 'investment_thesis'], } const findKey = (id: string): string | null => { const c = candidateKeys[id] if (!c) return null for (const k of c) { if (Object.prototype.hasOwnProperty.call(analyses, k)) return k } return null } // 去掉正文开头重复的大标题(Markdown 以 # 开头的行) const stripTopHeadings = (text: string): string => { const lines = String(text || '').split(/\r?\n/) let i = 0 while (i < lines.length) { const t = lines[i]?.trim() || '' if (t === '') { i += 1; continue } if (/^#{1,6}\s+/.test(t)) { i += 1; continue } break } return lines.slice(i).join('\n').trimStart() } return (

报告详情

{new Date(data.createdAt).toLocaleString()}
基本信息
股票代码:{data.symbol} {content?.normalizedSymbol && ( 标准代码:{String(content.normalizedSymbol)} )} {(() => { const companyName = (content?.financials?.name as string | undefined) || (content as any)?.company_name || (content as any)?.companyName return companyName ? ( 公司名称:{companyName} ) : null })()} {content?.market && ( 市场:{String(content.market)} )}
{ordered.map((o, idx) => ( {`${idx + 1}. ${o.label}`} ))} 财务数据(保存自读取结果) {(() => { const fin = (content?.financials ?? null) as null | { ts_code?: string name?: string series?: Record> meta?: any } const series = fin?.series || {} const allPoints = Object.values(series).flat() as Array<{ period: string; value: number | null }> const periods = Array.from(new Set(allPoints.map(p => p?.period).filter(Boolean) as string[])).sort((a, b) => b.localeCompare(a)) const numberFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const integerFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) const metricDisplayMap: Record = { roe: 'ROE', roa: 'ROA', roic: 'ROCE/ROIC', grossprofit_margin: '毛利率', netprofit_margin: '净利润率', tr_yoy: '收入增速', dt_netprofit_yoy: '净利润增速', revenue: '收入', n_income: '净利润', n_cashflow_act: '经营现金流', c_pay_acq_const_fiolta: '资本开支', cash_div_tax: '分红', buyback: '回购', total_assets: '总资产', total_hldr_eqy_exc_min_int: '股东权益', goodwill: '商誉', total_mv: '市值', } const metricGroupMap: Record = { revenue: 'income', n_income: 'income', total_assets: 'balancesheet', total_hldr_eqy_exc_min_int: 'balancesheet', goodwill: 'balancesheet', n_cashflow_act: 'cashflow', c_pay_acq_const_fiolta: 'cashflow', } if (periods.length === 0) { return (
暂无保存的财务数据。下次保存报告时会一并保存财务数据。
) } const currentYearStr = String(new Date().getFullYear()) const getQuarter = (month: number | null | undefined) => { if (month == null) return null return Math.floor((month - 1) / 3) + 1 } const PERCENT_KEYS = new Set(['roe','roa','roic','grossprofit_margin','netprofit_margin','tr_yoy','dt_netprofit_yoy']) 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' }, ] return (
指标 {periods.map((p) => ( {formatReportPeriod(p)} ))} {(() => { const summaryRow = ( 主要指标 {periods.map((p) => ( ))} ) const rows = ORDER.map(({ key, label, kind }) => { const isComputed = kind === 'computed' && key === '__free_cash_flow' const points = series[key] as Array<{ period?: string; value?: number | null }>|undefined const operating = series['n_cashflow_act'] as Array<{ period?: string; value?: number | null }>|undefined const capex = series['c_pay_acq_const_fiolta'] as Array<{ period?: string; value?: number | null }>|undefined return ( {label || metricDisplayMap[key] || key} {periods.map((p) => { let v: number | null | undefined = undefined if (isComputed) { const op = operating?.find(pt => pt?.period === p)?.value ?? null const cp = capex?.find(pt => pt?.period === p)?.value ?? null v = (op == null || cp == null) ? null : (Number(op) - Number(cp)) } else { v = points?.find(pt => pt?.period === p)?.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}% ) } if (key === 'roe' || 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 getVal = (arr: Array<{ period?: string; value?: number | null }> | undefined, p: string) => { const v = arr?.find(pt => pt?.period === p)?.value return typeof v === 'number' ? v : (v == null ? null : Number(v)) } // 费用指标 const feeHeaderRow = ( 费用指标 {periods.map((p) => ( ))} ) 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 }, { 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} {periods.map((p) => { let rate: number | null = null if (key === '__tax_rate') { const numerator = getVal(num, p) 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, p) const npRaw = getVal(series['netprofit_margin'] as any, p) const rev = getVal(series['revenue'] as any, p) const sell = getVal(series['sell_exp'] as any, p) const admin = getVal(series['admin_exp'] as any, p) const rd = getVal(series['rd_exp'] as any, p) 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 { const numerator = getVal(num, p) const denominator = getVal(den, p) 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 = ( 资产占比 {periods.map((p) => ( ))} ) const ratioCell = (value: number | null, p: 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: (p: string) => { const num = getVal(series['money_cap'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__inventories_ratio', label: '库存占比', calc: (p: string) => { const num = getVal(series['inventories'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__ar_ratio', label: '应收款占比', calc: (p: string) => { const num = getVal(series['accounts_receiv_bill'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__prepay_ratio', label: '预付款占比', calc: (p: string) => { const num = getVal(series['prepayment'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__fix_assets_ratio', label: '固定资产占比', calc: (p: string) => { const num = getVal(series['fix_assets'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__lt_invest_ratio', label: '长期投资占比', calc: (p: string) => { const num = getVal(series['lt_eqt_invest'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__goodwill_ratio', label: '商誉占比', calc: (p: string) => { const num = getVal(series['goodwill'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__other_assets_ratio', label: '其他资产占比', calc: (p: string) => { const total = getVal(series['total_assets'] as any, p) if (total == null || total === 0) return null const parts = [ getVal(series['money_cap'] as any, p) || 0, getVal(series['inventories'] as any, p) || 0, getVal(series['accounts_receiv_bill'] as any, p) || 0, getVal(series['prepayment'] as any, p) || 0, getVal(series['fix_assets'] as any, p) || 0, getVal(series['lt_eqt_invest'] as any, p) || 0, getVal(series['goodwill'] as any, p) || 0, ] const sumKnown = parts.reduce((acc: number, v: number) => acc + v, 0) return ((total - sumKnown) / total) * 100 } }, { key: '__ap_ratio', label: '应付款占比', calc: (p: string) => { const num = getVal(series['accounts_pay'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__adv_ratio', label: '预收款占比', calc: (p: string) => { const adv = getVal(series['adv_receipts'] as any, p) || 0 const contractLiab = getVal(series['contract_liab'] as any, p) || 0 const num = adv + contractLiab const den = getVal(series['total_assets'] as any, p) return den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__st_borr_ratio', label: '短期借款占比', calc: (p: string) => { const num = getVal(series['st_borr'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__lt_borr_ratio', label: '长期借款占比', calc: (p: string) => { const num = getVal(series['lt_borr'] as any, p) const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, { key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (p: string) => { const total = getVal(series['total_assets'] as any, p) if (total == null || total === 0) return null const st = getVal(series['st_borr'] as any, p) || 0 const lt = getVal(series['lt_borr'] as any, p) || 0 return ((st + lt) / total) * 100 } }, { key: '__operating_assets_ratio', label: '运营资产占比', calc: (p: string) => { const total = getVal(series['total_assets'] as any, p) if (total == null || total === 0) return null const inv = getVal(series['inventories'] as any, p) || 0 const ar = getVal(series['accounts_receiv_bill'] as any, p) || 0 const pre = getVal(series['prepayment'] as any, p) || 0 const ap = getVal(series['accounts_pay'] as any, p) || 0 const adv = getVal(series['adv_receipts'] as any, p) || 0 const contractLiab = getVal(series['contract_liab'] as any, p) || 0 const operating = inv + ar + pre - ap - adv - contractLiab return (operating / total) * 100 } }, ].map(({ key, label, calc }) => ( {label} {periods.map((p) => ratioCell(calc(p), p))} )) // 周转能力 const turnoverHeaderRow = ( 周转能力 {periods.map((p) => ( ))} ) const getYearNumber = (ys: string) => { const n = Number(ys) return Number.isFinite(n) ? n : null } const getPoint = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => { return arr?.find(p => p?.period === period)?.value ?? null } const getAvg = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => { const curr = getPoint(arr, period) const yNum = period.length >= 4 ? Number(period.substring(0, 4)) : null const prevYear = yNum != null ? String(yNum - 1) : null const prevPeriod = prevYear ? prevYear + period.substring(4) : null const prev = prevPeriod ? getPoint(arr, prevPeriod) : 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 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 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) => { let value: number | null = null if (key === 'payturn_days') { const avgAP = getAvg(series['accounts_pay'] as any, p) const cogs = getCOGS(p) value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs } else { const arr = series[key] as Array<{ period?: string; value?: number | null }> | undefined const v = arr?.find(pt => pt?.period === p)?.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} })} )) // 人均效率 const perCapitaHeaderRow = ( 人均效率 {periods.map((p) => ( ))} ) const employeesRow = ( 员工人数 {periods.map((p) => { const v = getVal(series['employees'] as any, p) if (v == null || !Number.isFinite(v)) { return - } return {integerFormatter.format(Math.round(v))} })} ) const revPerEmpRow = ( 人均创收(万元) {periods.map((p) => { const rev = getVal(series['revenue'] as any, p) const emp = getVal(series['employees'] as any, p) if (rev == null || emp == null || emp === 0) { return - } const val = (rev / emp) / 10000 return {numberFormatter.format(val)} })} ) const profitPerEmpRow = ( 人均创利(万元) {periods.map((p) => { const prof = getVal(series['n_income'] as any, p) const emp = getVal(series['employees'] as any, p) if (prof == null || emp == null || emp === 0) { return - } const val = (prof / emp) / 10000 return {numberFormatter.format(val)} })} ) const salaryPerEmpRow = ( 人均工资(万元) {periods.map((p) => { const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, p) const emp = getVal(series['employees'] as any, p) if (salaryPaid == null || emp == null || emp === 0) { return - } const val = (salaryPaid / emp) / 10000 return {numberFormatter.format(val)} })} ) // 市场表现 const marketHeaderRow = ( 市场表现 {periods.map((p) => ( ))} ) const priceRow = ( 股价 {periods.map((p) => { const arr = series['close'] as Array<{ period?: string; value?: number | null }> | undefined const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) if (num == null || !Number.isFinite(num)) return - return {numberFormatter.format(num)} })} ) const marketCapRow = ( 市值(亿元) {periods.map((p) => { const arr = series['total_mv'] as Array<{ period?: string; value?: number | null }> | undefined const v = arr?.find(pt => pt?.period === p)?.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))} })} ) const peRow = ( PE {periods.map((p) => { const arr = series['pe'] as Array<{ period?: string; value?: number | null }> | undefined const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) if (num == null || !Number.isFinite(num)) return - return {numberFormatter.format(num)} })} ) const pbRow = ( PB {periods.map((p) => { const arr = series['pb'] as Array<{ period?: string; value?: number | null }> | undefined const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) if (num == null || !Number.isFinite(num)) return - return {numberFormatter.format(num)} })} ) const holderNumRow = ( 股东户数 {periods.map((p) => { const arr = series['holder_num'] as Array<{ period?: string; value?: number | null }> | undefined const v = arr?.find(pt => pt?.period === p)?.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))} })} ) return [ summaryRow, ...rows, feeHeaderRow, ...feeRows, assetHeaderRow, ...assetRows, turnoverHeaderRow, ...turnoverRows, perCapitaHeaderRow, employeesRow, revPerEmpRow, profitPerEmpRow, salaryPerEmpRow, marketHeaderRow, priceRow, marketCapRow, peRow, pbRow, holderNumRow, ] })()}
) })()}
元数据(数据库原始记录)
                {JSON.stringify(data, null, 2)}
              
{ordered.filter(o => o.id !== 'financial' && o.id !== 'meta').map((o) => { const key = findKey(o.id) const item = key ? analyses[key] || {} : {} const md = stripTopHeadings(String(item?.content || '')) const err = item?.error as string | undefined return ( {err &&
{err}
}

{o.label}

{md}
) })}
) }