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