diff --git a/RuoYi-Vue/docs/superpowers/plans/2026-06-19-admin-chart-business-blocks.md b/RuoYi-Vue/docs/superpowers/plans/2026-06-19-admin-chart-business-blocks.md new file mode 100644 index 0000000..3fc0ebb --- /dev/null +++ b/RuoYi-Vue/docs/superpowers/plans/2026-06-19-admin-chart-business-blocks.md @@ -0,0 +1,2060 @@ +# 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 `