diff --git a/RuoYi-Vue/easycode-web/src/components/business-blocks/blockLayout.js b/RuoYi-Vue/easycode-web/src/components/business-blocks/blockLayout.js new file mode 100644 index 0000000..c491169 --- /dev/null +++ b/RuoYi-Vue/easycode-web/src/components/business-blocks/blockLayout.js @@ -0,0 +1,71 @@ +import { buildDefaultChartInstance, isChartDefinition } from './chartBlock.js' + +export const businessBlockCanvas = 'business-blocks-v1' + +export function parseBusinessBlockLayout(value) { + if (!value) { + return { canvas: businessBlockCanvas, blocks: [] } + } + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value + return { + ...parsed, + canvas: businessBlockCanvas, + blocks: Array.isArray(parsed?.blocks) ? parsed.blocks : [] + } + } catch (error) { + return { canvas: businessBlockCanvas, blocks: [] } + } +} + +export function addBlockInstance(layout, definition, idFactory = defaultIdFactory) { + const next = cloneLayout(layout) + const id = idFactory(definition.code) + next.blocks.push(isChartDefinition(definition) + ? buildDefaultChartInstance(definition, id) + : { + id, + blockCode: definition.code, + name: definition.name || definition.code, + config: {} + }) + return next +} + +export function updateBlockConfig(layout, blockId, config) { + const next = cloneLayout(layout) + next.blocks = next.blocks.map((block) => ( + block.id === blockId ? { ...block, config: { ...(config || {}) } } : block + )) + return next +} + +export function reorderBlockInstance(layout, fromIndex, toIndex) { + const next = cloneLayout(layout) + if (fromIndex < 0 || toIndex < 0 || fromIndex >= next.blocks.length || toIndex >= next.blocks.length) { + return next + } + const [moved] = next.blocks.splice(fromIndex, 1) + next.blocks.splice(toIndex, 0, moved) + return next +} + +export function removeBlockInstance(layout, blockId) { + const next = cloneLayout(layout) + next.blocks = next.blocks.filter((block) => block.id !== blockId) + return next +} + +export function validateRequiredConfig(definition, config) { + return (definition?.configs || []) + .filter((item) => item.required && !config?.[item.key]) + .map((item) => item.label || item.key) +} + +function cloneLayout(layout) { + return JSON.parse(JSON.stringify(parseBusinessBlockLayout(layout))) +} + +function defaultIdFactory(code) { + return `${code}_${Date.now()}` +} diff --git a/RuoYi-Vue/easycode-web/src/components/business-blocks/chartBlock.js b/RuoYi-Vue/easycode-web/src/components/business-blocks/chartBlock.js new file mode 100644 index 0000000..d3f3bc6 --- /dev/null +++ b/RuoYi-Vue/easycode-web/src/components/business-blocks/chartBlock.js @@ -0,0 +1,66 @@ +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 +} diff --git a/RuoYi-Vue/easycode-web/src/components/businessBlockDesigner.test.mjs b/RuoYi-Vue/easycode-web/src/components/businessBlockDesigner.test.mjs new file mode 100644 index 0000000..c9ea3d5 --- /dev/null +++ b/RuoYi-Vue/easycode-web/src/components/businessBlockDesigner.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +import { + addBlockInstance, + businessBlockCanvas, + parseBusinessBlockLayout, + reorderBlockInstance, + updateBlockConfig, + validateRequiredConfig +} from './business-blocks/blockLayout.js' +import { + buildDefaultChartInstance, + updateChartDataset, + updateChartDisplay, + validateChartInstance +} from './business-blocks/chartBlock.js' + +const currentDir = dirname(fileURLToPath(import.meta.url)) + +function readSource(relativePath) { + return readFileSync(resolve(currentDir, relativePath), 'utf8') +} + +const cartDefinition = { + code: 'cart', + name: 'Cart', + configs: [ + { key: 'cartTable', label: 'Cart table', type: 'table', required: true }, + { key: 'productName', label: 'Product name field', type: 'field', table: 'productTable', required: true } + ] +} + +const lineDefinition = { + code: 'admin_line_chart', + name: 'Line chart', + kind: 'chart', + chartType: 'line', + datasetSchema: 'single-table-aggregate-v1', + defaultSpan: 12 +} + +test('business block helpers create an empty business block canvas', () => { + const layout = parseBusinessBlockLayout('') + + assert.equal(layout.canvas, businessBlockCanvas) + assert.deepEqual(layout.blocks, []) +}) + +test('business block helpers add a block instance with stable generated id', () => { + const layout = addBlockInstance(parseBusinessBlockLayout(''), cartDefinition, () => 'cart_001') + + assert.equal(layout.blocks[0].id, 'cart_001') + assert.equal(layout.blocks[0].blockCode, 'cart') + assert.deepEqual(layout.blocks[0].config, {}) +}) + +test('business block helpers update block config without replacing other blocks', () => { + let layout = addBlockInstance(parseBusinessBlockLayout(''), cartDefinition, () => 'cart_001') + layout = addBlockInstance(layout, { ...cartDefinition, code: 'notice', name: 'Notice' }, () => 'notice_001') + + layout = updateBlockConfig(layout, 'cart_001', { cartTable: 'shop_cart' }) + + assert.equal(layout.blocks[0].config.cartTable, 'shop_cart') + assert.deepEqual(layout.blocks[1].config, {}) +}) + +test('business block helpers reorder block instances', () => { + let layout = addBlockInstance(parseBusinessBlockLayout(''), cartDefinition, () => 'cart_001') + layout = addBlockInstance(layout, { ...cartDefinition, code: 'notice', name: 'Notice' }, () => 'notice_001') + + layout = reorderBlockInstance(layout, 1, 0) + + assert.equal(layout.blocks[0].id, 'notice_001') + assert.equal(layout.blocks[1].id, 'cart_001') +}) + +test('business block helpers report missing required config labels', () => { + const missing = validateRequiredConfig(cartDefinition, { cartTable: 'shop_cart' }) + + assert.deepEqual(missing, ['Product name field']) +}) + +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 updates dataset and display immutably', () => { + const block = buildDefaultChartInstance(lineDefinition, 'line_001') + const dataset = { ...block.dataset, table: 'shop_order' } + const display = { ...block.display, smooth: false } + + const withDataset = updateChartDataset(block, dataset) + const withDisplay = updateChartDisplay(withDataset, display) + + assert.equal(block.dataset.table, '') + assert.equal(withDataset.dataset.table, 'shop_order') + assert.equal(withDisplay.display.smooth, false) +}) + +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) +}) + +test('business block designer is composed from focused subcomponents', () => { + assert.equal(existsSync(resolve(currentDir, 'business-blocks/BusinessBlockDesigner.vue')), true) + assert.equal(existsSync(resolve(currentDir, 'business-blocks/BlockPalette.vue')), true) + assert.equal(existsSync(resolve(currentDir, 'business-blocks/BlockCanvas.vue')), true) + assert.equal(existsSync(resolve(currentDir, 'business-blocks/BlockConfigForm.vue')), true) + + const source = readSource('business-blocks/BusinessBlockDesigner.vue') + assert.equal(source.includes("import BlockPalette from './BlockPalette.vue'"), true) + assert.equal(source.includes("import BlockCanvas from './BlockCanvas.vue'"), true) + assert.equal(source.includes("import BlockConfigForm from './BlockConfigForm.vue'"), true) + assert.equal(source.includes('update:layout-json'), true) + assert.equal(source.includes('select-block'), true) + assert.equal(source.includes('showConfig'), true) + assert.equal(source.includes('showPalette'), true) + assert.equal(source.includes('v-if="showPalette"'), true) + assert.equal(source.includes('v-if="showConfig"'), true) + assert.equal(source.includes('defineExpose'), true) + assert.equal(source.includes(' { + const source = readSource('business-blocks/BlockConfigForm.vue') + + assert.equal(source.includes("field.type === 'table'"), true) + assert.equal(source.includes("field.type === 'field'"), true) + assert.equal(source.includes(' { + const source = readSource('business-blocks/BlockCanvas.vue') + + assert.equal(source.includes('draggable="true"'), true) + assert.equal(source.includes('reorder-block'), true) + assert.equal(source.includes('remove-block'), true) +}) diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockConfigField.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockConfigField.java new file mode 100644 index 0000000..5a80133 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockConfigField.java @@ -0,0 +1,26 @@ +package com.ruoyi.generator.domain.front.dto.block; + +import java.util.List; + +public class BusinessBlockConfigField +{ + private String key; + private String label; + private String type; + private String table; + private Boolean required; + private List options; + + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } + public String getType() { return type; } + public void setType(String type) { this.type = type; } + public String getTable() { return table; } + public void setTable(String table) { this.table = table; } + public Boolean getRequired() { return required; } + public void setRequired(Boolean required) { this.required = required; } + public List getOptions() { return options; } + public void setOptions(List options) { this.options = options; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockInstance.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockInstance.java new file mode 100644 index 0000000..01b7aba --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockInstance.java @@ -0,0 +1,29 @@ +package com.ruoyi.generator.domain.front.dto.block; + +import java.util.Map; + +public class BusinessBlockInstance +{ + private String id; + private String blockCode; + private String name; + private Map config; + private Integer span; + private ChartDatasetConfig dataset; + private Map display; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getBlockCode() { return blockCode; } + public void setBlockCode(String blockCode) { this.blockCode = blockCode; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public Map getConfig() { return config; } + public void setConfig(Map config) { this.config = config; } + public Integer getSpan() { return span; } + public void setSpan(Integer span) { this.span = span; } + public ChartDatasetConfig getDataset() { return dataset; } + public void setDataset(ChartDatasetConfig dataset) { this.dataset = dataset; } + public Map getDisplay() { return display; } + public void setDisplay(Map display) { this.display = display; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockTemplateFile.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockTemplateFile.java new file mode 100644 index 0000000..3b0fb29 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/BusinessBlockTemplateFile.java @@ -0,0 +1,15 @@ +package com.ruoyi.generator.domain.front.dto.block; + +public class BusinessBlockTemplateFile +{ + private String key; + private String template; + private String outputPath; + + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + public String getTemplate() { return template; } + public void setTemplate(String template) { this.template = template; } + public String getOutputPath() { return outputPath; } + public void setOutputPath(String outputPath) { this.outputPath = outputPath; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDatasetConfig.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDatasetConfig.java new file mode 100644 index 0000000..91c67e8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDatasetConfig.java @@ -0,0 +1,32 @@ +package com.ruoyi.generator.domain.front.dto.block; + +import java.util.List; + +public class ChartDatasetConfig +{ + private String schema; + private String table; + private ChartDimensionConfig dimension; + private List metrics; + private List filters; + private ChartDateRangeConfig dateRange; + private ChartSortConfig sort; + private Integer limit; + + public String getSchema() { return schema; } + public void setSchema(String schema) { this.schema = schema; } + public String getTable() { return table; } + public void setTable(String table) { this.table = table; } + public ChartDimensionConfig getDimension() { return dimension; } + public void setDimension(ChartDimensionConfig dimension) { this.dimension = dimension; } + public List getMetrics() { return metrics; } + public void setMetrics(List metrics) { this.metrics = metrics; } + public List getFilters() { return filters; } + public void setFilters(List filters) { this.filters = filters; } + public ChartDateRangeConfig getDateRange() { return dateRange; } + public void setDateRange(ChartDateRangeConfig dateRange) { this.dateRange = dateRange; } + public ChartSortConfig getSort() { return sort; } + public void setSort(ChartSortConfig sort) { this.sort = sort; } + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDateRangeConfig.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDateRangeConfig.java new file mode 100644 index 0000000..1b64a04 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDateRangeConfig.java @@ -0,0 +1,18 @@ +package com.ruoyi.generator.domain.front.dto.block; + +public class ChartDateRangeConfig +{ + private String field; + private String preset; + private String start; + private String end; + + public String getField() { return field; } + public void setField(String field) { this.field = field; } + public String getPreset() { return preset; } + public void setPreset(String preset) { this.preset = preset; } + public String getStart() { return start; } + public void setStart(String start) { this.start = start; } + public String getEnd() { return end; } + public void setEnd(String end) { this.end = end; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDimensionConfig.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDimensionConfig.java new file mode 100644 index 0000000..73ef776 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartDimensionConfig.java @@ -0,0 +1,12 @@ +package com.ruoyi.generator.domain.front.dto.block; + +public class ChartDimensionConfig +{ + private String field; + private String timeUnit; + + public String getField() { return field; } + public void setField(String field) { this.field = field; } + public String getTimeUnit() { return timeUnit; } + public void setTimeUnit(String timeUnit) { this.timeUnit = timeUnit; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartFilterConfig.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartFilterConfig.java new file mode 100644 index 0000000..2aee854 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartFilterConfig.java @@ -0,0 +1,15 @@ +package com.ruoyi.generator.domain.front.dto.block; + +public class ChartFilterConfig +{ + private String field; + private String operator; + private Object value; + + public String getField() { return field; } + public void setField(String field) { this.field = field; } + public String getOperator() { return operator; } + public void setOperator(String operator) { this.operator = operator; } + public Object getValue() { return value; } + public void setValue(Object value) { this.value = value; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartMetricConfig.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartMetricConfig.java new file mode 100644 index 0000000..51ce6b3 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartMetricConfig.java @@ -0,0 +1,18 @@ +package com.ruoyi.generator.domain.front.dto.block; + +public class ChartMetricConfig +{ + private String field; + private String aggregate; + private String alias; + private String label; + + public String getField() { return field; } + public void setField(String field) { this.field = field; } + public String getAggregate() { return aggregate; } + public void setAggregate(String aggregate) { this.aggregate = aggregate; } + public String getAlias() { return alias; } + public void setAlias(String alias) { this.alias = alias; } + public String getLabel() { return label; } + public void setLabel(String label) { this.label = label; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartSortConfig.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartSortConfig.java new file mode 100644 index 0000000..574450e --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/ChartSortConfig.java @@ -0,0 +1,12 @@ +package com.ruoyi.generator.domain.front.dto.block; + +public class ChartSortConfig +{ + private String by; + private String order; + + public String getBy() { return by; } + public void setBy(String by) { this.by = by; } + public String getOrder() { return order; } + public void setOrder(String order) { this.order = order; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/PageBusinessBlockLayout.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/PageBusinessBlockLayout.java new file mode 100644 index 0000000..2a5a09d --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/block/PageBusinessBlockLayout.java @@ -0,0 +1,14 @@ +package com.ruoyi.generator.domain.front.dto.block; + +import java.util.List; + +public class PageBusinessBlockLayout +{ + private String canvas; + private List blocks; + + public String getCanvas() { return canvas; } + public void setCanvas(String canvas) { this.canvas = canvas; } + public List getBlocks() { return blocks; } + public void setBlocks(List blocks) { this.blocks = blocks; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java index f7aba1d..c9514d1 100644 --- a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/BusinessBlockRegistryServiceTest.java @@ -81,6 +81,24 @@ public class BusinessBlockRegistryServiceTest assertEquals("cart", layout.getBlocks().get(0).getBlockCode()); } + @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")); + } + @Test public void validateInstanceConfigRejectsMissingRequiredConfig() {