feat(ai-analysis): 添加图表渲染和分析API客户端

This commit is contained in:
2026-07-08 21:06:07 +08:00
parent 04db0000b5
commit 89ee44422b
7 changed files with 266 additions and 4 deletions

View File

@@ -0,0 +1,19 @@
'use client';
import dynamic from 'next/dynamic';
import type { UnifiedChartSpec } from '@ftb/shared';
import { toEChartsOption } from '@/lib/analysis-chart-renderer';
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false });
export function AnalysisChart({ spec }: { spec: UnifiedChartSpec }) {
return (
<div className="min-h-[260px] rounded-[28px] border border-white/60 bg-white/75 p-4 shadow-[0_18px_60px_rgba(15,23,42,0.08)] backdrop-blur-xl">
<div className="mb-3">
<h3 className="text-[14px] font-semibold text-[#111827]">{spec.title}</h3>
{spec.subtitle && <p className="mt-1 text-[12px] text-[#64748b]">{spec.subtitle}</p>}
</div>
<ReactECharts option={toEChartsOption(spec)} style={{ height: 240, width: '100%' }} notMerge lazyUpdate />
</div>
);
}

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

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

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

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

View File

@@ -15,6 +15,8 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@ftb/shared": "workspace:*", "@ftb/shared": "workspace:*",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next": "^14.2.0", "next": "^14.2.0",
"pinyin-pro": "^3.28.1", "pinyin-pro": "^3.28.1",

48
pnpm-lock.yaml generated
View File

@@ -102,6 +102,12 @@ importers:
'@ftb/shared': '@ftb/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
echarts:
specifier: ^6.1.0
version: 6.1.0
echarts-for-react:
specifier: ^3.0.6
version: 3.0.6(echarts@6.1.0)(react@18.3.1)
lucide-react: lucide-react:
specifier: ^1.17.0 specifier: ^1.17.0
version: 1.17.0(react@18.3.1) version: 1.17.0(react@18.3.1)
@@ -582,28 +588,24 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@14.2.33': '@next/swc-linux-arm64-musl@14.2.33':
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@14.2.33': '@next/swc-linux-x64-gnu@14.2.33':
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@14.2.33': '@next/swc-linux-x64-musl@14.2.33':
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@14.2.33': '@next/swc-win32-arm64-msvc@14.2.33':
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
@@ -1348,6 +1350,15 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts-for-react@3.0.6:
resolution: {integrity: sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==}
peerDependencies:
echarts: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
react: ^15.0.0 || >=16.0.0
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2555,6 +2566,9 @@ packages:
sisteransi@1.0.5: sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
size-sensor@1.0.3:
resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
slash@3.0.0: slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2822,6 +2836,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'} engines: {node: '>=6'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3013,6 +3030,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zustand@4.5.7: zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'} engines: {node: '>=12.7.0'}
@@ -4428,6 +4448,18 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
echarts-for-react@3.0.6(echarts@6.1.0)(react@18.3.1):
dependencies:
echarts: 6.1.0
fast-deep-equal: 3.1.3
react: 18.3.1
size-sensor: 1.0.3
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
ee-first@1.1.1: {} ee-first@1.1.1: {}
electron-to-chromium@1.5.368: {} electron-to-chromium@1.5.368: {}
@@ -5824,6 +5856,8 @@ snapshots:
sisteransi@1.0.5: {} sisteransi@1.0.5: {}
size-sensor@1.0.3: {}
slash@3.0.0: {} slash@3.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
@@ -6061,6 +6095,8 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
strip-bom: 3.0.0 strip-bom: 3.0.0
tslib@2.3.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
turbo@2.9.16: turbo@2.9.16:
@@ -6254,6 +6290,10 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zustand@4.5.7(@types/react@18.3.31)(react@18.3.1): zustand@4.5.7(@types/react@18.3.31)(react@18.3.1):
dependencies: dependencies:
use-sync-external-store: 1.6.0(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1)