# Admin Chart Business Blocks Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Extend the existing page designer and business-block generator so admin pages can use metric, line, bar, and pie chart blocks backed by a validated single-table aggregation DSL. **Architecture:** Keep `front_project_page_design.layout_json`, `BusinessBlockRegistryService`, and `BusinessBlockGenerationService` as the persistence, validation, and generation spine. Add protocol-v2 fields and chart dataset DTOs, filter definitions by page scope, render chart-specific controls in EasyCode, then generate fixed Vue 2/ECharts components and fixed MyBatis aggregation endpoints. Existing frontend blocks remain protocol-v1 compatible through normalization. **Tech Stack:** Vue 3 + Element Plus + ECharts 5.4 in `easycode-web`, Java 8 + Spring Boot + Fastjson2 + MyBatis + Velocity in the generator, generated Vue 2 + Element UI + ECharts 5.4, Node built-in tests, JUnit 4, Mockito. --- ## Scope This plan implements: - Protocol-v2 definition fields: `kind`, `surfaces`, `placements`, `chartType`, `datasetSchema`, and `defaultSpan`. - Legacy definition normalization without changing old JSON files. - Scope-filtered business-block API with project ownership validation. - Four admin-only blocks: metric, line, bar, and pie. - Single-table aggregation configuration with dimensions, metrics, filters, date range, sorting, and limit. - Three block widths: 12, 6, and 4 columns. - Design-time ECharts preview using deterministic sample data. - Save-time and generation-time chart DSL validation. - Generated Vue/API/Controller/Service/Mapper files and shared chart runtime. - Block-level loading, empty, error, and retry states. This plan does not implement joins, custom SQL, formulas, user-editable runtime filters, free-form grid positioning, block-level RBAC, department data scope, or chart drill-down. ## File Structure ### Protocol and validation - Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockDefinition.java` - Add protocol-v2 metadata while retaining `placement`. - Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockInstance.java` - Add `span`, `dataset`, and `display`. - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDatasetConfig.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDimensionConfig.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartMetricConfig.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartFilterConfig.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDateRangeConfig.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartSortConfig.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ChartDatasetValidator.java` - Own all chart DSL and field-type validation. - Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java` - Normalize old definitions, filter by surface, and delegate chart validation. ### API and designer - Modify `ruoyi-admin/src/main/java/com/ruoyi/web/controller/front/FrontProjectController.java` - Accept `scope`, verify project ownership, return filtered definitions. - Modify `ruoyi-admin/src/test/java/com/ruoyi/web/controller/front/FrontProjectControllerRunPreviewTest.java` - Modify `easycode-web/src/api/project.js` - Pass scope to `listBusinessBlocks`. - Modify `easycode-web/src/views/PageDesignerView.vue` - Load and pass filtered blocks for both scopes. - Modify `easycode-web/src/views/pageDesignerView.test.mjs` - Modify `easycode-web/package.json` and `easycode-web/package-lock.json` - Add ECharts 5.4. - Create `easycode-web/src/components/business-blocks/chartBlock.js` - Pure layout, defaulting, option-building, and local validation helpers. - Create `easycode-web/src/components/business-blocks/ChartBlockConfigForm.vue` - Create `easycode-web/src/components/business-blocks/ChartFilterEditor.vue` - Create `easycode-web/src/components/business-blocks/ChartBlockPreview.vue` - Modify `easycode-web/src/components/business-blocks/BusinessBlockDesigner.vue` - Modify `easycode-web/src/components/business-blocks/BlockCanvas.vue` - Modify `easycode-web/src/components/business-blocks/blockLayout.js` - Modify `easycode-web/src/components/FrontendPageDesigner.vue` - Modify `easycode-web/src/components/businessBlockDesigner.test.mjs` - Modify `easycode-web/src/components/frontendPageDesigner.test.mjs` ### Built-in definitions and generation - Create: - `ruoyi-generator/src/main/resources/business-blocks/admin_metric_chart/block.json` - `ruoyi-generator/src/main/resources/business-blocks/admin_line_chart/block.json` - `ruoyi-generator/src/main/resources/business-blocks/admin_bar_chart/block.json` - `ruoyi-generator/src/main/resources/business-blocks/admin_pie_chart/block.json` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryParameter.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryRenderModel.java` - Create `ruoyi-generator/src/main/java/com/ruoyi/generator/util/ChartBlockRenderSupport.java` - Convert validated metadata into static SQL fragments and bound parameter definitions. - Modify `ruoyi-generator/src/main/java/com/ruoyi/generator/service/BusinessBlockGenerationService.java` - Resolve shared templates, add chart context, emit one shared runtime file, and expose span. - Modify `ruoyi-generator/src/main/resources/business-blocks/page/business-block-page.vue.vm` - Modify `ruoyi-generator/src/main/resources/qing/index.vue.vm` - Modify `ruoyi-generator/src/main/resources/qing/vue-package.json.vm` - Create shared templates: - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/AdminChartBlock.vue.vm` - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartApi.js.vm` - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartRuntime.js.vm` - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartController.java.vm` - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartService.java.vm` - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartServiceImpl.java.vm` - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartMapper.java.vm` - `ruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartMapper.xml.vm` - Modify tests: - `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java` - `ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java` - `ruoyi-generator/src/test/java/com/ruoyi/generator/service/GenProjectServiceImplTest.java` - `ruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.java` - `ruoyi-generator/src/test/java/com/ruoyi/generator/util/ChartBlockRenderSupportTest.java` ## Task 1: Add protocol-v2 definition metadata and legacy normalization **Files:** - Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockDefinition.java` - Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java` - Test: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java` - [ ] **Step 1: Write failing normalization and surface-filter tests** Add these tests: ```java @Test public void legacyDefinitionsDefaultToFrontendBusinessBlocks() { BusinessBlockDefinition definition = service.findDefinition("cart"); assertEquals("business", definition.getKind()); assertEquals(Collections.singletonList("frontend"), definition.getSurfaces()); assertEquals(Collections.singletonList("page"), definition.getPlacements()); } @Test public void listDefinitionsFiltersBySurface() { for (BusinessBlockDefinition definition : service.listDefinitions("frontend")) { assertTrue(definition.getSurfaces().contains("frontend")); } for (BusinessBlockDefinition definition : service.listDefinitions("admin")) { assertTrue(definition.getSurfaces().contains("admin")); } } ``` - [ ] **Step 2: Run the focused test and verify failure** Run: ```powershell mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest test ``` Expected: compilation fails because the protocol-v2 getters and `listDefinitions(String)` do not exist. - [ ] **Step 3: Add protocol-v2 fields** Add to `BusinessBlockDefinition`: ```java private String kind; private List surfaces; private List placements; private String chartType; private String datasetSchema; private Integer defaultSpan; public String getKind() { return kind; } public void setKind(String kind) { this.kind = kind; } public List getSurfaces() { return surfaces; } public void setSurfaces(List surfaces) { this.surfaces = surfaces; } public List getPlacements() { return placements; } public void setPlacements(List placements) { this.placements = placements; } public String getChartType() { return chartType; } public void setChartType(String chartType) { this.chartType = chartType; } public String getDatasetSchema() { return datasetSchema; } public void setDatasetSchema(String datasetSchema) { this.datasetSchema = datasetSchema; } public Integer getDefaultSpan() { return defaultSpan; } public void setDefaultSpan(Integer defaultSpan) { this.defaultSpan = defaultSpan; } ``` - [ ] **Step 4: Normalize every loaded definition** In `BusinessBlockRegistryService`, call `normalizeDefinition` before caching: ```java private BusinessBlockDefinition normalizeDefinition(BusinessBlockDefinition definition) { if (definition == null) { return null; } if (StringUtils.isEmpty(definition.getKind())) { definition.setKind("business"); } if (definition.getSurfaces() == null || definition.getSurfaces().isEmpty()) { definition.setSurfaces(Collections.singletonList("frontend")); } if (definition.getPlacements() == null || definition.getPlacements().isEmpty()) { String placement = StringUtils.defaultIfEmpty(definition.getPlacement(), PLACEMENT_PAGE); definition.setPlacements(Collections.singletonList(placement)); } if (definition.getDefaultSpan() == null) { definition.setDefaultSpan(Integer.valueOf(12)); } return definition; } public List listDefinitions(String surface) { String normalized = "admin".equals(surface) ? "admin" : "frontend"; List result = new ArrayList(); for (BusinessBlockDefinition definition : listDefinitions()) { if (definition.getSurfaces().contains(normalized)) { result.add(definition); } } return Collections.unmodifiableList(result); } ``` Also change `isEmbeddedDefinition` to inspect normalized `placements`: ```java public boolean supportsPlacement(BusinessBlockDefinition definition, String placement) { return definition != null && definition.getPlacements() != null && definition.getPlacements().contains(placement); } ``` - [ ] **Step 5: Run the test and verify pass** Run: ```powershell mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest test ``` Expected: PASS, with existing cart/notice/carousel tests unchanged. - [ ] **Step 6: Commit** ```powershell git add ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockDefinition.java ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java git commit -m "Extend business block definition protocol" ``` ## Task 2: Filter business-block definitions by page scope **Files:** - Modify: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/front/FrontProjectController.java` - Modify: `ruoyi-admin/src/test/java/com/ruoyi/web/controller/front/FrontProjectControllerRunPreviewTest.java` - Modify: `easycode-web/src/api/project.js` - Modify: `easycode-web/src/views/PageDesignerView.vue` - Modify: `easycode-web/src/views/pageDesignerView.test.mjs` - [ ] **Step 1: Write failing controller and frontend source tests** Replace the controller test with: ```java @Mock private FrontProjectServiceImpl frontProjectService; @Test public void businessBlocksChecksOwnershipAndFiltersScope() { BusinessBlockDefinition definition = new BusinessBlockDefinition(); definition.setCode("admin_line_chart"); when(businessBlockRegistryService.listDefinitions("admin")) .thenReturn(Collections.singletonList(definition)); AjaxResult result = controller.businessBlocks(20L, "admin"); assertEquals(Collections.singletonList(definition), result.get(AjaxResult.DATA_TAG)); verify(frontProjectService).getProject(7L, 20L); verify(businessBlockRegistryService).listDefinitions("admin"); } ``` Inject `frontProjectService` in `setUp`. Change the Node assertions to: ```js assert.equal(source.includes('listBusinessBlocks(projectId.value, scope.value)'), true) assert.equal(source.includes(':block-definitions="businessBlockDefinitions"'), true) assert.equal(source.includes("scope === 'frontend' ? businessBlockDefinitions : []"), false) assert.equal(apiSource.includes('params: { scope }'), true) ``` - [ ] **Step 2: Run tests and verify failure** Run: ```powershell mvn -pl ruoyi-admin -am -Dtest=FrontProjectControllerRunPreviewTest test cd easycode-web node --test src/views/pageDesignerView.test.mjs ``` Expected: controller signature and frontend scope calls do not match. - [ ] **Step 3: Implement the scoped API** Controller: ```java @GetMapping("/{projectId}/business-blocks") public AjaxResult businessBlocks(@PathVariable Long projectId, @RequestParam(defaultValue = "frontend") String scope) { Long userId = SecurityUtils.getUserId(); frontProjectService.getProject(userId, projectId); return AjaxResult.success(businessBlockRegistryService.listDefinitions(scope)); } ``` Frontend API: ```js export function listBusinessBlocks(projectId, scope = 'frontend') { return request({ url: `/front/project/${projectId}/business-blocks`, method: 'get', params: { scope } }) } ``` Page load: ```js listBusinessBlocks(projectId.value, scope.value) ``` Pass the result directly: ```vue :block-definitions="businessBlockDefinitions" ``` - [ ] **Step 4: Run tests and verify pass** Run: ```powershell mvn -pl ruoyi-admin -am -Dtest=FrontProjectControllerRunPreviewTest test cd easycode-web node --test src/views/pageDesignerView.test.mjs ``` Expected: PASS. - [ ] **Step 5: Commit** ```powershell git add ruoyi-admin/src/main/java/com/ruoyi/web/controller/front/FrontProjectController.java ruoyi-admin/src/test/java/com/ruoyi/web/controller/front/FrontProjectControllerRunPreviewTest.java easycode-web/src/api/project.js easycode-web/src/views/PageDesignerView.vue easycode-web/src/views/pageDesignerView.test.mjs git commit -m "Filter business blocks by page scope" ``` ## Task 3: Add the chart dataset model on backend and frontend **Files:** - Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDatasetConfig.java` - Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDimensionConfig.java` - Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartMetricConfig.java` - Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartFilterConfig.java` - Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDateRangeConfig.java` - Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartSortConfig.java` - Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockInstance.java` - Create: `easycode-web/src/components/business-blocks/chartBlock.js` - Modify: `easycode-web/src/components/business-blocks/blockLayout.js` - Modify: `easycode-web/src/components/businessBlockDesigner.test.mjs` - [ ] **Step 1: Write failing Java parsing test** Add: ```java @Test public void parseLayoutReadsChartDatasetAndSpan() { PageBusinessBlockLayout layout = service.parseLayout( "{\"canvas\":\"business-blocks-v1\",\"blocks\":[{" + "\"id\":\"line_001\",\"blockCode\":\"admin_line_chart\",\"span\":6," + "\"dataset\":{\"schema\":\"single-table-aggregate-v1\",\"table\":\"shop_order\"," + "\"dimension\":{\"field\":\"create_time\",\"timeUnit\":\"day\"}," + "\"metrics\":[{\"field\":\"amount\",\"aggregate\":\"sum\",\"alias\":\"salesAmount\"}]}," + "\"display\":{\"showLegend\":true}}]}"); BusinessBlockInstance instance = layout.getBlocks().get(0); assertEquals(Integer.valueOf(6), instance.getSpan()); assertEquals("shop_order", instance.getDataset().getTable()); assertEquals("amount", instance.getDataset().getMetrics().get(0).getField()); assertEquals(Boolean.TRUE, instance.getDisplay().get("showLegend")); } ``` - [ ] **Step 2: Write failing JavaScript helper tests** Import `buildDefaultChartInstance`, `updateChartDataset`, `updateChartDisplay`, and `validateChartInstance`, then add: ```js const lineDefinition = { code: 'admin_line_chart', name: 'Line chart', kind: 'chart', chartType: 'line', datasetSchema: 'single-table-aggregate-v1', defaultSpan: 12 } test('chart helper builds a deterministic line chart instance', () => { const block = buildDefaultChartInstance(lineDefinition, 'line_001') assert.equal(block.span, 12) assert.equal(block.dataset.schema, 'single-table-aggregate-v1') assert.deepEqual(block.dataset.metrics, [{ field: '', aggregate: 'sum', alias: 'metric1', label: '指标 1' }]) assert.equal(block.display.showLegend, true) }) test('chart helper reports a missing dimension for line charts', () => { const block = buildDefaultChartInstance(lineDefinition, 'line_001') const errors = validateChartInstance(lineDefinition, block, []) assert.equal(errors.some((item) => item.key === 'dimension.field'), true) }) ``` - [ ] **Step 3: Run tests and verify failure** Run: ```powershell mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest test cd easycode-web node --test src/components/businessBlockDesigner.test.mjs ``` Expected: missing DTOs and chart helper exports. - [ ] **Step 4: Add Java DTOs** Use ordinary Java 8 beans. `ChartDatasetConfig` has: ```java private String schema; private String table; private ChartDimensionConfig dimension; private List metrics; private List filters; private ChartDateRangeConfig dateRange; private ChartSortConfig sort; private Integer limit; ``` The nested contracts are: ```java // ChartDimensionConfig private String field; private String timeUnit; // ChartMetricConfig private String field; private String aggregate; private String alias; private String label; // ChartFilterConfig private String field; private String operator; private Object value; // ChartDateRangeConfig private String field; private String preset; private String start; private String end; // ChartSortConfig private String by; private String order; ``` Add to `BusinessBlockInstance`: ```java private Integer span; private ChartDatasetConfig dataset; private Map display; ``` - [ ] **Step 5: Implement frontend pure helpers** Create `chartBlock.js` with these exports: ```js export const allowedChartSpans = [12, 6, 4] export function isChartDefinition(definition) { return definition?.kind === 'chart' } export function buildDefaultChartInstance(definition, id) { const chartType = definition?.chartType || 'line' return { id, blockCode: definition.code, name: definition.name || definition.code, span: allowedChartSpans.includes(definition.defaultSpan) ? definition.defaultSpan : 12, config: {}, dataset: { schema: definition.datasetSchema || 'single-table-aggregate-v1', table: '', dimension: chartType === 'metric' ? null : { field: '', timeUnit: '' }, metrics: [{ field: '', aggregate: chartType === 'metric' ? 'count' : 'sum', alias: 'metric1', label: '指标 1' }], filters: [], dateRange: null, sort: chartType === 'metric' ? null : { by: 'dimension', order: 'asc' }, limit: chartType === 'pie' ? 10 : chartType === 'bar' ? 20 : chartType === 'metric' ? 1 : 100 }, display: { showTitle: true, showLegend: chartType !== 'metric', emptyText: '暂无数据', smooth: chartType === 'line', donut: chartType === 'pie', horizontal: false, stacked: false } } } export function updateChartDataset(block, dataset) { return { ...block, dataset: structuredClone(dataset || {}) } } export function updateChartDisplay(block, display) { return { ...block, display: structuredClone(display || {}) } } export function validateChartInstance(definition, block, tables) { const errors = [] const dataset = block?.dataset || {} const table = tables.find((item) => item.tableName === dataset.table) if (!table) errors.push({ key: 'table', message: '请选择数据表' }) if (definition?.chartType !== 'metric' && !dataset.dimension?.field) { errors.push({ key: 'dimension.field', message: '请选择维度字段' }) } if (!Array.isArray(dataset.metrics) || !dataset.metrics.length || dataset.metrics.some((item) => !item.field)) { errors.push({ key: 'metrics', message: '请配置指标字段' }) } return errors } ``` Change `addBlockInstance` so chart definitions call `buildDefaultChartInstance`; legacy definitions retain `{ config: {} }`. - [ ] **Step 6: Run tests and verify pass** Run: ```powershell mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest test cd easycode-web node --test src/components/businessBlockDesigner.test.mjs ``` Expected: PASS. - [ ] **Step 7: Commit** ```powershell git add ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block easycode-web/src/components/business-blocks/chartBlock.js easycode-web/src/components/business-blocks/blockLayout.js easycode-web/src/components/businessBlockDesigner.test.mjs git commit -m "Add chart business block data model" ``` ## Task 4: Validate the single-table aggregation DSL **Files:** - Create: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ChartDatasetValidator.java` - Create: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ChartDatasetValidatorTest.java` - [ ] **Step 1: Write failing validator tests** Create chart test tables with typed columns: ```java private GenTableColumn typedColumn(String columnName, String javaField, String javaType, String columnType) { GenTableColumn column = column(columnName, javaField); column.setJavaType(javaType); column.setColumnType(columnType); return column; } ``` Add tests for: ```java @Test public void chartValidationRejectsSumOnTextField() { BusinessBlockInstance instance = validLineChartInstance(); instance.getDataset().getMetrics().get(0).setField("status"); ServiceException exception = expectServiceException(() -> validator.validate(lineChartDefinition(), instance, chartTables())); assertTrue(exception.getMessage().contains("SUM")); assertTrue(exception.getMessage().contains("status")); } @Test public void chartValidationAcceptsSingleTableTimeSeries() { validator.validate(lineChartDefinition(), validLineChartInstance(), chartTables()); } ``` Use this explicit definition helper so the validator test does not depend on classpath resources: ```java private BusinessBlockDefinition lineChartDefinition() { BusinessBlockDefinition definition = new BusinessBlockDefinition(); definition.setCode("admin_line_chart"); definition.setName("折线图"); definition.setKind("chart"); definition.setChartType("line"); definition.setDatasetSchema("single-table-aggregate-v1"); definition.setSurfaces(Collections.singletonList("admin")); definition.setPlacements(Arrays.asList("embedded", "page")); definition.setDefaultSpan(Integer.valueOf(12)); return definition; } ``` Also test invalid span, more than three metrics, more than ten filters, invalid `timeUnit`, invalid operator, duplicate aliases, and limit over 500. - [ ] **Step 2: Run tests and verify failure** Run: ```powershell mvn -pl ruoyi-generator -Dtest=ChartDatasetValidatorTest test ``` Expected: `ChartDatasetValidator` does not exist. - [ ] **Step 3: Implement `ChartDatasetValidator`** Expose: ```java public void validate(BusinessBlockDefinition definition, BusinessBlockInstance instance, List tables) ``` Use these fixed sets: ```java private static final Set AGGREGATES = new HashSet(Arrays.asList("count", "sum", "avg")); private static final Set TIME_UNITS = new HashSet(Arrays.asList("day", "week", "month")); private static final Set OPERATORS = new HashSet(Arrays.asList( "eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "is_null", "is_not_null")); private static final Set SPANS = new HashSet(Arrays.asList(Integer.valueOf(12), Integer.valueOf(6), Integer.valueOf(4))); ``` Numeric fields are recognized by Java type: ```java private boolean isNumeric(GenTableColumn column) { return StringUtils.equalsAnyIgnoreCase(column.getJavaType(), "Integer", "Long", "Double", "Float", "Short", "BigDecimal"); } ``` Date fields are recognized by Java or SQL type: ```java private boolean isTemporal(GenTableColumn column) { String columnType = StringUtils.defaultString(column.getColumnType()).toLowerCase(); return StringUtils.equalsAnyIgnoreCase(column.getJavaType(), "Date", "LocalDate", "LocalDateTime") || columnType.contains("date") || columnType.contains("time") || columnType.contains("timestamp") || columnType.contains("year"); } ``` Validation order: 1. Dataset schema equals `single-table-aggregate-v1`. 2. Table exists. 3. Span is 12, 6, or 4. 4. Metric chart has no required dimension; other charts require one. 5. Metric count is 1 for metric/pie and 1–3 for line/bar. 6. `sum` and `avg` require numeric fields. 7. Time unit requires a temporal dimension. 8. Alias matches `[A-Za-z][A-Za-z0-9_]{0,39}` and is unique. 9. Filters use known fields/operators and max at 10. 10. Limit is 1–500. - [ ] **Step 4: Run tests and verify pass** Run: ```powershell mvn -pl ruoyi-generator -Dtest=ChartDatasetValidatorTest test ``` Expected: PASS. - [ ] **Step 5: Commit** ```powershell git add ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ChartDatasetValidator.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ChartDatasetValidatorTest.java git commit -m "Validate chart business block datasets" ``` ## Task 5: Register the four built-in admin chart blocks **Files:** - Create: - `ruoyi-generator/src/main/resources/business-blocks/admin_metric_chart/block.json` - `ruoyi-generator/src/main/resources/business-blocks/admin_line_chart/block.json` - `ruoyi-generator/src/main/resources/business-blocks/admin_bar_chart/block.json` - `ruoyi-generator/src/main/resources/business-blocks/admin_pie_chart/block.json` - Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java` - Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontendPageDesignService.java` - Modify: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java` - Modify: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontendPageDesignServiceTest.java` - [ ] **Step 1: Write failing registry tests** ```java @Test public void adminSurfaceIncludesFourChartBlocks() { List definitions = service.listDefinitions("admin"); assertTrue(hasCode(definitions, "admin_metric_chart")); assertTrue(hasCode(definitions, "admin_line_chart")); assertTrue(hasCode(definitions, "admin_bar_chart")); assertTrue(hasCode(definitions, "admin_pie_chart")); assertEquals(4, countKind(definitions, "chart")); } @Test public void frontendSurfaceExcludesAdminCharts() { assertEquals(0, countKind(service.listDefinitions("frontend"), "chart")); } @Test public void chartValidationRejectsAdminChartOnFrontendPage() { ServiceException exception = expectServiceException(() -> service.validateInstanceConfig(service.findDefinition("admin_line_chart"), validLineChartInstance(), chartTables(), "frontend")); assertTrue(exception.getMessage().contains("后台")); } ``` Add these test helpers: ```java private boolean hasCode(List definitions, String code) { for (BusinessBlockDefinition definition : definitions) { if (code.equals(definition.getCode())) return true; } return false; } private int countKind(List definitions, String kind) { int count = 0; for (BusinessBlockDefinition definition : definitions) { if (kind.equals(definition.getKind())) count++; } return count; } ``` - [ ] **Step 2: Run and verify failure** Run: ```powershell mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest test ``` Expected: definitions are absent. - [ ] **Step 3: Add four definition files** Use this exact shared template set in each file: ```json [ { "key": "frontendComponent", "template": "/business-blocks/chart/templates/frontend/AdminChartBlock.vue.vm", "outputPath": "src/views/pages/${pageCode}/blocks/${instanceCode}/${InstanceClassName}.vue" }, { "key": "frontendApi", "template": "/business-blocks/chart/templates/frontend/chartApi.js.vm", "outputPath": "src/api/business-blocks/${instanceCode}.js" }, { "key": "controller", "template": "/business-blocks/chart/templates/backend/AdminChartController.java.vm", "outputPath": "src/main/java/${packagePath}/controller/block/${InstanceClassName}Controller.java" }, { "key": "service", "template": "/business-blocks/chart/templates/backend/AdminChartService.java.vm", "outputPath": "src/main/java/${packagePath}/service/block/I${InstanceClassName}Service.java" }, { "key": "serviceImpl", "template": "/business-blocks/chart/templates/backend/AdminChartServiceImpl.java.vm", "outputPath": "src/main/java/${packagePath}/service/block/impl/${InstanceClassName}ServiceImpl.java" }, { "key": "mapper", "template": "/business-blocks/chart/templates/backend/AdminChartMapper.java.vm", "outputPath": "src/main/java/${packagePath}/mapper/block/${InstanceClassName}Mapper.java" }, { "key": "mapperXml", "template": "/business-blocks/chart/templates/backend/AdminChartMapper.xml.vm", "outputPath": "src/main/resources/mapper/block/${InstanceClassName}Mapper.xml" } ] ``` Each definition declares: ```json { "kind": "chart", "surfaces": ["admin"], "placements": ["embedded", "page"], "datasetSchema": "single-table-aggregate-v1", "version": 2, "defaultSpan": 12 } ``` Set `chartType` to `metric`, `line`, `bar`, and `pie`. Use category `数据图表`. - [ ] **Step 4: Integrate chart validation into page save** Inject `ChartDatasetValidator` into `BusinessBlockRegistryService`. Preserve the repository's plain-constructor unit-test style with: ```java @Autowired private ChartDatasetValidator chartDatasetValidator; private ChartDatasetValidator chartValidator() { if (chartDatasetValidator == null) { chartDatasetValidator = new ChartDatasetValidator(); } return chartDatasetValidator; } ``` Add: ```java public void validateInstanceConfig(BusinessBlockDefinition definition, BusinessBlockInstance instance, List tables, String surface) { String normalizedSurface = "admin".equals(surface) ? "admin" : "frontend"; if (!definition.getSurfaces().contains(normalizedSurface)) { throw new ServiceException("业务块“" + definition.getName() + "”不能用于" + ("admin".equals(normalizedSurface) ? "后台" : "前台") + "页面"); } if ("chart".equals(definition.getKind())) { chartValidator().validate(definition, instance, tables); return; } validateInstanceConfig(definition, instance, tables); } ``` Update `validateLayout` and `validateEmbeddedBlocks` to accept `surface`, then update `FrontendPageDesignService.normalizeLayout` to pass its existing normalized scope. Add save tests proving a valid admin chart layout succeeds and the same layout with `scope=frontend` fails. - [ ] **Step 5: Run and verify pass** Run: ```powershell mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest,FrontendPageDesignServiceTest test ``` Expected: PASS. - [ ] **Step 6: Commit** ```powershell git add ruoyi-generator/src/main/resources/business-blocks/admin_metric_chart/block.json ruoyi-generator/src/main/resources/business-blocks/admin_line_chart/block.json ruoyi-generator/src/main/resources/business-blocks/admin_bar_chart/block.json ruoyi-generator/src/main/resources/business-blocks/admin_pie_chart/block.json ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontendPageDesignService.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontendPageDesignServiceTest.java git commit -m "Register admin chart business blocks" ``` ## Task 6: Build the chart configuration form **Files:** - Create: `easycode-web/src/components/business-blocks/ChartBlockConfigForm.vue` - Create: `easycode-web/src/components/business-blocks/ChartFilterEditor.vue` - Modify: `easycode-web/src/components/business-blocks/BusinessBlockDesigner.vue` - Modify: `easycode-web/src/components/businessBlockDesigner.test.mjs` - [ ] **Step 1: Write failing source tests** ```js test('chart blocks use a dedicated dataset and display editor', () => { const source = readSource('business-blocks/BusinessBlockDesigner.vue') const form = readSource('business-blocks/ChartBlockConfigForm.vue') const filters = readSource('business-blocks/ChartFilterEditor.vue') assert.equal(source.includes("import ChartBlockConfigForm from './ChartBlockConfigForm.vue'"), true) assert.equal(source.includes('selectedDefinition?.kind === \'chart\''), true) assert.equal(form.includes('update:dataset'), true) assert.equal(form.includes('update:display'), true) assert.equal(form.includes(' [] }, columns: { type: Array, default: () => [] } }) const emit = defineEmits(['update:modelValue']) ``` Operators: ```js const operators = [ { value: 'eq', label: '等于' }, { value: 'ne', label: '不等于' }, { value: 'gt', label: '大于' }, { value: 'gte', label: '大于等于' }, { value: 'lt', label: '小于' }, { value: 'lte', label: '小于等于' }, { value: 'between', label: '区间' }, { value: 'in', label: '包含任一' }, { value: 'is_null', label: '为空' }, { value: 'is_not_null', label: '不为空' } ] ``` Emit immutable arrays on add, remove, and edit. Cap additions at ten. - [ ] **Step 4: Implement `ChartBlockConfigForm.vue`** Required props: ```js const props = defineProps({ definition: { type: Object, required: true }, dataset: { type: Object, default: () => ({}) }, display: { type: Object, default: () => ({}) }, span: { type: Number, default: 12 }, tables: { type: Array, default: () => [] }, errors: { type: Array, default: () => [] } }) const emit = defineEmits(['update:dataset', 'update:display', 'update:span']) ``` Tabs: - Data: table, dimension, time unit, metrics, filters, date range, sort, limit. - Display: title visibility, legend, empty text, plus chart-specific toggles. - Permission: read-only text `继承所在后台页面;一期不支持块级覆盖`. Field options come only from the selected table. Aggregate options are: ```js function aggregateOptions(column) { const numeric = ['Integer', 'Long', 'Double', 'Float', 'Short', 'BigDecimal'] .includes(column?.javaType) return numeric ? [{ value: 'count', label: '计数' }, { value: 'sum', label: '求和' }, { value: 'avg', label: '平均值' }] : [{ value: 'count', label: '计数' }] } ``` - [ ] **Step 5: Route chart updates through `BusinessBlockDesigner`** Render: ```vue ``` Use immutable block updates and emit formatted JSON after every change. Add: ```js const selectedChartErrors = computed(() => ( selectedDefinition.value?.kind === 'chart' ? validateChartInstance(selectedDefinition.value, selectedBlock.value, props.tables) : [] )) function replaceSelectedBlock(patch) { if (!selectedBlock.value) return layout.value = { ...layout.value, blocks: layout.value.blocks.map((block) => ( block.id === selectedBlock.value.id ? { ...block, ...patch } : block )) } emitLayout() } function updateSelectedDataset(dataset) { replaceSelectedBlock({ dataset }) } function updateSelectedDisplay(display) { replaceSelectedBlock({ display }) } function updateSelectedSpan(span) { replaceSelectedBlock({ span }) } ``` - [ ] **Step 6: Run tests and verify pass** Run: ```powershell cd easycode-web node --test src/components/businessBlockDesigner.test.mjs ``` Expected: PASS. - [ ] **Step 7: Commit** ```powershell git add easycode-web/src/components/business-blocks/ChartBlockConfigForm.vue easycode-web/src/components/business-blocks/ChartFilterEditor.vue easycode-web/src/components/business-blocks/BusinessBlockDesigner.vue easycode-web/src/components/businessBlockDesigner.test.mjs git commit -m "Add admin chart block configuration UI" ``` ## Task 7: Add ECharts design-time preview and three-width canvas **Files:** - Modify: `easycode-web/package.json` - Modify: `easycode-web/package-lock.json` - Create: `easycode-web/src/components/business-blocks/ChartBlockPreview.vue` - Modify: `easycode-web/src/components/business-blocks/chartBlock.js` - Modify: `easycode-web/src/components/business-blocks/BlockCanvas.vue` - Modify: `easycode-web/src/components/FrontendPageDesigner.vue` - Modify: `easycode-web/src/components/businessBlockDesigner.test.mjs` - Modify: `easycode-web/src/components/frontendPageDesigner.test.mjs` - [ ] **Step 1: Write failing tests** Add assertions: ```js test('chart canvas renders previews in a twelve-column grid', () => { const canvas = readSource('business-blocks/BlockCanvas.vue') const preview = readSource('business-blocks/ChartBlockPreview.vue') assert.equal(canvas.includes('grid-template-columns: repeat(12, minmax(0, 1fr))'), true) assert.equal(canvas.includes(':style="{ gridColumn: `span ${block.span || 12}` }"'), true) assert.equal(canvas.includes(' { const source = readSource('components/FrontendPageDesigner.vue') assert.equal(source.includes("import ChartBlockPreview from '@/components/business-blocks/ChartBlockPreview.vue'"), true) assert.equal(source.includes(' item.label) const series = data.series.map((item) => ({ name: item.name, data: item.values })) if (definition?.chartType === 'pie') { return { tooltip: { trigger: 'item' }, legend: { show: block?.display?.showLegend !== false }, series: [{ type: 'pie', radius: block?.display?.donut ? ['45%', '70%'] : '70%', data: labels.map((name, index) => ({ name, value: series[0]?.data[index] || 0 })) }] } } return { tooltip: { trigger: 'axis' }, legend: { show: block?.display?.showLegend !== false }, xAxis: { type: 'category', data: labels }, yAxis: { type: 'value' }, series: series.map((item) => ({ ...item, type: definition?.chartType === 'bar' ? 'bar' : 'line', smooth: definition?.chartType === 'line' && block?.display?.smooth })) } } ``` - [ ] **Step 5: Implement `ChartBlockPreview.vue`** Behavior: - Metric type renders a number card without creating ECharts. - Other types create an ECharts instance in `onMounted`. - Watch option inputs deeply and call `setOption(option, true)`. - Add and remove `window.resize`. - Dispose on unmount. - Catch option errors and render a block-level retry button. - [ ] **Step 6: Convert canvases to 12-column grids** Change the chart-capable block wrapper from a `