70 KiB
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, anddefaultSpan. - 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.
- Add protocol-v2 metadata while retaining
- Modify
ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockInstance.java- Add
span,dataset, anddisplay.
- Add
- 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.
- Accept
- Modify
ruoyi-admin/src/test/java/com/ruoyi/web/controller/front/FrontProjectControllerRunPreviewTest.java - Modify
easycode-web/src/api/project.js- Pass scope to
listBusinessBlocks.
- Pass scope to
- 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.jsonandeasycode-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.jsonruoyi-generator/src/main/resources/business-blocks/admin_line_chart/block.jsonruoyi-generator/src/main/resources/business-blocks/admin_bar_chart/block.jsonruoyi-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.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartApi.js.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartRuntime.js.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartController.java.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartService.java.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartServiceImpl.java.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartMapper.java.vmruoyi-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.javaruoyi-generator/src/test/java/com/ruoyi/generator/service/BusinessBlockGenerationServiceTest.javaruoyi-generator/src/test/java/com/ruoyi/generator/service/GenProjectServiceImplTest.javaruoyi-generator/src/test/java/com/ruoyi/generator/util/QingTemplateSupportTest.javaruoyi-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:
- Dataset schema equals
single-table-aggregate-v1. - Table exists.
- Span is 12, 6, or 4.
- Metric chart has no required dimension; other charts require one.
- Metric count is 1 for metric/pie and 1–3 for line/bar.
sumandavgrequire numeric fields.- Time unit requires a temporal dimension.
- Alias matches
[A-Za-z][A-Za-z0-9_]{0,39}and is unique. - Filters use known fields/operators and max at 10.
- Limit is 1–500.
- 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.jsonruoyi-generator/src/main/resources/business-blocks/admin_line_chart/block.jsonruoyi-generator/src/main/resources/business-blocks/admin_bar_chart/block.jsonruoyi-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.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartService.java.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartServiceImpl.java.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/backend/AdminChartMapper.java.vmruoyi-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:
- Creates a
LinkedHashMap<String,Object>of bound filter parameters. - Calculates date preset start/end with
LocalDateandLocalDateTime. - Calls
mapper.selectData(params). - 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.vmruoyi-generator/src/main/resources/business-blocks/chart/templates/frontend/chartApi.js.vmruoyi-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()callsloadData. -
Metric charts render
summary.value. -
Other charts initialize after
$nextTick. -
loading,errorMessage, andhasDataare 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:
- Switch to 后台.
- Open or create a 业务块页面.
- Add one of each chart type.
- Configure line chart:
create_time/day,amount/sum,status=PAID. - Configure bar chart by
status/count. - Configure pie chart by
status/count. - Configure metric card as
amount/sum. - Set widths to 12, 6, 6, and 4.
- Save, generate, and run preview.
- Verify login is required for
/admin/business-blocks/**. - Verify each block independently shows loading, data or empty state, and retry on forced API failure.
- 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
- Tasks 1–2 establish compatible protocol and scoped discovery.
- Tasks 3–7 make chart blocks configurable and visible in EasyCode.
- Tasks 8–11 generate safe aggregation endpoints and ECharts components.
- 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.datasetusesChartDatasetConfig; definitions usesurfacesandplacements; chart generation usesChartQueryRenderModel; frontend helpers use the samedataset,display, andspanproperty names. - Security consistency: generated chart APIs use
/admin/business-blocks/**, while existing public frontend blocks remain under/business-blocks/**.