Skip to content

Commit ca2c931

Browse files
authored
新增表格设置弹窗 (#9)
* chore: 发布v1.14.0-dev版本并修复widget url拼接逻辑 1. 更新package.json和多语言文件中的版本号到v1.14.0-dev 2. 重构DebugWidget的url拼接逻辑,新增对/v1/proxy接口的单独处理 3. 为DashboardCanvas添加useMemo缓存过滤结果,同时修复重复widget/layout项的问题 * feat(table): add table settings modal and related features - 新增表格设置弹窗,支持配置小数位数和可见列 - 为表格组件添加小数位数格式化和列显示控制功能 - 新增多语言国际化词条支持中英文界面 - 添加表格渲染器测试用例覆盖格式化逻辑 - 实现仪表盘内表格设置的状态管理与持久化 * release: bump version to 1.14.0 将项目版本从开发版1.14.0-dev正式更新为1.14.0,同步更新中英文国际化文件中的版本号展示
1 parent 1382c65 commit ca2c931

10 files changed

Lines changed: 599 additions & 51 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "finanalyzer-app",
33
"private": true,
4-
"version": "1.13.16",
4+
"version": "1.14.0",
55
"description": "Finanalyzer 前端应用 - 投资组合管理平台",
66
"type": "module",
77
"scripts": {

src/components/dashboard/DashboardCanvas.tsx

Lines changed: 143 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Button, Icon } from "@openbb/ui";
2-
import { useCallback, useEffect, useRef, useState } from "react";
2+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
33
import type { Layout, LayoutItem } from "react-grid-layout";
44
import { ResponsiveGridLayout } from "react-grid-layout";
55
import "react-grid-layout/css/styles.css";
@@ -11,6 +11,7 @@ import {
1111
getDashboard,
1212
getDashboards,
1313
updateDashboard,
14+
updateWidget as updateWidgetApi,
1415
type Dashboard as ApiDashboard,
1516
type Widget as ApiWidget,
1617
} from "../../services/dashboardApi";
@@ -24,8 +25,10 @@ import {
2425
MarkdownWidget,
2526
MetricWidget,
2627
TableWidget,
28+
TableSettingsModal,
2729
WidgetMenuModal,
2830
} from "../widgets";
31+
import type { TableSettings } from "../widgets/TableSettingsModal";
2932
import { NavigationBar } from "../widgets/NavigationBar";
3033
import "./DashboardCanvas.css";
3134
import { ParameterInput } from "./ParameterInput";
@@ -121,18 +124,38 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
121124
// Determine active tab (use prop if provided, otherwise first tab)
122125
const activeTab = propActiveTab || (tabs.length > 0 ? tabs[0].id : "");
123126

124-
// Filter widgets based on active tab
125-
const filteredWidgets = tabs.length > 0
126-
? widgets.filter(widget => widget.data?.tabId === activeTab)
127-
: widgets;
128-
129-
// Filter layout based on active tab
130-
const filteredLayout = tabs.length > 0
131-
? layout.filter(item => {
132-
const widget = widgets.find(w => w.id === item.i);
133-
return widget?.data?.tabId === activeTab;
134-
})
135-
: layout;
127+
// Filter widgets based on active tab (with deduplication by widget.id)
128+
const filteredWidgets = useMemo(() => {
129+
const base = tabs.length > 0
130+
? widgets.filter(widget => widget.data?.tabId === activeTab)
131+
: widgets;
132+
133+
// Deduplicate by widget.id, keeping the first occurrence
134+
const seen = new Set<string>();
135+
return base.filter(widget => {
136+
if (!widget.id || seen.has(widget.id)) return false;
137+
seen.add(widget.id);
138+
return true;
139+
});
140+
}, [widgets, tabs, activeTab]);
141+
142+
// Filter layout based on active tab (with deduplication by item.i)
143+
const filteredLayout = useMemo(() => {
144+
const baseLayout = tabs.length > 0
145+
? layout.filter(item => {
146+
const widget = widgets.find(w => w.id === item.i);
147+
return widget?.data?.tabId === activeTab;
148+
})
149+
: layout;
150+
151+
// Deduplicate by item.i, keeping the first occurrence
152+
const seen = new Set<string>();
153+
return baseLayout.filter(item => {
154+
if (!item.i || seen.has(item.i)) return false;
155+
seen.add(item.i);
156+
return true;
157+
});
158+
}, [layout, widgets, tabs, activeTab]);
136159
const [loading, setLoading] = useState(true);
137160
const [error, setError] = useState<string | null>(null);
138161
const [gridWidth, setGridWidth] = useState(1200);
@@ -161,6 +184,8 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
161184
const fileInputRef = useRef<HTMLInputElement>(null);
162185
const [customWidgets, setCustomWidgets] = useState<WidgetConfig[]>([]);
163186
const [groupConfigs, setGroupConfigs] = useState<Group[]>([]);
187+
const [tableSettings, setTableSettings] = useState<Record<string, TableSettings>>({});
188+
const [openTableSettingsWidgetId, setOpenTableSettingsWidgetId] = useState<string | null>(null);
164189

165190
useEffect(() => {
166191
if (propId && propId !== activeId) {
@@ -198,6 +223,18 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
198223
h: widget.position.h,
199224
}));
200225
setLayout(rawLayout);
226+
// 初始化tableSettings
227+
const initialTableSettings: Record<string, TableSettings> = {};
228+
w.forEach(widget => {
229+
const savedConfig = (widget.data as Record<string, unknown>)?.tableConfig as Record<string, unknown> | undefined;
230+
if (savedConfig) {
231+
initialTableSettings[widget.id] = {
232+
decimalPlaces: savedConfig.decimalPlaces as number || 2,
233+
visibleColumns: (savedConfig.visibleColumns as string[]) || [],
234+
};
235+
}
236+
});
237+
setTableSettings(initialTableSettings);
201238
} else {
202239
setError("Dashboard not found");
203240
}
@@ -217,6 +254,18 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
217254
h: widget.position.h,
218255
}));
219256
setLayout(rawLayout);
257+
// 初始化tableSettings
258+
const initialTableSettings: Record<string, TableSettings> = {};
259+
w.forEach(widget => {
260+
const savedConfig = (widget.data as Record<string, unknown>)?.tableConfig as Record<string, unknown> | undefined;
261+
if (savedConfig) {
262+
initialTableSettings[widget.id] = {
263+
decimalPlaces: savedConfig.decimalPlaces as number || 2,
264+
visibleColumns: (savedConfig.visibleColumns as string[]) || [],
265+
};
266+
}
267+
});
268+
setTableSettings(initialTableSettings);
220269
}
221270
}
222271
} catch (e) {
@@ -495,7 +544,7 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
495544

496545
const handleLayoutChange = useCallback(
497546
(newLayout: Layout, _layouts: any) => {
498-
const layoutItems = newLayout as LayoutItem[];
547+
const layoutItems = (newLayout as LayoutItem[]).filter((item) => item.i);
499548
setLayout((prevLayout) => {
500549
const newLayoutMap = new Map(layoutItems.map((item) => [item.i, item]));
501550
return prevLayout.map((item) => newLayoutMap.get(item.i) || item);
@@ -830,7 +879,12 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
830879
<div className="py-1">
831880
<button
832881
className="widget-dropdown-item block px-4 py-2 text-sm w-full text-left"
833-
onClick={() => setOpenMenuWidgetId(null)}
882+
onClick={() => {
883+
setOpenMenuWidgetId(null);
884+
if (widget.type === "table") {
885+
setOpenTableSettingsWidgetId(widget.id);
886+
}
887+
}}
834888
>
835889
Settings
836890
</button>
@@ -936,6 +990,7 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
936990
error: widgetErrors[widget.id],
937991
} as any
938992
}
993+
settings={tableSettings[widget.id]}
939994
mode={widgetDebugModes[widget.id] ? "debug" : undefined}
940995
onUpdate={(params: Record<string, unknown>) => {
941996
Object.entries(params).forEach(([paramName, value]) => {
@@ -1211,6 +1266,79 @@ export function DashboardCanvas({ dashboardId: propId, activeTab: propActiveTab,
12111266
widgets={[...widgetDefinitions, ...customWidgets]}
12121267
loading={!widgetDefinitions.length && !customWidgets.length}
12131268
/>
1269+
{openTableSettingsWidgetId && (() => {
1270+
const widget = widgets.find(w => w.id === openTableSettingsWidgetId);
1271+
const columnDefs = (() => {
1272+
if (!widget) return [];
1273+
const widgetAny = widget as unknown as Record<string, unknown>;
1274+
1275+
// Check path 1: widget.tableConfig.columnsDefs
1276+
const tableConfig = widgetAny.tableConfig as Record<string, unknown> | undefined;
1277+
if (tableConfig && tableConfig.columnsDefs) {
1278+
const columnsDefs = tableConfig.columnsDefs as Array<{ field: string; headerName?: string }>;
1279+
if (columnsDefs && Array.isArray(columnsDefs) && columnsDefs.length > 0) {
1280+
return columnsDefs;
1281+
}
1282+
}
1283+
1284+
// Check path 2: widget.data.data.table.columnsDefs
1285+
const widgetData = widgetAny.data as Record<string, unknown> | undefined;
1286+
if (widgetData) {
1287+
const tableDataObj = widgetData.data as Record<string, unknown> | undefined;
1288+
if (tableDataObj) {
1289+
const table = tableDataObj.table as Record<string, unknown> | undefined;
1290+
if (table && table.columnsDefs) {
1291+
const columnsDefs = table.columnsDefs as Array<{ field: string; headerName?: string }>;
1292+
if (columnsDefs && Array.isArray(columnsDefs) && columnsDefs.length > 0) {
1293+
return columnsDefs;
1294+
}
1295+
}
1296+
}
1297+
1298+
// Check path 3: widget.data.columnsDefs
1299+
if (widgetData.columnsDefs) {
1300+
const columnsDefs = widgetData.columnsDefs as Array<{ field: string; headerName?: string }>;
1301+
if (columnsDefs && Array.isArray(columnsDefs) && columnsDefs.length > 0) {
1302+
return columnsDefs;
1303+
}
1304+
}
1305+
}
1306+
1307+
return [];
1308+
})();
1309+
// 从widget.data中读取已保存的配置
1310+
const savedConfig = (widget?.data as Record<string, unknown>)?.tableConfig as Record<string, unknown> | undefined;
1311+
const availableCols = columnDefs.map(c => c.field);
1312+
const currentSettings: TableSettings = {
1313+
decimalPlaces: savedConfig?.decimalPlaces as number || tableSettings[openTableSettingsWidgetId]?.decimalPlaces || 2,
1314+
visibleColumns: (savedConfig?.visibleColumns as string[]) || tableSettings[openTableSettingsWidgetId]?.visibleColumns || availableCols,
1315+
};
1316+
return (
1317+
<TableSettingsModal
1318+
isOpen={!!openTableSettingsWidgetId}
1319+
onClose={() => setOpenTableSettingsWidgetId(null)}
1320+
onSave={(settings) => {
1321+
// 更新前端状态
1322+
setTableSettings((prev) => ({
1323+
...prev,
1324+
[openTableSettingsWidgetId!]: settings,
1325+
}));
1326+
// 调用API保存到后端
1327+
if (activeId && widget) {
1328+
const updatedData = {
1329+
...widget.data,
1330+
tableConfig: settings,
1331+
};
1332+
updateWidgetApi(activeId, widget.id, { data: updatedData });
1333+
}
1334+
// 刷新widget以应用新设置
1335+
handleRefreshWidget(openTableSettingsWidgetId!);
1336+
}}
1337+
currentSettings={currentSettings}
1338+
columnDefs={columnDefs}
1339+
/>
1340+
);
1341+
})()}
12141342
<input
12151343
ref={fileInputRef}
12161344
type="file"

src/components/widgets/DebugWidget.tsx

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ function DebugWidget({
395395
if (!widgetDef) return "";
396396

397397
const endpoint = widgetDef.endpoint;
398+
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
398399

399400
// Check if endpoint is a full URL (starts with http:// or https://)
400401
const isFullUrl = endpoint.startsWith('http://') || endpoint.startsWith('https://');
@@ -405,19 +406,27 @@ function DebugWidget({
405406
// If endpoint is a full URL, use it directly without connectionUrl
406407
url = endpoint;
407408
} else {
408-
const connectionUrl = widgetDef.connectionUrl || "";
409-
410-
// Normalize connectionUrl: remove trailing slash
411-
const normalizedBaseUrl = connectionUrl.endsWith('/')
412-
? connectionUrl.slice(0, -1)
413-
: connectionUrl;
414-
415-
// Ensure endpoint starts with /
416-
const normalizedEndpoint = endpoint.startsWith('/')
417-
? endpoint
418-
: `/${endpoint}`;
419-
420-
url = `${normalizedBaseUrl}${normalizedEndpoint}`;
409+
// For proxy endpoint, use API_BASE_URL
410+
if (endpoint.startsWith('/v1/proxy')) {
411+
const normalizedBaseUrl = API_BASE_URL.endsWith('/')
412+
? API_BASE_URL.slice(0, -1)
413+
: API_BASE_URL;
414+
url = `${normalizedBaseUrl}${endpoint}`;
415+
} else {
416+
const connectionUrl = widgetDef.connectionUrl || "";
417+
418+
// Normalize connectionUrl: remove trailing slash
419+
const normalizedBaseUrl = connectionUrl.endsWith('/')
420+
? connectionUrl.slice(0, -1)
421+
: connectionUrl;
422+
423+
// Ensure endpoint starts with /
424+
const normalizedEndpoint = endpoint.startsWith('/')
425+
? endpoint
426+
: `/${endpoint}`;
427+
428+
url = `${normalizedBaseUrl}${normalizedEndpoint}`;
429+
}
421430
}
422431

423432
const queryParams = new URLSearchParams();

0 commit comments

Comments
 (0)