生成式 UI 在 Dashboard 搭建中的降本增效:从设计稿到可交互原型
数据看板的搭建长期是前端团队的高频重复工作。不同业务线、不同角色需要的看板布局和图表组合千差万别,手工开发效率严重受限。本文复盘将生成式 UI 技术引入 Dashboard 搭建流程的实践,量化分析从设计稿到可交互原型的全链路效率提升。
一、传统 Dashboard 的开发瓶颈
某数据平台需要为运营、销售、财务三个部门提供定制化 Dashboard。以运营 Dashboard 为例,一个完整看板包含 15-20 个图表卡片,涵盖折线图、柱状图、饼图、数据表格、指标卡等 5+ 种图表类型。从需求评审到上线,平均耗时 9 个工作日。
瓶颈集中在三个环节:
- 布局搭建:CSS Grid/Flexbox 的手写布局,不同屏幕尺寸的响应式适配
- 图表配置:ECharts/AntV 的 option 配置冗长,类型复杂,参数众多
- 数据对接:每个图表的 API 接口格式不一致,需要逐一手写数据转换函数
引入生成式 UI 的目标是:将布局搭建和图表配置两个环节自动化,将开发时间从 4 天压缩到 2 天以内。
二、生成式 UI 的架构方案
方案的核心思路是将 Dashboard 的生成拆分为三个阶段:设计解析 → 中间表示 → 代码生成。
设计解析器负责从 Figma 设计稿中提取结构化信息:
// design-parser.ts — Figma 设计稿解析器 // 用途:从 Figma API 返回的节点树中提取 Dashboard 的结构化描述 // 注意:此实现依赖 Figma REST API,需要在环境变量中配置 FIGMA_TOKEN interface ParsedCard { id: string; type: 'line-chart' | 'bar-chart' | 'pie-chart' | 'data-table' | 'metric-card'; title: string; position: { x: number; y: number; width: number; height: number }; gridSpan: { cols: number; rows: number }; } interface DashboardIR { title: string; layout: 'grid-12' | 'grid-8' | 'grid-masonry'; cards: ParsedCard[]; theme: { colorScheme: 'light' | 'dark'; primaryColor: string; borderRadius: number; gap: number; }; responsive: { breakpoints: { name: string; width: number; columns: number }[]; }; } export class FigmaDesignParser { private baseUrl = 'https://api.figma.com/v1'; /** * 解析 Figma 文件,输出 Dashboard 中间表示 * @param fileKey Figma 文件的唯一标识 * @param nodeId 目标页面的节点 ID(可选,默认解析首页) */ async parse(fileKey: string, nodeId?: string): Promise<DashboardIR> { const token = process.env.FIGMA_TOKEN; if (!token) { throw new Error('FIGMA_TOKEN 环境变量未设置,无法调用 Figma API'); } // 获取文件节点数据 const endpoint = nodeId ? `${this.baseUrl}/files/${fileKey}/nodes?ids=${nodeId}` : `${this.baseUrl}/files/${fileKey}`; const response = await fetch(endpoint, { headers: { 'X-Figma-Token': token }, }); if (!response.ok) { throw new Error(`Figma API 请求失败: ${response.status} ${response.statusText}`); } const data = await response.json(); // 提取页面根节点 const rootNode = nodeId ? data.nodes[nodeId]?.document : data.document.children?.[0]; // 第一个页面 if (!rootNode) { throw new Error('未找到有效页面节点,请检查 fileKey 和 nodeId'); } return this.extractDashboardIR(rootNode); } /** * 递归遍历节点树,提取 Dashboard 结构 */ private extractDashboardIR(node: Record<string, unknown>): DashboardIR { // 识别布局网格 const layout = this.detectLayout(node); // 提取所有卡片节点 const cards = this.extractCards(node); // 提取主题信息 const theme = this.extractTheme(node); // 计算响应式断点 const responsive = this.computeBreakpoints(layout, cards); return { title: (node.name as string) || 'Dashboard', layout, cards, theme, responsive, }; } /** 识别布局模式:12 列网格、8 列网格、瀑布流 */ private detectLayout(node: Record<string, unknown>): DashboardIR['layout'] { // 通过分析子节点的排列规律判定布局类型 const children = node.children as Record<string, unknown>[] | undefined; if (!children || children.length === 0) return 'grid-12'; // 提取子节点的宽度分布 const widths = children.map((c) => (c.absoluteBoundingBox as { width: number })?.width || 0); // 如果大部分子节点宽度能被 12 整除,判定为 grid-12 const grid12Count = widths.filter((w) => w > 0 && (960 % Math.round(w / 80)) < 2).length; return grid12Count > widths.length * 0.6 ? 'grid-12' : 'grid-masonry'; } /** 提取所有卡片节点,识别图表类型 */ private extractCards(node: Record<string, unknown>): ParsedCard[] { const children = (node.children || []) as Record<string, unknown>[]; return children .filter((child) => { // 过滤:节点名包含 'chart' 或 'card' 且不是装饰元素 const name = (child.name as string || '').toLowerCase(); return name.includes('chart') || name.includes('card') || name.includes('table'); }) .map((child) => this.parseCardNode(child)) .filter((card): card is ParsedCard => card !== null); } /** 解析单个卡片节点 */ private parseCardNode(node: Record<string, unknown>): ParsedCard | null { const name = (node.name as string || '').toLowerCase(); const box = node.absoluteBoundingBox as { x: number; y: number; width: number; height: number } | undefined; if (!box) return null; // 从节点名推断图表类型 let type: ParsedCard['type'] = 'metric-card'; if (name.includes('line')) type = 'line-chart'; else if (name.includes('bar') || name.includes('柱')) type = 'bar-chart'; else if (name.includes('pie') || name.includes('饼')) type = 'pie-chart'; else if (name.includes('table') || name.includes('表格')) type = 'data-table'; return { id: (node.id as string) || `card-${Math.random().toString(36).slice(2, 9)}`, type, title: this.extractCardTitle(node), position: { x: box.x, y: box.y, width: box.width, height: box.height }, // 网格跨度:按 80px 为一列的基准计算 gridSpan: { cols: Math.round(box.width / 80), rows: Math.max(1, Math.round(box.height / 120)), }, }; } private extractCardTitle(node: Record<string, unknown>): string { const children = node.children as Record<string, unknown>[] | undefined; const titleNode = children?.find((c) => (c.type as string) === 'TEXT'); return (titleNode?.characters as string) || (node.name as string) || '未命名卡片'; } private extractTheme(node: Record<string, unknown>): DashboardIR['theme'] { // 从设计稿中的第一个有背景色的节点提取主题信息 const bg = node.backgroundColor as { r: number; g: number; b: number } | undefined; const isLight = bg ? (bg.r + bg.g + bg.b) / 3 > 0.5 : true; return { colorScheme: isLight ? 'light' : 'dark', primaryColor: '#1677ff', borderRadius: 8, gap: 16, }; } private computeBreakpoints( layout: DashboardIR['layout'], cards: ParsedCard[] ): DashboardIR['responsive'] { const columns = layout === 'grid-12' ? 12 : 8; return [ { name: 'xl', width: 1920, columns }, { name: 'lg', width: 1440, columns: Math.min(columns, 10) }, { name: 'md', width: 1024, columns: Math.min(columns, 8) }, { name: 'sm', width: 768, columns: 4 }, { name: 'xs', width: 375, columns: 1 }, ]; } }三、代码生成器与模板匹配
中间表示(DashboardIR)生成后,进入代码生成阶段。核心是模板匹配引擎:根据每个卡片的图表类型和布局位置,从模板库中选择最合适的模板进行实例化。
// code-generator.ts — Dashboard 代码生成器 // 用途:将 DashboardIR 转化为可运行的 React 组件代码 + ECharts 配置 import { DashboardIR, ParsedCard } from './design-parser'; interface GeneratedDashboard { componentCode: string; // React 组件代码 styleCode: string; // CSS Modules 样式代码 chartConfigs: Record<string, string>; // cardId → ECharts option JSON dependencies: string[]; // npm 依赖列表 } export class DashboardGenerator { /** * 根据中间表示生成完整 Dashboard 代码 */ generate(ir: DashboardIR): GeneratedDashboard { // 生成图表配置 const chartConfigs = this.generateChartConfigs(ir.cards); // 生成组件代码 const componentCode = this.generateComponent(ir, chartConfigs); // 生成样式代码 const styleCode = this.generateStyles(ir); // 收集依赖 const dependencies = this.collectDependencies(ir.cards); return { componentCode, styleCode, chartConfigs, dependencies }; } /** 为每个卡片生成对应的 ECharts 配置 */ private generateChartConfigs( cards: ParsedCard[] ): Record<string, string> { const configs: Record<string, string> = {}; for (const card of cards) { configs[card.id] = this.generateSingleChartConfig(card); } return configs; } private generateSingleChartConfig(card: ParsedCard): string { const baseConfig = { title: { text: card.title, left: 'center' }, tooltip: { trigger: card.type === 'pie-chart' ? 'item' : 'axis' }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, }; switch (card.type) { case 'line-chart': return JSON.stringify({ ...baseConfig, xAxis: { type: 'category', data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] }, yAxis: { type: 'value' }, series: [{ data: [], type: 'line', smooth: true }], }, null, 2); case 'bar-chart': return JSON.stringify({ ...baseConfig, xAxis: { type: 'category', data: [] }, yAxis: { type: 'value' }, series: [{ data: [], type: 'bar' }], }, null, 2); case 'pie-chart': return JSON.stringify({ ...baseConfig, series: [{ type: 'pie', radius: ['40%', '70%'], data: [] }], }, null, 2); case 'data-table': return JSON.stringify({ type: 'table', columns: [], dataSource: [] }, null, 2); case 'metric-card': return JSON.stringify({ type: 'metric', value: 0, label: card.title }, null, 2); default: return JSON.stringify(baseConfig, null, 2); } } /** 生成 React 组件代码 */ private generateComponent( ir: DashboardIR, chartConfigs: Record<string, string> ): string { const importLines = [ "import React, { useEffect, useState } from 'react';", "import ReactECharts from 'echarts-for-react';", `import styles from './dashboard.module.css';`, ]; const cardRenderers = ir.cards.map((card) => { if (card.type === 'data-table') { return ` <div className={styles.card} style={{ gridColumn: \`span ${card.gridSpan.cols}\` }}> <div className={styles.cardHeader}> <h3>${card.title}</h3> </div> <div className={styles.cardBody}> <Table columns={columns} dataSource={dataSource} size="small" /> </div> </div>`; } if (card.type === 'metric-card') { return ` <div className={styles.card} style={{ gridColumn: \`span ${card.gridSpan.cols}\` }}> <div className={styles.metricCard}> <span className={styles.metricLabel}>${card.title}</span> <span className={styles.metricValue}>{metricValue}</span> </div> </div>`; } return ` <div className={styles.card} style={{ gridColumn: \`span ${card.gridSpan.cols}\` }}> <div className={styles.cardHeader}> <h3>${card.title}</h3> </div> <div className={styles.cardBody}> <ReactECharts option={chartConfigs['${card.id}']} style={{ height: ${card.gridSpan.rows * 120}px }} /> </div> </div>`; }); const columns = ir.layout === 'grid-12' ? 12 : 8; return `${importLines.join('\n')} // 自动生成的 Dashboard 组件 // 生成时间: ${new Date().toISOString()} // 卡片数量: ${ir.cards.length} const Dashboard: React.FC = () => { const [loading, setLoading] = useState(true); useEffect(() => { // 数据加载逻辑 fetchDashboardData().finally(() => setLoading(false)); }, []); if (loading) { return <div className={styles.loading}>加载中...</div>; } return ( <div className={styles.dashboard}>