FA3-Datafetch/frontend/src/components/bloomberg-view.tsx
2026-01-13 20:39:44 +08:00

996 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.

"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, DollarSign, RefreshCw, ChevronRight, ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { getFinancialData } from "@/lib/api"
import { formatNumber, formatLargeNumber, formatDate } from "@/lib/formatters"
import type { FinancialDataResponse } from "@/lib/types"
interface BloombergViewProps {
companyId: number
onBack?: () => void
selectedCurrency?: string
userMarket?: string
lastUpdate?: string
}
export function BloombergView({ companyId, onBack, selectedCurrency = "Auto", userMarket, lastUpdate }: BloombergViewProps) {
const [data, setData] = useState<FinancialDataResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const loadData = async () => {
if (!companyId) return
setLoading(true)
setError("")
try {
console.log("Fetching Bloomberg data for company:", companyId)
const result = await getFinancialData(companyId, "Bloomberg")
console.log("Bloomberg data result:", result)
setData(result)
} catch (err: any) {
console.error("Bloomberg fetch error:", err)
setError(err.message || "加载 Bloomberg 数据失败")
} finally {
setLoading(false)
}
}
useEffect(() => {
loadData()
}, [companyId, lastUpdate])
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground"> Bloomberg ...</p>
</div>
)
}
if (error) {
return (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="text-center text-destructive space-y-4">
<p>{error}</p>
<Button variant="outline" onClick={loadData}></Button>
</div>
</CardContent>
</Card>
)
}
if (!data) return null
// 如果后端提供了统一数据字段,直接使用
const mergedData = data.unified_data || []
return (
<div className="space-y-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-6">
<h2 className="text-2xl font-bold flex items-center gap-2 whitespace-nowrap">
<DollarSign className="w-6 h-6" />
</h2>
{/* Insert Inline Header Info here */}
<BasicInfoHeader data={mergedData} selectedCurrency={selectedCurrency} userMarket={userMarket} />
</div>
<div className="flex gap-2 ml-auto">
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
<RawDataTable title="财务数据总表" data={mergedData} selectedCurrency={selectedCurrency} userMarket={userMarket} />
</div>
)
}
// ---------------------------------------------------------------------------
// Basic Info Header Component
// ---------------------------------------------------------------------------
function BasicInfoHeader({ data, selectedCurrency = "Auto", userMarket }: { data: any[], selectedCurrency?: string, userMarket?: string }) {
if (!data || data.length === 0) return null
// 1. Determine Target Currency (Logic shared with Table)
let targetCurrency = selectedCurrency
if (targetCurrency === "Auto" && userMarket) {
const market = userMarket.toUpperCase()
if (market.includes("JP")) targetCurrency = "JPY"
else if (market.includes("VN")) targetCurrency = "VND"
else if (market.includes("CH")) targetCurrency = "CNY"
else if (market.includes("HK")) targetCurrency = "HKD"
else targetCurrency = "USD"
}
// 2. Find Latest Valid Data for each field
// We sort data by date descending to get latest first.
// Note: 'end_date' is the reporting period, 'update_date' is when it was fetched.
// Usually PE/PB/MarketCap are "current" values, so they might be associated with the latest reporting period or a special "current" row.
// Based on bloomberg logic, they are saved as time series. We take the latest available.
// Sort rows by date descending
const sortedRows = [...data].sort((a, b) => new Date(b.end_date).getTime() - new Date(a.end_date).getTime())
// Helper to find first non-null value
const findValue = (keys: string[]) => {
for (const row of sortedRows) {
// Check currency match if required (PE/PB/MarketCap are currency dependent usually, or at least MarketCap is)
// However, PE/PB are ratios.
// Strict currency check for everything to be safe?
if (targetCurrency !== 'Auto') {
if (row.currency && row.currency !== targetCurrency) continue
} else {
// If auto, try to match first row's currency if established, or just take any?
// Let's stick to the targetCurrency logic used in Table which resolved 'Auto' to a specific one if possible.
}
for (const key of keys) {
if (row[key] !== null && row[key] !== undefined && row[key] !== '') {
return { value: row[key], row }
}
}
}
return null
}
// Extract Fields
const companyName = findValue(['company_name'])?.value || '-'
// Find latest update_date from ALL rows matching currency
let maxUpdateDate = ''
for (const row of data) {
if (targetCurrency !== 'Auto' && row.currency && row.currency !== targetCurrency) continue
if (row.update_date && row.update_date > maxUpdateDate) {
maxUpdateDate = row.update_date
}
}
const updateDate = maxUpdateDate
// update_date is a timestamp string "2023-10-xx 10:00:00". User wants "Day only".
const displayDate = updateDate ? formatDate(updateDate) : '-'
const pe = findValue(['pe', 'pe_ratio'])?.value
const pb = findValue(['pb', 'pb_ratio'])?.value
const marketCapTuple = findValue(['market_cap'])
const marketCap = marketCapTuple?.value
const marketCapCurrency = marketCapTuple?.row?.currency || targetCurrency
const abroadRev = findValue(['rev_abroad', 'pct_revenue_from_foreign_sources'])?.value
const divYield = findValue(['dividend_yield', 'dividend_12_month_yield'])?.value
const ipoDateRaw = findValue(['ipo_date', 'IPO_date', 'eqy_init_po_dt'])?.value
const ipoDate = ipoDateRaw ? formatDate(ipoDateRaw) : '-'
// Formatters
const formatRatio = (val: any) => {
if (val === undefined || val === null) return '-'
const num = parseFloat(val)
if (isNaN(num)) return '-'
return num.toFixed(2)
}
const formatPercent = (val: any) => {
if (val === undefined || val === null) return '-'
const num = parseFloat(val)
if (isNaN(num)) return '-'
return num.toFixed(2) + '%'
}
const formatMoney = (val: any, currency: string) => {
if (val === undefined || val === null) return '-'
let num = parseFloat(val)
if (isNaN(num)) return '-'
// Backend now returns Market Cap in Millions for ALL currencies (via BDH).
// To convert Millions to "Yi" (100 Million), we divide by 100.
// This applies uniformly to JPY, USD, CNY, etc.
num = num / 100
return num.toLocaleString('en-US', { maximumFractionDigits: 2 }) + ' 亿'
}
return (
<div className="flex items-center gap-6 text-sm bg-muted/30 px-4 py-2 rounded-lg border">
{/* Group 1: Identity */}
<div className="flex flex-col gap-0.5 border-r pr-6">
<span className="font-bold text-lg text-primary truncate max-w-[200px]" title={companyName}>{companyName}</span>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>IPO: {ipoDate}</span>
<span>: {displayDate}</span>
</div>
</div>
{/* Group 2: Key Ratios */}
<div className="flex items-center gap-6">
<div className="flex flex-col items-center">
<span className="text-muted-foreground text-xs scale-90">PE</span>
<span className="font-mono font-bold text-base">{formatRatio(pe)}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-muted-foreground text-xs scale-90">PB</span>
<span className="font-mono font-bold text-base">{formatRatio(pb)}</span>
</div>
<div className="flex flex-col items-center">
<span className="text-muted-foreground text-xs scale-90"></span>
<span className="font-mono font-bold text-base">{formatPercent(divYield)}</span>
</div>
</div>
{/* Group 3: Market Size & Biz */}
<div className="flex items-center gap-6 border-l pl-6">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs scale-90"> ({marketCapCurrency})</span>
<span className="font-mono font-bold text-base text-blue-600 dark:text-blue-400">{formatMoney(marketCap, marketCapCurrency)}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs scale-90"></span>
<span className="font-mono font-bold text-base">{formatPercent(abroadRev)}</span>
</div>
</div>
</div>
)
}
function RawDataTable({ data, title, selectedCurrency = "Auto", userMarket }: { data: any[], title: string, selectedCurrency?: string, userMarket?: string }) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center text-muted-foreground py-8"></div>
</CardContent>
</Card>
)
}
// ---------------------------------------------------------------------------
// 1. Filter Data Rows (Handle Multi-Currency) - DO THIS FIRST!
// ---------------------------------------------------------------------------
// Determine Target Currency Strategy
let targetCurrency = selectedCurrency
if (targetCurrency === "Auto" && userMarket) {
const market = userMarket.toUpperCase()
if (market.includes("JP")) targetCurrency = "JPY"
else if (market.includes("VN")) targetCurrency = "VND"
else if (market.includes("CH")) targetCurrency = "CNY"
else if (market.includes("HK")) targetCurrency = "HKD"
else targetCurrency = "USD"
}
const filteredRows = data.filter(row => {
// Basic Validity Check
if (!row || row.revenue === null || row.revenue === undefined) return false
// Currency Filtering
// Case 1: Specific Currency Selected (or resolved from Auto)
if (targetCurrency !== "Auto") {
if (row.currency && row.currency !== targetCurrency) {
return false
}
}
// Case 2: Pure Auto (Fallback if no userMarket)
else if (selectedCurrency === "Auto") {
if (data.length > 0 && data[0].currency && row.currency && row.currency !== data[0].currency) {
return false
}
}
return true
})
// ---------------------------------------------------------------------------
// 1.5 Enrich Data with Ratios (Expense / Revenue)
// ---------------------------------------------------------------------------
filteredRows.forEach(row => {
const revenue = typeof row.revenue === 'string' ? parseFloat(row.revenue) : row.revenue
if (revenue && revenue !== 0) {
// Helper to calculate and assign if value exists
const calcRatio = (key: string, targetKey: string) => {
const val = typeof row[key] === 'string' ? parseFloat(row[key]) : row[key]
if (val !== null && val !== undefined && !isNaN(val)) {
row[targetKey] = (val / revenue) * 100
}
}
calcRatio('selling&marketing', 'selling&marketing_to_revenue')
calcRatio('general&admin', 'general&admin_to_revenue')
calcRatio('sg&a', 'sg&a_to_revenue')
calcRatio('r&d', 'r&d_to_revenue')
calcRatio('depreciation', 'depreciation_to_revenue')
// Asset Ratios (Denominator: Total Assets)
const calcAssetRatio = (key: string, targetKey: string) => {
const totalAssets = typeof row['total_assets'] === 'string' ? parseFloat(row['total_assets']) : row['total_assets']
const val = typeof row[key] === 'string' ? parseFloat(row[key]) : row[key]
if (totalAssets && totalAssets !== 0 && val !== null && val !== undefined && !isNaN(val)) {
row[targetKey] = (val / totalAssets) * 100
}
}
calcAssetRatio('cash', 'cash_to_assets')
calcAssetRatio('inventory', 'inventory_to_assets')
calcAssetRatio('accounts&notes_receivable', 'receivables_to_assets')
calcAssetRatio('prepaid', 'prepaid_to_assets')
calcAssetRatio('property_plant&equipment', 'ppe_to_assets')
calcAssetRatio('lt_investment', 'lt_investment_to_assets')
calcAssetRatio('goodwill', 'goodwill_to_assets')
calcAssetRatio('accounts_payable', 'payables_to_assets')
calcAssetRatio('st_defer_rev', 'deferred_revenue_to_assets')
calcAssetRatio('st_debt', 'st_debt_to_assets')
calcAssetRatio('lt_debt', 'lt_debt_to_assets')
// Calculate Other Assets Ratio
// Formula: 100 - Cash% - Inventory% - Receivables% - Prepaid% - PP&E% - LT Investment%
if (row['total_assets']) {
const getR = (k: string) => (typeof row[k] === 'number' ? row[k] : 0)
const sumKnown = getR('cash_to_assets') + getR('inventory_to_assets') +
getR('receivables_to_assets') + getR('prepaid_to_assets') +
getR('ppe_to_assets') + getR('lt_investment_to_assets') +
getR('goodwill_to_assets')
row['other_assets_ratio'] = 100 - sumKnown
}
// Calculate Other Expense Ratio
// Formula: Gross Margin - Net Margin - SG&A% - R&D%
// Note: All values are expected to be in Percentage (0-100) scale
const getVal = (k: string) => {
const v = row[k]
if (v === null || v === undefined) return 0
const num = typeof v === 'string' ? parseFloat(v) : v
return (typeof num === 'number' && !isNaN(num)) ? num : 0
}
// Check if we have the necessary components to make a meaningful calculation
// We need at least Gross Margin and Net Profit Margin to be present
// Note: We check against null/undefined, but getVal handles parsing so we can be more lenient in check or rely on getVal result (though 0 might be valid)
// It's safer to try calculating if keys exist
if (row['gross_margin'] !== undefined && row['net_profit_margin'] !== undefined) {
const gross = getVal('gross_margin')
const net = getVal('net_profit_margin')
const sga = getVal('sg&a_to_revenue')
const rd = getVal('r&d_to_revenue')
row['other_expense_ratio'] = gross - net - sga - rd
// Calculate Breakdown Rows
row['other_breakdown_gross'] = gross
row['other_breakdown_net'] = -1 * net
row['other_breakdown_sga'] = -1 * sga
row['other_breakdown_rd'] = -1 * rd
}
// Calculate Per Employee Metrics (Unit: Wan)
// Revenue input is in Millions (based on formatMoney logic / 100 -> Yi)
// Metric = (Val * 1,000,000) / Employee / 10,000
// Metric = (Val * 100) / Employee
const emp = getVal('num_of_employees') || getVal('employee') // handle both keys if present
if (emp && emp > 0) {
const rev = typeof row['revenue'] === 'string' ? parseFloat(row['revenue']) : row['revenue']
const net = typeof row['net_income'] === 'string' ? parseFloat(row['net_income']) : row['net_income']
if (rev !== undefined && !isNaN(rev)) {
row['revenue_per_employee'] = (rev * 100) / emp
}
if (net !== undefined && !isNaN(net)) {
row['profit_per_employee'] = (net * 100) / emp
}
}
}
})
// ---------------------------------------------------------------------------
// 2. Extract Indicators (From Filtered Data ONLY)
// ---------------------------------------------------------------------------
// 提取所有指标键值 (排除元数据)
const excludeKeys = ['id', 'company_code', 'code', 'symbol', 'market', 'update_date', 'create_time', 'end_date', 'ts_code']
// 从合并后的数据中收集所有可能的指标
const allIndicators = new Set<string>()
filteredRows.forEach(row => {
Object.keys(row).forEach(k => {
if (!excludeKeys.includes(k)) {
allIndicators.add(k)
}
})
})
// 对指标进行逻辑排序/分组
const priorityOrder = [
'currency',
'__SECTION_MAIN__',
'roe', 'roce', 'roa',
'gross_margin', 'net_profit_margin', 'ebitda_margin',
'revenue', 'revenue_growth',
'net_income', 'netincome_growth',
'cash_from_operating', 'capital_expenditure', 'free_cash_flow',
'dividends_paid', 'dividend_payout_ratio', 'repurchase',
'total_assets', 'equity',
'__SECTION_EXPENSE__',
'selling&marketing_to_revenue', 'selling&marketing',
'general&admin_to_revenue', 'general&admin',
'sg&a_to_revenue', 'sg&a',
'r&d_to_revenue', 'r&d',
'other_expense_ratio',
'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd',
'depreciation_to_revenue', 'depreciation',
'tax_rate',
'__SECTION_ASSET__',
'cash_to_assets', 'cash',
'inventory_to_assets', 'inventory',
'receivables_to_assets', 'accounts&notes_receivable',
'prepaid_to_assets', 'prepaid',
'ppe_to_assets', 'property_plant&equipment',
'lt_investment_to_assets', 'lt_investment',
'goodwill_to_assets', 'goodwill',
'other_assets_ratio',
'payables_to_assets', 'accounts_payable',
'deferred_revenue_to_assets', 'st_defer_rev',
'st_debt_to_assets', 'st_debt',
'lt_debt_to_assets', 'lt_debt',
'total_debt_ratio',
'__SECTION_TURNOVER__',
'inventory_days', 'days_sales_outstanding', 'payables_days',
'net_fixed_asset_turnover', 'asset_turnover',
'__SECTION_EFFICIENCY__',
'employee', 'num_of_employees',
'revenue_per_employee', 'profit_per_employee',
'__SECTION_MARKET__',
'last_price', 'market_cap', 'pe', 'pb',
'dividend_yield', 'shareholders'
]
// Ensure section header is in the list to be sorted
const sectionKeys = [
'__SECTION_MAIN__', '__SECTION_EXPENSE__', '__SECTION_ASSET__',
'__SECTION_TURNOVER__', '__SECTION_EFFICIENCY__', '__SECTION_MARKET__',
'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd',
]
sectionKeys.forEach(key => {
if (!allIndicators.has(key)) {
allIndicators.add(key)
}
})
const sortedIndicators = Array.from(allIndicators).sort((a, b) => {
const idxA = priorityOrder.indexOf(a)
const idxB = priorityOrder.indexOf(b)
// Items in priority list come first, sorted by their order in the list
if (idxA !== -1 && idxB !== -1) return idxA - idxB
// Items in priority list come before items not in list
if (idxA !== -1) return -1
if (idxB !== -1) return 1
// Remaining items sorted alphabetically
return a.localeCompare(b)
})
// ---------------------------------------------------------------------------
// 1. Filter Data Rows (Handle Multi-Currency)
// ---------------------------------------------------------------------------
// We must filter BEFORE creating the Map, because multiple rows might exist
// for the same date (e.g. 2023 JPY and 2023 USD).
// The Map key is 'end_date', so it can only hold one currency's data per date.
// ---------------------------------------------------------------------------
// 2. Build Data Structures from Filtered Rows
// ---------------------------------------------------------------------------
// 构建查找表: Map<DateString, RowData>
const dataMap = new Map()
filteredRows.forEach(row => {
dataMap.set(row.end_date, row)
})
// 提取日期列表
const dates = Array.from(new Set(filteredRows.map(row => row.end_date)))
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
// Tooltip State
const [tooltip, setTooltip] = useState<{ show: boolean, x: number, y: number, text: string }>({
show: false, x: 0, y: 0, text: ''
})
// Interactive Rows State
const [expandedMetrics, setExpandedMetrics] = useState<Set<string>>(new Set())
const ratioToAbsMap: Record<string, string | string[]> = {
'selling&marketing_to_revenue': 'selling&marketing',
'general&admin_to_revenue': 'general&admin',
'sg&a_to_revenue': 'sg&a',
'r&d_to_revenue': 'r&d',
'depreciation_to_revenue': 'depreciation',
'other_expense_ratio': ['other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd'],
// Asset Ratios
'cash_to_assets': 'cash',
'inventory_to_assets': 'inventory',
'receivables_to_assets': 'accounts&notes_receivable',
'prepaid_to_assets': 'prepaid',
'ppe_to_assets': 'property_plant&equipment',
'lt_investment_to_assets': 'lt_investment',
'goodwill_to_assets': 'goodwill',
'payables_to_assets': 'accounts_payable',
'deferred_revenue_to_assets': 'st_defer_rev',
'st_debt_to_assets': 'st_debt',
'lt_debt_to_assets': 'lt_debt',
}
const hiddenByDefault = new Set<string>()
Object.values(ratioToAbsMap).forEach(val => {
if (Array.isArray(val)) {
val.forEach(v => hiddenByDefault.add(v))
} else {
hiddenByDefault.add(val)
}
})
// Ensure virtual keys are included in sortedIndicators
// (Handled via sectionKeys logic above)
const handleRowClick = (indicator: string) => {
const target = ratioToAbsMap[indicator]
if (!target) return
setExpandedMetrics(prev => {
const next = new Set(prev)
const targets = Array.isArray(target) ? target : [target]
// Toggle logic: If first target is present, remove all. Else add all.
// This ensures consistent state even if desynced.
const isExpanded = next.has(targets[0])
targets.forEach(t => {
if (isExpanded) {
next.delete(t)
} else {
next.add(t)
}
})
return next
})
}
const handleMouseMove = (e: React.MouseEvent, text: string) => {
setTooltip({
show: true,
x: e.clientX,
y: e.clientY,
text
})
}
const handleMouseLeave = () => {
setTooltip(prev => ({ ...prev, show: false }))
}
// 获取第一行数据的货币单位 (假设一致)
const currency = data[0]?.currency || 'CNY'
return (
<div className="relative">
{tooltip.show && (
<div
className="fixed z-50 px-2 py-1 text-xs text-white bg-black/90 rounded pointer-events-none whitespace-nowrap"
style={{
left: tooltip.x + 10,
top: tooltip.y + 10
}}
>
{tooltip.text}
</div>
)}
<div className="rounded-md border">
<table className="w-full caption-bottom text-sm">
<TableHeader>
<TableRow>
<TableHead className="w-[200px] sticky left-0 top-0 bg-background z-50 border-r border-b shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]">
<div className="flex flex-col gap-1 py-1">
<span className="font-bold text-foreground"> / </span>
<span className="text-xs font-normal text-muted-foreground">(单位: 亿)</span>
</div>
</TableHead>
{dates.map(date => (
<TableHead key={date} className="text-right min-w-[120px] px-4 font-mono sticky top-0 bg-background z-40 border-b">
{formatDate(date)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{sortedIndicators.map(indicator => {
if (indicator === '__SECTION_MAIN__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_EXPENSE__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_ASSET__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_TURNOVER__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_EFFICIENCY__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_MARKET__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
// Check visibility
const isHidden = hiddenByDefault.has(indicator) && !expandedMetrics.has(indicator)
if (isHidden) return null
const isTrigger = indicator in ratioToAbsMap
// Check expansion state for array targets
let isExpanded = false
if (isTrigger) {
const target = ratioToAbsMap[indicator]
const firstTarget = Array.isArray(target) ? target[0] : target
isExpanded = expandedMetrics.has(firstTarget)
}
const isSubRow = hiddenByDefault.has(indicator)
const subRowClass = isSubRow ? "text-xs text-muted-foreground" : ""
return (
<TableRow
key={indicator}
className={`hover:bg-purple-100 dark:hover:bg-purple-900/40 ${isHidden ? 'hidden' : ''} ${subRowClass}`}
>
<TableCell
className={`font-medium sticky left-0 bg-background z-10 border-r shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] transition-colors hover:bg-muted ${['revenue_growth', 'netincome_growth'].includes(indicator) ? 'text-blue-600 italic dark:text-blue-400' : ''
} ${isTrigger ? 'cursor-pointer' : 'cursor-help'}`}
onMouseMove={(e) => !isTrigger && handleMouseMove(e, indicator)}
onMouseLeave={handleMouseLeave}
onClick={() => isTrigger && handleRowClick(indicator)}
>
<div className="flex items-center gap-1">
{isTrigger && (
isExpanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />
)}
<span className={isTrigger ? "" : (hiddenByDefault.has(indicator) ? "pl-8" : "")}>
{formatColumnName(indicator)}
</span>
</div>
</TableCell>
{dates.map(date => {
const row = dataMap.get(date)
const value = row ? row[indicator] : null
let highlightClass = ''
let textStyle = ''
if (value !== null && value !== undefined) {
const numVal = typeof value === 'string' ? parseFloat(value) : value
if (typeof numVal === 'number' && !isNaN(numVal)) {
const assetRatios = [
'cash_to_assets', 'inventory_to_assets', 'receivables_to_assets',
'prepaid_to_assets', 'ppe_to_assets', 'lt_investment_to_assets',
'payables_to_assets', 'deferred_revenue_to_assets',
'st_debt_to_assets', 'lt_debt_to_assets', 'other_assets_ratio',
'goodwill_to_assets'
]
if (assetRatios.includes(indicator) && numVal > 30) {
highlightClass = 'bg-red-100 dark:bg-red-900/40 font-bold'
}
else if (indicator === 'other_assets_ratio') highlightClass = 'bg-yellow-100 dark:bg-yellow-900/40'
else if (indicator === 'roe' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'gross_margin' && numVal > 40) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'net_profit_margin' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'roce' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'roa' && numVal > 10) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'revenue_growth') {
if (numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40'
}
else if (indicator === 'netincome_growth') {
if (numVal > 20) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40'
}
// Dynamic text style based on value for growth rates
if (['revenue_growth', 'netincome_growth'].includes(indicator)) {
if (numVal < 0) {
textStyle = 'text-red-600 italic font-bold dark:text-red-400'
} else {
textStyle = 'text-blue-600 italic dark:text-blue-400'
}
}
}
}
// Fallback for text style if value is missing/invalid but indicator is growth rate
if (textStyle === '' && ['revenue_growth', 'netincome_growth'].includes(indicator)) {
textStyle = 'text-blue-600 italic dark:text-blue-400'
}
return (
<TableCell key={date} className={`text-right px-4 tabular-nums ${highlightClass} ${textStyle}`} style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
{formatCellValue(indicator, value)}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</table>
</div>
</div>
)
}
// 复用之前的 formatting 逻辑
function formatColumnName(column: string): string {
const nameMap: Record<string, string> = {
// --- 核心利润表 ---
'revenue': '营业收入',
'net_income': '净利润',
'operating_profit': '营业利润',
'gross_margin': '毛利率',
'net_profit_margin': '净利率',
'ebitda_margin': 'EBITDA利润率',
'sg&a': '销售管理费用(SG&A)',
'sg&a_to_revenue': '销售管理费用率',
'selling&marketing': '销售费用',
'selling&marketing_to_revenue': '销售费用率',
'general&admin': '管理费用',
'general&admin_to_revenue': '管理费用率',
'ga_exp': '管理费用', // Alias
'rd_exp': '研发费用',
'r&d': '研发费用',
'r&d_to_revenue': '研发费用率',
'other_expense_ratio': '其他费用率',
'other_breakdown_gross': '毛利率',
'other_breakdown_net': '(-) 净利率',
'other_breakdown_sga': '(-) 销售管理费用率(SG&A)',
'other_breakdown_rd': '(-) 研发费用率',
'depreciation': '折旧',
'depreciation_to_revenue': '折旧率',
'tax_rate': '有效税率',
'dividends': '分红',
'dividend_payout_ratio': '分红支付率',
'netincome_growth': '净利润增长率',
'revenue_growth': '营收增长率',
// --- 资产负债表 ---
'total_assets': '总资产',
'total_liabilities': '总负债',
'shareholders_equity': '净资产', // Alias
'total_equity': '净资产', // Alias
'equity': '净资产', // Real Bloomberg Key
'cash': '现金',
'inventory': '存货',
'accounts&notes_receivable': '应收账款及票据',
'receivables': '应收账款',
'accounts_payable': '应付账款',
'prepayment': '预付款项',
'prepaid': '预付款项',
'fixed_assets': '固定资产',
'property_plant&equipment': '固定资产(PP&E)',
'long_term_investments': '长期投资',
'lt_investment': '长期投资',
'short_term_debt': '短期借款',
'st_debt': '短期借款',
'st_debt_to_assets': '短期借款占比',
'long_term_debt': '长期借款',
'lt_debt': '长期借款',
'lt_debt_to_assets': '长期借款占比',
'deferred_revenue': '递延收入',
'st_defer_rev': '短期递延收入',
'deferred_revenue_to_assets': '预收占比',
'goodwill': '商誉',
'goodwill_to_assets': '商誉占比',
'total_debt_ratio': '总债务比率',
'cash_to_assets': '现金占比',
'inventory_to_assets': '存货占比',
'receivables_to_assets': '应收占比',
'prepaid_to_assets': '预付占比',
'ppe_to_assets': '固定资产占比',
'lt_investment_to_assets': '长期投资占比',
'payables_to_assets': '应付占比',
'other_assets_ratio': '其他资产占比',
// --- 现金流量表 ---
'cash_from_operating': '经营现金流',
'ocf': '经营现金流', // Alias
'capital_expenditure': '资本支出',
'capex': '资本支出', // Alias
'free_cash_flow': '自由现金流',
'fcf': '自由现金流', // Alias
'dividends_paid': '支付股息',
'repurchase': '股份回购',
// --- 运营效率/周转 ---
'asset_turnover': '总资产周转率',
'net_fixed_asset_turnover': '固定资产周转率', // Correct Bloomberg Key
'net_fixed_asset_turn': '固定资产周转率', // Alias just in case
'inventory_days': '存货周转天数',
'invent_days': '存货周转天数',
'days_sales_outstanding': '应收账款周转天数',
'payables_days': '应付账款周转天数',
'employee': '员工人数',
'num_of_employees': '员工人数',
'revenue_per_employee': '人均创收(万)',
'profit_per_employee': '人均创利(万)',
// --- 估值与市场 ---
'pe': '市盈率(PE)',
'pe_ratio': '市盈率(PE)',
'pb': '市净率(PB)',
'pb_ratio': '市净率(PB)',
'market_cap': '市值',
'price': '最新股价',
'last_price': '最新股价',
'roe': 'ROE',
'roa': 'ROA',
'roce': 'ROCE',
'dividend_yield': '股息率',
'shareholders': '股东人数',
'company_name': '公司名称',
'ipo_date': '上市日期',
'rev_abroad': '海外收入占比',
'currency': '货币',
}
return nameMap[column.toLowerCase()] || column
}
function formatCellValue(column: string, value: any): string {
if (value === null || value === undefined) return '-'
// 尝试解析文本数字
let numVal = value
if (typeof value === 'string') {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
numVal = parsed
}
}
if (typeof numVal === 'number') {
const lowerCol = column.toLowerCase()
// Special scaling for tax_rate
if (lowerCol === 'tax_rate') {
numVal = numVal * 100
}
const isRatio = ['roe', 'roce', 'roa', 'gross_margin', 'net_profit_margin', 'ebitda_margin', 'dividend_yield', 'rev_abroad', 'tax_rate', 'revenue_growth', 'netincome_growth',
'selling&marketing_to_revenue', 'general&admin_to_revenue', 'sg&a_to_revenue', 'r&d_to_revenue', 'depreciation_to_revenue',
'other_expense_ratio',
'other_breakdown_gross', 'other_breakdown_net', 'other_breakdown_sga', 'other_breakdown_rd',
'cash_to_assets', 'inventory_to_assets', 'receivables_to_assets', 'prepaid_to_assets', 'ppe_to_assets', 'lt_investment_to_assets',
'payables_to_assets', 'deferred_revenue_to_assets', 'st_debt_to_assets', 'lt_debt_to_assets',
'other_assets_ratio', 'goodwill_to_assets'
].includes(lowerCol)
// Special handling for Per Employee (Just number formatting, 2 decimals)
if (['revenue_per_employee', 'profit_per_employee'].includes(lowerCol)) {
if (numVal === undefined || numVal === null || isNaN(numVal)) return '-'
return numVal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
// Scale huge monetary values to "Yi" (100 Million)
// Bloomberg raw data is in Millions.
// Millions -> Yi (100 Millions) = divide by 100.
const isMoney = [
'revenue', 'net_income', 'operating_profit',
'total_assets', 'total_liabilities', 'equity', 'shareholders_equity', 'total_equity',
'cash', 'inventory', 'accounts&notes_receivable', 'receivables', 'accounts_payable', 'prepaid', 'prepayment',
'property_plant&equipment', 'fixed_assets', 'lt_investment', 'long_term_investments',
'st_debt', 'short_term_debt', 'lt_debt', 'long_term_debt', 'st_defer_rev', 'deferred_revenue', 'goodwill',
'cash_from_operating', 'capital_expenditure', 'capex', 'free_cash_flow', 'fcf',
'dividends_paid', 'repurchase',
'sg&a', 'selling&marketing', 'general&admin', 'r&d', 'depreciation',
'market_cap'
].includes(lowerCol)
if (isMoney) {
numVal = numVal / 100
}
if (isRatio) {
return numVal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}) + '%'
}
// 如果大于 1000不保留小数否则保留两位小数
const isLarge = Math.abs(numVal) > 1000
return numVal.toLocaleString('en-US', {
minimumFractionDigits: isLarge ? 0 : 2,
maximumFractionDigits: isLarge ? 0 : 2
})
}
return String(value)
}