From 83c88ee945fd860b068fb8bfa851803394e83157 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:44:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=AC=E5=8F=B8=E4=BB=8B=E7=BB=8D=20?= =?UTF-8?q?=E8=B4=A2=E5=8A=A1=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/models.py | 55 ++ backend/routes/stock.py | 69 ++- backend/services/eastmoney.py | 370 ++++++++++++ pnpm-workspace.yaml | 2 + src/components/stock-profile-tabs.tsx | 829 ++++++++++++++++++++++++++ src/lib/stock-api.ts | 133 +++++ src/routes/stock.$code.tsx | 45 +- 7 files changed, 1498 insertions(+), 5 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 src/components/stock-profile-tabs.tsx diff --git a/backend/models.py b/backend/models.py index 7433e55..a01f942 100644 --- a/backend/models.py +++ b/backend/models.py @@ -119,3 +119,58 @@ class ShareLink(BaseModel): short_code: str created_at: str 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] diff --git a/backend/routes/stock.py b/backend/routes/stock.py index c5b4a7e..f49c6a7 100644 --- a/backend/routes/stock.py +++ b/backend/routes/stock.py @@ -5,7 +5,7 @@ import re import math 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() @@ -71,6 +71,73 @@ async def stock_history( 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="资金流向") async def stock_fund_flow( code: str = Query(..., description="6位股票代码"), diff --git a/backend/services/eastmoney.py b/backend/services/eastmoney.py index 7a7e835..e89734b 100644 --- a/backend/services/eastmoney.py +++ b/backend/services/eastmoney.py @@ -363,6 +363,376 @@ async def fetch_sector_list_direct(sector_type: str) -> list[dict]: 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]: """回退到东方财富 push2his 接口(自动重试一次)""" market = get_eastmoney_market(code) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/components/stock-profile-tabs.tsx b/src/components/stock-profile-tabs.tsx new file mode 100644 index 0000000..dc0eab9 --- /dev/null +++ b/src/components/stock-profile-tabs.tsx @@ -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(null); + + const [financialSubTab, setFinancialSubTab] = useState("main"); + const [statementTab, setStatementTab] = useState("balance"); + const [statementPeriodMode, setStatementPeriodMode] = useState<"reportPeriod" | "singleQuarter">("reportPeriod"); + const [statementPeriodFilter, setStatementPeriodFilter] = useState("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(); + 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 = { 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(); + 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(); + const allItems = new Set(); + 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(); + 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 = {}; + 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 = { 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 = { + balance: ["总资产", "总负债", "所有者权益"], + income: ["营业总收入", "归母净利润"], + cashflow: ["经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额"], + }; + + const statementContent = useMemo(() => { + if (statementView === "chart" && statementChartData.length > 0) { + const chartKeys = statementChartKeys[statementTab] || []; + return ( +
+ + + + + (v / 1e8).toFixed(1) + "亿"} /> + [fmt(value), name]} + /> + + {chartKeys.map((k, i) => + k === "归母净利润" + ? + : + )} + + +
+ ); + } + const sections: Record = { + balance: ["总资产", "总负债", "所有者权益", "流动资产占比", "非流动资产占比", "流动负债占比"], + income: ["营业总收入", "毛利润", "营业利润", "归母净利润", "扣非净利润"], + cashflow: ["经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额", "企业自由现金流"], + }; + const keys = sections[statementTab] || []; + const isPct = (k: string) => ["占比"].some(s => k.includes(s)); + const moneyKeys = new Set(["营业总收入", "毛利润", "营业利润", "归母净利润", "扣非净利润", "总资产", "总负债", "所有者权益", "经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额", "企业自由现金流"]); + return ( + + + + + {statementData.slice(0, 6).map(r => ( + + ))} + + + + {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 ( + + + {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 ; + })} + + ); + })} + +
项目{r.reportDateName}
{label}{displayVal}
+ ); + }, [statementView, statementChartData, statementTab, statementData, statementPeriodMode]); + + return ( + + + + + 公司介绍 + 经营分析 + 财务分析 + + + + {companyProfile ? ( +
+
+

公司简介

+

{companyProfile.companyProfile}

+
+
+

经营范围

+

{companyProfile.businessScope}

+
+
+ {[ + ["所属行业", 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]) => ( +
+ {label} + {value || "-"} +
+ ))} +
+
+ 联系方式 +
+ {companyProfile.phone &&
电话: {companyProfile.phone}
} + {companyProfile.fax &&
传真: {companyProfile.fax}
} + {companyProfile.email &&
邮箱: {companyProfile.email}
} + {companyProfile.website &&
网址: {companyProfile.website}
} + {companyProfile.officeAddress &&
办公地址: {companyProfile.officeAddress}
} + {companyProfile.registeredAddress &&
注册地址: {companyProfile.registeredAddress}
} +
+
+
+ ) : ( +
+
加载中... +
+ )} +
+ + + {businessSegments ? ( +
+ {/* Title + 表格/图表 toggle */} +
+

主营构成分析

+
+ + +
+
+ + {businessView === "chart" ? ( + <> + {/* 按产品 | 按行业 | 按地区 + 报告期 */} +
+
+ {(["product", "industry", "region"] as const).map(t => ( + + ))} +
+ {periods.length > 0 && ( +
+ 报告期: + +
+ )} +
+ + {cur ? ( + <> + {/* Donut chart + Detail table */} +
+ {/* Donut chart */} +
+

+ {chartMetric === "income" ? "主营收入" : chartMetric === "cost" ? "主营成本" : "主营利润"}构成 +

+ + + + {pieData.map(item => )} + + { + const item = props.payload; + return [`${fmt(value)}${item?.ratio != null ? ` (${pct(item.ratio)})` : ""}`, item?.name]; + }} + /> + {v}} + /> + + +
+ + {/* Detail table */} +
+

+ {cur.reportName}:主营收入 {fmt(totalIncome)} +  主营利润 {fmt(totalProfit)} +

+ + + + + + + + + + + + + + + {cur.items.map((item, idx) => ( + 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" : ""}`} + > + + + + + + + + + + ))} + +
名称 + setChartMetric("income")} + className={`cursor-pointer ${chartMetric === "income" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`} + >主营收入 + 收入比例 + setChartMetric("cost")} + className={`cursor-pointer ${chartMetric === "cost" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`} + >主营成本 + 成本比例 + setChartMetric("profit")} + className={`cursor-pointer ${chartMetric === "profit" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`} + >主营利润 + 利润比例毛利率
+ + {item.itemName} + {fmt(item.income)}{pct(item.incomeRatio)}{fmt(item.cost)}{pct(item.costRatio)}{fmt(item.profit)}{pct(item.profitRatio)}= 40 ? "text-red-500" : item.grossRatio >= 20 ? "text-orange-500" : "text-green-500" : "" + }`}>{pct(item.grossRatio)}
+

点击表头切换环形图,点击行查看历年数据

+
+
+ + {/* Bottom: 年报|中报 toggle + Stacked bar chart */} +
+
+
+ {(["annual", "semi"] as const).map(f => ( + + ))} +
+
+
+ {(["income", "profit"] as const).map(m => ( + + ))} +
+ 收入比例 +
+
+ {stackedData.length > 0 ? ( + + + + + `${(v / 1e8).toFixed(1)}亿`} /> + {selectedItem && ( + `${v}%`} width={40} /> + )} + { + 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]; + }} + /> + + {stackedKeys.map(k => ( + + ))} + {selectedItem && ( + + )} + + + + ) : ( +
暂无数据
+ )} +
+ + ) : ( +
暂无数据
+ )} + + ) : ( + /* Table view */ +
+ {periods.length > 0 ? ( + + + + + + + + + + + + + + + + {periods.map(period => ( + + + + + {period.items.map(item => ( + + + + + + + + + + + + ))} + + ))} + +
报告期项目主营收入收入比例主营成本成本比例主营利润利润比例毛利率
{period.reportName}
{item.itemName}{fmt(item.income)}{pct(item.incomeRatio)}{fmt(item.cost)}{pct(item.costRatio)}{fmt(item.profit)}{pct(item.profitRatio)}= 40 ? "text-red-500" : item.grossRatio >= 20 ? "text-orange-500" : "text-green-500" : "" + }`}>{pct(item.grossRatio)}
+ ) : ( +
暂无数据
+ )} +
+ )} +
+ ) : ( +
+
加载中... +
+ )} +
+ + + {financialData ? ( + + + 主要指标 + 财务报表 + + + +
+ + + + + {financialData.data.slice(0, 6).map(r => ( + + ))} + + + + {(() => { + 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 ( + + + {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 ( + + ); + })} + + ); + }) + .filter(Boolean); + if (!section.name || rows.length === 0) return rows; + return [ + + + , + ...rows, + ]; + }); + })()} + +
指标{r.reportDateName}
{label}{displayVal}
{section.name}
+
+
+ + +
+
+
+
+ {["balance", "income", "cashflow"].map(t => ( + + ))} +
+
+
+ + +
+
+ + +
+
+
+ {["all", "annual", "q3", "h1", "q1"].map(f => ( + + ))} +
+
+
+ {statementContent} +
+
+
+ ) : ( +
+
加载中... +
+ )} +
+
+
+
+ ); +} diff --git a/src/lib/stock-api.ts b/src/lib/stock-api.ts index 96750fc..45dd332 100755 --- a/src/lib/stock-api.ts +++ b/src/lib/stock-api.ts @@ -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 { + 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 { + 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; +} + +export interface FinancialDataResponse { + data: FinancialReportItem[]; + count: number; + code: string; + name: string; +} + +/** + * 获取财务指标数据 + */ +export async function fetchFinancialData(code: string, years: number = 5): Promise { + 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 { diff --git a/src/routes/stock.$code.tsx b/src/routes/stock.$code.tsx index 2a14f94..ceca374 100755 --- a/src/routes/stock.$code.tsx +++ b/src/routes/stock.$code.tsx @@ -1,13 +1,14 @@ 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 { 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 { formatMoney } from "@/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; 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 { ComposedChart, @@ -22,6 +23,9 @@ import { Scatter, Brush, ReferenceLine, + PieChart, + Pie, + Legend, } from "recharts"; export const Route = createFileRoute("/stock/$code")({ @@ -78,10 +82,32 @@ function StockDetail() { const [error, setError] = useState(null); const [inCollection, setInCollection] = useState(false); + // 公司介绍、经营分析、财务分析 + const [companyProfile, setCompanyProfile] = useState(null); + const [businessSegments, setBusinessSegments] = useState(null); + const [financialData, setFinancialData] = useState(null); + const [profileTab, setProfileTab] = useState("profile"); + const [pieType, setPieType] = useState<"income" | "cost" | "profit">("income"); + useEffect(() => { loadStockData(); }, [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 () => { setLoading(true); setError(null); @@ -706,7 +732,6 @@ function StockDetail() { )} - {/* Fund Flow Loading Skeleton */} {fundFlowLoading && fundFlowData.length === 0 && ( @@ -934,6 +959,18 @@ function StockDetail() { )} + + );