From 89ee44422befd2776aff693bf3b0b7858d1c3212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 21:06:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-analysis):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E6=B8=B2=E6=9F=93=E5=92=8C=E5=88=86=E6=9E=90?= =?UTF-8?q?API=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/components/analysis/AnalysisChart.tsx | 19 +++ apps/web/lib/analysis-api.test.ts | 26 ++++ apps/web/lib/analysis-api.ts | 6 + apps/web/lib/analysis-chart-renderer.test.ts | 45 +++++++ apps/web/lib/analysis-chart-renderer.ts | 124 ++++++++++++++++++ apps/web/package.json | 2 + pnpm-lock.yaml | 48 ++++++- 7 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 apps/web/components/analysis/AnalysisChart.tsx create mode 100644 apps/web/lib/analysis-api.test.ts create mode 100644 apps/web/lib/analysis-api.ts create mode 100644 apps/web/lib/analysis-chart-renderer.test.ts create mode 100644 apps/web/lib/analysis-chart-renderer.ts 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)