Fundamental_Analysis/frontend/src/app/reports/[id]/page.tsx
Lv, Qi 9d62a53b73 refactor(architecture): Align frontend & docs with DB gateway pattern
本次提交旨在完成一次架构一致性重构,核心目标是使前端代码和相关文档完全符合“`data-persistence-service`是唯一数据库守门人”的设计原则。

主要变更包括:
1.  **移除前端数据库直连**:
    *   从`docker-compose.yml`中删除了`frontend`服务的`DATABASE_URL`。
    *   彻底移除了`frontend`项目中的`Prisma`依赖、配置文件和客户端实例。
2.  **清理前端UI**:
    *   从配置页面中删除了所有与数据库设置相关的UI组件和业务逻辑。
3.  **同步更新文档**:
    *   更新了《用户使用文档》和《需求文档》,移除了所有提及或要求前端进行数据库配置的过时内容。

此次重构后,系统前端的数据交互已完全收敛至`api-gateway`,确保了架构的统一性、健壮性和高内聚。
2025-11-17 01:29:56 +08:00

789 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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