diff --git a/apps/web/components/analysis/AnalysisChart.tsx b/apps/web/components/analysis/AnalysisChart.tsx
new file mode 100644
index 0000000..2a927ed
--- /dev/null
+++ b/apps/web/components/analysis/AnalysisChart.tsx
@@ -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 (
+
+
+
{spec.title}
+ {spec.subtitle &&
{spec.subtitle}
}
+
+
+
+ );
+}
diff --git a/apps/web/lib/analysis-api.test.ts b/apps/web/lib/analysis-api.test.ts
new file mode 100644
index 0000000..ee48010
--- /dev/null
+++ b/apps/web/lib/analysis-api.test.ts
@@ -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;
+ }
+});
diff --git a/apps/web/lib/analysis-api.ts b/apps/web/lib/analysis-api.ts
new file mode 100644
index 0000000..dccbc14
--- /dev/null
+++ b/apps/web/lib/analysis-api.ts
@@ -0,0 +1,6 @@
+import type { AnalysisRequest, AnalysisResponse } from '@ftb/shared';
+import { api } from './api';
+
+export function requestAnalysis(request: AnalysisRequest, permissions: string[] = []): Promise {
+ return api.post('/ai/analysis', { ...request, permissions });
+}
diff --git a/apps/web/lib/analysis-chart-renderer.test.ts b/apps/web/lib/analysis-chart-renderer.test.ts
new file mode 100644
index 0000000..357c1db
--- /dev/null
+++ b/apps/web/lib/analysis-chart-renderer.test.ts
@@ -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);
+});
diff --git a/apps/web/lib/analysis-chart-renderer.ts b/apps/web/lib/analysis-chart-renderer.ts
new file mode 100644
index 0000000..4b464cc
--- /dev/null
+++ b/apps/web/lib/analysis-chart-renderer.ts
@@ -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;
+}
diff --git a/apps/web/package.json b/apps/web/package.json
index 6b4b24a..8d59b4a 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,6 +15,8 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@ftb/shared": "workspace:*",
+ "echarts": "^6.1.0",
+ "echarts-for-react": "^3.0.6",
"lucide-react": "^1.17.0",
"next": "^14.2.0",
"pinyin-pro": "^3.28.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bc4f4b2..1754041 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -102,6 +102,12 @@ importers:
'@ftb/shared':
specifier: workspace:*
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:
specifier: ^1.17.0
version: 1.17.0(react@18.3.1)
@@ -582,28 +588,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-arm64-musl@14.2.33':
resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@next/swc-linux-x64-gnu@14.2.33':
resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@next/swc-linux-x64-musl@14.2.33':
resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@next/swc-win32-arm64-msvc@14.2.33':
resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==}
@@ -1348,6 +1350,15 @@ packages:
eastasianwidth@0.2.0:
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:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2555,6 +2566,9 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+ size-sensor@1.0.3:
+ resolution: {integrity: sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==}
+
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -2822,6 +2836,9 @@ packages:
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
engines: {node: '>=6'}
+ tslib@2.3.0:
+ resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3013,6 +3030,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ zrender@6.1.0:
+ resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
+
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
@@ -4428,6 +4448,18 @@ snapshots:
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: {}
electron-to-chromium@1.5.368: {}
@@ -5824,6 +5856,8 @@ snapshots:
sisteransi@1.0.5: {}
+ size-sensor@1.0.3: {}
+
slash@3.0.0: {}
source-map-js@1.2.1: {}
@@ -6061,6 +6095,8 @@ snapshots:
minimist: 1.2.8
strip-bom: 3.0.0
+ tslib@2.3.0: {}
+
tslib@2.8.1: {}
turbo@2.9.16:
@@ -6254,6 +6290,10 @@ snapshots:
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):
dependencies:
use-sync-external-store: 1.6.0(react@18.3.1)