FA3-Datafetch/frontend/src/components/financial-tables.tsx
2026-01-12 19:20:18 +08:00

307 lines
11 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.

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