fix: API path prefix + style defaults for chart rendering
- All 4 API clients now consistently use /api/v1/ prefix on all routes - ChartRenderer fills missing style fields with defaults to prevent undefined property crashes when backend returns minimal style - KPICard uses safe defaults for background/border/title Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d03ee90f40
commit
ccbe63f9e4
|
|
@ -1,6 +1,8 @@
|
|||
import { ApiClient } from './ApiClient';
|
||||
import { API_BASE_URLS } from './config';
|
||||
|
||||
const PREFIX = '/api/v1/charts';
|
||||
|
||||
export class ChartServiceClient {
|
||||
private client: ApiClient;
|
||||
|
||||
|
|
@ -8,13 +10,8 @@ export class ChartServiceClient {
|
|||
this.client = new ApiClient(API_BASE_URLS.chart);
|
||||
}
|
||||
|
||||
async createChart(
|
||||
datasetId: string,
|
||||
chartType: string,
|
||||
bindings?: any[],
|
||||
style?: any,
|
||||
): Promise<any> {
|
||||
return this.client.post<any>('/api/v1/charts', {
|
||||
async createChart(datasetId: string, chartType: string, bindings?: any[], style?: any): Promise<any> {
|
||||
return this.client.post<any>(PREFIX, {
|
||||
dataset_id: datasetId,
|
||||
chart_type: chartType,
|
||||
bindings,
|
||||
|
|
@ -23,42 +20,26 @@ export class ChartServiceClient {
|
|||
}
|
||||
|
||||
async listCharts(): Promise<any[]> {
|
||||
return this.client.get<any[]>('/charts');
|
||||
return this.client.get<any[]>(PREFIX);
|
||||
}
|
||||
|
||||
async getChart(id: string): Promise<any> {
|
||||
return this.client.get<any>(`/charts/${encodeURIComponent(id)}`);
|
||||
return this.client.get<any>(`${PREFIX}/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async updateChart(
|
||||
id: string,
|
||||
updates: {
|
||||
bindings?: any;
|
||||
style?: any;
|
||||
filters?: any;
|
||||
sort_config?: any;
|
||||
top_n?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
return this.client.put<any>(
|
||||
`/charts/${encodeURIComponent(id)}`,
|
||||
updates,
|
||||
);
|
||||
async updateChart(id: string, updates: { bindings?: any; style?: any; filters?: any; sort_config?: any; top_n?: number }): Promise<any> {
|
||||
return this.client.put<any>(`${PREFIX}/${encodeURIComponent(id)}`, updates);
|
||||
}
|
||||
|
||||
async deleteChart(id: string): Promise<void> {
|
||||
return this.client.delete(`/charts/${encodeURIComponent(id)}`);
|
||||
return this.client.delete(`${PREFIX}/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async getChartOption(id: string): Promise<any> {
|
||||
return this.client.get<any>(
|
||||
`/charts/${encodeURIComponent(id)}/option`,
|
||||
);
|
||||
return this.client.get<any>(`${PREFIX}/${encodeURIComponent(id)}/option`);
|
||||
}
|
||||
|
||||
async recommendCharts(datasetId: string): Promise<any[]> {
|
||||
return this.client.post<any[]>('/charts/recommend', {
|
||||
dataset_id: datasetId,
|
||||
});
|
||||
return this.client.post<any[]>(`${PREFIX}/recommend`, { dataset_id: datasetId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { ApiClient } from './ApiClient';
|
||||
import { API_BASE_URLS } from './config';
|
||||
|
||||
const PREFIX = '/api/v1/datasets';
|
||||
|
||||
export class DataServiceClient {
|
||||
private client: ApiClient;
|
||||
|
||||
|
|
@ -11,48 +13,38 @@ export class DataServiceClient {
|
|||
async importFile(file: File): Promise<any[]> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.client.postForm<any[]>('/api/v1/datasets/import', formData);
|
||||
return this.client.postForm<any[]>(`${PREFIX}/import`, formData);
|
||||
}
|
||||
|
||||
async listDatasets(): Promise<any[]> {
|
||||
return this.client.get<any[]>('/datasets');
|
||||
return this.client.get<any[]>(PREFIX);
|
||||
}
|
||||
|
||||
async getDataset(id: string): Promise<any> {
|
||||
return this.client.get<any>(`/datasets/${encodeURIComponent(id)}`);
|
||||
return this.client.get<any>(`${PREFIX}/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async getRows(
|
||||
id: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
): Promise<any> {
|
||||
async getRows(id: string, limit?: number, offset?: number): Promise<any> {
|
||||
const params: Record<string, string> = {};
|
||||
if (limit !== undefined) params.limit = String(limit);
|
||||
if (offset !== undefined) params.offset = String(offset);
|
||||
return this.client.get<any>(
|
||||
`/datasets/${encodeURIComponent(id)}/rows`,
|
||||
`${PREFIX}/${encodeURIComponent(id)}/rows`,
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteDataset(id: string): Promise<void> {
|
||||
return this.client.delete(`/datasets/${encodeURIComponent(id)}`);
|
||||
return this.client.delete(`${PREFIX}/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async getStructure(id: string): Promise<any> {
|
||||
return this.client.get<any>(
|
||||
`/datasets/${encodeURIComponent(id)}/structure`,
|
||||
);
|
||||
return this.client.get<any>(`${PREFIX}/${encodeURIComponent(id)}/structure`);
|
||||
}
|
||||
|
||||
async updateCell(
|
||||
id: string,
|
||||
rowIndex: number,
|
||||
data: any,
|
||||
): Promise<void> {
|
||||
async updateCell(id: string, rowIndex: number, data: any): Promise<void> {
|
||||
await this.client.put<void>(
|
||||
`/datasets/${encodeURIComponent(id)}/rows/${rowIndex}`,
|
||||
`${PREFIX}/${encodeURIComponent(id)}/rows/${rowIndex}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ApiClient } from './ApiClient';
|
||||
import { API_BASE_URLS } from './config';
|
||||
|
||||
const PREFIX = '/api/v1/exports';
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
||||
const DEFAULT_MAX_RETRIES = 60;
|
||||
|
||||
|
|
@ -11,68 +12,29 @@ export class ExportServiceClient {
|
|||
this.client = new ApiClient(API_BASE_URLS.export);
|
||||
}
|
||||
|
||||
async createExport(data: {
|
||||
format: string;
|
||||
chart_ids: string[];
|
||||
dataset_id: string;
|
||||
file_name?: string;
|
||||
}): Promise<{ id: string; status: string }> {
|
||||
return this.client.post<{ id: string; status: string }>(
|
||||
'/api/v1/exports',
|
||||
data,
|
||||
);
|
||||
async createExport(data: { format: string; chart_ids: string[]; dataset_id: string; file_name?: string }): Promise<{ id: string; status: string }> {
|
||||
return this.client.post<{ id: string; status: string }>(PREFIX, data);
|
||||
}
|
||||
|
||||
async getExportStatus(
|
||||
id: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
file_path?: string;
|
||||
error_message?: string;
|
||||
}> {
|
||||
return this.client.get<{
|
||||
id: string;
|
||||
status: string;
|
||||
file_path?: string;
|
||||
error_message?: string;
|
||||
}>(`/exports/${encodeURIComponent(id)}`);
|
||||
async getExportStatus(id: string): Promise<{ id: string; status: string; file_path?: string; error_message?: string }> {
|
||||
return this.client.get(`${PREFIX}/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async downloadExport(id: string): Promise<Blob> {
|
||||
return this.client.getBlob(
|
||||
`/exports/${encodeURIComponent(id)}/download`,
|
||||
);
|
||||
return this.client.getBlob(`${PREFIX}/${encodeURIComponent(id)}/download`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the export status until it completes, then download the result.
|
||||
* Throws if the export fails or polling exceeds `maxRetries`.
|
||||
*/
|
||||
async waitForExport(
|
||||
id: string,
|
||||
intervalMs: number = DEFAULT_POLL_INTERVAL_MS,
|
||||
maxRetries: number = DEFAULT_MAX_RETRIES,
|
||||
): Promise<Blob> {
|
||||
async waitForExport(id: string, intervalMs = DEFAULT_POLL_INTERVAL_MS, maxRetries = DEFAULT_MAX_RETRIES): Promise<Blob> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const status = await this.getExportStatus(id);
|
||||
|
||||
if (status.status === 'completed' || status.status === 'done') {
|
||||
return this.downloadExport(id);
|
||||
}
|
||||
|
||||
if (status.status === 'failed' || status.status === 'error') {
|
||||
throw new Error(
|
||||
status.error_message || `Export ${id} failed with status: ${status.status}`,
|
||||
);
|
||||
throw new Error(status.error_message || `Export ${id} failed`);
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Export ${id} did not complete within ${maxRetries} retries`,
|
||||
);
|
||||
throw new Error(`Export ${id} did not complete within ${maxRetries} retries`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { ApiClient } from './ApiClient';
|
||||
import { API_BASE_URLS } from './config';
|
||||
|
||||
const PREFIX = '/api/v1/templates';
|
||||
|
||||
export class TemplateServiceClient {
|
||||
private client: ApiClient;
|
||||
|
||||
|
|
@ -8,42 +10,31 @@ export class TemplateServiceClient {
|
|||
this.client = new ApiClient(API_BASE_URLS.template);
|
||||
}
|
||||
|
||||
async saveTemplate(data: {
|
||||
name: string;
|
||||
description: string;
|
||||
chart_configs: any[];
|
||||
layout: any[];
|
||||
theme: string;
|
||||
}): Promise<any> {
|
||||
return this.client.post<any>('/api/v1/templates', data);
|
||||
async saveTemplate(data: { name: string; description: string; chart_configs: any[]; layout: any[]; theme: string }): Promise<any> {
|
||||
return this.client.post<any>(PREFIX, data);
|
||||
}
|
||||
|
||||
async listTemplates(): Promise<any[]> {
|
||||
return this.client.get<any[]>('/templates');
|
||||
return this.client.get<any[]>(PREFIX);
|
||||
}
|
||||
|
||||
async getTemplate(id: string): Promise<any> {
|
||||
return this.client.get<any>(`/templates/${encodeURIComponent(id)}`);
|
||||
return this.client.get<any>(`${PREFIX}/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async updateTemplate(id: string, data: any): Promise<any> {
|
||||
return this.client.put<any>(
|
||||
`/templates/${encodeURIComponent(id)}`,
|
||||
data,
|
||||
);
|
||||
return this.client.put<any>(`${PREFIX}/${encodeURIComponent(id)}`, data);
|
||||
}
|
||||
|
||||
async deleteTemplate(id: string): Promise<void> {
|
||||
return this.client.delete(`/templates/${encodeURIComponent(id)}`);
|
||||
return this.client.delete(`${PREFIX}/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
async importTemplate(json: string): Promise<any> {
|
||||
return this.client.post<any>('/templates/import', JSON.parse(json));
|
||||
return this.client.post<any>(`${PREFIX}/import`, JSON.parse(json));
|
||||
}
|
||||
|
||||
async exportTemplate(id: string): Promise<any> {
|
||||
return this.client.get<any>(
|
||||
`/templates/${encodeURIComponent(id)}/export`,
|
||||
);
|
||||
return this.client.get<any>(`${PREFIX}/${encodeURIComponent(id)}/export`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,66 @@
|
|||
|
||||
import React, { useMemo } from 'react';
|
||||
import { type ChartInstance } from '@/domain/entities/ChartInstance';
|
||||
import { type StyleConfig } from '@/domain/entities/StyleConfig';
|
||||
import { chartRenderer } from '@/frameworks/di/container';
|
||||
import { EChartsBase } from './EChartsBase';
|
||||
import { KPICard } from './KPICard';
|
||||
import { DataTable } from './DataTable';
|
||||
|
||||
const DEFAULT_STYLE: StyleConfig = {
|
||||
title: { text: '', visible: true, fontSize: 16, fontWeight: 'bold', color: '#333', align: 'left' },
|
||||
colors: ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4'],
|
||||
legend: { visible: true, position: 'top', orient: 'horizontal', fontSize: 12, color: '#666' },
|
||||
xAxis: { visible: true, labelVisible: true, labelFontSize: 12, labelColor: '#666', labelRotation: 0, titleVisible: false, titleText: '', gridVisible: true, gridColor: '#eee' },
|
||||
yAxis: { visible: true, labelVisible: true, labelFontSize: 12, labelColor: '#666', labelRotation: 0, titleVisible: false, titleText: '', gridVisible: true, gridColor: '#eee' },
|
||||
dataLabel: { visible: false, fontSize: 12, color: '#333', position: 'outside', format: 'value' },
|
||||
background: { color: '#fff', opacity: 1, borderRadius: 4 },
|
||||
border: { visible: false, color: '#ddd', width: 1, style: 'solid' },
|
||||
animation: { enabled: true, duration: 500, easing: 'ease-out' },
|
||||
};
|
||||
|
||||
function ensureStyle(style: any): StyleConfig {
|
||||
if (!style || typeof style !== 'object') return DEFAULT_STYLE;
|
||||
return {
|
||||
title: { ...DEFAULT_STYLE.title, ...(style.title ?? {}) },
|
||||
colors: style.colors ?? DEFAULT_STYLE.colors,
|
||||
legend: { ...DEFAULT_STYLE.legend, ...(style.legend ?? {}) },
|
||||
xAxis: { ...DEFAULT_STYLE.xAxis, ...(style.xAxis ?? {}) },
|
||||
yAxis: { ...DEFAULT_STYLE.yAxis, ...(style.yAxis ?? {}) },
|
||||
dataLabel: { ...DEFAULT_STYLE.dataLabel, ...(style.dataLabel ?? {}) },
|
||||
background: { ...DEFAULT_STYLE.background, ...(style.background ?? {}) },
|
||||
border: { ...DEFAULT_STYLE.border, ...(style.border ?? {}) },
|
||||
animation: { ...DEFAULT_STYLE.animation, ...(style.animation ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChartRendererProps {
|
||||
chart: ChartInstance;
|
||||
data: Record<string, any>[];
|
||||
}
|
||||
|
||||
export const ChartRenderer: React.FC<ChartRendererProps> = ({ chart, data }) => {
|
||||
const echartsOption = useMemo(() => {
|
||||
if (chart.type === 'kpi' || chart.type === 'data-table') return null;
|
||||
return chartRenderer.build(chart, data);
|
||||
}, [chart, data]);
|
||||
const safeChart = useMemo(() => ({
|
||||
...chart,
|
||||
style: ensureStyle(chart.style),
|
||||
}), [chart]);
|
||||
|
||||
switch (chart.type) {
|
||||
const echartsOption = useMemo(() => {
|
||||
if (safeChart.type === 'kpi' || safeChart.type === 'data-table') return null;
|
||||
try {
|
||||
return chartRenderer.build(safeChart, data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [safeChart, data]);
|
||||
|
||||
switch (safeChart.type) {
|
||||
case 'kpi':
|
||||
return <KPICard chart={chart} data={data} />;
|
||||
return <KPICard chart={safeChart} data={data} />;
|
||||
case 'data-table':
|
||||
return <DataTable chart={chart} data={data} />;
|
||||
return <DataTable chart={safeChart} data={data} />;
|
||||
default:
|
||||
return echartsOption ? <EChartsBase option={echartsOption} /> : null;
|
||||
return echartsOption ? <EChartsBase option={echartsOption} /> : <div style={{ padding: 16, color: '#999' }}>请绑定数据字段</div>;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ export const KPICard: React.FC<KPICardProps> = ({ chart, data }) => {
|
|||
return { mainValue: val, label: lbl, yoy: yoyVal, mom: momVal };
|
||||
}, [data, valueBinding, labelBinding]);
|
||||
|
||||
const { background, border, title: titleStyle } = style;
|
||||
const background = style.background ?? { color: '#fff', opacity: 1, borderRadius: 4 };
|
||||
const border = style.border ?? { visible: false, color: '#ddd', width: 1, style: 'solid' as const };
|
||||
const titleStyle = style.title ?? { text: '', visible: true, fontSize: 16, fontWeight: 'bold' as const, color: '#333', align: 'left' as const };
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
|
|
|||
Loading…
Reference in New Issue