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

2061 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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