307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useState } from "react"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||
import { Loader2, TrendingUp, TrendingDown, DollarSign } from "lucide-react"
|
||
import { getFinancialData } from "@/lib/api"
|
||
import { formatNumber, formatLargeNumber, formatDate } from "@/lib/formatters"
|
||
import type { FinancialDataResponse } from "@/lib/types"
|
||
|
||
interface FinancialTablesProps {
|
||
companyId: number
|
||
dataSource: string
|
||
}
|
||
|
||
export function FinancialTables({ companyId, dataSource }: FinancialTablesProps) {
|
||
const [data, setData] = useState<FinancialDataResponse | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState("")
|
||
|
||
useEffect(() => {
|
||
async function loadData() {
|
||
setLoading(true)
|
||
setError("")
|
||
try {
|
||
const result = await getFinancialData(companyId, dataSource)
|
||
setData(result)
|
||
} catch (err: any) {
|
||
setError(err.message || "加载财务数据失败")
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
loadData()
|
||
}, [companyId, dataSource])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="pt-6 flex items-center justify-center min-h-[300px]">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="text-center text-destructive">{error}</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data) {
|
||
return null
|
||
}
|
||
|
||
const isBloomberg = dataSource === 'Bloomberg'
|
||
const TableComponent = isBloomberg ? TransposedFinancialTable : FinancialTable
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<DollarSign className="h-5 w-5" />
|
||
财务数据
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-8">
|
||
<div className="space-y-4">
|
||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||
<TrendingUp className="h-4 w-4" />
|
||
利润表
|
||
</h3>
|
||
<TableComponent
|
||
data={data.income_statement}
|
||
title="利润表"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||
<TrendingDown className="h-4 w-4" />
|
||
资产负债表
|
||
</h3>
|
||
<TableComponent
|
||
data={data.balance_sheet}
|
||
title="资产负债表"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||
<DollarSign className="h-4 w-4" />
|
||
现金流量表
|
||
</h3>
|
||
<TableComponent
|
||
data={data.cash_flow}
|
||
title="现金流量表"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||
<TrendingUp className="h-4 w-4" />
|
||
估值数据
|
||
</h3>
|
||
<TableComponent
|
||
data={data.daily_basic}
|
||
title="估值数据"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
interface FinancialTableProps {
|
||
data: Record<string, any>[]
|
||
title: string
|
||
icon?: React.ReactNode
|
||
}
|
||
|
||
function FinancialTable({ data, title }: FinancialTableProps) {
|
||
if (!data || data.length === 0) {
|
||
return (
|
||
<div className="text-center text-muted-foreground py-8">
|
||
暂无{title}数据
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 获取所有列名(排除 id, code, ticker, market, update_date 等元数据列)
|
||
const excludeColumns = ['id', 'code', 'ticker', 'market', 'update_date', 'ts_code']
|
||
const columns = Object.keys(data[0]).filter(key => !excludeColumns.includes(key))
|
||
|
||
return (
|
||
<div className="rounded-md border">
|
||
<div className="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{columns.map((column) => (
|
||
<TableHead key={column} className="whitespace-nowrap">
|
||
{formatColumnName(column)}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{data.map((row, idx) => (
|
||
<TableRow key={idx}>
|
||
{columns.map((column) => (
|
||
<TableCell key={column} className="text-right tabular-nums" style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
|
||
{formatCellValue(column, row[column])}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
<div className="px-4 py-2 text-xs text-muted-foreground border-t">
|
||
共 {data.length} 条记录
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TransposedFinancialTable({ data, title }: FinancialTableProps) {
|
||
if (!data || data.length === 0) {
|
||
return (
|
||
<div className="text-center text-muted-foreground py-8">
|
||
暂无{title}数据
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 1. 提取所有日期 (Headers)
|
||
// 假设 data 是按时间倒序排列的 (API通常如此)
|
||
// 如果不是,建议在这里做个sort
|
||
const sortedData = [...data].sort((a, b) =>
|
||
new Date(b.end_date).getTime() - new Date(a.end_date).getTime()
|
||
)
|
||
const dates = sortedData.map(row => row['end_date']).filter(Boolean)
|
||
|
||
// 2. 提取所有指标 (Rows)
|
||
const excludeColumns = ['id', 'code', 'ticker', 'market', 'update_date', 'ts_code', 'end_date']
|
||
// 从第一行获取所有指标key
|
||
const indicators = Object.keys(sortedData[0]).filter(key => !excludeColumns.includes(key))
|
||
|
||
return (
|
||
<div className="rounded-md border">
|
||
<div className="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
{/* 第一列:指标名称 */}
|
||
<TableHead className="whitespace-nowrap w-[200px] min-w-[150px] sticky left-0 bg-background z-10 border-r shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]">
|
||
指标 / 报告期
|
||
</TableHead>
|
||
{/* 其他列:日期 */}
|
||
{dates.map((date, idx) => (
|
||
<TableHead key={idx} className="whitespace-nowrap text-right px-4 min-w-[120px]">
|
||
{formatDate(date)}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{indicators.map((indicator) => (
|
||
<TableRow key={indicator}>
|
||
{/* 指标名 */}
|
||
<TableCell className="font-medium sticky left-0 bg-background z-10 border-r shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]">
|
||
{formatColumnName(indicator)}
|
||
</TableCell>
|
||
{/* 值 */}
|
||
{sortedData.map((row, rowIdx) => (
|
||
<TableCell key={rowIdx} className="text-right px-4 tabular-nums" style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
|
||
{formatCellValue(indicator, row[indicator])}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
<div className="px-4 py-2 text-xs text-muted-foreground border-t">
|
||
共 {indicators.length} 个指标
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 格式化列名
|
||
function formatColumnName(column: string): string {
|
||
const nameMap: Record<string, string> = {
|
||
'end_date': '报告期',
|
||
// Common
|
||
'revenue': '营业收入',
|
||
'net_income': '净利润',
|
||
// Balance Sheet
|
||
'total_assets': '总资产',
|
||
'total_liabilities': '总负债',
|
||
'total_equity': '股东权益',
|
||
'cash': '货币资金',
|
||
'inventory': '存货',
|
||
'receivables': '应收账款',
|
||
'prepayment': '预付款项',
|
||
'fixed_assets': '固定资产',
|
||
'long_term_investments': '长期投资',
|
||
'accounts_payable': '应付账款',
|
||
'short_term_debt': '短期借款',
|
||
'long_term_debt': '长期借款',
|
||
'deferred_revenue': '递延收入',
|
||
'goodwill': '商誉',
|
||
// Income
|
||
'sga_exp': '销售管理费用',
|
||
'selling_marketing_exp': '销售费用',
|
||
'ga_exp': '管理费用',
|
||
'rd_exp': '研发费用',
|
||
'depreciation': '折旧',
|
||
'operating_profit': '营业利润',
|
||
// Cash Flow
|
||
'ocf': '经营现金流',
|
||
'capex': '资本支出',
|
||
'dividends': '分红支付',
|
||
'fcf': '自由现金流',
|
||
// Metrics
|
||
'pe': '市盈率(PE)',
|
||
'pb': '市净率(PB)',
|
||
'market_cap': '市值',
|
||
'price': '最新股价',
|
||
'roe': 'ROE',
|
||
'roa': 'ROA',
|
||
}
|
||
return nameMap[column] || column
|
||
}
|
||
|
||
// 格式化单元格值
|
||
function formatCellValue(column: string, value: any): string {
|
||
if (value === null || value === undefined) {
|
||
return '-'
|
||
}
|
||
|
||
// 日期列
|
||
if (column.includes('date')) {
|
||
return formatDate(value)
|
||
}
|
||
|
||
// 数值列
|
||
if (typeof value === 'number') {
|
||
// 比率和百分比(PE、PB等)
|
||
if (column.includes('ratio') || column.includes('pe') || column.includes('pb') || column.includes('margin') || column.includes('roe') || column.includes('roa') || column.includes('rate')) {
|
||
return formatNumber(value)
|
||
}
|
||
// 大数字(金额、市值等)
|
||
return formatLargeNumber(value)
|
||
}
|
||
|
||
return String(value)
|
||
}
|