Add chart business block data model
This commit is contained in:
@@ -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()}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user