674 lines
25 KiB
TypeScript
674 lines
25 KiB
TypeScript
"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>近10年指标(A股)</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>
|
||
);
|
||
} |