本次提交旨在完成一次架构一致性重构,核心目标是使前端代码和相关文档完全符合“`data-persistence-service`是唯一数据库守门人”的设计原则。
主要变更包括:
1. **移除前端数据库直连**:
* 从`docker-compose.yml`中删除了`frontend`服务的`DATABASE_URL`。
* 彻底移除了`frontend`项目中的`Prisma`依赖、配置文件和客户端实例。
2. **清理前端UI**:
* 从配置页面中删除了所有与数据库设置相关的UI组件和业务逻辑。
3. **同步更新文档**:
* 更新了《用户使用文档》和《需求文档》,移除了所有提及或要求前端进行数据库配置的过时内容。
此次重构后,系统前端的数据交互已完全收敛至`api-gateway`,确保了架构的统一性、健壮性和高内聚。
789 lines
46 KiB
TypeScript
789 lines
46 KiB
TypeScript
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 <div className="text-sm text-red-600">未找到报告</div>
|
||
}
|
||
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 <div className="text-sm text-red-600">未找到报告</div>
|
||
}
|
||
|
||
const content = (data.content ?? {}) as any
|
||
const analyses = (content?.analyses ?? {}) as Record<string, any>
|
||
|
||
// 规范化显示顺序(与生成报告时一致的中文 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<string, string[]> = {
|
||
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 (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-2xl font-semibold">报告详情</h1>
|
||
<div className="text-sm text-muted-foreground">{new Date(data.createdAt).toLocaleString()}</div>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">基本信息</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="text-sm space-y-1">
|
||
<div className="flex flex-wrap items-center gap-4">
|
||
<span>股票代码:<span className="font-medium">{data.symbol}</span></span>
|
||
{content?.normalizedSymbol && (
|
||
<span>标准代码:<span className="font-medium">{String(content.normalizedSymbol)}</span></span>
|
||
)}
|
||
{(() => {
|
||
const companyName = (content?.financials?.name as string | undefined) || (content as any)?.company_name || (content as any)?.companyName
|
||
return companyName ? (
|
||
<span>公司名称:<span className="font-medium">{companyName}</span></span>
|
||
) : null
|
||
})()}
|
||
{content?.market && (
|
||
<span>市场:<span className="font-medium">{String(content.market)}</span></span>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Tabs defaultValue={'financial'} className="mt-2">
|
||
<TabsList className="flex-wrap">
|
||
{ordered.map((o, idx) => (
|
||
<TabsTrigger key={o.id} value={o.id}>{`${idx + 1}. ${o.label}`}</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
|
||
<TabsContent value="financial" className="space-y-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">财务数据(保存自读取结果)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{(() => {
|
||
const fin = (content?.financials ?? null) as null | {
|
||
ts_code?: string
|
||
name?: string
|
||
series?: Record<string, Array<{ period: string; value: number | null }>>
|
||
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<string, string> = {
|
||
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<string, string> = {
|
||
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 (
|
||
<div className="text-sm text-muted-foreground">
|
||
暂无保存的财务数据。下次保存报告时会一并保存财务数据。
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="overflow-x-auto">
|
||
<Table className="min-w-full text-sm">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead className="text-left p-2">指标</TableHead>
|
||
{periods.map((p) => (
|
||
<TableHead key={p} className="text-right p-2">{formatReportPeriod(p)}</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{(() => {
|
||
const summaryRow = (
|
||
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">主要指标</TableCell>
|
||
{periods.map((p) => (
|
||
<TableCell key={p} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
)
|
||
|
||
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 (
|
||
<TableRow key={key} className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">{label || metricDisplayMap[key] || key}</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
if (PERCENT_KEYS.has(key)) {
|
||
const perc = Math.abs(rawNum) <= 1 ? rawNum * 100 : rawNum
|
||
const text = Number.isFinite(perc) ? numberFormatter.format(perc) : '-'
|
||
const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy'
|
||
if (isGrowthRow) {
|
||
const isNeg = typeof perc === 'number' && perc < 0
|
||
return (
|
||
<TableCell key={p} className="text-right p-2">
|
||
<span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span>
|
||
</TableCell>
|
||
)
|
||
}
|
||
if (key === 'roe' || key === 'roic') {
|
||
const highlight = typeof perc === 'number' && perc > 12
|
||
return (
|
||
<TableCell key={p} className={`text-right p-2 ${highlight ? 'bg-green-200' : ''}`}>{`${text}%`}</TableCell>
|
||
)
|
||
}
|
||
return <TableCell key={p} className="text-right p-2">{`${text}%`}</TableCell>
|
||
} else {
|
||
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'
|
||
const scaled = key === 'total_mv' ? rawNum / 10000 : (isFinGroup || isComputed ? rawNum / 1e8 : rawNum)
|
||
const formatter = key === 'total_mv' ? integerFormatter : numberFormatter
|
||
const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-'
|
||
if (key === '__free_cash_flow') {
|
||
const isNeg = typeof scaled === 'number' && scaled < 0
|
||
return (
|
||
<TableCell key={p} className="text-right p-2">{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}</TableCell>
|
||
)
|
||
}
|
||
return <TableCell key={p} className="text-right p-2">{text}</TableCell>
|
||
}
|
||
})}
|
||
</TableRow>
|
||
)
|
||
})
|
||
|
||
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 = (
|
||
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">费用指标</TableCell>
|
||
{periods.map((p) => (
|
||
<TableCell key={p} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
)
|
||
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 }) => (
|
||
<TableRow key={key} className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
const rateText = numberFormatter.format(rate)
|
||
const isNegative = rate < 0
|
||
return (
|
||
<TableCell key={p} className="text-right p-2">
|
||
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`}
|
||
</TableCell>
|
||
)
|
||
})}
|
||
</TableRow>
|
||
))
|
||
|
||
// 资产占比
|
||
const assetHeaderRow = (
|
||
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">资产占比</TableCell>
|
||
{periods.map((p) => (
|
||
<TableCell key={p} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
)
|
||
const ratioCell = (value: number | null, p: string) => {
|
||
if (value == null || !Number.isFinite(value)) {
|
||
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
const text = numberFormatter.format(value)
|
||
const isNegative = value < 0
|
||
return (
|
||
<TableCell key={p} className="text-right p-2">
|
||
{isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`}
|
||
</TableCell>
|
||
)
|
||
}
|
||
const assetRows = [
|
||
{ key: '__money_cap_ratio', label: '现金占比', calc: (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 }) => (
|
||
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
|
||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||
{periods.map((p) => ratioCell(calc(p), p))}
|
||
</TableRow>
|
||
))
|
||
|
||
// 周转能力
|
||
const turnoverHeaderRow = (
|
||
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">周转能力</TableCell>
|
||
{periods.map((p) => (
|
||
<TableCell key={p} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
)
|
||
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 }) => (
|
||
<TableRow key={key} className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
const text = numberFormatter.format(value)
|
||
if (key === 'arturn_days' && value > 90) {
|
||
return (
|
||
<TableCell key={p} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell>
|
||
)
|
||
}
|
||
return <TableCell key={p} className="text-right p-2">{text}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
))
|
||
|
||
// 人均效率
|
||
const perCapitaHeaderRow = (
|
||
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">人均效率</TableCell>
|
||
{periods.map((p) => (
|
||
<TableCell key={p} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
)
|
||
const employeesRow = (
|
||
<TableRow key="__employees_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">员工人数</TableCell>
|
||
{periods.map((p) => {
|
||
const v = getVal(series['employees'] as any, p)
|
||
if (v == null || !Number.isFinite(v)) {
|
||
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
const revPerEmpRow = (
|
||
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">人均创收(万元)</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
const val = (rev / emp) / 10000
|
||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
const profitPerEmpRow = (
|
||
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">人均创利(万元)</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
const val = (prof / emp) / 10000
|
||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
const salaryPerEmpRow = (
|
||
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">人均工资(万元)</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
}
|
||
const val = (salaryPaid / emp) / 10000
|
||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
|
||
// 市场表现
|
||
const marketHeaderRow = (
|
||
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
|
||
<TableCell className="p-2 font-medium ">市场表现</TableCell>
|
||
{periods.map((p) => (
|
||
<TableCell key={p} className="p-2"></TableCell>
|
||
))}
|
||
</TableRow>
|
||
)
|
||
const priceRow = (
|
||
<TableRow key="__price_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">股价</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
const marketCapRow = (
|
||
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">市值(亿元)</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
const scaled = num / 10000
|
||
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
const peRow = (
|
||
<TableRow key="__pe_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">PE</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
const pbRow = (
|
||
<TableRow key="__pb_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
const holderNumRow = (
|
||
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
|
||
<TableCell className="p-2 text-muted-foreground">股东户数</TableCell>
|
||
{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 <TableCell key={p} className="text-right p-2">-</TableCell>
|
||
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell>
|
||
})}
|
||
</TableRow>
|
||
)
|
||
|
||
return [
|
||
summaryRow,
|
||
...rows,
|
||
feeHeaderRow,
|
||
...feeRows,
|
||
assetHeaderRow,
|
||
...assetRows,
|
||
turnoverHeaderRow,
|
||
...turnoverRows,
|
||
perCapitaHeaderRow,
|
||
employeesRow,
|
||
revPerEmpRow,
|
||
profitPerEmpRow,
|
||
salaryPerEmpRow,
|
||
marketHeaderRow,
|
||
priceRow,
|
||
marketCapRow,
|
||
peRow,
|
||
pbRow,
|
||
holderNumRow,
|
||
]
|
||
})()}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="meta" className="space-y-4">
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">元数据(数据库原始记录)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<pre className="text-xs leading-relaxed overflow-auto">
|
||
{JSON.stringify(data, null, 2)}
|
||
</pre>
|
||
</CardContent>
|
||
</Card>
|
||
</TabsContent>
|
||
|
||
{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 (
|
||
<TabsContent key={o.id} value={o.id} className="space-y-3">
|
||
{err && <div className="text-sm text-red-600">{err}</div>}
|
||
<div className="border rounded-lg p-6 bg-card">
|
||
<article className="markdown-body" style={{
|
||
boxSizing: 'border-box', minWidth: '200px', maxWidth: '980px', margin: '0 auto', padding: 0
|
||
}}>
|
||
<h2 className="text-lg font-medium mb-3">{o.label}</h2>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||
{md}
|
||
</ReactMarkdown>
|
||
</article>
|
||
</div>
|
||
</TabsContent>
|
||
)
|
||
})}
|
||
</Tabs>
|
||
</div>
|
||
)
|
||
}
|