公司介绍 财务数据

This commit is contained in:
Sakurasan
2026-07-06 19:44:40 +08:00
parent cffdea5407
commit 83c88ee945
7 changed files with 1498 additions and 5 deletions
+829
View File
@@ -0,0 +1,829 @@
import { Fragment, useState, useMemo } from "react";
import { Card, CardHeader } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
ComposedChart,
Bar,
Line,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Legend,
Brush,
} from "recharts";
import { fetchBusinessSegments, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
interface StockProfileTabsProps {
code: string;
profileTab: string;
setProfileTab: (v: string) => void;
companyProfile: CompanyProfile | null;
businessSegments: BusinessSegmentsResponse | null;
financialData: FinancialDataResponse | null;
setBusinessSegments: (d: BusinessSegmentsResponse | null) => void;
pieType: "income" | "cost" | "profit";
setPieType: (v: "income" | "cost" | "profit") => void;
}
const COLORS = ["#4672F6", "#00B7F4", "#EDBE46", "#FF6D6D", "#D580FF", "#8976FF", "#F67C40", "#5CC9A0", "#EC4899", "#F59E0B"];
function fmt(v: unknown): string {
if (v == null) return "-";
const n = typeof v === "number" ? v : Number(v);
if (isNaN(n)) return "-";
const abs = Math.abs(n);
const sign = n < 0 ? "-" : "";
if (abs >= 1e8) return `${sign}${(abs / 1e8).toFixed(3)}亿`;
if (abs >= 1e4) {
const val = abs / 1e4;
return `${sign}${val % 1 === 0 ? val.toFixed(0) : val.toFixed(2)}`;
}
return n.toFixed(2);
}
function pct(v: unknown): string {
if (v == null) return "-";
const n = typeof v === "number" ? v : Number(v);
if (isNaN(n)) return "-";
return `${n.toFixed(2)}%`;
}
function reportLabel(raw: string): string {
const m = raw.match(/^(\d{4})/);
const y = m ? m[1] : raw;
if (raw.includes("12-31") || raw.includes("年报")) return `${y}年报`;
if (raw.includes("06-30") || raw.includes("中报")) return `${y}中报`;
return raw;
}
function reportYear(raw: string): number {
const m = raw.match(/^(\d{4})/);
return m ? parseInt(m[1]) : 0;
}
function isAnnual(raw: string): boolean {
return raw.includes("12-31") || raw.includes("年报");
}
function getPeriodType(r: { reportDate: string; reportDateName: string }): string {
if (r.reportDate.includes("12-31") || r.reportDateName.includes("年报")) return "annual";
if (r.reportDate.includes("09-30") || r.reportDateName.includes("三季报")) return "q3";
if (r.reportDate.includes("06-30") || r.reportDateName.includes("中报")) return "h1";
if (r.reportDate.includes("03-31") || r.reportDateName.includes("一季报")) return "q1";
return "unknown";
}
function periodFilterLabel(mode: "reportPeriod" | "singleQuarter", filter: string): string {
if (filter === "all") return "全部";
if (mode === "reportPeriod") {
return { annual: "年报", q3: "三季报", h1: "中报", q1: "一季报" }[filter] || filter;
}
return { annual: "四季度", q3: "三季度", h1: "二季度", q1: "一季度" }[filter] || filter;
}
export default function StockProfileTabs({
code,
profileTab,
setProfileTab,
companyProfile,
businessSegments,
financialData,
setBusinessSegments,
pieType,
setPieType,
}: StockProfileTabsProps) {
const [businessView, setBusinessView] = useState<"chart" | "table">("chart");
const [selectedPeriodIdx, setSelectedPeriodIdx] = useState(0);
const [segType, setSegType] = useState<"product" | "industry" | "region">("product");
const [barFilter, setBarFilter] = useState<"annual" | "semi">("annual");
const [barMetric, setBarMetric] = useState<"income" | "profit">("income");
const [chartMetric, setChartMetric] = useState<"income" | "cost" | "profit">(pieType);
const [selectedItem, setSelectedItem] = useState<string | null>(null);
const [financialSubTab, setFinancialSubTab] = useState("main");
const [statementTab, setStatementTab] = useState("balance");
const [statementPeriodMode, setStatementPeriodMode] = useState<"reportPeriod" | "singleQuarter">("reportPeriod");
const [statementPeriodFilter, setStatementPeriodFilter] = useState<string>("all");
const [statementView, setStatementView] = useState<"table" | "chart">("table");
const handleSegType = (t: "product" | "industry" | "region") => {
setSegType(t);
setSelectedPeriodIdx(0);
setSelectedItem(null);
fetchBusinessSegments(code, t, 30).then(r => r && setBusinessSegments(r));
};
const periods = businessSegments?.data || [];
const cur = periods[selectedPeriodIdx];
const totalIncome = cur ? cur.items.reduce((s, i) => s + (i.income || 0), 0) : 0;
const totalProfit = cur ? cur.items.reduce((s, i) => s + (i.profit || 0), 0) : 0;
const pieData = useMemo(() => {
if (!cur) return [];
return cur.items.filter(i => i[chartMetric] != null).map(i => ({
name: i.itemName,
value: i[chartMetric]!,
ratio: chartMetric === "income" ? i.incomeRatio : chartMetric === "cost" ? i.costRatio : i.profitRatio,
}));
}, [cur, chartMetric]);
// Stacked bar data
const stackedData = useMemo(() => {
if (!periods.length) return [];
const filtered = periods.filter(p => {
const a = isAnnual(p.reportName);
return barFilter === "annual" ? a : !a;
}).sort((a, b) => reportYear(a.reportName) - reportYear(b.reportName) || a.reportName.localeCompare(b.reportName));
// Top items across filtered periods
const totals = new Map<string, number>();
for (const p of filtered) {
for (const item of p.items) {
const val = item[barMetric] || 0;
totals.set(item.itemName, (totals.get(item.itemName) || 0) + Math.abs(val));
}
}
const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 9).map(([n]) => n);
return filtered.map(p => {
const row: Record<string, any> = { period: reportLabel(p.reportName), _raw: p.reportName };
if (selectedItem) {
const item = p.items.find(i => i.itemName === selectedItem);
row[selectedItem] = item ? (item[barMetric] || 0) : 0;
row._ratio = item ? (barMetric === "income" ? item.incomeRatio : item.profitRatio) : null;
} else {
let other = 0;
for (const item of p.items) {
const val = item[barMetric] || 0;
if (top.includes(item.itemName)) row[item.itemName] = val;
else other += val;
}
if (other !== 0) row["其他"] = other;
}
return row;
});
}, [periods, barFilter, barMetric, selectedItem]);
const stackedKeys = useMemo(() => {
const keys = new Set<string>();
for (const row of stackedData) {
for (const k of Object.keys(row)) {
if (!k.startsWith("_") && k !== "period") keys.add(k);
}
}
return [...keys];
}, [stackedData]);
// 统一颜色映射:所有项目中同一个名称使用同一种颜色
const itemColorMap = useMemo(() => {
const map = new Map<string, string>();
const allItems = new Set<string>();
for (const p of periods) {
for (const item of p.items) {
allItems.add(item.itemName);
}
}
[...allItems].sort().forEach((name, i) => {
map.set(name, COLORS[i % COLORS.length]);
});
return map;
}, [periods]);
const statementData = useMemo(() => {
if (!financialData) return [];
let data = [...financialData.data];
if (statementPeriodMode === "singleQuarter") {
const byYear = new Map<number, typeof data>();
for (const r of data) {
const y = reportYear(r.reportDate);
if (!byYear.has(y)) byYear.set(y, []);
byYear.get(y)!.push(r);
}
const result: typeof data = [];
const order = ["q1", "h1", "q3", "annual"];
for (const [, recs] of byYear) {
recs.sort((a, b) => order.indexOf(getPeriodType(a)) - order.indexOf(getPeriodType(b)));
for (let i = 0; i < recs.length; i++) {
const r = recs[i];
const pt = getPeriodType(r);
const prev = i > 0 ? recs[i - 1] : null;
const indicators: Record<string, any> = {};
for (const [key, val] of Object.entries(r.indicators)) {
if (key.endsWith("(亿)") || key.endsWith("(万)")) continue;
const prevVal = prev?.indicators[key];
if (statementTab !== "balance" && typeof val === "number" && typeof prevVal === "number" && prev) {
indicators[key] = val - prevVal;
} else if (val !== undefined) {
indicators[key] = val;
}
}
const qLabels: Record<string, string> = { q1: "一季度", h1: "二季度", q3: "三季度", annual: "四季度" };
result.push({ ...r, indicators, reportDateName: `${reportYear(r.reportDate)}${qLabels[pt]}` });
}
}
const orderIdx = (r: any) => order.indexOf(getPeriodType(r));
data = result.sort((a, b) => {
const yA = reportYear(a.reportDate), yB = reportYear(b.reportDate);
return yA !== yB ? yB - yA : orderIdx(b) - orderIdx(a);
});
}
if (statementPeriodFilter !== "all") {
data = data.filter(r => getPeriodType(r) === statementPeriodFilter);
}
return data;
}, [financialData, statementPeriodMode, statementPeriodFilter, statementTab]);
const statementChartData = useMemo(() => {
return statementData.slice(0, 6).reverse().map(r => ({
name: r.reportDateName,
...r.indicators,
}));
}, [statementData]);
const statementChartKeys: Record<string, string[]> = {
balance: ["总资产", "总负债", "所有者权益"],
income: ["营业总收入", "归母净利润"],
cashflow: ["经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额"],
};
const statementContent = useMemo(() => {
if (statementView === "chart" && statementChartData.length > 0) {
const chartKeys = statementChartKeys[statementTab] || [];
return (
<div className="p-3">
<ResponsiveContainer width="100%" height={300}>
<ComposedChart data={statementChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.3} />
<XAxis dataKey="name" fontSize={11} stroke="var(--muted-foreground)" />
<YAxis fontSize={11} stroke="var(--muted-foreground)" tickFormatter={(v: number) => (v / 1e8).toFixed(1) + "亿"} />
<Tooltip contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "13px" }}
formatter={(value: number, name: string) => [fmt(value), name]}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
{chartKeys.map((k, i) =>
k === "归母净利润"
? <Line key={k} type="monotone" dataKey={k} stroke={COLORS[i % COLORS.length]} strokeWidth={2} dot={{ r: 3 }} />
: <Bar key={k} dataKey={k} fill={COLORS[i % COLORS.length]} radius={[4, 4, 0, 0]} />
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
const sections: Record<string, string[]> = {
balance: ["总资产", "总负债", "所有者权益", "流动资产占比", "非流动资产占比", "流动负债占比"],
income: ["营业总收入", "毛利润", "营业利润", "归母净利润", "扣非净利润"],
cashflow: ["经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额", "企业自由现金流"],
};
const keys = sections[statementTab] || [];
const isPct = (k: string) => ["占比"].some(s => k.includes(s));
const moneyKeys = new Set(["营业总收入", "毛利润", "营业利润", "归母净利润", "扣非净利润", "总资产", "总负债", "所有者权益", "经营活动现金流净额", "投资活动现金流净额", "筹资活动现金流净额", "企业自由现金流"]);
return (
<table className="w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
{statementData.slice(0, 6).map(r => (
<th key={r.reportDate} className="text-right py-2 px-2 font-medium text-muted-foreground whitespace-nowrap text-xs md:text-sm">{r.reportDateName}</th>
))}
</tr>
</thead>
<tbody>
{keys.map(indicator => {
const vals = statementData.slice(0, 6).map(r => r.indicators[indicator]);
if (!vals.some(v => v != null && v !== "")) return null;
const label = isPct(indicator) ? `${indicator}(%)` : indicator;
return (
<tr key={indicator} className="border-b border-border/30 hover:bg-muted/20">
<td className="py-1.5 pr-2 whitespace-nowrap text-xs md:text-sm">{label}</td>
{statementData.slice(0, 6).map((r, i) => {
const v = r.indicators[indicator];
let displayVal = "-";
if (v != null && v !== "") {
if (moneyKeys.has(indicator)) {
if (statementPeriodMode === "singleQuarter") {
displayVal = fmt(v);
} else {
const cellUnit = r.indicators[indicator + "(亿)"] ? "亿" : r.indicators[indicator + "(万)"] ? "万" : "";
if (cellUnit) {
displayVal = `${typeof v === "number" ? v.toLocaleString() : v}${cellUnit}`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
}
} else if (isPct(indicator) && typeof v === "number") {
displayVal = `${v}%`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
}
return <td key={i} className="text-right py-1.5 px-2 font-mono text-xs md:text-sm">{displayVal}</td>;
})}
</tr>
);
})}
</tbody>
</table>
);
}, [statementView, statementChartData, statementTab, statementData, statementPeriodMode]);
return (
<Card className="mt-4 md:mt-6 shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<Tabs value={profileTab} onValueChange={setProfileTab}>
<TabsList className="w-full justify-start overflow-x-auto">
<TabsTrigger value="profile" className="text-xs md:text-sm"></TabsTrigger>
<TabsTrigger value="business" className="text-xs md:text-sm"></TabsTrigger>
<TabsTrigger value="financial" className="text-xs md:text-sm"></TabsTrigger>
</TabsList>
<TabsContent value="profile" className="mt-4">
{companyProfile ? (
<div className="space-y-3 text-xs md:text-sm">
<div>
<h4 className="font-semibold text-sm md:text-base mb-1"></h4>
<p className="text-muted-foreground leading-relaxed">{companyProfile.companyProfile}</p>
</div>
<div>
<h4 className="font-semibold text-sm md:text-base mb-1"></h4>
<p className="text-muted-foreground leading-relaxed">{companyProfile.businessScope}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mt-3">
{[
["所属行业", companyProfile.industry], ["上市板块", companyProfile.boardType],
["交易所", companyProfile.exchange], ["成立日期", companyProfile.establishmentDate],
["上市日期", companyProfile.listingDate], ["董事长", companyProfile.chairman],
["总经理", companyProfile.generalManager], ["董秘", companyProfile.secretary],
["注册资本", companyProfile.registeredCapital], ["员工人数", companyProfile.employees],
["所属地区", companyProfile.region], ["发行价格", companyProfile.listingPrice ? `¥${companyProfile.listingPrice}` : ""],
].map(([label, value]) => (
<div key={String(label)} className="flex flex-col p-2 rounded bg-muted/30">
<span className="text-muted-foreground text-[10px] md:text-xs">{label}</span>
<span className="font-medium text-xs md:text-sm">{value || "-"}</span>
</div>
))}
</div>
<details className="mt-2">
<summary className="cursor-pointer text-muted-foreground text-xs hover:text-foreground"></summary>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-2 text-xs md:text-sm text-muted-foreground">
{companyProfile.phone && <div>: {companyProfile.phone}</div>}
{companyProfile.fax && <div>: {companyProfile.fax}</div>}
{companyProfile.email && <div>: {companyProfile.email}</div>}
{companyProfile.website && <div>: {companyProfile.website}</div>}
{companyProfile.officeAddress && <div>: {companyProfile.officeAddress}</div>}
{companyProfile.registeredAddress && <div>: {companyProfile.registeredAddress}</div>}
</div>
</details>
</div>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>...
</div>
)}
</TabsContent>
<TabsContent value="business" className="mt-4">
{businessSegments ? (
<div className="space-y-4">
{/* Title + 表格/图表 toggle */}
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-base md:text-lg font-bold"></h3>
<div className="flex gap-1 text-sm">
<button onClick={() => setBusinessView("table")}
className={`px-3 py-1 rounded ${businessView === "table" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
></button>
<button onClick={() => setBusinessView("chart")}
className={`px-3 py-1 rounded ${businessView === "chart" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
></button>
</div>
</div>
{businessView === "chart" ? (
<>
{/* 按产品 | 按行业 | 按地区 + 报告期 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex gap-1 text-sm">
{(["product", "industry", "region"] as const).map(t => (
<button key={t} onClick={() => handleSegType(t)}
className={`px-3 py-1 rounded text-xs md:text-sm border transition-colors ${
segType === t ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-border hover:border-primary"
}`}
>{t === "product" ? "按产品" : t === "industry" ? "按行业" : "按地区"}</button>
))}
</div>
{periods.length > 0 && (
<div className="flex items-center gap-1 text-xs">
<span className="text-muted-foreground">:</span>
<select
value={selectedPeriodIdx}
onChange={(e) => setSelectedPeriodIdx(Number(e.target.value))}
className="bg-background border border-border rounded px-1.5 py-0.5 text-foreground text-xs"
>
{periods.map((p, idx) => (
<option key={p.reportName} value={idx}>{p.reportName}</option>
))}
</select>
</div>
)}
</div>
{cur ? (
<>
{/* Donut chart + Detail table */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Donut chart */}
<div className="bg-muted/20 rounded-lg p-3 flex flex-col items-center">
<h4 className="font-semibold text-xs md:text-sm mb-2">
{chartMetric === "income" ? "主营收入" : chartMetric === "cost" ? "主营成本" : "主营利润"}
</h4>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" innerRadius={50} outerRadius={90}
paddingAngle={2} dataKey="value"
>
{pieData.map(item => <Cell key={item.name} fill={itemColorMap.get(item.name) || COLORS[0]} />)}
</Pie>
<Tooltip contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "12px" }}
formatter={(value: number, _n: string, props: any) => {
const item = props.payload;
return [`${fmt(value)}${item?.ratio != null ? ` (${pct(item.ratio)})` : ""}`, item?.name];
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }}
formatter={(v: string) => <span className="text-muted-foreground">{v}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Detail table */}
<div className="bg-muted/20 rounded-lg p-3 overflow-x-auto">
<p className="text-xs mb-2 text-muted-foreground">
{cur.reportName} <span className="text-red-500 font-medium">{fmt(totalIncome)}</span>
&nbsp; <span className="text-red-500 font-medium">{fmt(totalProfit)}</span>
</p>
<table className="w-full text-[10px] md:text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left py-1 pr-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium">
<span onClick={() => setChartMetric("income")}
className={`cursor-pointer ${chartMetric === "income" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}
></span>
</th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium">
<span onClick={() => setChartMetric("cost")}
className={`cursor-pointer ${chartMetric === "cost" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}
></span>
</th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium">
<span onClick={() => setChartMetric("profit")}
className={`cursor-pointer ${chartMetric === "profit" ? "text-primary" : "text-muted-foreground hover:text-foreground"}`}
></span>
</th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-1 px-1 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{cur.items.map((item, idx) => (
<tr key={item.itemName}
onClick={() => setSelectedItem(selectedItem === item.itemName ? null : item.itemName)}
className={`border-b border-border/30 hover:bg-muted/30 cursor-pointer ${selectedItem === item.itemName ? "bg-primary/5" : ""}`}
>
<td className="py-1 pr-1 whitespace-nowrap">
<span className="inline-block w-2 h-2 rounded-full mr-1 align-middle" style={{ backgroundColor: itemColorMap.get(item.itemName) || COLORS[0] }}></span>
{item.itemName}
</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.income)}</td>
<td className="text-right py-1 px-1">{pct(item.incomeRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.cost)}</td>
<td className="text-right py-1 px-1">{pct(item.costRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.profit)}</td>
<td className="text-right py-1 px-1">{pct(item.profitRatio)}</td>
<td className={`text-right py-1 px-1 font-medium ${
item.grossRatio != null ? item.grossRatio >= 40 ? "text-red-500" : item.grossRatio >= 20 ? "text-orange-500" : "text-green-500" : ""
}`}>{pct(item.grossRatio)}</td>
</tr>
))}
</tbody>
</table>
<p className="text-[10px] text-muted-foreground mt-2"></p>
</div>
</div>
{/* Bottom: 年报|中报 toggle + Stacked bar chart */}
<div className="bg-muted/20 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex gap-1 text-xs">
{(["annual", "semi"] as const).map(f => (
<button key={f} onClick={() => setBarFilter(f)}
className={`px-2.5 py-1 rounded border transition-colors ${
barFilter === f ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-border"
}`}
>{f === "annual" ? "年报" : "中报"}</button>
))}
</div>
<div className="flex gap-3 text-xs items-center">
<div className="flex gap-1">
{(["income", "profit"] as const).map(m => (
<button key={m} onClick={() => setBarMetric(m)}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
barMetric === m ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-border"
}`}
>{m === "income" ? "主营收入" : "主营利润"}</button>
))}
</div>
<span className="text-[10px] text-muted-foreground"></span>
</div>
</div>
{stackedData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<ComposedChart data={stackedData} barSize={28} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.3} />
<XAxis dataKey="period" fontSize={11} stroke="var(--muted-foreground)" />
<YAxis yAxisId="left" fontSize={11} stroke="var(--muted-foreground)" tickFormatter={v => `${(v / 1e8).toFixed(1)}亿`} />
{selectedItem && (
<YAxis yAxisId="right" orientation="right" fontSize={11} stroke="#F97316"
domain={['auto', 'auto']} tickFormatter={v => `${v}%`} width={40} />
)}
<Tooltip contentStyle={{ backgroundColor: "var(--card)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "13px" }}
formatter={(value: number, name: string, props: any) => {
if (props.dataKey === "_ratio" || name === "_ratio") {
return [`${Number(value).toFixed(2)}%`, "利润比例"];
}
if (props.payload?._ratio != null) {
return [`${fmt(value)} (${pct(props.payload._ratio)})`, name];
}
return [fmt(value), name];
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
{stackedKeys.map(k => (
<Bar key={k} dataKey={k} stackId="a" yAxisId="left"
fill={selectedItem ? "#38BDF8" : (itemColorMap.get(k) || COLORS[0])} />
))}
{selectedItem && (
<Line yAxisId="right" type="monotone" dataKey="_ratio" stroke="#F97316"
strokeWidth={2} dot={{ r: 3 }} name="利润比例" />
)}
<Brush
dataKey="period"
startIndex={Math.max(0, stackedData.length - 5)}
endIndex={stackedData.length - 1}
height={20}
stroke="var(--primary)"
fill="var(--muted)"
/>
</ComposedChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-40 text-muted-foreground text-sm"></div>
)}
</div>
</>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm"></div>
)}
</>
) : (
/* Table view */
<div className="overflow-x-auto">
{periods.length > 0 ? (
<table className="w-full text-[10px] md:text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
<th className="text-right py-2 px-1 font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{periods.map(period => (
<Fragment key={period.reportName}>
<tr className="bg-muted/20">
<td colSpan={9} className="py-1.5 font-semibold text-[10px] text-muted-foreground">{period.reportName}</td>
</tr>
{period.items.map(item => (
<tr key={`${period.reportName}-${item.itemName}`} className="border-b border-border/30 hover:bg-muted/20">
<td></td>
<td className="py-1 pr-2">{item.itemName}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.income)}</td>
<td className="text-right py-1 px-1">{pct(item.incomeRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.cost)}</td>
<td className="text-right py-1 px-1">{pct(item.costRatio)}</td>
<td className="text-right py-1 px-1 font-mono">{fmt(item.profit)}</td>
<td className="text-right py-1 px-1">{pct(item.profitRatio)}</td>
<td className={`text-right py-1 px-1 font-medium ${
item.grossRatio != null ? item.grossRatio >= 40 ? "text-red-500" : item.grossRatio >= 20 ? "text-orange-500" : "text-green-500" : ""
}`}>{pct(item.grossRatio)}</td>
</tr>
))}
</Fragment>
))}
</tbody>
</table>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm"></div>
)}
</div>
)}
</div>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>...
</div>
)}
</TabsContent>
<TabsContent value="financial" className="mt-4">
{financialData ? (
<Tabs value={financialSubTab} onValueChange={setFinancialSubTab} className="space-y-4">
<TabsList>
<TabsTrigger value="main"></TabsTrigger>
<TabsTrigger value="statements"></TabsTrigger>
</TabsList>
<TabsContent value="main">
<div className="overflow-x-auto rounded-lg border border-border/40">
<table className="w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-border bg-muted/20">
<th className="text-left py-2 pr-2 font-medium text-muted-foreground"></th>
{financialData.data.slice(0, 6).map(r => (
<th key={r.reportDate} className="text-right py-2 px-2 font-medium text-muted-foreground whitespace-nowrap text-xs md:text-sm">{r.reportDateName}</th>
))}
</tr>
</thead>
<tbody>
{(() => {
const sections: { name?: string; keys: string[] }[] = [
{ name: "每股指标", keys: ["基本每股收益", "扣非每股收益", "每股净资产", "每股资本公积", "每股未分配利润", "每股经营现金流"] },
{ name: "成长能力", keys: ["营业总收入", "毛利润", "归母净利润", "扣非净利润", "营收同比增长", "净利润同比增长", "扣非净利润同比增长"] },
{ name: "盈利能力", keys: ["加权净资产收益率", "总资产净利率", "销售毛利率", "销售净利率", "投入资本回报率"] },
{ name: "收益质量", keys: ["经营现金流占营收比", "销售现金流占营收比", "实际税率"] },
{ name: "财务风险", keys: ["资产负债率", "流动比率", "速动比率", "权益乘数", "产权比率"] },
{ name: "营运能力", keys: ["存货周转天数", "应收账款周转天数", "总资产周转天数", "应付账款周转天数", "营业周期"] },
];
const keyIndicators = new Set([
"营业总收入", "归母净利润", "扣非净利润",
"营收同比增长", "净利润同比增长",
"销售毛利率", "销售净利率", "加权净资产收益率",
]);
const isPctIndicator = (k: string) =>
["同比增长", "毛利率", "净利率", "收益率", "资本回报率", "占比", "资产负债率"].some(s => k.includes(s));
const moneyIndicators = new Set([
"营业总收入", "毛利润", "归母净利润", "扣非净利润",
]);
return sections.flatMap(section => {
const rows = section.keys
.map(indicator => {
const vals = financialData.data.slice(0, 6).map(r => r.indicators[indicator]);
if (!vals.some(v => v != null && v !== "")) return null;
const isKey = keyIndicators.has(indicator);
const label = isPctIndicator(indicator) ? `${indicator}(%)` : indicator;
return (
<tr key={indicator} className={`border-b border-border/30 hover:bg-muted/20 ${isKey ? "bg-primary/[0.07]" : ""}`}>
<td className={`py-1.5 pr-2 whitespace-nowrap text-xs md:text-sm ${isKey ? "font-semibold" : ""}`}>{label}</td>
{financialData.data.slice(0, 6).map((r, i) => {
const v = r.indicators[indicator];
let colorClass = "";
let displayVal = "-";
if (v != null && v !== "") {
if (moneyIndicators.has(indicator)) {
const cellUnit = r.indicators[indicator + "(亿)"] ? "亿" : r.indicators[indicator + "(万)"] ? "万" : "";
if (cellUnit) {
displayVal = `${typeof v === "number" ? v.toLocaleString() : v}${cellUnit}`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
} else if (isPctIndicator(indicator) && typeof v === "number") {
displayVal = `${v}%`;
} else {
displayVal = typeof v === "number" ? v.toLocaleString() : String(v);
}
}
if (v != null && v !== "" && typeof v === "number") {
if (isPctIndicator(indicator)) {
colorClass = v > 0 ? "text-red-500" : v < 0 ? "text-green-500" : "";
} else if (indicator === "资产负债率") {
colorClass = v > 70 ? "text-red-500" : v < 40 ? "text-green-500" : "";
}
}
return (
<td key={i} className={`text-right py-1.5 px-2 font-mono text-xs md:text-sm ${colorClass}`}>{displayVal}</td>
);
})}
</tr>
);
})
.filter(Boolean);
if (!section.name || rows.length === 0) return rows;
return [
<tr key={`h-${section.name}`} className="bg-muted/50 border-t-2 border-border/60">
<td colSpan={financialData.data.slice(0, 6).length + 1} className="py-2 px-2 text-sm font-bold text-foreground">{section.name}</td>
</tr>,
...rows,
];
});
})()}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="statements">
<div className="overflow-x-auto rounded-lg border border-border/40">
<div className="border-b border-border/60 bg-muted/20">
<div className="flex items-center">
<div className="flex">
{["balance", "income", "cashflow"].map(t => (
<button key={t} onClick={() => setStatementTab(t)}
className={`px-4 py-2 text-xs md:text-sm font-medium border-b-2 transition-colors ${
statementTab === t
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>{t === "balance" ? "资产负债表" : t === "income" ? "利润表" : "现金流量表"}</button>
))}
</div>
<div className="ml-auto flex items-center gap-2 mr-2">
<div className="flex gap-0.5 text-xs border rounded overflow-hidden">
<button onClick={() => setStatementPeriodMode("reportPeriod")}
className={`px-2.5 py-1 transition-colors ${
statementPeriodMode === "reportPeriod"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
<button onClick={() => { setStatementPeriodMode("singleQuarter"); setStatementPeriodFilter("all"); }}
className={`px-2.5 py-1 transition-colors ${
statementPeriodMode === "singleQuarter"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
</div>
<div className="flex gap-0.5 text-xs border rounded overflow-hidden">
<button onClick={() => setStatementView("table")}
className={`px-2.5 py-1 transition-colors ${
statementView === "table"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
<button onClick={() => setStatementView("chart")}
className={`px-2.5 py-1 transition-colors ${
statementView === "chart"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
></button>
</div>
</div>
<div className="flex gap-0.5 text-xs px-3 pb-2 flex-wrap ml-auto">
{["all", "annual", "q3", "h1", "q1"].map(f => (
<button key={f} onClick={() => setStatementPeriodFilter(f)}
className={`px-2.5 py-1 rounded transition-colors ${
statementPeriodFilter === f
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground bg-background/50"
}`}
>{periodFilterLabel(statementPeriodMode, f)}</button>
))}
</div>
</div>
</div>
{statementContent}
</div>
</TabsContent>
</Tabs>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>...
</div>
)}
</TabsContent>
</Tabs>
</CardHeader>
</Card>
);
}
+133
View File
@@ -288,6 +288,139 @@ export async function fetchStockFundFlow(code: string, name?: string, days: numb
}
}
// ---- 公司概况 ----
export interface CompanyProfile {
code: string;
name: string;
fullName: string;
englishName: string;
boardType: string;
industry: string;
exchange: string;
industryDetail: string;
chairman: string;
generalManager: string;
secretary: string;
phone: string;
email: string;
fax: string;
website: string;
officeAddress: string;
registeredAddress: string;
region: string;
postalCode: string;
registeredCapital: string;
unifiedCreditCode: string;
employees: string;
managementCount: string;
lawFirm: string;
auditor: string;
businessScope: string;
companyProfile: string;
establishmentDate: string;
listingDate: string;
listingPrice: string;
issuePriceEarningsRatio: string;
issueAmount: string;
netProceeds: string;
issueMethod: string;
}
/**
* 获取公司概况
*/
export async function fetchCompanyProfile(code: string): Promise<CompanyProfile | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/profile?code=${code}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
const result = await resp.json();
return result.data || null;
} catch (err) {
console.error("[stock-api] 获取公司概况失败:", err);
return null;
}
}
// ---- 经营分析(收入构成)----
export interface BusinessSegmentItem {
itemName: string;
income: number | null;
cost: number | null;
profit: number | null;
grossRatio: number | null;
incomeRatio: number | null;
profitRatio: number | null;
costRatio: number | null;
}
export interface BusinessSegmentPeriod {
reportName: string;
items: BusinessSegmentItem[];
}
export interface BusinessSegmentsResponse {
data: BusinessSegmentPeriod[];
count: number;
code: string;
name: string;
byType: string;
}
type SegmentType = "product" | "industry" | "region";
/**
* 获取主营构成(收入构成)
*/
export async function fetchBusinessSegments(code: string, byType: SegmentType = "product", years: number = 3): Promise<BusinessSegmentsResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/business-segments?code=${code}&by_type=${byType}&years=${years}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[stock-api] 获取主营构成失败:", err);
return null;
}
}
// ---- 财务指标 ----
export interface FinancialReportItem {
reportDate: string;
reportType: string;
reportDateName: string;
noticeDate: string;
indicators: Record<string, number | string | boolean>;
}
export interface FinancialDataResponse {
data: FinancialReportItem[];
count: number;
code: string;
name: string;
}
/**
* 获取财务指标数据
*/
export async function fetchFinancialData(code: string, years: number = 5): Promise<FinancialDataResponse | null> {
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/stock/financial?code=${code}&years=${years}`;
try {
const resp = await fetch(url);
if (!resp.ok) return null;
return await resp.json();
} catch (err) {
console.error("[stock-api] 获取财务数据失败:", err);
return null;
}
}
// ---- 板块数据 ----
export interface SectorItem {
+41 -4
View File
@@ -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<string | null>(null);
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(() => {
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() {
</Card>
)}
{/* Fund Flow Loading Skeleton */}
{fundFlowLoading && fundFlowData.length === 0 && (
<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">
@@ -934,6 +959,18 @@ function StockDetail() {
</div>
</>
)}
<StockProfileTabs
code={code}
profileTab={profileTab}
setProfileTab={setProfileTab}
companyProfile={companyProfile}
businessSegments={businessSegments}
financialData={financialData}
setBusinessSegments={setBusinessSegments}
pieType={pieType}
setPieType={setPieType}
/>
</div>
</div>
);