Add admin chart block configuration UI

This commit is contained in:
王鹏
2026-06-19 23:41:01 +08:00
parent 93007343a6
commit 36cbbbae22
4 changed files with 806 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
<template>
<section class="business-block-designer" :class="{ 'with-config': showConfig, 'without-palette': !showPalette }">
<BlockPalette v-if="showPalette" :definitions="blockDefinitions" @add-block="addBlock" />
<BlockCanvas
:blocks="layout.blocks"
:selected-id="selectedBlockId"
@select-block="selectBlock"
@reorder-block="reorderBlock"
@remove-block="removeBlock"
/>
<template v-if="showConfig">
<ChartBlockConfigForm
v-if="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
:definition="selectedDefinition"
:model-value="selectedBlock?.config || {}"
:tables="tables"
@update:model-value="updateSelectedConfig"
/>
</template>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import BlockPalette from './BlockPalette.vue'
import BlockCanvas from './BlockCanvas.vue'
import BlockConfigForm from './BlockConfigForm.vue'
import ChartBlockConfigForm from './ChartBlockConfigForm.vue'
import { validateChartInstance } from './chartBlock.js'
import {
addBlockInstance,
parseBusinessBlockLayout,
removeBlockInstance,
reorderBlockInstance,
updateBlockConfig
} from './blockLayout.js'
const props = defineProps({
layoutJson: { type: String, default: '' },
blockDefinitions: { type: Array, default: () => [] },
tables: { type: Array, default: () => [] },
showConfig: { type: Boolean, default: true },
showPalette: { type: Boolean, default: true }
})
const emit = defineEmits(['update:layout-json', 'select-block'])
const layout = ref(parseBusinessBlockLayout(props.layoutJson))
const selectedBlockId = ref('')
const selectedBlock = computed(() => layout.value.blocks.find((block) => block.id === selectedBlockId.value) || null)
const selectedDefinition = computed(() => props.blockDefinitions.find((item) => item.code === selectedBlock.value?.blockCode) || null)
const selectedChartErrors = computed(() => (
selectedDefinition.value?.kind === 'chart'
? validateChartInstance(selectedDefinition.value, selectedBlock.value, props.tables)
: []
))
watch(
() => props.layoutJson,
(value) => {
layout.value = parseBusinessBlockLayout(value)
if (!layout.value.blocks.some((block) => block.id === selectedBlockId.value)) {
selectedBlockId.value = layout.value.blocks[0]?.id || ''
}
},
{ immediate: true }
)
watch(
() => ({
block: selectedBlock.value,
definition: selectedDefinition.value
}),
(payload) => {
emit('select-block', payload)
},
{ immediate: true }
)
function addBlock(definition) {
layout.value = addBlockInstance(layout.value, definition, nextBlockId)
selectedBlockId.value = layout.value.blocks[layout.value.blocks.length - 1]?.id || ''
emitLayout()
}
function selectBlock(blockId) {
selectedBlockId.value = blockId || ''
}
function updateSelectedConfig(config) {
if (!selectedBlock.value) return
layout.value = updateBlockConfig(layout.value, selectedBlock.value.id, config)
emitLayout()
}
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 })
}
function reorderBlock(payload) {
layout.value = reorderBlockInstance(layout.value, payload.fromIndex, payload.toIndex)
emitLayout()
}
function removeBlock(blockId) {
layout.value = removeBlockInstance(layout.value, blockId)
selectedBlockId.value = layout.value.blocks[0]?.id || ''
emitLayout()
}
function nextBlockId(code) {
const count = layout.value.blocks.filter((block) => block.blockCode === code).length + 1
return `${code}_${String(count).padStart(3, '0')}`
}
function emitLayout() {
emit('update:layout-json', JSON.stringify(layout.value, null, 2))
}
defineExpose({ addBlock })
</script>
<style scoped lang="scss">
.business-block-designer {
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(420px, 1fr);
align-items: start;
gap: 16px;
}
.business-block-designer.with-config {
grid-template-columns: minmax(190px, 240px) minmax(360px, 1fr) minmax(320px, 380px);
}
.business-block-designer.without-palette {
grid-template-columns: minmax(360px, 1fr);
}
.business-block-designer.with-config.without-palette {
grid-template-columns: minmax(360px, 1fr) minmax(320px, 380px);
}
@media (max-width: 1080px) {
.business-block-designer {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,444 @@
<template>
<section class="chart-block-config-form">
<el-tabs v-model="activeTab">
<el-tab-pane label="数据" name="data">
<el-form label-position="top">
<el-form-item label="数据表" required :error="errorFor('table')">
<el-select
:model-value="dataset.table || ''"
filterable
placeholder="选择数据表"
@change="changeTable"
>
<el-option
v-for="table in tables"
:key="table.tableName"
:label="table.tableComment || table.tableName"
:value="table.tableName"
/>
</el-select>
</el-form-item>
<template v-if="definition.chartType !== 'metric'">
<el-form-item label="维度字段" required :error="errorFor('dimension.field')">
<el-select
:model-value="dataset.dimension?.field || ''"
filterable
placeholder="选择维度字段"
@change="patchDimension({ field: $event, timeUnit: '' })"
>
<el-option
v-for="column in columns"
:key="fieldKey(column)"
:label="column.columnComment || column.javaField || column.columnName"
:value="fieldKey(column)"
/>
</el-select>
</el-form-item>
<el-form-item v-if="selectedDimensionIsTemporal" label="时间粒度">
<el-select
:model-value="dataset.dimension?.timeUnit || ''"
clearable
@change="patchDimension({ timeUnit: $event })"
>
<el-option label="按天" value="day" />
<el-option label="按周" value="week" />
<el-option label="按月" value="month" />
</el-select>
</el-form-item>
</template>
<el-form-item label="指标" required :error="errorFor('metrics')">
<div class="metric-list">
<div v-for="(metric, index) in metrics" :key="index" class="metric-row">
<el-select
:model-value="metric.field || ''"
clearable
filterable
placeholder="字段COUNT 可留空"
@change="updateMetric(index, { field: $event, aggregate: defaultAggregate($event) })"
>
<el-option label="整表计数" value="" />
<el-option
v-for="column in columns"
:key="fieldKey(column)"
:label="column.columnComment || column.javaField || column.columnName"
:value="fieldKey(column)"
/>
</el-select>
<el-select
:model-value="metric.aggregate || 'count'"
@change="updateMetric(index, { aggregate: $event })"
>
<el-option
v-for="option in aggregateOptions(columnByField(metric.field))"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-input
:model-value="metric.label || ''"
placeholder="显示名称"
@input="updateMetric(index, { label: $event })"
/>
<el-button
v-if="metrics.length > 1"
type="danger"
link
@click="removeMetric(index)"
>删除</el-button>
</div>
<el-button v-if="canAddMetric" @click="addMetric">+ 添加指标</el-button>
</div>
</el-form-item>
<el-form-item label="筛选条件">
<ChartFilterEditor
:model-value="dataset.filters || []"
:columns="columns"
@update:model-value="patchDataset({ filters: $event })"
/>
</el-form-item>
<el-form-item label="日期范围">
<div class="date-range-row">
<el-select
:model-value="dataset.dateRange?.field || ''"
clearable
placeholder="日期字段"
@change="changeDateRangeField"
>
<el-option
v-for="column in temporalColumns"
:key="fieldKey(column)"
:label="column.columnComment || column.javaField || column.columnName"
:value="fieldKey(column)"
/>
</el-select>
<el-select
v-if="dataset.dateRange?.field"
:model-value="dataset.dateRange?.preset || 'last_30_days'"
@change="patchDateRange({ preset: $event })"
>
<el-option label="今天" value="today" />
<el-option label="最近 7 天" value="last_7_days" />
<el-option label="最近 30 天" value="last_30_days" />
<el-option label="本月" value="this_month" />
<el-option label="本年" value="this_year" />
<el-option label="固定日期" value="fixed" />
</el-select>
<el-date-picker
v-if="dataset.dateRange?.preset === 'fixed'"
:model-value="[dataset.dateRange?.start, dataset.dateRange?.end]"
type="daterange"
value-format="YYYY-MM-DD"
@change="changeFixedDateRange"
/>
</div>
</el-form-item>
<div v-if="definition.chartType !== 'metric'" class="form-grid">
<el-form-item label="排序依据">
<el-select
:model-value="dataset.sort?.by || 'dimension'"
@change="patchSort({ by: $event })"
>
<el-option label="维度" value="dimension" />
<el-option
v-for="metric in metrics"
:key="metric.alias"
:label="metric.label || metric.alias"
:value="metric.alias"
/>
</el-select>
</el-form-item>
<el-form-item label="排序方向">
<el-select
:model-value="dataset.sort?.order || 'asc'"
@change="patchSort({ order: $event })"
>
<el-option label="升序" value="asc" />
<el-option label="降序" value="desc" />
</el-select>
</el-form-item>
</div>
<div class="form-grid">
<el-form-item label="分组上限">
<el-input-number
:model-value="dataset.limit || defaultLimit"
:min="1"
:max="500"
@change="patchDataset({ limit: $event })"
/>
</el-form-item>
<el-form-item label="块宽度">
<el-select :model-value="span" @change="emit('update:span', $event)">
<el-option label="整行" :value="12" />
<el-option label="半行" :value="6" />
<el-option label="三分之一" :value="4" />
</el-select>
</el-form-item>
</div>
</el-form>
</el-tab-pane>
<el-tab-pane label="展示" name="display">
<el-form label-position="top">
<el-form-item label="空数据文案">
<el-input
:model-value="display.emptyText || '暂无数据'"
@input="patchDisplay({ emptyText: $event })"
/>
</el-form-item>
<el-form-item label="显示标题">
<el-switch
:model-value="display.showTitle !== false"
@change="patchDisplay({ showTitle: $event })"
/>
</el-form-item>
<el-form-item v-if="definition.chartType !== 'metric'" label="显示图例">
<el-switch
:model-value="display.showLegend !== false"
@change="patchDisplay({ showLegend: $event })"
/>
</el-form-item>
<el-form-item v-if="definition.chartType === 'line'" label="平滑曲线">
<el-switch
:model-value="Boolean(display.smooth)"
@change="patchDisplay({ smooth: $event })"
/>
</el-form-item>
<el-form-item v-if="definition.chartType === 'line'" label="面积填充">
<el-switch
:model-value="Boolean(display.area)"
@change="patchDisplay({ area: $event })"
/>
</el-form-item>
<el-form-item v-if="definition.chartType === 'bar'" label="横向柱状图">
<el-switch
:model-value="Boolean(display.horizontal)"
@change="patchDisplay({ horizontal: $event })"
/>
</el-form-item>
<el-form-item v-if="definition.chartType === 'bar'" label="堆叠">
<el-switch
:model-value="Boolean(display.stacked)"
@change="patchDisplay({ stacked: $event })"
/>
</el-form-item>
<el-form-item v-if="definition.chartType === 'pie'" label="环形图">
<el-switch
:model-value="display.donut !== false"
@change="patchDisplay({ donut: $event })"
/>
</el-form-item>
<el-form-item v-if="definition.chartType === 'pie'" label="显示标签">
<el-switch
:model-value="display.showLabel !== false"
@change="patchDisplay({ showLabel: $event })"
/>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="权限" name="permission">
<el-alert
title="继承所在后台页面;一期不支持块级覆盖"
type="info"
:closable="false"
show-icon
/>
</el-tab-pane>
</el-tabs>
</section>
</template>
<script setup>
import { computed, ref } from 'vue'
import ChartFilterEditor from './ChartFilterEditor.vue'
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'])
const activeTab = ref('data')
const metrics = computed(() => props.dataset.metrics || [])
const selectedTable = computed(() => props.tables.find((item) => item.tableName === props.dataset.table) || null)
const columns = computed(() => selectedTable.value?.columns || selectedTable.value?.table?.columns || [])
const temporalColumns = computed(() => columns.value.filter(isTemporal))
const selectedDimensionIsTemporal = computed(() => isTemporal(columnByField(props.dataset.dimension?.field)))
const maxMetrics = computed(() => ['metric', 'pie'].includes(props.definition.chartType) ? 1 : 3)
const canAddMetric = computed(() => metrics.value.length < maxMetrics.value)
const defaultLimit = computed(() => {
if (props.definition.chartType === 'metric') return 1
if (props.definition.chartType === 'pie') return 10
if (props.definition.chartType === 'bar') return 20
return 100
})
function patchDataset(patch) {
emit('update:dataset', { ...props.dataset, ...patch })
}
function patchDisplay(patch) {
emit('update:display', { ...props.display, ...patch })
}
function changeTable(table) {
patchDataset({
table,
dimension: props.definition.chartType === 'metric' ? null : { field: '', timeUnit: '' },
metrics: [{
field: '',
aggregate: props.definition.chartType === 'metric' ? 'count' : 'sum',
alias: 'metric1',
label: '指标 1'
}],
filters: [],
dateRange: null
})
}
function patchDimension(patch) {
patchDataset({ dimension: { ...(props.dataset.dimension || {}), ...patch } })
}
function addMetric() {
if (!canAddMetric.value) return
const index = metrics.value.length + 1
patchDataset({
metrics: metrics.value.concat({
field: '',
aggregate: 'sum',
alias: `metric${index}`,
label: `指标 ${index}`
})
})
}
function updateMetric(index, patch) {
patchDataset({
metrics: metrics.value.map((metric, currentIndex) => (
currentIndex === index ? { ...metric, ...patch } : metric
))
})
}
function removeMetric(index) {
patchDataset({ metrics: metrics.value.filter((_, currentIndex) => currentIndex !== index) })
}
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: '计数' }]
}
function defaultAggregate(field) {
return columnByField(field) && aggregateOptions(columnByField(field)).some((item) => item.value === 'sum')
? 'sum'
: 'count'
}
function changeDateRangeField(field) {
patchDataset({
dateRange: field ? { field, preset: 'last_30_days', start: '', end: '' } : null
})
}
function patchDateRange(patch) {
patchDataset({ dateRange: { ...(props.dataset.dateRange || {}), ...patch } })
}
function changeFixedDateRange(value) {
const [start = '', end = ''] = Array.isArray(value) ? value : []
patchDateRange({ start, end })
}
function patchSort(patch) {
patchDataset({ sort: { ...(props.dataset.sort || { by: 'dimension', order: 'asc' }), ...patch } })
}
function errorFor(key) {
return props.errors.find((item) => item.key === key)?.message || ''
}
function columnByField(field) {
return columns.value.find((column) => fieldKey(column) === field) || null
}
function fieldKey(column) {
return column?.javaField || column?.columnName || ''
}
function isTemporal(column) {
const type = String(column?.columnType || '').toLowerCase()
return ['Date', 'LocalDate', 'LocalDateTime'].includes(column?.javaType)
|| type.includes('date')
|| type.includes('time')
|| type.includes('timestamp')
|| type.includes('year')
}
</script>
<style scoped lang="scss">
.chart-block-config-form {
min-width: 0;
}
.chart-block-config-form :deep(.el-select),
.chart-block-config-form :deep(.el-input),
.chart-block-config-form :deep(.el-input-number),
.chart-block-config-form :deep(.el-date-editor) {
width: 100%;
}
.metric-list,
.metric-row,
.date-range-row,
.form-grid {
display: grid;
gap: 8px;
width: 100%;
}
.metric-row {
grid-template-columns: minmax(130px, 1fr) minmax(90px, 0.65fr) minmax(110px, 0.8fr) auto;
align-items: center;
}
.date-range-row {
grid-template-columns: minmax(130px, 1fr) minmax(130px, 1fr);
}
.date-range-row :deep(.el-date-editor) {
grid-column: 1 / -1;
}
.form-grid {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 720px) {
.metric-row,
.date-range-row,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<section class="chart-filter-editor">
<div v-for="(filter, index) in filters" :key="index" class="filter-row">
<el-select
:model-value="filter.field || ''"
filterable
placeholder="字段"
@change="updateFilter(index, { field: $event })"
>
<el-option
v-for="column in columns"
:key="fieldKey(column)"
:label="column.columnComment || column.javaField || column.columnName"
:value="fieldKey(column)"
/>
</el-select>
<el-select
:model-value="filter.operator || 'eq'"
placeholder="运算符"
@change="updateFilter(index, { operator: $event, value: defaultValue($event) })"
>
<el-option v-for="item in operators" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<template v-if="filter.operator === 'between'">
<el-input
:model-value="arrayValue(filter.value)[0]"
placeholder="开始值"
@input="updateArrayValue(index, 0, $event)"
/>
<el-input
:model-value="arrayValue(filter.value)[1]"
placeholder="结束值"
@input="updateArrayValue(index, 1, $event)"
/>
</template>
<el-input
v-else-if="filter.operator === 'in'"
:model-value="arrayValue(filter.value).join(',')"
placeholder="多个值用逗号分隔"
@input="updateFilter(index, { value: splitValues($event) })"
/>
<el-input
v-else-if="!noValueOperators.includes(filter.operator)"
:model-value="filter.value ?? ''"
placeholder="筛选值"
@input="updateFilter(index, { value: $event })"
/>
<span v-else class="filter-no-value">无需填写值</span>
<el-button type="danger" link @click="removeFilter(index)">删除</el-button>
</div>
<el-button :disabled="filters.length >= 10" @click="addFilter">+ 添加条件</el-button>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue'])
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: '不为空' }
]
const noValueOperators = ['is_null', 'is_not_null']
const filters = computed(() => props.modelValue || [])
function addFilter() {
if (filters.value.length >= 10) return
emit('update:modelValue', filters.value.concat({
field: '',
operator: 'eq',
value: ''
}))
}
function updateFilter(index, patch) {
emit('update:modelValue', filters.value.map((filter, currentIndex) => (
currentIndex === index ? { ...filter, ...patch } : filter
)))
}
function removeFilter(index) {
emit('update:modelValue', filters.value.filter((_, currentIndex) => currentIndex !== index))
}
function updateArrayValue(index, valueIndex, value) {
const next = arrayValue(filters.value[index]?.value)
next[valueIndex] = value
updateFilter(index, { value: next })
}
function defaultValue(operator) {
if (operator === 'between') return ['', '']
if (operator === 'in') return []
if (noValueOperators.includes(operator)) return null
return ''
}
function arrayValue(value) {
if (Array.isArray(value)) return value.slice()
return value === null || value === undefined || value === '' ? [] : [value]
}
function splitValues(value) {
return String(value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function fieldKey(column) {
return column?.javaField || column?.columnName || ''
}
</script>
<style scoped lang="scss">
.chart-filter-editor {
display: grid;
gap: 10px;
}
.filter-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(110px, 0.8fr) minmax(120px, 1fr) auto;
gap: 8px;
align-items: center;
}
.filter-row:has(input[placeholder="开始值"]) {
grid-template-columns: minmax(110px, 1fr) minmax(100px, 0.8fr) minmax(90px, 0.8fr) minmax(90px, 0.8fr) auto;
}
.filter-no-value {
color: #98a2b3;
font-size: 12px;
}
@media (max-width: 720px) {
.filter-row,
.filter-row:has(input[placeholder="开始值"]) {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -144,6 +144,28 @@ test('business block schema form renders table and field controls', () => {
assert.equal(source.includes('update:modelValue'), true)
})
test('chart blocks use a dedicated dataset and display editor', () => {
const source = readSource('business-blocks/BusinessBlockDesigner.vue')
const formPath = resolve(currentDir, 'business-blocks/ChartBlockConfigForm.vue')
const filterPath = resolve(currentDir, 'business-blocks/ChartFilterEditor.vue')
assert.equal(existsSync(formPath), true)
assert.equal(existsSync(filterPath), true)
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(source.includes('selectedChartErrors'), true)
assert.equal(form.includes('update:dataset'), true)
assert.equal(form.includes('update:display'), true)
assert.equal(form.includes('update:span'), 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)
})
test('business block canvas supports drag reorder and block removal', () => {
const source = readSource('business-blocks/BlockCanvas.vue')