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