2061 lines
70 KiB
Markdown
2061 lines
70 KiB
Markdown
# 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 1–3 for line/bar.
|
||
6. `sum` and `avg` require numeric fields.
|
||
7. Time unit requires a temporal dimension.
|
||
8. Alias matches `[A-Za-z][A-Za-z0-9_]{0,39}` and is unique.
|
||
9. Filters use known fields/operators and max at 10.
|
||
10. Limit is 1–500.
|
||
|
||
- [ ] **Step 4: Run tests and verify pass**
|
||
|
||
Run:
|
||
|
||
```powershell
|
||
mvn -pl ruoyi-generator -Dtest=ChartDatasetValidatorTest test
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```powershell
|
||
git add ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ChartDatasetValidator.java ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ChartDatasetValidatorTest.java
|
||
git commit -m "Validate chart business block datasets"
|
||
```
|
||
|
||
## Task 5: Register the four built-in admin chart blocks
|
||
|
||
**Files:**
|
||
|
||
- Create:
|
||
- `ruoyi-generator/src/main/resources/business-blocks/admin_metric_chart/block.json`
|
||
- `ruoyi-generator/src/main/resources/business-blocks/admin_line_chart/block.json`
|
||
- `ruoyi-generator/src/main/resources/business-blocks/admin_bar_chart/block.json`
|
||
- `ruoyi-generator/src/main/resources/business-blocks/admin_pie_chart/block.json`
|
||
- Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/BusinessBlockRegistryService.java`
|
||
- Modify: `ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/FrontendPageDesignService.java`
|
||
- Modify: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java`
|
||
- Modify: `ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/FrontendPageDesignServiceTest.java`
|
||
|
||
- [ ] **Step 1: Write failing registry tests**
|
||
|
||
```java
|
||
@Test
|
||
public void adminSurfaceIncludesFourChartBlocks()
|
||
{
|
||
List<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 1–2 establish compatible protocol and scoped discovery.
|
||
2. Tasks 3–7 make chart blocks configurable and visible in EasyCode.
|
||
3. Tasks 8–11 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/**`.
|