Add admin chart block configuration UI
This commit is contained in:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -144,6 +144,28 @@ test('business block schema form renders table and field controls', () => {
|
|||||||
assert.equal(source.includes('update:modelValue'), true)
|
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', () => {
|
test('business block canvas supports drag reorder and block removal', () => {
|
||||||
const source = readSource('business-blocks/BlockCanvas.vue')
|
const source = readSource('business-blocks/BlockCanvas.vue')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user