feat(ai-analysis): 添加图表渲染和分析API客户端
This commit is contained in:
26
apps/web/lib/analysis-api.test.ts
Normal file
26
apps/web/lib/analysis-api.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { requestAnalysis } from './analysis-api';
|
||||
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';
|
||||
|
||||
test('requestAnalysis posts to /ai/analysis', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
const apiBase = resolveApiBase();
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
calls.push(String(input));
|
||||
return new Response(JSON.stringify(calls.length === 1 ? {} : { ok: false, code: 'NO_DATA', message: 'no rows' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
__resetApiAvailabilityForTests();
|
||||
const result = await requestAnalysis({ question: '哪个部门最忙', context: { surface: 'ai_assistant' } }, ['management:view']);
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(calls, [`${apiBase}/config/ai`, `${apiBase}/ai/analysis`]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
6
apps/web/lib/analysis-api.ts
Normal file
6
apps/web/lib/analysis-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
|
||||
import { api } from './api';
|
||||
|
||||
export function requestAnalysis(request: AnalysisRequest, permissions: string[] = []): Promise<AnalysisResponse> {
|
||||
return api.post<AnalysisResponse>('/ai/analysis', { ...request, permissions });
|
||||
}
|
||||
45
apps/web/lib/analysis-chart-renderer.test.ts
Normal file
45
apps/web/lib/analysis-chart-renderer.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { toEChartsOption } from './analysis-chart-renderer';
|
||||
import type { UnifiedChartSpec } from '@ftb/shared';
|
||||
|
||||
test('toEChartsOption renders line_area with smooth line and area gradient', () => {
|
||||
const spec: UnifiedChartSpec = {
|
||||
kind: 'line_area',
|
||||
title: '需求完成趋势',
|
||||
dataset: { source: [{ label: '2026-07-01', value: 3 }], x: 'label', y: 'value' },
|
||||
encoding: {
|
||||
x: { field: 'label', label: '日期' },
|
||||
y: { field: 'value', label: '完成数' },
|
||||
value: { field: 'value', label: '完成数' },
|
||||
color: { mode: 'single' },
|
||||
},
|
||||
annotations: [{ type: 'peak', label: '峰值', field: 'value', value: 3 }],
|
||||
stylePreset: 'apple_vision_light',
|
||||
};
|
||||
|
||||
const option: any = toEChartsOption(spec);
|
||||
|
||||
assert.equal(option.series[0].type, 'line');
|
||||
assert.equal(option.series[0].smooth, true);
|
||||
assert.ok(option.series[0].areaStyle);
|
||||
assert.equal(option.xAxis.show, true);
|
||||
assert.equal(option.yAxis.splitLine.show, false);
|
||||
});
|
||||
|
||||
test('toEChartsOption renders horizontal_bar with rounded bars and single color', () => {
|
||||
const option: any = toEChartsOption({
|
||||
kind: 'horizontal_bar',
|
||||
title: '部门负载',
|
||||
dataset: { source: [{ label: '研发', value: 8 }], label: 'label', value: 'value' },
|
||||
encoding: {
|
||||
value: { field: 'value', label: '待办数' },
|
||||
color: { mode: 'single' },
|
||||
},
|
||||
stylePreset: 'apple_vision_light',
|
||||
});
|
||||
|
||||
assert.equal(option.series[0].type, 'bar');
|
||||
assert.deepEqual(option.series[0].itemStyle.borderRadius, [0, 8, 8, 0]);
|
||||
assert.equal(option.color.length, 1);
|
||||
});
|
||||
124
apps/web/lib/analysis-chart-renderer.ts
Normal file
124
apps/web/lib/analysis-chart-renderer.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { UnifiedChartSpec } from '@ftb/shared';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
const ACCENT = '#0f172a';
|
||||
const MUTED = '#94a3b8';
|
||||
const RISK = '#f97316';
|
||||
|
||||
export function toEChartsOption(spec: UnifiedChartSpec): EChartsOption {
|
||||
if (spec.kind === 'line_area') return lineAreaOption(spec);
|
||||
if (spec.kind === 'horizontal_bar' || spec.kind === 'stacked_horizontal_bar') return horizontalBarOption(spec);
|
||||
if (spec.kind === 'donut') return donutOption(spec);
|
||||
return numberCardFallbackOption(spec);
|
||||
}
|
||||
|
||||
function lineAreaOption(spec: UnifiedChartSpec): EChartsOption {
|
||||
const xField = spec.dataset.x ?? spec.encoding.x?.field ?? 'label';
|
||||
const yField = spec.dataset.y ?? spec.encoding.y?.field ?? spec.encoding.value?.field ?? 'value';
|
||||
return {
|
||||
color: [ACCENT],
|
||||
grid: { left: 8, right: 8, top: 18, bottom: 24, containLabel: true },
|
||||
tooltip: { trigger: 'axis', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)', textStyle: { color: '#111827' } },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: true,
|
||||
boundaryGap: false,
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: MUTED, fontSize: 11 },
|
||||
data: spec.dataset.source.map((row) => formatCategory(row[xField])),
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: true,
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
series: [{
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
data: spec.dataset.source.map((row) => formatNumeric(row[yField])),
|
||||
lineStyle: { width: 3 },
|
||||
areaStyle: { opacity: 0.14 },
|
||||
markPoint: buildMarkPoints(spec),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function horizontalBarOption(spec: UnifiedChartSpec): EChartsOption {
|
||||
const labelField = spec.dataset.label ?? 'label';
|
||||
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
||||
const rows = spec.dataset.source.slice().reverse();
|
||||
return {
|
||||
color: [spec.encoding.color?.mode === 'risk' ? RISK : ACCENT],
|
||||
grid: { left: 8, right: 32, top: 12, bottom: 12, containLabel: true },
|
||||
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
||||
xAxis: { type: 'value', show: false },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
axisTick: { show: false },
|
||||
axisLine: { show: false },
|
||||
axisLabel: { color: '#334155', fontSize: 12 },
|
||||
data: rows.map((row) => formatCategory(row[labelField])),
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: rows.map((row) => formatNumeric(row[valueField])),
|
||||
barWidth: 12,
|
||||
itemStyle: { borderRadius: [0, 8, 8, 0] },
|
||||
label: { show: true, position: 'right', color: '#64748b', fontSize: 11 },
|
||||
animationDuration: 520,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function donutOption(spec: UnifiedChartSpec): EChartsOption {
|
||||
const labelField = spec.dataset.label ?? 'label';
|
||||
const valueField = spec.dataset.value ?? spec.encoding.value?.field ?? 'value';
|
||||
return {
|
||||
color: ['#0f172a', '#64748b', '#94a3b8', '#cbd5e1', '#e2e8f0', '#f97316', '#fb923c', '#fed7aa'],
|
||||
tooltip: { trigger: 'item', borderWidth: 0, backgroundColor: 'rgba(255,255,255,0.92)' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['62%', '82%'],
|
||||
avoidLabelOverlap: true,
|
||||
label: { color: '#334155', fontSize: 11 },
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
data: spec.dataset.source.map((row) => ({ name: formatCategory(row[labelField]), value: formatNumeric(row[valueField]) })),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function numberCardFallbackOption(spec: UnifiedChartSpec): EChartsOption {
|
||||
return horizontalBarOption({ ...spec, kind: 'horizontal_bar' });
|
||||
}
|
||||
|
||||
function buildMarkPoints(spec: UnifiedChartSpec) {
|
||||
if (!spec.annotations?.length) return undefined;
|
||||
return {
|
||||
symbolSize: 42,
|
||||
label: { fontSize: 10 },
|
||||
data: spec.annotations.map((item) => ({
|
||||
type: item.type === 'peak' ? 'max' as const : undefined,
|
||||
name: item.label,
|
||||
value: item.value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function formatCategory(value: string | number | null): string {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function formatNumeric(value: string | number | null): number {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user