Fundamental_Analysis/frontend/src/app/page.tsx
2025-10-21 20:17:14 +08:00

674 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* 主页组件 - 上市公司基本面分析平台
*
* 功能包括:
* - 股票搜索和建议
* - 财务数据查询和展示
* - 表格行配置管理
* - 执行状态显示
* - 图表可视化
*/
import { useState, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { StatusBar, useStatusBar } from "@/components/ui/status-bar";
import { createDefaultStepManager } from "@/lib/execution-step-manager";
import { RowSettingsPanel } from "@/components/ui/row-settings";
import { useRowConfig } from "@/hooks/use-row-config";
import { EnhancedTable } from "@/components/ui/enhanced-table";
import { Notification } from "@/components/ui/notification";
import {
normalizeTsCode,
flattenApiGroups,
enhanceErrorMessage,
isRetryableError,
formatFinancialValue,
getMetricUnit
} from "@/lib/financial-utils";
import type {
MarketType,
ChartType,
CompanyInfo,
CompanySuggestion,
RevenueDataPoint,
FinancialMetricConfig,
FinancialDataSeries,
ExecutionStep,
BatchFinancialDataResponse,
FinancialConfigResponse,
SearchApiResponse,
BatchDataRequest
} from "@/types";
import {
ResponsiveContainer,
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from "recharts";
export default function Home() {
// ============================================================================
// 基础状态管理
// ============================================================================
const [market, setMarket] = useState<MarketType>("cn");
const [query, setQuery] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [chartType, setChartType] = useState<ChartType>("bar");
// ============================================================================
// 数据状态管理
// ============================================================================
const [items, setItems] = useState<RevenueDataPoint[]>([]);
const [selected, setSelected] = useState<CompanyInfo | null>(null);
const [configItems, setConfigItems] = useState<FinancialMetricConfig[]>([]);
const [metricSeries, setMetricSeries] = useState<FinancialDataSeries>({});
const [selectedMetric, setSelectedMetric] = useState<string>("revenue");
const [selectedMetricName, setSelectedMetricName] = useState<string>("营业收入");
const [paramToGroup, setParamToGroup] = useState<Record<string, string>>({});
const [paramToApi, setParamToApi] = useState<Record<string, string>>({});
// ============================================================================
// 搜索相关状态
// ============================================================================
const [suggestions, setSuggestions] = useState<CompanySuggestion[]>([]);
const [typingTimer, setTypingTimer] = useState<NodeJS.Timeout | null>(null);
// ============================================================================
// 状态栏管理
// ============================================================================
const {
statusBarState,
showStatusBar,
showSuccess,
showError,
hideStatusBar
} = useStatusBar();
// ============================================================================
// 执行步骤管理
// ============================================================================
const [stepManager] = useState(() => {
return createDefaultStepManager({
onStepStart: (step: ExecutionStep, index: number, total: number) => {
showStatusBar(step, index, total);
},
onStepComplete: (_step: ExecutionStep, index: number, total: number) => {
// If there are more steps, update to next step
if (index < total - 1) {
// This will be handled by the next step start
} else {
// All steps completed
showSuccess();
}
},
onStepError: (_step: ExecutionStep, _index: number, _total: number, error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
},
onComplete: () => {
showSuccess();
},
onError: (error: Error) => {
// 判断错误是否可重试
const isRetryable = isRetryableError(error) || stepManager.canRetry();
showError(error.message, isRetryable);
}
});
});
// ============================================================================
// 表格行配置管理
// ============================================================================
const [isRowSettingsPanelOpen, setIsRowSettingsPanelOpen] = useState(false);
// Row configuration management - memoize to prevent infinite re-renders
const rowIds = useMemo(() =>
configItems.map(item => item.tushareParam || '').filter(Boolean),
[configItems]
);
const {
rowConfigs,
customRows,
updateRowConfig,
saveStatus,
clearSaveStatus,
addCustomRow,
deleteCustomRow,
updateRowOrder
} = useRowConfig(selected?.ts_code || null, rowIds);
const rowDisplayTexts = useMemo(() => {
const texts = configItems.reduce((acc, item) => {
if (item.tushareParam) {
acc[item.tushareParam] = item.displayText;
}
return acc;
}, {} as Record<string, string>);
// 添加自定义行的显示文本
Object.entries(customRows).forEach(([rowId, customRow]) => {
texts[rowId] = customRow.displayText;
});
return texts;
}, [configItems, customRows]);
// ============================================================================
// 搜索建议功能
// ============================================================================
/**
* 获取搜索建议
* @param text - 搜索文本
*/
async function fetchSuggestions(text: string): Promise<void> {
if (market !== "cn") {
setSuggestions([]);
return;
}
const searchQuery = (text || "").trim();
if (!searchQuery) {
setSuggestions([]);
return;
}
try {
const response = await fetch(
`http://localhost:8000/api/search?query=${encodeURIComponent(searchQuery)}&limit=8`
);
const data: SearchApiResponse = await response.json();
const suggestions = Array.isArray(data?.items) ? data.items : [];
setSuggestions(suggestions);
} catch (error) {
console.warn('Failed to fetch suggestions:', error);
setSuggestions([]);
}
}
// ============================================================================
// 搜索处理功能
// ============================================================================
/**
* 重试搜索的函数
*/
const retrySearch = async (): Promise<void> => {
if (stepManager.canRetry()) {
try {
await stepManager.retry();
} catch {
// 错误已经在stepManager中处理
}
} else {
// 如果不能重试,重新执行搜索
await handleSearch();
}
};
/**
* 处理搜索请求
*/
async function handleSearch(): Promise<void> {
// 重置状态
setError("");
setItems([]);
setMetricSeries({});
setConfigItems([]);
setSelectedMetric("revenue");
// 验证市场支持
if (market !== "cn") {
setError("目前仅支持 A 股查询,请选择\"中国\"市场。");
return;
}
// 获取并验证股票代码
const tsCode = selected?.ts_code || normalizeTsCode(query);
if (!tsCode) {
setError("请输入有效的 A 股股票代码(如 600519 / 000001 或带后缀 600519.SH。");
return;
}
setLoading(true);
try {
// 创建搜索执行步骤
const searchStep: ExecutionStep = {
id: 'fetch_financial_data',
name: '正在读取财务数据',
description: '从Tushare API获取公司财务指标数据',
execute: async () => {
await executeSearchStep(tsCode);
}
};
// 清空之前的步骤并添加新的搜索步骤
stepManager.clearSteps();
stepManager.addStep(searchStep);
// 执行搜索步骤
await stepManager.execute();
} catch (error) {
const errorMsg = enhanceErrorMessage(error);
setError(errorMsg);
// 错误处理已经在stepManager的回调中处理
} finally {
setLoading(false);
}
}
/**
* 执行搜索步骤的具体逻辑
* @param tsCode - 股票代码
*/
async function executeSearchStep(tsCode: string): Promise<void> {
// 1) 获取配置tushare专用解析 api_groups -> 扁平 items
const configResponse = await fetch(`http://localhost:8000/api/financial-config`);
const configData: FinancialConfigResponse = await configResponse.json();
const groups = configData?.api_groups || {};
const { items, groupMap, apiMap } = flattenApiGroups(groups);
setConfigItems(items);
setParamToGroup(groupMap);
setParamToApi(apiMap);
// 2) 批量请求年度序列同API字段合并读取
const years = 10;
const metrics = Array.from(new Set(["revenue", ...items.map(i => i.tushareParam)]));
const batchRequest: BatchDataRequest = {
ts_code: tsCode,
years,
metrics
};
const batchResponse = await fetch(
`http://localhost:8000/api/financialdata/${encodeURIComponent(market)}/batch`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batchRequest),
}
);
const batchData: BatchFinancialDataResponse = await batchResponse.json();
const seriesObj = batchData?.series || {};
// 处理数据系列
const processedSeries: FinancialDataSeries = {};
for (const metric of metrics) {
const series = Array.isArray(seriesObj[metric]) ? seriesObj[metric] : [];
processedSeries[metric] = [...series].sort((a, b) => Number(a.year) - Number(b.year));
}
setMetricSeries(processedSeries);
// 3) 设置选中公司与默认图表序列(收入)
setSelected({
ts_code: batchData?.ts_code || tsCode,
name: batchData?.name
});
const revenueSeries = processedSeries["revenue"] || [];
setItems(revenueSeries.map(d => ({ year: d.year, revenue: d.value })));
const revenueName = items.find(i => i.tushareParam === "revenue")?.displayText || "营业收入";
setSelectedMetricName(revenueName);
if (revenueSeries.length === 0) {
throw new Error("未查询到数据,请确认代码或稍后重试。");
}
}
// ============================================================================
// 渲染组件
// ============================================================================
return (
<div className="space-y-8">
{/* StatusBar Component */}
<StatusBar
isVisible={statusBarState.isVisible}
currentStep={statusBarState.currentStep}
stepIndex={statusBarState.stepIndex}
totalSteps={statusBarState.totalSteps}
status={statusBarState.status}
errorMessage={statusBarState.errorMessage}
onDismiss={hideStatusBar}
onRetry={retrySearch}
retryable={statusBarState.retryable}
/>
{/* Configuration Save Status Notification */}
{saveStatus.status !== 'idle' && (
<Notification
message={saveStatus.message || ''}
type={saveStatus.status === 'success' ? 'success' : saveStatus.status === 'error' ? 'error' : 'info'}
isVisible={true}
onDismiss={clearSaveStatus}
position="bottom-right"
autoHide={saveStatus.status === 'success'}
autoHideDelay={2000}
/>
)}
{/* Row Settings Panel */}
<RowSettingsPanel
isOpen={isRowSettingsPanelOpen}
onClose={() => setIsRowSettingsPanelOpen(false)}
rowConfigs={rowConfigs}
rowDisplayTexts={rowDisplayTexts}
onConfigChange={updateRowConfig}
onRowOrderChange={updateRowOrder}
onDeleteCustomRow={deleteCustomRow}
enableRowReordering={true}
/>
<section className="space-y-3">
<h1 className="text-2xl font-semibold"></h1>
<p className="text-sm text-muted-foreground">
使 Next.js + shadcn/ui
</p>
<div className="flex gap-2 max-w-xl relative">
<Select value={market} onValueChange={(v) => setMarket(v as MarketType)}>
<SelectTrigger className="w-28 sm:w-40" aria-label="选择市场">
<SelectValue placeholder="选择市场" />
</SelectTrigger>
<SelectContent>
<SelectItem value="cn"></SelectItem>
<SelectItem value="us"></SelectItem>
<SelectItem value="hk"></SelectItem>
<SelectItem value="jp"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
<Input
placeholder="输入股票代码或公司名,例如 600519 / 000001 / 贵州茅台"
value={query}
onChange={(e) => {
const v = e.target.value;
setQuery(v);
setSelected(null);
if (typingTimer) clearTimeout(typingTimer);
const t = setTimeout(() => fetchSuggestions(v), 250);
setTypingTimer(t);
}}
/>
{/* 下拉建议 */}
{suggestions.length > 0 && (
<div className="absolute top-12 left-0 right-0 z-10 bg-white border rounded shadow">
{suggestions.map((s, i) => (
<div
key={s.ts_code + i}
className="px-3 py-2 hover:bg-gray-100 cursor-pointer flex justify-between"
onClick={() => {
setQuery(`${s.ts_code} ${s.name}`);
setSelected({ ts_code: s.ts_code, name: s.name });
setSuggestions([]);
}}
>
<span>{s.name}</span>
<span className="text-muted-foreground">{s.ts_code}</span>
</div>
))}
</div>
)}
<Button onClick={handleSearch} disabled={loading}>
{loading ? "查询中..." : "搜索"}
</Button>
</div>
{error && <Badge variant="secondary">{error}</Badge>}
</section>
<section>
{items.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle>10A股</CardTitle>
<CardDescription> Tushare{selected && selected.name ? `${selected.name} (${selected.ts_code})` : selected?.ts_code}</CardDescription>
</div>
<div className="mt-1">
<Select value={chartType} onValueChange={(v) => setChartType(v as ChartType)}>
<SelectTrigger className="w-40" aria-label="选择图表类型">
<SelectValue placeholder="选择图表类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bar"></SelectItem>
<SelectItem value="line">线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* 使用 Recharts 渲染图表(动态单位) */}
{(() => {
// 获取当前选中指标的信息
const currentMetricInfo = configItems.find(ci => (ci.tushareParam || "") === selectedMetric);
const metricGroup = currentMetricInfo?.group;
const metricApi = currentMetricInfo?.api;
const metricUnit = getMetricUnit(metricGroup, metricApi, selectedMetric);
// 构建完整的图例名称
const legendName = `${selectedMetricName}${metricUnit}`;
// 根据指标类型确定数据缩放和单位
const shouldScaleToYi = (
metricGroup === "income" ||
metricGroup === "balancesheet" ||
metricGroup === "cashflow" ||
selectedMetric === "total_mv"
);
const chartData = items.map((d) => {
let scaledValue = typeof d.revenue === "number" ? d.revenue : 0;
if (shouldScaleToYi) {
// 对于财务报表数据,转换为亿元
if (selectedMetric === "total_mv") {
// 市值从万元转为亿元
scaledValue = scaledValue / 1e4;
} else {
// 其他财务数据从元转为亿元
scaledValue = scaledValue / 1e8;
}
}
return {
year: d.year,
metricValue: scaledValue,
};
});
return (
<div className="w-full h-[320px]">
<ResponsiveContainer width="100%" height="100%">
{chartType === "bar" ? (
<BarChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Bar dataKey="metricValue" name={legendName} fill="#4f46e5" />
</BarChart>
) : (
<LineChart data={chartData} margin={{ top: 16, right: 16, left: 8, bottom: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="year" />
<YAxis tickFormatter={(v) => `${Math.round(v)}`} />
<Tooltip formatter={(value) => {
const v = Number(value);
const nf1 = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const nf0 = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 });
if (shouldScaleToYi) {
if (selectedMetric === "total_mv") {
return [`${nf0.format(Math.round(v))} 亿元`, selectedMetricName];
} else {
return [`${nf1.format(v)} 亿元`, selectedMetricName];
}
} else {
return [`${nf1.format(v)}`, selectedMetricName];
}
}} />
<Legend />
<Line type="monotone" dataKey="metricValue" name={legendName} stroke="#4f46e5" dot />
</LineChart>
)}
</ResponsiveContainer>
</div>
);
})()}
{/* 增强数据表格 */}
{(() => {
const series = metricSeries;
const allYears = Object.values(series)
.flat()
.map(d => d.year);
if (allYears.length === 0) return null;
const yearsDesc = Array.from(new Set(allYears)).sort((a, b) => Number(b) - Number(a));
const columns = yearsDesc;
function valueOf(m: string, year: string): number | null | undefined {
const s = series[m] || [];
const f = s.find(d => d.year === year);
return f ? f.value : undefined;
}
function fmtCell(m: string, y: string): string {
const v = valueOf(m, y);
const group = paramToGroup[m] || "";
const api = paramToApi[m] || "";
return formatFinancialValue(v, group, api, m);
}
// 行点击切换图表数据源
function onRowClick(m: string) {
setSelectedMetric(m);
// 指标中文名传给图表
const rowInfo = configItems.find(ci => (ci.tushareParam || "") === m);
setSelectedMetricName(rowInfo?.displayText || m);
const s = series[m] || [];
setItems(s.map(d => ({ year: d.year, revenue: d.value })));
}
// 准备表格数据
const baseTableData = configItems.map((row, idx) => {
const m = (row.tushareParam || "").trim();
const values: Record<string, string> = {};
if (m) {
columns.forEach(year => {
values[year] = fmtCell(m, year);
});
} else {
columns.forEach(year => {
values[year] = "-";
});
}
return {
id: m || `row_${idx}`,
displayText: row.displayText + getMetricUnit(row.group, row.api, row.tushareParam),
values,
group: row.group,
api: row.api,
tushareParam: row.tushareParam,
isCustomRow: false
};
});
// 添加自定义行数据(仅分隔线)
const customRowData = Object.entries(customRows)
.filter(([, customRow]) => customRow.customRowType === 'separator')
.map(([rowId, customRow]) => {
const values: Record<string, string> = {};
columns.forEach(year => {
values[year] = "-"; // 分隔线不显示数据
});
return {
id: rowId,
displayText: customRow.displayText,
values,
isCustomRow: true,
customRowType: customRow.customRowType
};
});
// 合并基础数据和自定义行数据
const tableData = [...baseTableData, ...customRowData];
return (
<EnhancedTable
data={tableData}
columns={columns}
rowConfigs={rowConfigs}
selectedRowId={selectedMetric}
onRowClick={onRowClick}
onRowConfigChange={updateRowConfig}
onOpenSettings={() => setIsRowSettingsPanelOpen(true)}
onAddCustomRow={addCustomRow}
onDeleteCustomRow={deleteCustomRow}
onRowOrderChange={updateRowOrder}
enableAnimations={true}
animationDuration={300}
enableVirtualization={configItems.length > 50}
maxVisibleRows={50}
enableRowDragging={true}
/>
);
})()}
</CardContent>
</Card>
)}
</section>
</div>
);
}