Add chart business block data model

This commit is contained in:
王鹏
2026-06-19 23:26:51 +08:00
parent 3d4294a164
commit 9a07a42d63
14 changed files with 499 additions and 0 deletions

View File

@@ -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()}`
}

View File

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

View File

@@ -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('<BlockConfigForm'), true)
})
test('business block schema form renders table and field controls', () => {
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('<el-select'), true)
assert.equal(source.includes('update:modelValue'), true)
})
test('business block canvas supports drag reorder and block removal', () => {
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)
})

View File

@@ -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<String> 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<String> getOptions() { return options; }
public void setOptions(List<String> options) { this.options = options; }
}

View File

@@ -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<String, String> config;
private Integer span;
private ChartDatasetConfig dataset;
private Map<String, Object> 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<String, String> getConfig() { return config; }
public void setConfig(Map<String, String> 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<String, Object> getDisplay() { return display; }
public void setDisplay(Map<String, Object> display) { this.display = display; }
}

View File

@@ -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; }
}

View File

@@ -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<ChartMetricConfig> metrics;
private List<ChartFilterConfig> 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<ChartMetricConfig> getMetrics() { return metrics; }
public void setMetrics(List<ChartMetricConfig> metrics) { this.metrics = metrics; }
public List<ChartFilterConfig> getFilters() { return filters; }
public void setFilters(List<ChartFilterConfig> 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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -0,0 +1,14 @@
package com.ruoyi.generator.domain.front.dto.block;
import java.util.List;
public class PageBusinessBlockLayout
{
private String canvas;
private List<BusinessBlockInstance> blocks;
public String getCanvas() { return canvas; }
public void setCanvas(String canvas) { this.canvas = canvas; }
public List<BusinessBlockInstance> getBlocks() { return blocks; }
public void setBlocks(List<BusinessBlockInstance> blocks) { this.blocks = blocks; }
}

View File

@@ -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()
{