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:
hailin 2026-04-05 01:53:58 -07:00
parent d03ee90f40
commit ccbe63f9e4
6 changed files with 89 additions and 124 deletions

View File

@ -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 });
}
}

View File

@ -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,
);
}

View File

@ -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`);
}
}

View File

@ -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`);
}
}

View File

@ -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>;
}
};

View File

@ -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