公司介绍 财务数据
This commit is contained in:
@@ -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
@@ -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位股票代码"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
@@ -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>
|
||||||
|
主营利润 <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user