公司介绍 财务数据

This commit is contained in:
Sakurasan
2026-07-06 19:44:40 +08:00
parent cffdea5407
commit 83c88ee945
7 changed files with 1498 additions and 5 deletions
+55
View File
@@ -119,3 +119,58 @@ class ShareLink(BaseModel):
short_code: str short_code: str
created_at: str created_at: str
expires_at: Optional[str] = None expires_at: Optional[str] = None
class CompanyProfile(BaseModel):
"""公司概况"""
code: str
name: str
fullName: str
englishName: str
boardType: str
industry: str
exchange: str
industryDetail: str
chairman: str
generalManager: str
secretary: str
phone: str
email: str
fax: str
website: str
officeAddress: str
registeredAddress: str
region: str
postalCode: str
registeredCapital: str
unifiedCreditCode: str
employees: str
managementCount: str
lawFirm: str
auditor: str
businessScope: str
companyProfile: str
establishmentDate: str
listingDate: str
listingPrice: str
issuePriceEarningsRatio: str
issueAmount: str
netProceeds: str
issueMethod: str
class FinancialReportItem(BaseModel):
"""单期财务报告"""
reportDate: str
reportType: str
reportDateName: str
noticeDate: str
indicators: dict
class FinancialDataResponse(BaseModel):
"""财务数据响应"""
code: str
name: str
count: int
data: list[FinancialReportItem]
+68 -1
View File
@@ -5,7 +5,7 @@ import re
import math import math
from services import tencent, sina, eastmoney from services import tencent, sina, eastmoney
from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary from models import StockSearchResult, StockQuote, KLineData, FundFlowData, FundFlowSummary, CompanyProfile, FinancialReportItem, FinancialDataResponse
router = APIRouter() router = APIRouter()
@@ -71,6 +71,73 @@ async def stock_history(
raise HTTPException(status_code=404, detail="未获取到K线数据") raise HTTPException(status_code=404, detail="未获取到K线数据")
@router.get("/profile", response_model=dict, summary="公司概况")
async def company_profile(code: str = Query(..., description="6位股票代码")):
"""获取东方财富F10公司概况数据"""
if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
data = await eastmoney.fetch_company_profile(code)
if not data:
raise HTTPException(status_code=404, detail="未获取到公司概况数据")
return {"data": data}
@router.get("/financial", response_model=dict, summary="财务分析数据")
async def financial_data(
code: str = Query(..., description="6位股票代码"),
years: int = Query(5, description="获取年数", ge=1, le=10),
):
"""获取东方财富财务分析指标数据(每股指标、盈利能力、成长能力、偿债能力等)"""
if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
data = await eastmoney.fetch_financial_data_main(code, years)
# 获取股票名称
name = code
if data:
quote = await tencent.fetch_quote(code)
if quote:
name = quote.get("name", code)
if not data:
raise HTTPException(status_code=404, detail="未获取到财务数据")
return {
"data": data,
"count": len(data),
"code": code,
"name": name,
}
@router.get("/business-segments", response_model=dict, summary="主营构成/收入构成")
async def business_segments(
code: str = Query(..., description="6位股票代码"),
by_type: str = Query("product", description="分类方式: product=按产品, industry=按行业, region=按地区"),
years: int = Query(3, description="获取年数", ge=1, le=30),
):
"""获取东方财富经营分析 - 主营构成数据(收入构成、成本构成、利润构成、毛利率)"""
if not re.match(r"^\d{6}$", code):
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
if by_type not in ("product", "industry", "region"):
raise HTTPException(status_code=400, detail="by_type 需为 product/industry/region")
data = await eastmoney.fetch_business_segments(code, by_type, years)
if not data:
raise HTTPException(status_code=404, detail="未获取到主营构成数据")
quote = await tencent.fetch_quote(code)
name = quote.get("name", code) if quote else code
return {
"data": data,
"count": len(data),
"code": code,
"name": name,
"byType": by_type,
}
@router.get("/fund-flow", summary="资金流向") @router.get("/fund-flow", summary="资金流向")
async def stock_fund_flow( async def stock_fund_flow(
code: str = Query(..., description="6位股票代码"), code: str = Query(..., description="6位股票代码"),
+370
View File
@@ -363,6 +363,376 @@ async def fetch_sector_list_direct(sector_type: str) -> list[dict]:
return [] return []
# ---- 公司概况 ----
_F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
_DATACENTER_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
def _code_to_f10(code: str) -> str:
"""6位代码 → 东方财富 F10 格式 (SH600519 / SZ000001)"""
prefix = code[0]
mkt = _F10_MARKET_MAP.get(prefix, "SH")
return f"{mkt}{code}"
def _code_to_secucode(code: str) -> str:
"""6位代码 → 数据中心格式 (600519.SH / 000001.SZ)"""
prefix = code[0]
mkt = _DATACENTER_MARKET_MAP.get(prefix, "SH")
return f"{code}.{mkt}"
async def fetch_company_profile(code: str) -> Optional[dict]:
"""东方财富 F10 - 公司概况(缓存24小时)"""
cache_key = f"company_profile:{code}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
em_code = _code_to_f10(code)
url = (
f"https://emweb.securities.eastmoney.com/PC_HSF10"
f"/CompanySurvey/CompanySurveyAjax?code={em_code}"
)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": (
f"https://emweb.securities.eastmoney.com/PC_HSF10"
f"/CompanySurvey/Index?type=web&code={em_code}"
),
}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, headers=headers, timeout=15)
if resp.status_code != 200:
return None
raw = resp.json()
except Exception as e:
print(f"[eastmoney] fetch_company_profile error: {e}")
return None
jbzl = raw.get("jbzl", {})
fxxg = raw.get("fxxg", {})
profile = {
"code": code,
"name": jbzl.get("agjc", ""),
"fullName": jbzl.get("gsmc", ""),
"englishName": jbzl.get("ywmc", ""),
"boardType": jbzl.get("zqlb", ""),
"industry": jbzl.get("sshy", ""),
"exchange": jbzl.get("ssjys", ""),
"industryDetail": jbzl.get("sszjhhy", ""),
# 高管
"chairman": jbzl.get("dsz", ""),
"generalManager": jbzl.get("zjl", ""),
"secretary": jbzl.get("dm", ""),
# 联系方式
"phone": jbzl.get("lxdh", ""),
"email": jbzl.get("dzxx", ""),
"fax": jbzl.get("cz", ""),
"website": jbzl.get("gswz", ""),
# 地址
"officeAddress": jbzl.get("bgdz", ""),
"registeredAddress": jbzl.get("zcdz", ""),
"region": jbzl.get("qy", ""),
"postalCode": jbzl.get("yzbm", ""),
# 注册信息
"registeredCapital": jbzl.get("zczb", ""),
"unifiedCreditCode": jbzl.get("gsdj", ""),
"employees": jbzl.get("gyrs", 0),
"managementCount": jbzl.get("glryrs", 0),
# 中介机构
"lawFirm": jbzl.get("lssws", ""),
"auditor": jbzl.get("kjssws", ""),
# 公司简介&业务
"businessScope": jbzl.get("jyfw", ""),
"companyProfile": jbzl.get("gsjj", ""),
# 发行信息
"establishmentDate": fxxg.get("clrq", ""),
"listingDate": fxxg.get("ssrq", ""),
"listingPrice": fxxg.get("mgfxj", ""),
"issuePriceEarningsRatio": fxxg.get("fxsyl", ""),
"issueAmount": fxxg.get("fxl", ""),
"netProceeds": fxxg.get("mjzjje", ""),
"issueMethod": fxxg.get("fxfs", ""),
}
set_cache(cache_key, json.dumps(profile, ensure_ascii=False), ttl_hours=24)
return profile
# ---- 财务数据 ----
# 财务指标关键字段映射(英文 → 中文)
FINANCIAL_FIELD_LABELS: dict[str, str] = {
"EPSJB": "基本每股收益",
"EPSKCJB": "扣非每股收益",
"BPS": "每股净资产",
"MGWFPLR": "每股未分配利润",
"MGJYXJJE": "每股经营现金流",
"TOTALOPERATEREVE": "营业总收入",
"MLR": "毛利润",
"PARENTNETPROFIT": "归母净利润",
"KCFJCXSYJLR": "扣非净利润",
"TOTALOPERATEREVETZ": "营收同比增长",
"PARENTNETPROFITTZ": "净利润同比增长",
"KCFJCXSYJLRTZ": "扣非净利润同比增长",
"ROEJQ": "加权净资产收益率",
"ROEKCJQ": "扣非ROE",
"ZZCJLL": "总资产净利率",
"XSJLL": "销售净利率",
"XSMLL": "销售毛利率",
"LD": "流动比率",
"SD": "速动比率",
"ZCFZL": "资产负债率",
"XJLLB": "现金流比率",
"YSZKZZTS": "应收账款周转天数",
"CHZZTS": "存货周转天数",
"ZZCZZTS": "总资产周转天数",
"TOAZZL": "总资产周转率",
"CHZZL": "存货周转率",
"ROIC": "投入资本回报率",
"QYCS": "权益乘数",
"CQBL": "产权比率",
"TAXRATE": "实际税率",
"TOTAL_SHARE": "总股本",
"A_FREE_SHARE": "流通A股",
"DJD_TOI_YOY": "单季营收同比",
"DJD_DPNP_YOY": "单季净利润同比",
"EPSJBTZ": "每股收益同比增长",
"BPSTZ": "每股净资产同比增长",
"ROEJQTZ": "ROE同比增长",
# 利润表补充
"OPERATE_PROFIT_PK": "营业利润",
"PER_TOI": "每股营业收入",
"PER_EBIT": "每股EBIT",
# 资产负债表补充
"TOTAL_ASSETS_PK": "总资产",
"LIABILITY": "总负债",
"TOTAL_EQUITY_PK": "所有者权益",
"MGZBGJ": "每股资本公积",
"CA_TA": "流动资产占比",
"NCA_TA": "非流动资产占比",
"CL_TL": "流动负债占比",
# 现金流量表补充
"NETCASH_OPERATE_PK": "经营活动现金流净额",
"NETCASH_INVEST_PK": "投资活动现金流净额",
"NETCASH_FINANCE_PK": "筹资活动现金流净额",
"JYXJLYYSR": "经营现金流占营收比",
"XSJXLYYSR": "销售现金流占营收比",
"FCFF_FORWARD": "企业自由现金流",
# 营运能力补充
"OPERATE_CYCLE": "营业周期",
"CURRENT_ASSET_TR": "流动资产周转率",
"FIXED_ASSET_TR": "固定资产周转率",
"ACCOUNTS_PAYABLE_TR": "应付账款周转率",
"PREPAID_ACCOUNTS_RATIO": "预收账款占营收比",
"PAYABLE_TDAYS": "应付账款周转天数",
}
# 小数比率字段(后端返回小数,前端需显示为百分比)
_DECIMAL_RATIO_FIELDS = {
"JYXJLYYSR", "XSJXLYYSR", "PREPAID_ACCOUNTS_RATIO",
"NCO_OP", "NCO_FIXED", "SALE_ER_PK",
}
def _parse_financial_record(record: dict) -> dict:
"""将原始财务记录转为整洁的输出"""
result = {}
for field, label in FINANCIAL_FIELD_LABELS.items():
val = record.get(field)
if val is not None:
if isinstance(val, (int, float)):
# 小数比率字段转为百分比
if field in _DECIMAL_RATIO_FIELDS and abs(val) < 1:
result[label] = round(val * 100, 2)
result[label + "(%)"] = True
elif abs(val) > 1_0000_0000:
result[label] = round(val / 1_0000_0000, 2)
result[label + "(亿)"] = True
elif abs(val) > 1_0000:
result[label] = round(val / 1_0000, 2)
result[label + "(万)"] = True
else:
result[label] = round(val, 2)
else:
result[label] = val
return result
async def fetch_financial_data_main(code: str, years: int = 5) -> Optional[list[dict]]:
"""东方财富数据中心 - 主要财务指标(缓存6小时)"""
cache_key = f"financial_main:{code}:{years}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
secucode = _code_to_secucode(code)
url = "https://datacenter.eastmoney.com/securities/api/data/v1/get"
params = {
"reportName": "RPT_F10_FINANCE_MAINFINADATA",
"columns": "ALL",
"filter": f'(SECUCODE="{secucode}")',
"pageNumber": 1,
"pageSize": max(years * 4, 4), # 每年最多4份报告
"sortTypes": -1,
"sortColumns": "REPORT_DATE",
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://datacenter.eastmoney.com/",
}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, params=params, headers=headers, timeout=15)
if resp.status_code != 200:
return None
body = resp.json()
if not body.get("result") or not body["result"].get("data"):
return None
records = body["result"]["data"]
except Exception as e:
print(f"[eastmoney] fetch_financial_data error: {e}")
return None
items = []
for r in records:
items.append({
"reportDate": r.get("REPORT_DATE", ""),
"reportType": r.get("REPORT_TYPE", ""),
"reportDateName": r.get("REPORT_DATE_NAME", ""),
"noticeDate": r.get("NOTICE_DATE", ""),
"indicators": _parse_financial_record(r),
})
set_cache(cache_key, json.dumps(items, ensure_ascii=False), ttl_hours=6)
return items
# ---- 经营分析(主营构成/收入构成)----
_SEGMENT_TYPE_MAP = {
"industry": "1", # 按行业
"product": "2", # 按产品
"region": "3", # 按地区
}
async def fetch_business_segments(code: str, by_type: str = "product", years: int = 3) -> Optional[list[dict]]:
"""东方财富经营分析 - 主营构成(按产品/行业/地区),缓存6小时
Args:
code: 6位股票代码
by_type: "product"|"industry"|"region"
years: 返回最近N年的数据
"""
seg_type = _SEGMENT_TYPE_MAP.get(by_type, "2")
cache_key = f"business_segments:{code}:{by_type}:{years}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
secucode = _code_to_secucode(code)
url = "https://datacenter.eastmoney.com/securities/api/data/v1/get"
params = {
"reportName": "RPT_F10_FN_SEGMENTSV",
"columns": (
"SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,MAINOP_TYPE,ITEM_NAME,"
"ITEM_CODE,ITEM_PARENT_CODE,MAIN_BUSINESS_INCOME,MAIN_BUSINESS_RPOFIT,"
"GROSS_RPOFIT_RATIO,MBI_RATIO,MBR_RATIO,REPORT_DATE,REPORT_NAME,"
"REPORT_DATE_CODE,MAIN_BUSINESS_COST,MBC_RATIO"
),
"filter": f'(SECUCODE="{secucode}")(MAINOP_TYPE="{seg_type}")(ITEM_PARENT_CODE="999999")',
"pageNumber": 1,
"pageSize": 200,
"sortTypes": "1,1",
"sortColumns": "MAINOP_TYPE,RANK3",
}
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Referer": "https://datacenter.eastmoney.com/",
}
async with httpx.AsyncClient() as client:
try:
resp = await client.get(url, params=params, headers=headers, timeout=15)
if resp.status_code != 200:
return None
body = resp.json()
if not body.get("result") or not body["result"].get("data"):
return None
records = body["result"]["data"]
except Exception as e:
print(f"[eastmoney] fetch_business_segments error: {e}")
return None
# 按报告期分组
from collections import OrderedDict
period_map: dict[str, list[dict]] = OrderedDict()
for r in records:
report_name = r.get("REPORT_NAME", "")
report_date = r.get("REPORT_DATE", "")
item_name = r.get("ITEM_NAME", "")
income = r.get("MAIN_BUSINESS_INCOME")
cost = r.get("MAIN_BUSINESS_COST")
profit = r.get("MAIN_BUSINESS_RPOFIT")
gross_ratio = r.get("GROSS_RPOFIT_RATIO")
mbi_ratio = r.get("MBI_RATIO")
mbr_ratio = r.get("MBR_RATIO")
mbc_ratio = r.get("MBC_RATIO")
entry = {
"itemName": item_name,
"income": income,
"cost": cost,
"profit": profit,
"grossRatio": round(gross_ratio * 100, 2) if gross_ratio is not None else None,
"incomeRatio": round(mbi_ratio * 100, 2) if mbi_ratio is not None else None,
"profitRatio": round(mbr_ratio * 100, 2) if mbr_ratio is not None else None,
"costRatio": round(mbc_ratio * 100, 2) if mbc_ratio is not None else None,
}
if report_name not in period_map:
period_map[report_name] = []
period_map[report_name].append(entry)
# 补充 profitRatio:若 API 未返回,用该期总利润推算各项目利润占比
for pname, items in period_map.items():
total_profit = sum(it["profit"] for it in items if it["profit"] is not None)
if total_profit != 0:
for it in items:
if it["profitRatio"] is None and it["profit"] is not None:
it["profitRatio"] = round(it["profit"] / total_profit * 100, 2)
# 取最近N年
all_periods = list(period_map.keys())
all_periods.sort(reverse=True)
selected = all_periods[:max(years * 4, 4)]
result = []
for p in selected:
result.append({
"reportName": p,
"items": period_map[p],
})
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=6)
return result
async def fetch_push2his(code: str, days: int) -> Optional[list]: async def fetch_push2his(code: str, days: int) -> Optional[list]:
"""回退到东方财富 push2his 接口(自动重试一次)""" """回退到东方财富 push2his 接口(自动重试一次)"""
market = get_eastmoney_market(code) market = get_eastmoney_market(code)
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+829
View File
@@ -0,0 +1,829 @@
import { Fragment, useState, useMemo } from "react";
import { Card, CardHeader } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
ComposedChart,
Bar,
Line,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Legend,
Brush,
} from "recharts";
import { fetchBusinessSegments, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
interface StockProfileTabsProps {
code: string;
profileTab: string;
setProfileTab: (v: string) => void;
companyProfile: CompanyProfile | null;
businessSegments: BusinessSegmentsResponse | null;
financialData: FinancialDataResponse | null;
setBusinessSegments: (d: BusinessSegmentsResponse | null) => void;
pieType: "income" | "cost" | "profit";
setPieType: (v: "income" | "cost" | "profit") => void;
}
const COLORS = ["#4672F6", "#00B7F4", "#EDBE46", "#FF6D6D", "#D580FF", "#8976FF", "#F67C40", "#5CC9A0", "#EC4899", "#F59E0B"];
function fmt(v: unknown): string {
if (v == null) return "-";
const n = typeof v === "number" ? v : Number(v);
if (isNaN(n)) return "-";
const abs = Math.abs(n);
const sign = n < 0 ? "-" : "";
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(3)}亿`;
if (abs >= 1e4) {
const val = abs / 1e4;
return `${sign}${val % 1 === 0 ? val.toFixed(0) : val.toFixed(2)}`;
}
return n.toFixed(2);
}
function pct(v: unknown): string {
if (v == null) return "-";
const n = typeof v === "number" ? v : Number(v);
if (isNaN(n)) return "-";
return `${n.toFixed(2)}%`;
}
function reportLabel(raw: string): string {
const m = raw.match(/^(\d{4})/);
const y = m ? m[1] : raw;
if (raw.includes("12-31") || raw.includes("年报")) return `${y}年报`;
if (raw.includes("06-30") || raw.includes("中报")) return `${y}中报`;
return raw;
}
function reportYear(raw: string): number {
const m = raw.match(/^(\d{4})/);
return m ? parseInt(m[1]) : 0;
}
function isAnnual(raw: string): boolean {
return raw.includes("12-31") || raw.includes("年报");
}
function getPeriodType(r: { reportDate: string; reportDateName: string }): string {
if (r.reportDate.includes("12-31") || r.reportDateName.includes("年报")) return "annual";
if (r.reportDate.includes("09-30") || r.reportDateName.includes("三季报")) return "q3";
if (r.reportDate.includes("06-30") || r.reportDateName.includes("中报")) return "h1";
if (r.reportDate.includes("03-31") || r.reportDateName.includes("一季报")) return "q1";
return "unknown";
}
function periodFilterLabel(mode: "reportPeriod" | "singleQuarter", filter: string): string {
if (filter === "all") return "全部";
if (mode === "reportPeriod") {
return { annual: "年报", q3: "三季报", h1: "中报", q1: "一季报" }[filter] || filter;
}
return { annual: "四季度", q3: "三季度", h1: "二季度", q1: "一季度" }[filter] || filter;
}
export default function StockProfileTabs({
code,
profileTab,
setProfileTab,
companyProfile,
businessSegments,
financialData,
setBusinessSegments,
pieType,
setPieType,
}: StockProfileTabsProps) {
const [businessView, setBusinessView] = useState<"chart" | "table">("chart");
const [selectedPeriodIdx, setSelectedPeriodIdx] = useState(0);
const [segType, setSegType] = useState<"product" | "industry" | "region">("product");
const [barFilter, setBarFilter] = useState<"annual" | "semi">("annual");
const [barMetric, setBarMetric] = useState<"income" | "profit">("income");
const [chartMetric, setChartMetric] = useState<"income" | "cost" | "profit">(pieType);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const [financialSubTab, setFinancialSubTab] = useState("main");
const [statementTab, setStatementTab] = useState("balance");
const [statementPeriodMode, setStatementPeriodMode] = useState<"reportPeriod" | "singleQuarter">("reportPeriod");
const [statementPeriodFilter, setStatementPeriodFilter] = useState<string>("all");
const [statementView, setStatementView] = useState<"table" | "chart">("table");
const handleSegType = (t: "product" | "industry" | "region") => {
setSegType(t);
setSelectedPeriodIdx(0);
setSelectedItem(null);
fetchBusinessSegments(code, t, 30).then(r => r && setBusinessSegments(r));
};
const periods = businessSegments?.data || [];
const cur = periods[selectedPeriodIdx];
const totalIncome = cur ? cur.items.reduce((s, i) => s + (i.income || 0), 0) : 0;
const totalProfit = cur ? cur.items.reduce((s, i) => s + (i.profit || 0), 0) : 0;
const pieData = useMemo(() => {
if (!cur) return [];
return cur.items.filter(i => i[chartMetric] != null).map(i => ({
name: i.itemName,
value: i[chartMetric]!,
ratio: chartMetric === "income" ? i.incomeRatio : chartMetric === "cost" ? i.costRatio : i.profitRatio,
}));
}, [cur, chartMetric]);
// Stacked bar data
const stackedData = useMemo(() => {
if (!periods.length) return [];
const filtered = periods.filter(p => {
const a = isAnnual(p.reportName);
return barFilter === "annual" ? a : !a;
}).sort((a, b) => reportYear(a.reportName) - reportYear(b.reportName) || a.reportName.localeCompare(b.reportName));
// Top items across filtered periods
const totals = new Map<string, number>();
for (const p of filtered) {
for (const item of p.items) {
const val = item[barMetric] || 0;
totals.set(item.itemName, (totals.get(item.itemName) || 0) + Math.abs(val));
}
}
const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 9).map(([n]) => n);
return filtered.map(p => {
const row: Record<string, any> = { period: reportLabel(p.reportName), _raw: p.reportName };
if (selectedItem) {
const item = p.items.find(i => i.itemName === selectedItem);
row[selectedItem] = item ? (item[barMetric] || 0) : 0;
row._ratio = item ? (barMetric === "income" ? item.incomeRatio : item.profitRatio) : null;
} else {
let other = 0;
for (const item of p.items) {
const val = item[barMetric] || 0;
if (top.includes(item.itemName)) row[item.itemName] = val;
else other += val;
}
if (other !== 0) row["其他"] = other;
}
return row;
});
}, [periods, barFilter, barMetric, selectedItem]);
const stackedKeys = useMemo(() => {
const keys = new Set<string>();
for (const row of stackedData) {
for (const k of Object.keys(row)) {
if (!k.startsWith("_") && k !== "period") keys.add(k);
}
}
return [...keys];
}, [stackedData]);
// 统一颜色映射:所有项目中同一个名称使用同一种颜色
const itemColorMap = useMemo(() => {
const map = new Map<string, string>();
const allItems = new Set<string>();
for (const p of periods) {
for (const item of p.items) {
allItems.add(item.itemName);
}
}
[...allItems].sort().forEach((name, i) => {
map.set(name, COLORS[i % COLORS.length]);
});
return map;
}, [periods]);
const statementData = useMemo(() => {
if (!financialData) return [];
let data = [...financialData.data];
if (statementPeriodMode === "singleQuarter") {
const byYear = new Map<number, typeof data>();
for (const r of data) {
const y = reportYear(r.reportDate);
if (!byYear.has(y)) byYear.set(y, []);
byYear.get(y)!.push(r);
}
const result: typeof data = [];
const order = ["q1", "h1", "q3", "annual"];
for (const [, recs] of byYear) {
recs.sort((a, b) => order.indexOf(getPeriodType(a)) - order.indexOf(getPeriodType(b)));
for (let i = 0; i < recs.length; i++) {
const r = recs[i];
const pt = getPeriodType(r);
const prev = i > 0 ? recs[i - 1] : null;
const indicators: Record<string, any> = {};
for (const [key, val] of Object.entries(r.indicators)) {
if (key.endsWith("(亿)") || key.endsWith("(万)")) continue;
const prevVal = prev?.indicators[key];
if (statementTab !== "balance" && typeof val === "number" && typeof prevVal === "number" && prev) {
indicators[key] = val - prevVal;
} else if (val !== undefined) {
indicators[key] = val;
}
}
const qLabels: Record<string, string> = { q1: "一季度", h1: "二季度", q3: "三季度", annual: "四季度" };
result.push({ ...r, indicators, reportDateName: `${reportYear(r.reportDate)}${qLabels[pt]}` });
}
}
const orderIdx = (r: any) => order.indexOf(getPeriodType(r));
data = result.sort((a, b) => {
const yA = reportYear(a.reportDate), yB = reportYear(b.reportDate);
return yA !== yB ? yB - yA : orderIdx(b) - orderIdx(a);
});
}
if (statementPeriodFilter !== "all") {
data = data.filter(r => getPeriodType(r) === statementPeriodFilter);
}
return data;
}, [financialData, statementPeriodMode, statementPeriodFilter, statementTab]);
const statementChartData = useMemo(() => {
return statementData.slice(0, 6).reverse().map(r => ({
name: r.reportDateName,
...r.indicators,
}));
}, [statementData]);
const statementChartKeys: Record<string, string[]> = {
balance: ["总资产", "总负债", "所有者权益"],
income: ["营业总收入", "归母净利润"],
cashflow: ["经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额"],
};
const statementContent = useMemo(() => {
if (statementView === "chart" && statementChartData.length > 0) {
const chartKeys = statementChartKeys[statementTab] || [];
return (
<div className="p-3">
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={statementChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.3} />
<XAxis dataKey="name" fontSize={11} stroke="var(--muted-foreground)" />
<YAxis fontSize={11} stroke="var(--muted-foreground)" tickFormatter={(v: number) => (v / 1e8).toFixed(1) + "亿"} />
<Tooltip contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "13px" }}
formatter={(value: number, name: string) => [fmt(value), name]}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
{chartKeys.map((k, i) =>
k === "归母净利润"
? <Line key={k} type="monotone" dataKey={k} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={{ r: 3 }} />
: <Bar key={k} dataKey={k} fill={COLORS[i % COLORS.length]} radius={[4, 4, 0, 0]} />
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
const sections: Record<string, string[]> = {
balance: ["总资产", "总负债", "所有者权益", "流动资产占比", "非流动资产占比", "流动负债占比"],
income: ["营业总收入", "毛利润", "营业利润", "归母净利润", "扣非净利润"],
cashflow: ["经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额", "企业自由现金流"],
};
const keys = sections[statementTab] || [];
const isPct = (k: string) => ["占比"].some(s => k.includes(s));
const moneyKeys = new Set(["营业总收入", "毛利润", "营业利润", "归母净利润", "扣非净利润", "总资产", "总负债", "所有者权益", "经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额", "企业自由现金流"]);
return (
<table className="w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
{statementData.slice(0, 6).map(r => (
<th key={r.reportDate} className="text-right py-2 px-2 font-medium text-muted-foreground whitespace-nowrap text-xs md:text-sm">{r.reportDateName}</th>
))}
</tr>
</thead>
<tbody>
{keys.map(indicator => {
const vals = statementData.slice(0, 6).map(r => r.indicators[indicator]);
if (!vals.some(v => v != null && v !== "")) return null;
const label = isPct(indicator) ? `${indicator}(%)` : indicator;
return (
<tr key={indicator} className="border-b border-border/30 hover:bg-muted/20">
<td className="py-1.5 pr-2 whitespace-nowrap text-xs md:text-sm">{label}</td>
{statementData.slice(0, 6).map((r, i) => {
const v = r.indicators[indicator];
let displayVal = "-";
if (v != null && v !== "") {
if (moneyKeys.has(indicator)) {
if (statementPeriodMode === "singleQuarter") {
displayVal = fmt(v);
} else {
const cellUnit = r.indicators[indicator + "(亿)"] ? "亿" : r.indicators[indicator + "(万)"] ? "万" : "";
if (cellUnit) {
displayVal = `${typeof v === "number" ? v.toLocaleString() : v}${cellUnit}`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
}
} else if (isPct(indicator) && typeof v === "number") {
displayVal = `${v}%`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
}
return <td key={i} className="text-right py-1.5 px-2 font-mono text-xs md:text-sm">{displayVal}</td>;
})}
</tr>
);
})}
</tbody>
</table>
);
}, [statementView, statementChartData, statementTab, statementData, statementPeriodMode]);
return (
<Card className="mt-4 md:mt-6 shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<Tabs value={profileTab} onValueChange={setProfileTab}>
<TabsList className="w-full justify-start overflow-x-auto">
<TabsTrigger value="profile" className="text-xs md:text-sm"></TabsTrigger>
<TabsTrigger value="business" className="text-xs md:text-sm"></TabsTrigger>
<TabsTrigger value="financial" className="text-xs md:text-sm"></TabsTrigger>
</TabsList>
<TabsContent value="profile" className="mt-4">
{companyProfile ? (
<div className="space-y-3 text-xs md:text-sm">
<div>
<h4 className="font-semibold text-sm md:text-base mb-1"></h4>
<p className="text-muted-foreground leading-relaxed">{companyProfile.companyProfile}</p>
</div>
<div>
<h4 className="font-semibold text-sm md:text-base mb-1"></h4>
<p className="text-muted-foreground leading-relaxed">{companyProfile.businessScope}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mt-3">
{[
["所属行业", companyProfile.industry], ["上市板块", companyProfile.boardType],
["交易所", companyProfile.exchange], ["成立日期", companyProfile.establishmentDate],
["上市日期", companyProfile.listingDate], ["董事长", companyProfile.chairman],
["总经理", companyProfile.generalManager], ["董秘", companyProfile.secretary],
["注册资本", companyProfile.registeredCapital], ["员工人数", companyProfile.employees],
["所属地区", companyProfile.region], ["发行价格", companyProfile.listingPrice ? `¥${companyProfile.listingPrice}` : ""],
].map(([label, value]) => (
<div key={String(label)} className="flex flex-col p-2 rounded bg-muted/30">
<span className="text-muted-foreground text-[10px] md:text-xs">{label}</span>
<span className="font-medium text-xs md:text-sm">{value || "-"}</span>
</div>
))}
</div>
<details className="mt-2">
<summary className="cursor-pointer text-muted-foreground text-xs hover:text-foreground"></summary>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-2 text-xs md:text-sm text-muted-foreground">
{companyProfile.phone && <div>: {companyProfile.phone}</div>}
{companyProfile.fax && <div>: {companyProfile.fax}</div>}
{companyProfile.email && <div>: {companyProfile.email}</div>}
{companyProfile.website && <div>: {companyProfile.website}</div>}
{companyProfile.officeAddress && <div>: {companyProfile.officeAddress}</div>}
{companyProfile.registeredAddress && <div>: {companyProfile.registeredAddress}</div>}
</div>
</details>
</div>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>...
</div>
)}
</TabsContent>
<TabsContent value="business" className="mt-4">
{businessSegments ? (
<div className="space-y-4">
{/* Title + 表格/图表 toggle */}
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-base md:text-lg font-bold"></h3>
<div className="flex gap-1 text-sm">
<button onClick={() => setBusinessView("table")}
className={`px-3 py-1 rounded ${businessView === "table" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
></button>
<button onClick={() => setBusinessView("chart")}
className={`px-3 py-1 rounded ${businessView === "chart" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
></button>
</div>
</div>
{businessView === "chart" ? (
<>
{/* 按产品 | 按行业 | 按地区 + 报告期 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex gap-1 text-sm">
{(["product", "industry", "region"] as const).map(t => (
<button key={t} onClick={() => handleSegType(t)}
className={`px-3 py-1 rounded text-xs md:text-sm border transition-colors ${
segType === t ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-border hover:border-primary"
}`}
>{t === "product" ? "按产品" : t === "industry" ? "按行业" : "按地区"}</button>
))}
</div>
{periods.length > 0 && (
<div className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">:</span>
<select
value={selectedPeriodIdx}
onChange={(e) => setSelectedPeriodIdx(Number(e.target.value))}
className="bg-background border border-border rounded px-1.5 py-0.5 text-foreground text-xs"
>
{periods.map((p, idx) => (
<option key={p.reportName} value={idx}>{p.reportName}</option>
))}
</select>
</div>
)}
</div>
{cur ? (
<>
{/* Donut chart + Detail table */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Donut chart */}
<div className="bg-muted/20 rounded-lg p-3 flex flex-col items-center">
<h4 className="font-semibold text-xs md:text-sm mb-2">
{chartMetric === "income" ? "主营收入" : chartMetric === "cost" ? "主营成本" : "主营利润"}
</h4>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" innerRadius={50} outerRadius={90}
paddingAngle={2} dataKey="value"
>
{pieData.map(item => <Cell key={item.name} fill={itemColorMap.get(item.name) || COLORS[0]} />)}
</Pie>
<Tooltip contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "12px" }}
formatter={(value: number, _n: string, props: any) => {
const item = props.payload;
return [`${fmt(value)}${item?.ratio != null ? ` (${pct(item.ratio)})` : ""}`, item?.name];
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }}
formatter={(v: string) => <span className="text-muted-foreground">{v}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Detail table */}
<div className="bg-muted/20 rounded-lg p-3 overflow-x-auto">
<p className="text-xs mb-2 text-muted-foreground">
{cur.reportName} <span className="text-red-500 font-medium">{fmt(totalIncome)}</span>
&nbsp; <span className="text-red-500 font-medium">{fmt(totalProfit)}</span>
</p>
<table className="w-full text-[10px] md:text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left py-1 pr-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium">
<span onClick={() => setChartMetric("income")}
className={`cursor-pointer ${chartMetric === "income" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}
></span>
</th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium">
<span onClick={() => setChartMetric("cost")}
className={`cursor-pointer ${chartMetric === "cost" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}
></span>
</th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium">
<span onClick={() => setChartMetric("profit")}
className={`cursor-pointer ${chartMetric === "profit" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}
></span>
</th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{cur.items.map((item, idx) => (
<tr key={item.itemName}
onClick={() => setSelectedItem(selectedItem === item.itemName ? null : item.itemName)}
className={`border-b border-border/30 hover:bg-muted/30 cursor-pointer ${selectedItem === item.itemName ? "bg-primary/5" : ""}`}
>
<td className="py-1 pr-1 whitespace-nowrap">
<span className="inline-block w-2 h-2 rounded-full mr-1 align-middle" style={{ backgroundColor: itemColorMap.get(item.itemName) || COLORS[0] }}></span>
{item.itemName}
</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.income)}</td>
<td className="text-right py-1 px-1">{pct(item.incomeRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.cost)}</td>
<td className="text-right py-1 px-1">{pct(item.costRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.profit)}</td>
<td className="text-right py-1 px-1">{pct(item.profitRatio)}</td>
<td className={`text-right py-1 px-1 font-medium ${
item.grossRatio != null ? item.grossRatio >= 40 ? "text-red-500" : item.grossRatio >= 20 ? "text-orange-500" : "text-green-500" : ""
}`}>{pct(item.grossRatio)}</td>
</tr>
))}
</tbody>
</table>
<p className="text-[10px] text-muted-foreground mt-2"></p>
</div>
</div>
{/* Bottom: 年报|中报 toggle + Stacked bar chart */}
<div className="bg-muted/20 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex gap-1 text-xs">
{(["annual", "semi"] as const).map(f => (
<button key={f} onClick={() => setBarFilter(f)}
className={`px-2.5 py-1 rounded border transition-colors ${
barFilter === f ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-border"
}`}
>{f === "annual" ? "年报" : "中报"}</button>
))}
</div>
<div className="flex gap-3 text-xs items-center">
<div className="flex gap-1">
{(["income", "profit"] as const).map(m => (
<button key={m} onClick={() => setBarMetric(m)}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
barMetric === m ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-border"
}`}
>{m === "income" ? "主营收入" : "主营利润"}</button>
))}
</div>
<span className="text-[10px] text-muted-foreground"></span>
</div>
</div>
{stackedData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={stackedData} barSize={28} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.3} />
<XAxis dataKey="period" fontSize={11} stroke="var(--muted-foreground)" />
<YAxis yAxisId="left" fontSize={11} stroke="var(--muted-foreground)" tickFormatter={v => `${(v / 1e8).toFixed(1)}亿`} />
{selectedItem && (
<YAxis yAxisId="right" orientation="right" fontSize={11} stroke="#F97316"
domain={['auto', 'auto']} tickFormatter={v => `${v}%`} width={40} />
)}
<Tooltip contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "13px" }}
formatter={(value: number, name: string, props: any) => {
if (props.dataKey === "_ratio" || name === "_ratio") {
return [`${Number(value).toFixed(2)}%`, "利润比例"];
}
if (props.payload?._ratio != null) {
return [`${fmt(value)} (${pct(props.payload._ratio)})`, name];
}
return [fmt(value), name];
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
{stackedKeys.map(k => (
<Bar key={k} dataKey={k} stackId="a" yAxisId="left"
fill={selectedItem ? "#38BDF8" : (itemColorMap.get(k) || COLORS[0])} />
))}
{selectedItem && (
<Line yAxisId="right" type="monotone" dataKey="_ratio" stroke="#F97316"
strokeWidth={2} dot={{ r: 3 }} name="利润比例" />
)}
<Brush
dataKey="period"
startIndex={Math.max(0, stackedData.length - 5)}
endIndex={stackedData.length - 1}
height={20}
stroke="var(--primary)"
fill="var(--muted)"
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-40 text-muted-foreground text-sm"></div>
)}
</div>
</>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm"></div>
)}
</>
) : (
/* Table view */
<div className="overflow-x-auto">
{periods.length > 0 ? (
<table className="w-full text-[10px] md:text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{periods.map(period => (
<Fragment key={period.reportName}>
<tr className="bg-muted/20">
<td colSpan={9} className="py-1.5 font-semibold text-[10px] text-muted-foreground">{period.reportName}</td>
</tr>
{period.items.map(item => (
<tr key={`${period.reportName}-${item.itemName}`} className="border-b border-border/30 hover:bg-muted/20">
<td></td>
<td className="py-1 pr-2">{item.itemName}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.income)}</td>
<td className="text-right py-1 px-1">{pct(item.incomeRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.cost)}</td>
<td className="text-right py-1 px-1">{pct(item.costRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.profit)}</td>
<td className="text-right py-1 px-1">{pct(item.profitRatio)}</td>
<td className={`text-right py-1 px-1 font-medium ${
item.grossRatio != null ? item.grossRatio >= 40 ? "text-red-500" : item.grossRatio >= 20 ? "text-orange-500" : "text-green-500" : ""
}`}>{pct(item.grossRatio)}</td>
</tr>
))}
</Fragment>
))}
</tbody>
</table>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm"></div>
)}
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>...
</div>
)}
</TabsContent>
<TabsContent value="financial" className="mt-4">
{financialData ? (
<Tabs value={financialSubTab} onValueChange={setFinancialSubTab} className="space-y-4">
<TabsList>
<TabsTrigger value="main"></TabsTrigger>
<TabsTrigger value="statements"></TabsTrigger>
</TabsList>
<TabsContent value="main">
<div className="overflow-x-auto rounded-lg border border-border/40">
<table className="w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-border bg-muted/20">
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
{financialData.data.slice(0, 6).map(r => (
<th key={r.reportDate} className="text-right py-2 px-2 font-medium text-muted-foreground whitespace-nowrap text-xs md:text-sm">{r.reportDateName}</th>
))}
</tr>
</thead>
<tbody>
{(() => {
const sections: { name?: string; keys: string[] }[] = [
{ name: "每股指标", keys: ["基本每股收益", "扣非每股收益", "每股净资产", "每股资本公积", "每股未分配利润", "每股经营现金流"] },
{ name: "成长能力", keys: ["营业总收入", "毛利润", "归母净利润", "扣非净利润", "营收同比增长", "净利润同比增长", "扣非净利润同比增长"] },
{ name: "盈利能力", keys: ["加权净资产收益率", "总资产净利率", "销售毛利率", "销售净利率", "投入资本回报率"] },
{ name: "收益质量", keys: ["经营现金流占营收比", "销售现金流占营收比", "实际税率"] },
{ name: "财务风险", keys: ["资产负债率", "流动比率", "速动比率", "权益乘数", "产权比率"] },
{ name: "营运能力", keys: ["存货周转天数", "应收账款周转天数", "总资产周转天数", "应付账款周转天数", "营业周期"] },
];
const keyIndicators = new Set([
"营业总收入", "归母净利润", "扣非净利润",
"营收同比增长", "净利润同比增长",
"销售毛利率", "销售净利率", "加权净资产收益率",
]);
const isPctIndicator = (k: string) =>
["同比增长", "毛利率", "净利率", "收益率", "资本回报率", "占比", "资产负债率"].some(s => k.includes(s));
const moneyIndicators = new Set([
"营业总收入", "毛利润", "归母净利润", "扣非净利润",
]);
return sections.flatMap(section => {
const rows = section.keys
.map(indicator => {
const vals = financialData.data.slice(0, 6).map(r => r.indicators[indicator]);
if (!vals.some(v => v != null && v !== "")) return null;
const isKey = keyIndicators.has(indicator);
const label = isPctIndicator(indicator) ? `${indicator}(%)` : indicator;
return (
<tr key={indicator} className={`border-b border-border/30 hover:bg-muted/20 ${isKey ? "bg-primary/[0.07]" : ""}`}>
<td className={`py-1.5 pr-2 whitespace-nowrap text-xs md:text-sm ${isKey ? "font-semibold" : ""}`}>{label}</td>
{financialData.data.slice(0, 6).map((r, i) => {
const v = r.indicators[indicator];
let colorClass = "";
let displayVal = "-";
if (v != null && v !== "") {
if (moneyIndicators.has(indicator)) {
const cellUnit = r.indicators[indicator + "(亿)"] ? "亿" : r.indicators[indicator + "(万)"] ? "万" : "";
if (cellUnit) {
displayVal = `${typeof v === "number" ? v.toLocaleString() : v}${cellUnit}`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
} else if (isPctIndicator(indicator) && typeof v === "number") {
displayVal = `${v}%`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
}
if (v != null && v !== "" && typeof v === "number") {
if (isPctIndicator(indicator)) {
colorClass = v > 0 ? "text-red-500" : v < 0 ? "text-green-500" : "";
} else if (indicator === "资产负债率") {
colorClass = v > 70 ? "text-red-500" : v < 40 ? "text-green-500" : "";
}
}
return (
<td key={i} className={`text-right py-1.5 px-2 font-mono text-xs md:text-sm ${colorClass}`}>{displayVal}</td>
);
})}
</tr>
);
})
.filter(Boolean);
if (!section.name || rows.length === 0) return rows;
return [
<tr key={`h-${section.name}`} className="bg-muted/50 border-t-2 border-border/60">
<td colSpan={financialData.data.slice(0, 6).length + 1} className="py-2 px-2 text-sm font-bold text-foreground">{section.name}</td>
</tr>,
...rows,
];
});
})()}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="statements">
<div className="overflow-x-auto rounded-lg border border-border/40">
<div className="border-b border-border/60 bg-muted/20">
<div className="flex items-center">
<div className="flex">
{["balance", "income", "cashflow"].map(t => (
<button key={t} onClick={() => setStatementTab(t)}
className={`px-4 py-2 text-xs md:text-sm font-medium border-b-2 transition-colors ${
statementTab === t
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>{t === "balance" ? "资产负债表" : t === "income" ? "利润表" : "现金流量表"}</button>
))}
</div>
<div className="ml-auto flex items-center gap-2 mr-2">
<div className="flex gap-0.5 text-xs border rounded overflow-hidden">
<button onClick={() => setStatementPeriodMode("reportPeriod")}
className={`px-2.5 py-1 transition-colors ${
statementPeriodMode === "reportPeriod"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
<button onClick={() => { setStatementPeriodMode("singleQuarter"); setStatementPeriodFilter("all"); }}
className={`px-2.5 py-1 transition-colors ${
statementPeriodMode === "singleQuarter"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
</div>
<div className="flex gap-0.5 text-xs border rounded overflow-hidden">
<button onClick={() => setStatementView("table")}
className={`px-2.5 py-1 transition-colors ${
statementView === "table"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
<button onClick={() => setStatementView("chart")}
className={`px-2.5 py-1 transition-colors ${
statementView === "chart"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
</div>
</div>
<div className="flex gap-0.5 text-xs px-3 pb-2 flex-wrap ml-auto">
{["all", "annual", "q3", "h1", "q1"].map(f => (
<button key={f} onClick={() => setStatementPeriodFilter(f)}
className={`px-2.5 py-1 rounded transition-colors ${
statementPeriodFilter === f
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground bg-background/50"
}`}
>{periodFilterLabel(statementPeriodMode, f)}</button>
))}
</div>
</div>
</div>
{statementContent}
</div>
</TabsContent>
</Tabs>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>...
</div>
)}
</TabsContent>
</Tabs>
</CardHeader>
</Card>
);
}
+133
View File
@@ -288,6 +288,139 @@ export async function fetchStockFundFlow(code: string, name?: string, days: numb
} }
} }
// ---- 公司概况 ----
export interface CompanyProfile {
code: string;
name: string;
fullName: string;
englishName: string;
boardType: string;
industry: string;
exchange: string;
industryDetail: string;
chairman: string;
generalManager: string;
secretary: string;
phone: string;
email: string;
fax: string;
website: string;
officeAddress: string;
registeredAddress: string;
region: string;
postalCode: string;
registeredCapital: string;
unifiedCreditCode: string;
employees: string;
managementCount: string;
lawFirm: string;
auditor: string;
businessScope: string;
companyProfile: string;
establishmentDate: string;
listingDate: string;
listingPrice: string;
issuePriceEarningsRatio: string;
issueAmount: string;
netProceeds: string;
issueMethod: string;
}
/**
* 获取公司概况
*/
export async function fetchCompanyProfile(code: string): Promise<CompanyProfile | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/profile?code=${code}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[stock-api] 获取公司概况失败:", err);
return null;
}
}
// ---- 经营分析(收入构成)----
export interface BusinessSegmentItem {
itemName: string;
income: number | null;
cost: number | null;
profit: number | null;
grossRatio: number | null;
incomeRatio: number | null;
profitRatio: number | null;
costRatio: number | null;
}
export interface BusinessSegmentPeriod {
reportName: string;
items: BusinessSegmentItem[];
}
export interface BusinessSegmentsResponse {
data: BusinessSegmentPeriod[];
count: number;
code: string;
name: string;
byType: string;
}
type SegmentType = "product" | "industry" | "region";
/**
* 获取主营构成(收入构成)
*/
export async function fetchBusinessSegments(code: string, byType: SegmentType = "product", years: number = 3): Promise<BusinessSegmentsResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/business-segments?code=${code}&by_type=${byType}&years=${years}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[stock-api] 获取主营构成失败:", err);
return null;
}
}
// ---- 财务指标 ----
export interface FinancialReportItem {
reportDate: string;
reportType: string;
reportDateName: string;
noticeDate: string;
indicators: Record<string, number | string | boolean>;
}
export interface FinancialDataResponse {
data: FinancialReportItem[];
count: number;
code: string;
name: string;
}
/**
* 获取财务指标数据
*/
export async function fetchFinancialData(code: string, years: number = 5): Promise<FinancialDataResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/financial?code=${code}&years=${years}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[stock-api] 获取财务数据失败:", err);
return null;
}
}
// ---- 板块数据 ---- // ---- 板块数据 ----
export interface SectorItem { export interface SectorItem {
+41 -4
View File
@@ -1,13 +1,14 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, Fragment } from "react";
import { collectionsApi } from "@/lib/api-client"; import { collectionsApi } from "@/lib/api-client";
import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary } from "@/lib/stock-api"; import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
import StockProfileTabs from "@/components/stock-profile-tabs";
import { getUserId } from "@/lib/user-id"; import { getUserId } from "@/lib/user-id";
import { formatMoney } from "@/lib/utils"; import { formatMoney } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, TrendingUp, TrendingDown, Calendar, ExternalLink, Newspaper } from "lucide-react"; import { ArrowLeft, TrendingUp, TrendingDown, Calendar, ExternalLink, Newspaper } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { import {
ComposedChart, ComposedChart,
@@ -22,6 +23,9 @@ import {
Scatter, Scatter,
Brush, Brush,
ReferenceLine, ReferenceLine,
PieChart,
Pie,
Legend,
} from "recharts"; } from "recharts";
export const Route = createFileRoute("/stock/$code")({ export const Route = createFileRoute("/stock/$code")({
@@ -78,10 +82,32 @@ function StockDetail() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [inCollection, setInCollection] = useState(false); const [inCollection, setInCollection] = useState(false);
// 公司介绍、经营分析、财务分析
const [companyProfile, setCompanyProfile] = useState<CompanyProfile | null>(null);
const [businessSegments, setBusinessSegments] = useState<BusinessSegmentsResponse | null>(null);
const [financialData, setFinancialData] = useState<FinancialDataResponse | null>(null);
const [profileTab, setProfileTab] = useState("profile");
const [pieType, setPieType] = useState<"income" | "cost" | "profit">("income");
useEffect(() => { useEffect(() => {
loadStockData(); loadStockData();
}, [code]); }, [code]);
// 加载公司概况、经营分析、财务分析(并行后置加载)
useEffect(() => {
if (!loading && stockInfo) {
Promise.all([
fetchCompanyProfile(code),
fetchBusinessSegments(code, "product", 30),
fetchFinancialData(code, 5),
]).then(([profile, segments, financial]) => {
if (profile) setCompanyProfile(profile);
if (segments) setBusinessSegments(segments);
if (financial) setFinancialData(financial);
});
}
}, [loading, stockInfo]);
const loadStockData = async () => { const loadStockData = async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -706,7 +732,6 @@ function StockDetail() {
</Card> </Card>
)} )}
{/* Fund Flow Loading Skeleton */}
{fundFlowLoading && fundFlowData.length === 0 && ( {fundFlowLoading && fundFlowData.length === 0 && (
<Card className="mt-4 md:mt-6 shadow-lg"> <Card className="mt-4 md:mt-6 shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6"> <CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
@@ -934,6 +959,18 @@ function StockDetail() {
</div> </div>
</> </>
)} )}
<StockProfileTabs
code={code}
profileTab={profileTab}
setProfileTab={setProfileTab}
companyProfile={companyProfile}
businessSegments={businessSegments}
financialData={financialData}
setBusinessSegments={setBusinessSegments}
pieType={pieType}
setPieType={setPieType}
/>
</div> </div>
</div> </div>
); );