Files
yidaima/RuoYi-Vue/docs/superpowers/plans/2026-06-19-admin-chart-business-blocks.md
2026-06-19 23:15:07 +08:00

70 KiB
Raw Permalink Blame History

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:

@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:

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:

private String kind;
private List<String> surfaces;
private List<String> 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<String> getSurfaces() { return surfaces; }
public void setSurfaces(List<String> surfaces) { this.surfaces = surfaces; }
public List<String> getPlacements() { return placements; }
public void setPlacements(List<String> 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:

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<BusinessBlockDefinition> listDefinitions(String surface)
{
    String normalized = "admin".equals(surface) ? "admin" : "frontend";
    List<BusinessBlockDefinition> result = new ArrayList<BusinessBlockDefinition>();
    for (BusinessBlockDefinition definition : listDefinitions())
    {
        if (definition.getSurfaces().contains(normalized))
        {
            result.add(definition);
        }
    }
    return Collections.unmodifiableList(result);
}

Also change isEmbeddedDefinition to inspect normalized placements:

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:

mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest test

Expected: PASS, with existing cart/notice/carousel tests unchanged.

  • Step 6: Commit
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:

@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:

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:

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:

@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:

export function listBusinessBlocks(projectId, scope = 'frontend') {
  return request({
    url: `/front/project/${projectId}/business-blocks`,
    method: 'get',
    params: { scope }
  })
}

Page load:

listBusinessBlocks(projectId.value, scope.value)

Pass the result directly:

:block-definitions="businessBlockDefinitions"
  • Step 4: Run tests and verify pass

Run:

mvn -pl ruoyi-admin -am -Dtest=FrontProjectControllerRunPreviewTest test
cd easycode-web
node --test src/views/pageDesignerView.test.mjs

Expected: PASS.

  • Step 5: Commit
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:

@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:

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:

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:

private String schema;
private String table;
private ChartDimensionConfig dimension;
private List<ChartMetricConfig> metrics;
private List<ChartFilterConfig> filters;
private ChartDateRangeConfig dateRange;
private ChartSortConfig sort;
private Integer limit;

The nested contracts are:

// 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:

private Integer span;
private ChartDatasetConfig dataset;
private Map<String, Object> display;
  • Step 5: Implement frontend pure helpers

Create chartBlock.js with these exports:

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:

mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest test
cd easycode-web
node --test src/components/businessBlockDesigner.test.mjs

Expected: PASS.

  • Step 7: Commit
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:

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:

@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:

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:

mvn -pl ruoyi-generator -Dtest=ChartDatasetValidatorTest test

Expected: ChartDatasetValidator does not exist.

  • Step 3: Implement ChartDatasetValidator

Expose:

public void validate(BusinessBlockDefinition definition, BusinessBlockInstance instance,
        List<GenTable> tables)

Use these fixed sets:

private static final Set<String> AGGREGATES =
        new HashSet<String>(Arrays.asList("count", "sum", "avg"));
private static final Set<String> TIME_UNITS =
        new HashSet<String>(Arrays.asList("day", "week", "month"));
private static final Set<String> OPERATORS =
        new HashSet<String>(Arrays.asList(
                "eq", "ne", "gt", "gte", "lt", "lte", "between", "in", "is_null", "is_not_null"));
private static final Set<Integer> SPANS =
        new HashSet<Integer>(Arrays.asList(Integer.valueOf(12), Integer.valueOf(6), Integer.valueOf(4)));

Numeric fields are recognized by Java type:

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:

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 13 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 1500.
  • Step 4: Run tests and verify pass

Run:

mvn -pl ruoyi-generator -Dtest=ChartDatasetValidatorTest test

Expected: PASS.

  • Step 5: Commit
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

@Test
public void adminSurfaceIncludesFourChartBlocks()
{
    List<BusinessBlockDefinition> 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:

private boolean hasCode(List<BusinessBlockDefinition> definitions, String code)
{
    for (BusinessBlockDefinition definition : definitions)
    {
        if (code.equals(definition.getCode())) return true;
    }
    return false;
}

private int countKind(List<BusinessBlockDefinition> 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:

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:

[
  {
    "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:

{
  "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:

@Autowired
private ChartDatasetValidator chartDatasetValidator;

private ChartDatasetValidator chartValidator()
{
    if (chartDatasetValidator == null)
    {
        chartDatasetValidator = new ChartDatasetValidator();
    }
    return chartDatasetValidator;
}

Add:

public void validateInstanceConfig(BusinessBlockDefinition definition, BusinessBlockInstance instance,
        List<GenTable> 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:

mvn -pl ruoyi-generator -Dtest=BusinessBlockRegistryServiceTest,FrontendPageDesignServiceTest test

Expected: PASS.

  • Step 6: Commit
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

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('<ChartFilterEditor'), true)
  assert.equal(filters.includes('eq'), true)
  assert.equal(filters.includes('between'), true)
  assert.equal(filters.includes('is_not_null'), true)
})
  • Step 2: Run and verify failure

Run:

cd easycode-web
node --test src/components/businessBlockDesigner.test.mjs

Expected: chart components do not exist.

  • Step 3: Implement ChartFilterEditor.vue

Props and events:

const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  columns: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue'])

Operators:

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:

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:

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:

<ChartBlockConfigForm
  v-if="showConfig && selectedDefinition?.kind === 'chart'"
  :definition="selectedDefinition"
  :dataset="selectedBlock?.dataset || {}"
  :display="selectedBlock?.display || {}"
  :span="selectedBlock?.span || 12"
  :tables="tables"
  :errors="selectedChartErrors"
  @update:dataset="updateSelectedDataset"
  @update:display="updateSelectedDisplay"
  @update:span="updateSelectedSpan"
/>
<BlockConfigForm
  v-else-if="showConfig"
  :definition="selectedDefinition"
  :model-value="selectedBlock?.config || {}"
  :tables="tables"
  @update:model-value="updateSelectedConfig"
/>

Use immutable block updates and emit formatted JSON after every change.

Add:

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:

cd easycode-web
node --test src/components/businessBlockDesigner.test.mjs

Expected: PASS.

  • Step 7: Commit
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:

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('<ChartBlockPreview'), true)
  assert.equal(preview.includes("import * as echarts from 'echarts'"), true)
  assert.equal(preview.includes('chart.resize()'), true)
  assert.equal(preview.includes('chart.dispose()'), true)
})

test('embedded admin blocks retain chart span and use grid wrappers', () => {
  const source = readSource('components/FrontendPageDesigner.vue')
  assert.equal(source.includes("import ChartBlockPreview from '@/components/business-blocks/ChartBlockPreview.vue'"), true)
  assert.equal(source.includes('<ChartBlockPreview'), true)
  assert.equal(source.includes('embedded-block-grid'), true)
  assert.equal(source.includes('block.span || 12'), true)
})
  • Step 2: Run tests and verify failure

Run:

cd easycode-web
node --test src/components/businessBlockDesigner.test.mjs src/components/frontendPageDesigner.test.mjs

Expected: missing preview and grid behavior.

  • Step 3: Install ECharts in the designer

Run:

cd easycode-web
npm install echarts@5.4.0

Expected: package.json and package-lock.json contain echarts.

  • Step 4: Add deterministic preview option builders

Add to chartBlock.js:

export function sampleChartData(definition) {
  if (definition?.chartType === 'metric') {
    return { dimensions: [], series: [], summary: { value: 86420, count: 328 } }
  }
  return {
    dimensions: [
      { key: 'A', label: 'A' },
      { key: 'B', label: 'B' },
      { key: 'C', label: 'C' },
      { key: 'D', label: 'D' }
    ],
    series: [{ key: 'metric1', name: '指标 1', values: [32, 51, 46, 78] }],
    summary: { value: 207, count: 4 }
  }
}

export function buildChartOption(definition, block, data = sampleChartData(definition)) {
  const labels = data.dimensions.map((item) => 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 <button> to an <article role="button" tabindex="0"> so the preview retry button is not nested inside another button. Preserve click and keyboard selection and keep the remove action as a separate button.

Import and render ChartBlockPreview in both BlockCanvas.vue and the embedded-block sections of FrontendPageDesigner.vue. Legacy business blocks continue to render their existing compact name/code cards.

Wrap every block in a grid item using:

:style="{ gridColumn: `span ${block.span || 12}` }"

Use:

.block-canvas,
.embedded-block-grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: 12px;
}

At widths below 900px, force every item to grid-column: 1 / -1.

  • Step 7: Run tests and build

Run:

cd easycode-web
node --test src/components/businessBlockDesigner.test.mjs src/components/frontendPageDesigner.test.mjs
npm run build

Expected: tests PASS and Vite build succeeds.

  • Step 8: Commit
git add easycode-web/package.json easycode-web/package-lock.json easycode-web/src/components/business-blocks/ChartBlockPreview.vue easycode-web/src/components/business-blocks/chartBlock.js easycode-web/src/components/business-blocks/BlockCanvas.vue easycode-web/src/components/FrontendPageDesigner.vue easycode-web/src/components/businessBlockDesigner.test.mjs easycode-web/src/components/frontendPageDesigner.test.mjs
git commit -m "Preview admin chart blocks in the designer"

Task 8: Build a safe static chart query render model

Files:

  • 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

  • Create: ruoyi-generator/src/test/java/com/ruoyi/generator/util/ChartBlockRenderSupportTest.java

  • Step 1: Write failing SQL render tests

@Test
public void rendersBoundSingleTableAggregation()
{
    ChartQueryRenderModel model = support.build(orderTable(), lineDefinition(), lineInstance());

    assertEquals("shop_order", model.getTableName());
    assertEquals("DATE_FORMAT(t.create_time, '%Y-%m-%d')", model.getDimensionSql());
    assertTrue(model.getMetricSelectSql().contains("SUM(t.amount) AS salesAmount"));
    assertTrue(model.getWhereSql().contains("t.status = #{filter0}"));
    assertEquals("PAID", model.getParameters().get(0).getValue());
    assertEquals("dimensionKey ASC", model.getOrderBySql());
    assertEquals(Integer.valueOf(100), model.getLimit());
}

@Test
public void neverCopiesFilterValuesIntoSql()
{
    BusinessBlockInstance instance = lineInstance();
    instance.getDataset().getFilters().get(0).setValue("PAID' OR 1=1 --");

    ChartQueryRenderModel model = support.build(orderTable(), lineDefinition(), instance);

    assertTrue(model.getWhereSql().contains("#{filter0}"));
    assertTrue(!model.getWhereSql().contains("OR 1=1"));
}

Also test day/week/month formats, count/sum/avg, between, in, null operators, metric no-group query, and pie/bar limits.

  • Step 2: Run and verify failure

Run:

mvn -pl ruoyi-generator -Dtest=ChartBlockRenderSupportTest test

Expected: classes do not exist.

  • Step 3: Add render DTOs

ChartQueryParameter:

private String name;
private Object value;

ChartQueryRenderModel:

private String tableName;
private String dimensionSql;
private String metricSelectSql;
private String whereSql;
private String groupBySql;
private String orderBySql;
private Integer limit;
private List<ChartQueryParameter> parameters;
private List<ChartMetricConfig> metrics;
private ChartDateRangeConfig dateRange;
private boolean metricOnly;
  • Step 4: Implement ChartBlockRenderSupport

The public entry point is:

public ChartQueryRenderModel build(GenTable table, BusinessBlockDefinition definition,
        BusinessBlockInstance instance)

Only use column names resolved from GenTable.columns. Never copy the configured field string directly after lookup.

Time SQL:

private String dimensionSql(String column, String timeUnit)
{
    if ("day".equals(timeUnit)) return "DATE_FORMAT(t." + column + ", '%Y-%m-%d')";
    if ("week".equals(timeUnit)) return "DATE_FORMAT(t." + column + ", '%x-W%v')";
    if ("month".equals(timeUnit)) return "DATE_FORMAT(t." + column + ", '%Y-%m')";
    return "t." + column;
}

Metrics:

private String metricSql(String column, String aggregate, String alias)
{
    if ("count".equals(aggregate))
    {
        return StringUtils.isEmpty(column) ? "COUNT(*) AS " + alias : "COUNT(t." + column + ") AS " + alias;
    }
    if ("avg".equals(aggregate)) return "AVG(t." + column + ") AS " + alias;
    return "SUM(t." + column + ") AS " + alias;
}

Filters create #{filterN} parameters. between creates two parameters and in creates one parameter per value. Null operators create none. Date ranges add t.<column> >= #{dateStart} and t.<column> < #{dateEnd} to whereSql, while the original ChartDateRangeConfig is stored in ChartQueryRenderModel.dateRange for the generated service to resolve.

  • Step 5: Run tests and verify pass

Run:

mvn -pl ruoyi-generator -Dtest=ChartBlockRenderSupportTest test

Expected: PASS.

  • Step 6: Commit
git add ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryParameter.java ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartQueryRenderModel.java ruoyi-generator/src/main/java/com/ruoyi/generator/util/ChartBlockRenderSupport.java ruoyi-generator/src/test/java/com/ruoyi/generator/util/ChartBlockRenderSupportTest.java
git commit -m "Build safe chart aggregation query models"

Task 9: Extend business-block generation for shared chart templates

Files:

  • Modify: ruoyi-generator/src/main/java/com/ruoyi/generator/service/BusinessBlockGenerationService.java

  • Modify: ruoyi-generator/src/main/java/com/ruoyi/generator/domain/GeneratedBusinessBlockFile.java

  • Modify: ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java

  • Step 1: Write failing generation context tests

@Test
public void chartBlockUsesSharedTemplatesAndAdminApiPath()
{
    List<GeneratedBusinessBlockFile> files = service.listFiles(chartProject(), "admin_frontend");
    GeneratedBusinessBlockFile component = find(files, "admin_line_chart_001", "frontendComponent");

    assertEquals("business-blocks/chart/templates/frontend/AdminChartBlock.vue.vm",
            component.getTemplateResource());
    assertEquals(Integer.valueOf(6), component.getInstance().getSpan());

    String content = service.renderFile(chartProject(), component);
    assertTrue(content.contains("/admin/business-blocks/admin_line_chart_001/data"));
    assertTrue(content.contains("chartType: \"line\""));
}

@Test
public void chartProjectEmitsOneSharedRuntime()
{
    List<GeneratedBusinessBlockFile> files = service.listFiles(chartProject(), "admin_frontend");
    assertEquals(1, countOutput(files, "src/utils/chartRuntime.js"));
}

@Test
public void adminChartGeneratesFrontendAndBackendFiles()
{
    assertTrue(hasBlockFile(service.listFiles(chartProject(), "admin_frontend"),
            "admin_line_chart_001", "frontendComponent"));
    assertTrue(hasBlockFile(service.listFiles(chartProject(), "backend"),
            "admin_line_chart_001", "mapperXml"));
}
  • Step 2: Run and verify failure

Run:

mvn -pl ruoyi-generator -Dtest=BusinessBlockGenerationServiceTest test

Expected: absolute shared template paths and runtime support are absent.

  • Step 3: Resolve absolute classpath template paths

Add:

private String templateResource(String blockCode, String template)
{
    String normalized = normalizePath(template);
    if (normalized.startsWith("/"))
    {
        return normalized.substring(1);
    }
    return "business-blocks/" + blockCode + "/" + normalized;
}

Use it from blockFile.

  • Step 4: Add chart generation context

Inject ChartBlockRenderSupport and add the same lazy fallback pattern used by the existing businessBlockService()/registry() helpers:

@Autowired
private ChartBlockRenderSupport chartBlockRenderSupport;

private ChartBlockRenderSupport chartRenderSupport()
{
    if (chartBlockRenderSupport == null)
    {
        chartBlockRenderSupport = new ChartBlockRenderSupport();
    }
    return chartBlockRenderSupport;
}

For chart blocks:

ChartQueryRenderModel chartQuery = chartRenderSupport().build(
        requireTable(project, instance.getDataset().getTable()), file.getDefinition(), instance);
context.put("chartQuery", chartQuery);
context.put("chartType", file.getDefinition().getChartType());
context.put("chartDataset", instance.getDataset());
context.put("chartDisplay", instance.getDisplay());
context.put("apiBasePath", "/admin/business-blocks/" + file.getInstanceCode());

Non-chart blocks keep /business-blocks/{instanceCode}.

  • Step 5: Route admin frontend and backend templates correctly

Replace the current scope gate with:

if ("frontend".equals(templateType) && adminScope)
{
    continue;
}
if ("admin_frontend".equals(templateType) && !adminScope)
{
    continue;
}

When selecting template files, treat both frontend targets as Vue/API files:

private boolean templateMatches(String requestedType, BusinessBlockTemplateFile templateFile)
{
    String fileType = templateType(templateFile);
    if ("admin_frontend".equals(requestedType))
    {
        return "frontend".equals(fileType);
    }
    return requestedType.equals(fileType);
}

Backend files are therefore generated for both frontend and admin page scopes, while Vue/API files remain scope-specific.

  • Step 6: Emit one shared runtime file

When listFiles(project, "admin_frontend") encounters the first chart block, prepend:

category: business-block:admin_frontend:shared:chartRuntime
outputPath: src/utils/chartRuntime.js
templateResource: business-blocks/chart/templates/frontend/chartRuntime.js.vm

Use a Set<String> keyed by output path so multiple chart blocks cannot duplicate it.

  • Step 7: Expose span to generated page view models

Add:

block.put("span", instance.getSpan() == null ? Integer.valueOf(12) : instance.getSpan());
  • Step 8: Run tests and verify pass

Run:

mvn -pl ruoyi-generator -Dtest=BusinessBlockGenerationServiceTest test

Expected: PASS.

  • Step 9: Commit
git add ruoyi-generator/src/main/java/com/ruoyi/generator/service/BusinessBlockGenerationService.java ruoyi-generator/src/main/java/com/ruoyi/generator/domain/GeneratedBusinessBlockFile.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java
git commit -m "Prepare chart business block generation"

Task 10: Generate chart backend endpoints

Files:

  • Create:

    • 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: ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java

  • Step 1: Write failing generated-backend assertions

@Test
public void generatedChartBackendUsesBoundParametersAndUnifiedResponse()
{
    Map<String, String> rendered = renderChartFiles(chartProject());

    assertTrue(rendered.get("mapperXml").contains("t.status = #{filter0}"));
    assertTrue(!rendered.get("mapperXml").contains("PAID"));
    assertTrue(rendered.get("mapperXml").contains("limit 100"));
    assertTrue(rendered.get("serviceImpl").contains("dimensions"));
    assertTrue(rendered.get("serviceImpl").contains("series"));
    assertTrue(rendered.get("serviceImpl").contains("summary"));
    assertTrue(rendered.get("controller").contains("@RequestMapping(\"/admin/business-blocks/admin_line_chart_001\")"));
}
  • Step 2: Run and verify failure

Run:

mvn -pl ruoyi-generator -Dtest=BusinessBlockGenerationServiceTest test

Expected: shared backend templates do not exist.

  • Step 3: Add mapper contract

Generated mapper:

List<Map<String, Object>> selectData(Map<String, Object> params);

Generated XML shape:

<select id="selectData" parameterType="java.util.Map" resultType="java.util.LinkedHashMap">
    select
#if(!$chartQuery.metricOnly)
        ${chartQuery.dimensionSql} as dimensionKey,
#end
        ${chartQuery.metricSelectSql}
    from ${chartQuery.tableName} t
#if($chartQuery.whereSql)
    where ${chartQuery.whereSql}
#end
#if($chartQuery.groupBySql)
    group by ${chartQuery.groupBySql}
#end
#if($chartQuery.orderBySql)
    order by ${chartQuery.orderBySql}
#end
    limit ${chartQuery.limit}
</select>

All ${...} values above are generated from validated metadata by ChartBlockRenderSupport; runtime values remain #{...}.

  • Step 4: Add service response assembly

The generated service:

  1. Creates a LinkedHashMap<String,Object> of bound filter parameters.
  2. Calculates date preset start/end with LocalDate and LocalDateTime.
  3. Calls mapper.selectData(params).
  4. Returns:
{
    "dimensions": List<Map<String, Object>>,
    "series": List<Map<String, Object>>,
    "summary": Map<String, Object>
}

For metric-only charts, read the first row into summary.value and set summary.count. For grouped charts, preserve row order, create one dimension entry per row, and append each metric alias value to its series.

Generate this private date helper in the service implementation:

private void putDateRange(Map<String, Object> params)
{
#if($chartQuery.dateRange)
    LocalDate today = LocalDate.now();
#if($chartQuery.dateRange.preset == "today")
    params.put("dateStart", today.atStartOfDay());
    params.put("dateEnd", today.plusDays(1).atStartOfDay());
#elseif($chartQuery.dateRange.preset == "last_7_days")
    params.put("dateStart", today.minusDays(6).atStartOfDay());
    params.put("dateEnd", today.plusDays(1).atStartOfDay());
#elseif($chartQuery.dateRange.preset == "last_30_days")
    params.put("dateStart", today.minusDays(29).atStartOfDay());
    params.put("dateEnd", today.plusDays(1).atStartOfDay());
#elseif($chartQuery.dateRange.preset == "this_month")
    params.put("dateStart", today.withDayOfMonth(1).atStartOfDay());
    params.put("dateEnd", today.withDayOfMonth(1).plusMonths(1).atStartOfDay());
#elseif($chartQuery.dateRange.preset == "this_year")
    params.put("dateStart", today.withDayOfYear(1).atStartOfDay());
    params.put("dateEnd", today.withDayOfYear(1).plusYears(1).atStartOfDay());
#else
    params.put("dateStart", LocalDate.parse("${chartQuery.dateRange.start}").atStartOfDay());
    params.put("dateEnd", LocalDate.parse("${chartQuery.dateRange.end}").plusDays(1).atStartOfDay());
#end
#end
}
  • Step 5: Add controller
@RestController
@RequestMapping("${apiBasePath}")
public class ${InstanceClassName}Controller
{
    @Autowired
    private I${InstanceClassName}Service ${instanceClassName}Service;

    @GetMapping("/data")
    public Result<Map<String, Object>> data()
    {
        return Result.success(${instanceClassName}Service.loadData());
    }
}

The /admin/business-blocks/** path is not in Qing's public interceptor exclusions, so it remains login-protected.

  • Step 6: Run tests and verify pass

Run:

mvn -pl ruoyi-generator -Dtest=BusinessBlockGenerationServiceTest test

Expected: PASS.

  • Step 7: Commit
git add ruoyi-generator/src/main/resources/business-blocks/chart/templates/backend ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java
git commit -m "Generate admin chart aggregation endpoints"

Task 11: Generate Vue 2/ECharts chart components and grid pages

Files:

  • Create:

    • 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
  • 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

  • Modify: ruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.java

  • Modify: ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java

  • Step 1: Write failing frontend-generation tests

@Test
public void qingPackageIncludesEcharts()
{
    String content = render("qing/vue-package.json.vm", projectContext());
    assertTrue(content.contains("\"echarts\": \"5.4.0\""));
}

@Test
public void generatedChartComponentHasBlockLevelStatesAndCleanup()
{
    String component = renderChartFile("frontendComponent");
    assertTrue(component.contains("el-skeleton"));
    assertTrue(component.contains("el-empty"));
    assertTrue(component.contains("retry"));
    assertTrue(component.contains("disposeChart"));
    assertTrue(component.contains("window.removeEventListener"));
}

@Test
public void businessBlockPageUsesTwelveColumnSpanClasses()
{
    String page = renderChartPage();
    assertTrue(page.contains("business-block-page__item--span-6"));
    assertTrue(page.contains("grid-template-columns: repeat(12, minmax(0, 1fr))"));
}
  • Step 2: Run and verify failure

Run:

mvn -pl ruoyi-generator -Dtest=BusinessBlockGenerationServiceTest,QingTemplateSupportTest test

Expected: ECharts and chart templates are absent.

  • Step 3: Add generated chart runtime

chartRuntime.js.vm exports:

import * as echarts from "echarts"

export function createChart(element) {
  return echarts.init(element)
}

export function buildChartOption(chartType, display, payload) {
  const safeDisplay = display || {}
  const dimensions = (payload && payload.dimensions) || []
  const sourceSeries = (payload && payload.series) || []
  const labels = dimensions.map(item => item.label)

  if (chartType === "pie") {
    const values = sourceSeries.length ? sourceSeries[0].values : []
    return {
      tooltip: { trigger: "item" },
      legend: { show: safeDisplay.showLegend !== false },
      series: [{
        type: "pie",
        radius: safeDisplay.donut ? ["45%", "70%"] : "70%",
        label: { show: safeDisplay.showLabel !== false },
        data: labels.map((name, index) => ({
          name,
          value: values[index] == null ? 0 : values[index]
        }))
      }]
    }
  }

  const horizontal = chartType === "bar" && safeDisplay.horizontal
  const series = sourceSeries.map(item => ({
    name: item.name,
    type: chartType === "bar" ? "bar" : "line",
    smooth: chartType === "line" && Boolean(safeDisplay.smooth),
    stack: chartType === "bar" && safeDisplay.stacked ? "total" : undefined,
    areaStyle: chartType === "line" && safeDisplay.area ? {} : undefined,
    data: item.values || []
  }))

  return {
    tooltip: { trigger: "axis" },
    legend: { show: safeDisplay.showLegend !== false },
    xAxis: horizontal
      ? { type: "value" }
      : { type: "category", data: labels },
    yAxis: horizontal
      ? { type: "category", data: labels }
      : { type: "value" },
    series
  }
}

export function hasChartData(payload) {
  return Boolean(payload && (
    (payload.summary && payload.summary.value !== null && payload.summary.value !== undefined)
    || (payload.dimensions && payload.dimensions.length)
  ))
}

The option builder contains only the four supported chart types and supported display flags.

  • Step 4: Add API and component templates

API:

import request from "@/api/request"

export function load${InstanceClassName}Data() {
  return request({
    url: "${apiBasePath}/data",
    method: "get"
  })
}

Component behavior:

  • created() calls loadData.

  • Metric charts render summary.value.

  • Other charts initialize after $nextTick.

  • loading, errorMessage, and hasData are block-local.

  • Retry calls loadData.

  • beforeDestroy() removes resize and disposes.

  • Step 5: Add grid wrappers to generated pages

Page block:

    <div class="business-block-page__item business-block-page__item--span-${block.span}">
      <${block.componentName} />
    </div>

CSS:

.business-block-page,
.embedded-business-blocks {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: 16px;
}

.business-block-page__item--span-12,
.embedded-business-block__item--span-12 { grid-column: span 12; }
.business-block-page__item--span-6,
.embedded-business-block__item--span-6 { grid-column: span 6; }
.business-block-page__item--span-4,
.embedded-business-block__item--span-4 { grid-column: span 4; }

At max-width: 900px, all wrappers span 12.

  • Step 6: Add ECharts to generated package

Add:

"echarts": "5.4.0"

to qing/vue-package.json.vm.

  • Step 7: Run tests and verify pass

Run:

mvn -pl ruoyi-generator -Dtest=BusinessBlockGenerationServiceTest,QingTemplateSupportTest test

Expected: PASS.

  • Step 8: Commit
git add ruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend ruoyi-generator/src/main/resources/business-blocks/page/business-block-page.vue.vm ruoyi-generator/src/main/resources/qing/index.vue.vm ruoyi-generator/src/main/resources/qing/vue-package.json.vm ruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java
git commit -m "Generate admin ECharts business blocks"

Task 12: Merge chart files into preview/download and verify compatibility

Files:

  • Modify: ruoyi-generator/src/test/java/com/ruoyi/generator/service/GenProjectServiceImplTest.java

  • Modify: ruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.java

  • Step 1: Add end-to-end structure tests

@Test
public void adminChartFilesAppearInPreviewAndDownloadStructure()
{
    GenProject project = chartProject();
    List<GenStructureNode> frontend = service.getProjectStructure(project, "admin_frontend");
    List<GenStructureNode> backend = service.getProjectStructure(project, "backend");

    assertTrue(hasPath(frontend, "src/utils/chartRuntime.js"));
    assertTrue(hasPath(frontend, "src/views/pages/admin_home/blocks/admin_line_chart_001/AdminLineChart001Block.vue"));
    assertTrue(hasPath(backend, "src/main/resources/mapper/block/AdminLineChart001BlockMapper.xml"));
}

@Test
public void legacyFrontendBlockGenerationIsUnchanged()
{
    List<GeneratedBusinessBlockFile> files = generationService.listFiles(frontendCartProject(), "frontend");
    assertTrue(hasOutput(files, "src/api/business-blocks/cart_001.js"));
    assertTrue(!hasOutput(files, "src/utils/chartRuntime.js"));
}
  • Step 2: Run focused integration tests

Run:

mvn -pl ruoyi-generator -Dtest=GenProjectServiceImplTest,BusinessBlockGenerationServiceTest test

Expected: PASS. GenProjectServiceImpl.addGeneratedFileNode already builds nodes from arbitrary output paths, so src/utils/chartRuntime.js must require no chart-specific production branch.

  • Step 3: Run all frontend tests

Run:

cd easycode-web
node --test
npm run build

Expected: all Node tests PASS and Vite build succeeds.

  • Step 4: Run all focused backend tests

Run:

mvn -pl ruoyi-generator,ruoyi-admin -am -Dtest=BusinessBlockRegistryServiceTest,ChartBlockRenderSupportTest,FrontendPageDesignServiceTest,BusinessBlockGenerationServiceTest,GenProjectServiceImplTest,QingTemplateSupportTest,FrontProjectControllerRunPreviewTest test

Expected: all selected tests PASS.

  • Step 5: Run module test suites

Run:

mvn -pl ruoyi-generator,ruoyi-admin -am test

Expected: BUILD SUCCESS.

  • Step 6: Manual run-preview smoke test

Use a project containing:

shop_order.id            bigint
shop_order.amount        decimal
shop_order.status        varchar
shop_order.create_time   datetime

In the page designer:

  1. Switch to 后台.
  2. Open or create a 业务块页面.
  3. Add one of each chart type.
  4. Configure line chart: create_time/day, amount/sum, status=PAID.
  5. Configure bar chart by status/count.
  6. Configure pie chart by status/count.
  7. Configure metric card as amount/sum.
  8. Set widths to 12, 6, 6, and 4.
  9. Save, generate, and run preview.
  10. Verify login is required for /admin/business-blocks/**.
  11. Verify each block independently shows loading, data or empty state, and retry on forced API failure.
  12. Verify a frontend page still lists only existing frontend business blocks.
  • Step 7: Keep verification tied to the owning task

If a verification command fails, return to the task that owns the failing file, make the smallest repair there, rerun that task's focused test, and use that task's explicit git add file list and commit message. Do not create a catch-all verification commit.

Rollout Order

  1. Tasks 12 establish compatible protocol and scoped discovery.
  2. Tasks 37 make chart blocks configurable and visible in EasyCode.
  3. Tasks 811 generate safe aggregation endpoints and ECharts components.
  4. Task 12 verifies preview/download integration and legacy compatibility.

Self-Review

  • Spec coverage: protocol compatibility, admin-only scope, four chart types, single-table DSL, three widths, design preview, validation, generation, authentication boundary, error isolation, and compatibility each map to explicit tasks.
  • Placeholder scan: the plan contains no unresolved markers or deferred implementation steps.
  • Type consistency: BusinessBlockInstance.dataset uses ChartDatasetConfig; definitions use surfaces and placements; chart generation uses ChartQueryRenderModel; frontend helpers use the same dataset, display, and span property names.
  • Security consistency: generated chart APIs use /admin/business-blocks/**, while existing public frontend blocks remain under /business-blocks/**.