增加报表配置管理

This commit is contained in:
2026-06-25 13:32:52 +08:00
parent 9fbe2c6d64
commit a6fdba912d
10 changed files with 1665 additions and 0 deletions
@@ -0,0 +1,95 @@
<template>
<el-dialog :title="dialogTitle" :model-value="visible" width="480px" :close-on-click-modal="false" destroy-on-close @update:model-value="emit('update:visible', $event)">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="业务编码" prop="businessCode">
<el-input
v-model="form.businessCode"
placeholder="请输入业务编码,如 KUAISHOU"
/>
</el-form-item>
<el-form-item label="业务名称" prop="businessName">
<el-input v-model="form.businessName" placeholder="请输入业务名称,如 快手电商" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
export default { name: 'reportEngineBusinessManager' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { FormInstance } from 'element-plus';
const props = defineProps<{
visible: boolean;
editData?: { id?: number; businessCode: string; businessName: string } | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'confirm', data: { id?: number; businessCode: string; businessName: string }): void;
}>();
const formRef = ref<FormInstance>();
const saving = ref(false);
const form = reactive({
id: undefined as number | undefined,
businessCode: '',
businessName: '',
});
const rules = {
businessCode: [{ required: true, message: '请输入业务编码', trigger: 'blur' }],
businessName: [{ required: true, message: '请输入业务名称', trigger: 'blur' }],
};
const dialogTitle = ref('新增业务');
watch(
() => props.visible,
(val) => {
if (val) {
if (props.editData) {
form.id = props.editData.id;
form.businessCode = props.editData.businessCode;
form.businessName = props.editData.businessName;
dialogTitle.value = '编辑业务';
} else {
form.id = undefined;
form.businessCode = '';
form.businessName = '';
dialogTitle.value = '新增业务';
}
}
}
);
const handleCancel = () => {
emit('update:visible', false);
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (!valid) return;
saving.value = true;
try {
const payload: { id?: number; businessCode: string; businessName: string } = {
businessCode: form.businessCode,
businessName: form.businessName,
};
if (form.id) payload.id = form.id;
emit('confirm', payload);
} finally {
saving.value = false;
}
});
};
</script>
@@ -0,0 +1,250 @@
<template>
<el-dialog
:title="dialogTitle"
:model-value="visible"
width="750px"
:close-on-click-modal="false"
destroy-on-close
@update:model-value="emit('update:visible', $event)"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" v-loading="loading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="抽取编码" prop="extractCode">
<el-input v-model="form.extractCode" placeholder="如 shop_daily_direct" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="抽取名称" prop="extractName">
<el-input v-model="form.extractName" placeholder="如 店铺日订单直接抽取" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="源表名" prop="sourceTableName">
<el-input v-model="form.sourceTableName" placeholder="如 ods_kuaishou_orders" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="源表别名" prop="sourceTableAlias">
<el-input v-model="form.sourceTableAlias" placeholder="如 o" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="目标表名" prop="targetTableName">
<el-input v-model="form.targetTableName" placeholder="如 stat_kuaishou_shop_daily" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="抽取类型" prop="extractType">
<el-select v-model="form.extractType" style="width: 100%">
<el-option label="增量抽取" value="INCREMENTAL" />
<el-option label="全量抽取" value="FULL" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="抽取模式" prop="extractMode">
<el-select v-model="form.extractMode" style="width: 100%">
<el-option label="直接抽取" value="DIRECT" />
<el-option label="SQL转换" value="SQL" />
<el-option label="API抽取" value="API" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="抽取键字段" prop="extractKeyField">
<el-input v-model="form.extractKeyField" placeholder="如 created_at" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="每批数量" prop="batchSize">
<el-input-number v-model="form.batchSize" :min="1" :max="100000" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="是否启用" prop="isEnabled">
<el-switch v-model="form.isEnabled" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="负责人" prop="operator">
<el-input v-model="form.operator" placeholder="如 system" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分组字段" prop="groupByFields">
<el-select v-model="form.groupByFields" multiple filterable allow-create default-first-option style="width: 100%" placeholder="输入字段名后回车添加">
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="过滤表达式" prop="filterExpression">
<el-input v-model="form.filterExpression" type="textarea" :rows="2" placeholder="如 o.order_status = '已完成'" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
export default { name: 'reportEngineExtractConfigForm' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { ElMessage, FormInstance } from 'element-plus';
import { getExtractConfig, saveExtractConfig, type ExtractConfigSaveData } from '/@/api/report-engine/report';
const props = defineProps<{
visible: boolean;
editId?: number | null;
businessCode: string;
reportCode: string;
reportName: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'confirm'): void;
}>();
const formRef = ref<FormInstance>();
const saving = ref(false);
const loading = ref(false);
const dialogTitle = ref('新增抽取配置');
const form = reactive<ExtractConfigSaveData>({
businessCode: '',
reportCode: '',
extractCode: '',
extractName: '',
sourceTableName: '',
sourceTableAlias: '',
targetTableName: '',
isEnabled: true,
extractType: 'INCREMENTAL',
extractMode: 'DIRECT',
extractKeyField: '',
batchSize: 1000,
status: 'ACTIVE',
groupByFields: [],
filterExpression: '',
operator: '',
});
const rules = {
extractCode: [{ required: true, message: '请输入抽取编码', trigger: 'blur' }],
extractName: [{ required: true, message: '请输入抽取名称', trigger: 'blur' }],
sourceTableName: [{ required: true, message: '请输入源表名', trigger: 'blur' }],
targetTableName: [{ required: true, message: '请输入目标表名', trigger: 'blur' }],
extractType: [{ required: true, message: '请选择抽取类型', trigger: 'change' }],
extractMode: [{ required: true, message: '请选择抽取模式', trigger: 'change' }],
};
const resetForm = () => {
form.id = undefined;
form.businessCode = props.businessCode;
form.reportCode = props.reportCode;
form.extractCode = '';
form.extractName = '';
form.sourceTableName = '';
form.sourceTableAlias = '';
form.targetTableName = '';
form.isEnabled = true;
form.extractType = 'INCREMENTAL';
form.extractMode = 'DIRECT';
form.extractKeyField = '';
form.batchSize = 1000;
form.status = 'ACTIVE';
form.groupByFields = [];
form.filterExpression = '';
form.operator = '';
};
watch(
() => props.visible,
async (val) => {
if (val) {
form.businessCode = props.businessCode;
form.reportCode = props.reportCode;
if (props.editId) {
dialogTitle.value = '编辑抽取配置';
loading.value = true;
try {
const res = await getExtractConfig(props.editId);
const raw = res.data || {};
const detail = raw.extractConfig || raw.data || raw;
form.id = detail.id;
form.businessCode = detail.businessCode || props.businessCode;
form.reportCode = detail.reportCode || props.reportCode;
form.extractCode = detail.extractCode || '';
form.extractName = detail.extractName || '';
form.sourceTableName = detail.sourceTableName || '';
form.sourceTableAlias = detail.sourceTableAlias || '';
form.targetTableName = detail.targetTableName || '';
form.isEnabled = detail.isEnabled !== undefined ? detail.isEnabled : true;
form.extractType = detail.extractType || 'INCREMENTAL';
form.extractMode = detail.extractMode || 'DIRECT';
form.extractKeyField = detail.extractKeyField || '';
form.batchSize = detail.batchSize || 1000;
form.status = detail.status || 'ACTIVE';
form.groupByFields = detail.groupByFields || [];
form.filterExpression = detail.filterExpression || '';
form.operator = detail.operator || '';
} catch (_e) {
ElMessage.error('获取抽取配置详情失败');
} finally {
loading.value = false;
}
} else {
dialogTitle.value = '新增抽取配置';
resetForm();
}
}
}
);
const handleCancel = () => {
emit('update:visible', false);
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (!valid) return;
saving.value = true;
try {
const payload: any = { ...form };
if (!payload.id) delete payload.id;
await saveExtractConfig(payload);
ElMessage.success(props.editId ? '修改成功' : '新增成功');
emit('update:visible', false);
emit('confirm');
} catch (_e) {
ElMessage.error(props.editId ? '修改失败' : '新增失败');
} finally {
saving.value = false;
}
});
};
</script>
@@ -0,0 +1,398 @@
<template>
<div class="field-editor">
<div class="field-editor-header">
<span class="field-editor-title">字段配置</span>
<el-button size="small" type="primary" @click="onAddField">
<el-icon><ele-Plus /></el-icon> 新增字段
</el-button>
</div>
<!-- 字段列表 -->
<el-table
ref="tableRef"
:data="localFields"
row-key="fieldCode"
border
size="small"
class="field-table"
empty-text="暂无字段请点击新增字段添加"
>
<el-table-column label="排序" width="56" align="center">
<template #default="scope">
<span
class="drag-handle"
draggable="true"
@dragstart="onDragStart($event, scope.$index)"
@dragover.prevent="onDragOver($event, scope.$index)"
@drop.prevent="onDrop($event, scope.$index)"
>
<el-icon><ele-Rank /></el-icon>
</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="序号" width="60" align="center" />
<el-table-column prop="fieldCode" label="字段编码" min-width="110" show-overflow-tooltip />
<el-table-column prop="fieldName" label="字段名称" min-width="110" show-overflow-tooltip />
<el-table-column prop="fieldType" label="类型" width="85" />
<el-table-column prop="fieldRole" label="角色" width="70" align="center">
<template #default="scope">
<el-tag :type="scope.row.fieldRole === 'DIMENSION' ? 'primary' : 'warning'" size="small" effect="plain">
{{ scope.row.fieldRole === 'DIMENSION' ? '维度' : '指标' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="defaultAggregate" label="默认聚合" width="100" show-overflow-tooltip />
<el-table-column prop="groupName" label="分组" width="90" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" text type="primary" @click="onEditField(scope.$index)">编辑</el-button>
<el-button size="small" text type="danger" @click="onDeleteField(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 字段编辑弹窗 -->
<el-dialog
:title="fieldDialog.title"
v-model="fieldDialog.visible"
width="700px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
>
<el-form ref="fieldFormRef" :model="fieldForm" :rules="fieldRules" label-width="100px" label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="字段编码" prop="fieldCode">
<el-input v-model="fieldForm.fieldCode" placeholder="如 shop_id" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="字段名称" prop="fieldName">
<el-input v-model="fieldForm.fieldName" placeholder="如 店铺ID" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="字段类型" prop="fieldType">
<el-select v-model="fieldForm.fieldType" style="width: 100%">
<el-option v-for="opt in FIELD_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据类型">
<el-select v-model="fieldForm.dataType" style="width: 100%" clearable>
<el-option v-for="opt in FIELD_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="字段角色" prop="fieldRole">
<el-select v-model="fieldForm.fieldRole" style="width: 100%">
<el-option label="维度 (DIMENSION)" value="DIMENSION" />
<el-option label="指标 (INDICATOR)" value="INDICATOR" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认聚合">
<el-select v-model="fieldForm.defaultAggregate" style="width: 100%" clearable placeholder="无">
<el-option label="无" value="" />
<el-option v-for="opt in AGGREGATE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分组名称">
<el-input v-model="fieldForm.groupName" placeholder="如 基础信息" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位">
<el-input v-model="fieldForm.unit" placeholder="如 元、%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="fieldForm.status" style="width: 100%">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序号">
<el-input-number v-model="fieldForm.sortOrder" :min="1" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="表达式">
<el-input v-model="fieldForm.expression" placeholder="衍生字段表达式,如 {refund}/{order}*100" />
</el-form-item>
<el-form-item label="属性">
<el-checkbox v-model="fieldForm.isAggregatable">可聚合</el-checkbox>
<el-checkbox v-model="fieldForm.isFilterable">可筛选</el-checkbox>
<el-checkbox v-model="fieldForm.isQueryable">可查询</el-checkbox>
<el-checkbox v-model="fieldForm.isSortable">可排序</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="fieldDialog.visible = false">取消</el-button>
<el-button type="primary" @click="onFieldConfirm" :loading="fieldDialog.saving">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default { name: 'reportEngineFieldEditor' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import type { FormInstance } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FieldDefine } from '/@/api/report-engine/report';
const FIELD_TYPE_OPTIONS = [
{ value: 'STRING', label: 'STRING (字符串)' },
{ value: 'INT', label: 'INT (整数)' },
{ value: 'FLOAT', label: 'FLOAT (浮点)' },
{ value: 'BIGINT', label: 'BIGINT (长整)' },
{ value: 'DECIMAL', label: 'DECIMAL (小数)' },
{ value: 'DATE', label: 'DATE (日期)' },
{ value: 'DATETIME', label: 'DATETIME (时间)' },
{ value: 'BOOLEAN', label: 'BOOLEAN (布尔)' },
];
const AGGREGATE_OPTIONS = [
{ value: 'SUM', label: 'SUM (求和)' },
{ value: 'COUNT', label: 'COUNT (计数)' },
{ value: 'AVG', label: 'AVG (平均)' },
{ value: 'MAX', label: 'MAX (最大)' },
{ value: 'MIN', label: 'MIN (最小)' },
];
const props = defineProps<{
modelValue: FieldDefine[];
}>();
const emit = defineEmits<{
(e: 'update:modelValue', val: FieldDefine[]): void;
}>();
const tableRef = ref();
const fieldFormRef = ref<FormInstance>();
const localFields = ref<FieldDefine[]>([]);
let dragSourceIndex = -1;
watch(
() => props.modelValue,
(val) => {
localFields.value = JSON.parse(JSON.stringify(val || []));
},
{ immediate: true }
);
const emitUpdate = () => {
emit('update:modelValue', JSON.parse(JSON.stringify(localFields.value)));
};
// ========== 拖拽排序 ==========
const onDragStart = (event: DragEvent, index: number) => {
dragSourceIndex = index;
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (event: DragEvent, index: number) => {
if (dragSourceIndex === -1 || dragSourceIndex === index) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
};
const onDrop = (event: DragEvent, targetIndex: number) => {
if (dragSourceIndex === -1 || dragSourceIndex === targetIndex) return;
const list = localFields.value;
const [moved] = list.splice(dragSourceIndex, 1);
list.splice(targetIndex, 0, moved);
dragSourceIndex = -1;
reindexSortOrder();
emitUpdate();
};
const reindexSortOrder = () => {
localFields.value.forEach((f, i) => {
f.sortOrder = i + 1;
});
};
// ========== 字段编辑弹窗 ==========
const fieldDialog = reactive({
visible: false,
title: '新增字段',
saving: false,
editingIndex: -1 as number,
});
const defaultFieldForm = () => ({
id: undefined,
fieldCode: '',
fieldName: '',
fieldType: 'STRING' as FieldDefine['fieldType'],
fieldRole: 'DIMENSION' as FieldDefine['fieldRole'],
dataType: '',
defaultAggregate: '',
groupName: '',
sortOrder: 1,
unit: '',
expression: '',
status: 'ACTIVE',
isAggregatable: false,
isFilterable: false,
isQueryable: false,
isSortable: false,
});
const fieldForm = reactive(defaultFieldForm());
const fieldRules = {
fieldCode: [
{ required: true, message: '请输入字段编码', trigger: 'blur' },
],
fieldName: [{ required: true, message: '请输入字段名称', trigger: 'blur' }],
fieldType: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
fieldRole: [{ required: true, message: '请选择字段角色', trigger: 'change' }],
};
const onAddField = () => {
fieldDialog.title = '新增字段';
fieldDialog.editingIndex = -1;
resetFieldForm();
fieldDialog.visible = true;
};
const onEditField = (index: number) => {
fieldDialog.title = '编辑字段';
fieldDialog.editingIndex = index;
const item = localFields.value[index];
Object.assign(fieldForm, JSON.parse(JSON.stringify(item)));
fieldDialog.visible = true;
};
const onDeleteField = (index: number) => {
const item = localFields.value[index];
ElMessageBox.confirm(`确定要删除字段「${item.fieldName || item.fieldCode}」吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
localFields.value.splice(index, 1);
reindexSortOrder();
emitUpdate();
ElMessage.success('已删除');
})
.catch(() => {});
};
const resetFieldForm = () => {
const defaults = defaultFieldForm();
Object.assign(fieldForm, defaults);
if (localFields.value.length > 0) {
fieldForm.sortOrder = Math.max(...localFields.value.map((f) => f.sortOrder)) + 1;
} else {
fieldForm.sortOrder = 1;
}
};
const onFieldConfirm = () => {
if (!fieldFormRef.value) return;
fieldFormRef.value.validate(async (valid) => {
if (!valid) return;
fieldDialog.saving = true;
try {
const data: FieldDefine = {
id: fieldForm.id || undefined,
fieldCode: fieldForm.fieldCode,
fieldName: fieldForm.fieldName,
fieldType: fieldForm.fieldType,
fieldRole: fieldForm.fieldRole,
dataType: fieldForm.dataType || undefined,
defaultAggregate: fieldForm.defaultAggregate || undefined,
groupName: fieldForm.groupName || undefined,
sortOrder: fieldForm.sortOrder,
unit: fieldForm.unit || undefined,
expression: fieldForm.expression || undefined,
status: fieldForm.status || 'ACTIVE',
isAggregatable: fieldForm.isAggregatable,
isFilterable: fieldForm.isFilterable,
isQueryable: fieldForm.isQueryable,
isSortable: fieldForm.isSortable,
};
if (fieldDialog.editingIndex >= 0) {
localFields.value[fieldDialog.editingIndex] = data;
} else {
data.sortOrder = localFields.value.length + 1;
localFields.value.push(data);
reindexSortOrder();
}
emitUpdate();
fieldDialog.visible = false;
} finally {
fieldDialog.saving = false;
}
});
};
</script>
<style scoped lang="scss">
.field-editor {
margin-top: 16px;
border-top: 1px solid var(--el-border-color-light);
padding-top: 16px;
}
.field-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.field-editor-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.field-table {
.drag-handle {
cursor: grab;
color: var(--el-text-color-placeholder);
font-size: 16px;
display: inline-block;
padding: 4px;
&:active {
cursor: grabbing;
}
&:hover {
color: var(--el-color-primary);
}
}
}
</style>
@@ -0,0 +1,196 @@
<template>
<el-dialog :title="dialogTitle" :model-value="visible" width="900px" :close-on-click-modal="false" destroy-on-close @update:model-value="emit('update:visible', $event)">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" v-loading="loading">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="报表编码" prop="reportCode">
<el-input v-model="form.reportCode" placeholder="如 shop_daily" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报表名称" prop="reportName">
<el-input v-model="form.reportName" placeholder="如 店铺日报" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="报表描述" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="INACTIVE" />
</el-select>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="统计表名" prop="statTableName">
<el-input v-model="form.statTableName" placeholder="如 stat_kuaishou_shop_daily" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="表注释" prop="statTableComment">
<el-input v-model="form.statTableComment" placeholder="如 快手电商-店铺日报" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="日期字段" prop="dateField">
<el-input v-model="form.dateField" placeholder="如 stat_date" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主键" prop="primaryKeys">
<el-input v-model="primaryKeysStr" placeholder="多个主键用逗号分隔,如 shop_id,stat_date" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="冲突键" prop="conflictKeys">
<el-input v-model="conflictKeysStr" placeholder="多个冲突键用逗号分隔,如 shop_id,stat_date" />
</el-form-item>
<!-- 字段编辑器 -->
<FieldEditor ref="fieldEditorRef" v-model="fields" />
</el-form>
<template #footer>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="saving">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts">
export default { name: 'reportEngineReportForm' };
</script>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { ElMessage, FormInstance } from 'element-plus';
import { getReport } from '/@/api/report-engine/report';
import type { FieldDefine } from '/@/api/report-engine/report';
import FieldEditor from './FieldEditor.vue';
const props = defineProps<{
visible: boolean;
editId?: number | null;
businessCode: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'confirm', data: any): void;
}>();
const formRef = ref<FormInstance>();
const fieldEditorRef = ref<InstanceType<typeof FieldEditor>>();
const saving = ref(false);
const loading = ref(false);
const dialogTitle = ref('新增报表');
const fields = ref<FieldDefine[]>([]);
const primaryKeysStr = ref('');
const conflictKeysStr = ref('');
const form = reactive({
reportCode: '',
reportName: '',
description: '',
status: 'ACTIVE',
statTableName: '',
statTableComment: '',
dateField: '',
primaryKeys: [] as string[],
conflictKeys: [] as string[],
});
const rules = {
reportCode: [{ required: true, message: '请输入报表编码', trigger: 'blur' }],
reportName: [{ required: true, message: '请输入报表名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
statTableName: [{ required: true, message: '请输入统计表名', trigger: 'blur' }],
dateField: [{ required: true, message: '请输入日期字段', trigger: 'blur' }],
};
watch(
() => props.visible,
async (val) => {
if (val) {
if (props.editId) {
dialogTitle.value = '编辑报表';
loading.value = true;
try {
const res = await getReport(props.editId);
const raw = res.data || {};
const detail = raw.report || raw.data || raw;
form.reportCode = detail.reportCode || '';
form.reportName = detail.reportName || '';
form.description = detail.description || '';
form.status = detail.status || 'ACTIVE';
form.statTableName = detail.statTableName || '';
form.statTableComment = detail.statTableComment || '';
form.dateField = detail.dateField || '';
form.primaryKeys = detail.primaryKeys || [];
form.conflictKeys = detail.conflictKeys || [];
primaryKeysStr.value = (detail.primaryKeys || []).join(',');
conflictKeysStr.value = (detail.conflictKeys || []).join(',');
fields.value = detail.fields || [];
} catch (_e) {
ElMessage.error('获取报表详情失败');
} finally {
loading.value = false;
}
} else {
dialogTitle.value = '新增报表';
resetForm();
}
}
}
);
const resetForm = () => {
form.reportCode = '';
form.reportName = '';
form.description = '';
form.status = 'ACTIVE';
form.statTableName = '';
form.statTableComment = '';
form.dateField = '';
form.primaryKeys = [];
form.conflictKeys = [];
primaryKeysStr.value = '';
conflictKeysStr.value = '';
fields.value = [];
};
const handleCancel = () => {
emit('update:visible', false);
};
const handleSubmit = () => {
if (!formRef.value) return;
formRef.value.validate(async (valid) => {
if (!valid) return;
saving.value = true;
try {
// 将逗号分隔字符串转为数组
const toArray = (str: string) =>
str
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean);
emit('confirm', {
businessCode: props.businessCode,
...form,
id: props.editId || undefined,
primaryKeys: toArray(primaryKeysStr.value),
conflictKeys: toArray(conflictKeysStr.value),
fields: JSON.parse(JSON.stringify(fields.value)),
});
} finally {
saving.value = false;
}
});
};
</script>
+520
View File
@@ -0,0 +1,520 @@
<template>
<div class="report-engine-report-container">
<el-card shadow="hover">
<!-- 业务 Tab -->
<div class="biz-tabs-header mb15">
<el-tabs v-model="activeBusinessCode" type="card" @tab-click="handleTabClick" @tab-remove="handleTabRemove" class="biz-tabs">
<el-tab-pane
v-for="biz in businesses"
:key="biz.businessCode"
:name="biz.businessCode"
closable
>
<template #label>
<span class="custom-tab-label">
<span class="tab-name">{{ biz.businessName }}</span>
<el-icon class="tab-edit-btn" @click.stop="onOpenBusinessEdit(biz)">
<ele-EditPen />
</el-icon>
</span>
</template>
</el-tab-pane>
</el-tabs>
<el-button
size="default"
type="primary"
@click="onOpenBusinessAdd"
class="biz-add-btn"
>
<el-icon><ele-Plus /></el-icon>
<span>新增业务</span>
</el-button>
</div>
<!-- 搜索栏 -->
<div class="system-user-search mb15">
<el-form :model="tableData.param" ref="queryRef" :inline="true" label-width="80px">
<el-form-item label="报表名称" prop="reportName">
<el-input
v-model="tableData.param.reportName"
placeholder="请输入报表名称"
clearable
size="default"
style="width: 220px"
@keyup.enter.native="getList"
/>
</el-form-item>
<el-form-item>
<el-button size="default" type="primary" @click="getList">
<el-icon><ele-Search /></el-icon>
查询
</el-button>
<el-button size="default" @click="resetQuery">
<el-icon><ele-Refresh /></el-icon>
重置
</el-button>
<el-button size="default" type="success" @click="onOpenReportAdd">
<el-icon><ele-Plus /></el-icon>
新增报表
</el-button>
</el-form-item>
</el-form>
</div>
<!-- 报表表格 -->
<el-table :data="tableData.data" style="width: 100%" v-loading="tableData.loading">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="reportCode" label="报表编码" show-overflow-tooltip />
<el-table-column prop="reportName" label="报表名称" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'ACTIVE' ? 'success' : 'danger'" size="small">
{{ scope.row.status === 'ACTIVE' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" show-overflow-tooltip />
<el-table-column prop="statTableName" label="统计表名" show-overflow-tooltip />
<el-table-column label="操作" width="320" fixed="right">
<template #default="scope">
<el-button size="small" text type="primary" @click="onOpenReportEdit(scope.row)">编辑</el-button>
<el-button size="small" text type="primary" @click="onOpenExtractConfig(scope.row)">抽取配置</el-button>
<el-button size="small" text type="success" @click="onDailyExtract(scope.row)">按天回刷</el-button>
<el-button size="small" text type="danger" @click="onExtractConfigDel(scope.row)" v-if="scope.row.extractConfigIds">删除抽取配置</el-button>
<el-button size="small" text type="danger" @click="onReportDel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<pagination
v-show="tableData.total > 0"
:total="tableData.total"
v-model:page="tableData.param.pageNum"
v-model:limit="tableData.param.pageSize"
@pagination="getList"
/>
</el-card>
<!-- 业务管理弹窗 -->
<BusinessManager
v-model:visible="businessDialog.visible"
:edit-data="businessDialog.editData"
@confirm="onBusinessConfirm"
/>
<!-- 报表表单弹窗 -->
<ReportForm
v-model:visible="reportDialog.visible"
:edit-id="reportDialog.editId"
:business-code="activeBusinessCode"
@confirm="onReportConfirm"
/>
<!-- 抽取配置弹窗 -->
<ExtractConfigForm
v-model:visible="extractDialog.visible"
:edit-id="extractDialog.editId"
:business-code="extractDialog.businessCode"
:report-code="extractDialog.reportCode"
:report-name="extractDialog.reportName"
@confirm="onExtractConfigConfirm"
/>
<!-- 按天回刷弹窗 -->
<el-dialog
v-model="dailyExtractDialog.visible"
title="按天回刷"
width="420px"
destroy-on-close
>
<el-form label-width="100px">
<el-form-item label="报表编码">
<el-input :model-value="dailyExtractDialog.reportCode" disabled />
</el-form-item>
<el-form-item label="日期范围" required>
<el-date-picker
v-model="dailyExtractDialog.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
:disabled-date="(t: Date) => t > new Date()"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dailyExtractDialog.visible = false">取消</el-button>
<el-button type="primary" :loading="dailyExtractDialog.loading" @click="onDailyExtractConfirm">确认回刷</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
export default { name: 'reportEngineReport' };
</script>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, ElMessageBox, FormInstance } from 'element-plus';
import { listBusinesses, saveBusiness, deleteBusiness, saveReportWithFields, deleteReport, listReports, deleteExtractConfig, execDailyBackfill } from '/@/api/report-engine/report';
import BusinessManager from './components/BusinessManager.vue';
import ReportForm from './components/ReportForm.vue';
import ExtractConfigForm from './components/ExtractConfigForm.vue';
const queryRef = ref<FormInstance>();
// --- 业务列表 ---
const businesses = ref<any[]>([]);
const activeBusinessCode = ref('');
// --- 报表表格数据 ---
const tableData = reactive({
data: [] as any[],
total: 0,
loading: false,
param: {
pageNum: 1,
pageSize: 10,
reportName: '',
},
});
// --- 业务弹窗 ---
const businessDialog = reactive({
visible: false,
editData: null as { id?: number; businessCode: string; businessName: string } | null,
});
// --- 报表弹窗 ---
const reportDialog = reactive({
visible: false,
editId: null as number | null,
});
// --- 抽取配置弹窗 ---
const extractDialog = reactive({
visible: false,
editId: null as number | null,
businessCode: '',
reportCode: '',
reportName: '',
});
// --- 按天回刷弹窗 ---
const dailyExtractDialog = reactive({
visible: false,
businessCode: '',
reportCode: '',
dateRange: [] as string[],
loading: false,
});
// ========== 业务相关 ==========
const loadBusinesses = async () => {
try {
const res = await listBusinesses();
businesses.value = res.data?.list || res.data || [];
if (businesses.value.length > 0 && !activeBusinessCode.value) {
activeBusinessCode.value = businesses.value[0].businessCode;
getList();
}
} catch (_e) {
ElMessage.error('获取业务列表失败');
}
};
const handleTabClick = (pane?: any) => {
const code = pane?.props?.name;
if (code) activeBusinessCode.value = code;
tableData.param.pageNum = 1;
getList();
};
const handleTabRemove = (name: string) => {
const biz = businesses.value.find((b) => b.businessCode === name);
if (!biz || !biz.id) return;
const label = biz.businessName;
ElMessageBox.confirm(`确定要删除业务「${label}」吗?该操作同时会删除该业务下所有报表配置。`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteBusiness(biz.id);
ElMessage.success('删除成功');
const idx = businesses.value.findIndex((b) => b.businessCode === name);
await loadBusinesses();
if (businesses.value.length > 0) {
const newIdx = Math.min(idx, businesses.value.length - 1);
activeBusinessCode.value = businesses.value[newIdx].businessCode;
} else {
activeBusinessCode.value = '';
tableData.data = [];
tableData.total = 0;
}
getList();
} catch (_e) {
ElMessage.error('删除失败');
}
})
.catch(() => {});
};
const onOpenBusinessAdd = () => {
businessDialog.editData = null;
businessDialog.visible = true;
};
const onOpenBusinessEdit = (biz: { id?: number; businessCode: string; businessName: string }) => {
businessDialog.editData = { ...biz };
businessDialog.visible = true;
};
const onBusinessConfirm = async (data: { id?: number; businessCode: string; businessName: string }) => {
try {
const isEdit = !!data.id;
await saveBusiness(data);
ElMessage.success(isEdit ? '修改成功' : '新增成功');
businessDialog.visible = false;
await loadBusinesses();
activeBusinessCode.value = data.businessCode;
getList();
} catch (_e) {
ElMessage.error(data.id ? '修改失败' : '新增失败');
}
};
// ========== 报表相关 ==========
const getList = async () => {
if (!activeBusinessCode.value) return;
tableData.loading = true;
try {
const res = await listReports(
activeBusinessCode.value,
tableData.param.pageNum,
tableData.param.pageSize,
tableData.param.reportName || undefined
);
const raw = res.data;
if (Array.isArray(raw)) {
tableData.data = raw;
tableData.total = raw.length;
} else if (raw) {
tableData.data = raw.list || raw.records || raw.rows || [];
tableData.total = raw.total || tableData.data.length;
}
} catch (_e) {
tableData.data = [];
tableData.total = 0;
} finally {
tableData.loading = false;
}
};
const resetQuery = () => {
if (!queryRef.value) return;
queryRef.value.resetFields();
tableData.param.pageNum = 1;
getList();
};
const onOpenReportAdd = () => {
reportDialog.editId = null;
reportDialog.visible = true;
};
const onOpenReportEdit = async (row: any) => {
reportDialog.editId = row.id;
reportDialog.visible = true;
};
const onReportConfirm = async (data: any) => {
try {
const isEdit = reportDialog.editId !== null;
await saveReportWithFields(data);
ElMessage.success(isEdit ? '修改成功' : '新增成功');
reportDialog.visible = false;
getList();
} catch (_e) {
ElMessage.error(reportDialog.editId ? '修改失败' : '新增失败');
}
};
const onReportDel = (row: any) => {
ElMessageBox.confirm(`确定要删除报表「${row.reportName}」吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
const res = await deleteReport(row.id);
if (res.data?.success === false) {
// 业务层面拒绝删除(如存在关联字段)
ElMessage.warning(res.data?.message || '无法删除');
return;
}
ElMessage.success('删除成功');
getList();
} catch (_e) {
ElMessage.error('删除失败');
}
})
.catch(() => {});
};
// ========== 抽取配置 ==========
const onOpenExtractConfig = (row: any) => {
extractDialog.businessCode = row.businessCode;
extractDialog.reportCode = row.reportCode;
extractDialog.reportName = row.reportName;
extractDialog.editId = row.extractConfigIds ? Number(row.extractConfigIds) : null;
extractDialog.visible = true;
};
const onExtractConfigConfirm = () => {
extractDialog.visible = false;
getList();
};
const onExtractConfigDel = (row: any) => {
ElMessageBox.confirm(`确定要删除「${row.reportName}」的抽取配置吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await deleteExtractConfig(Number(row.extractConfigIds));
ElMessage.success('删除成功');
getList();
} catch (_e) {
ElMessage.error('删除失败');
}
})
.catch(() => {});
};
// ========== 按天回刷 ==========
const onDailyExtract = (row: any) => {
dailyExtractDialog.businessCode = row.businessCode;
dailyExtractDialog.reportCode = row.reportCode;
dailyExtractDialog.dateRange = [];
dailyExtractDialog.loading = false;
dailyExtractDialog.visible = true;
};
const onDailyExtractConfirm = async () => {
if (!dailyExtractDialog.dateRange || dailyExtractDialog.dateRange.length !== 2) {
ElMessage.warning('请选择日期范围');
return;
}
dailyExtractDialog.loading = true;
try {
const res = await execDailyBackfill({
businessCode: dailyExtractDialog.businessCode,
reportCode: dailyExtractDialog.reportCode,
startDate: dailyExtractDialog.dateRange[0],
endDate: dailyExtractDialog.dateRange[1],
});
if (res.data?.success === false) {
ElMessage.warning(res.data?.message || '回刷失败');
return;
}
ElMessage.success('回刷任务已提交');
dailyExtractDialog.visible = false;
} catch (_e) {
ElMessage.error('执行回刷失败');
} finally {
dailyExtractDialog.loading = false;
}
};
// ========== 初始化 ==========
onMounted(() => {
loadBusinesses();
});
</script>
<style scoped lang="scss">
.biz-tabs-header {
display: flex;
align-items: stretch;
.biz-tabs {
flex: 1;
min-width: 0;
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__content) {
display: none;
}
:deep(.el-tabs__item) {
position: relative;
height: 34px;
line-height: 34px;
padding: 0 10px;
// 关闭图标始终可见
.is-icon-close {
width: 14px !important;
overflow: visible !important;
opacity: 0.45;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
}
.biz-add-btn {
flex-shrink: 0;
margin-left: 12px;
height: 34px;
align-self: flex-end;
}
}
.custom-tab-label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
.tab-name {
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-edit-btn {
font-size: 13px;
cursor: pointer;
margin-left: 3px;
color: var(--el-text-color-placeholder);
transition: color 0.2s;
&:hover {
color: var(--el-color-primary);
}
}
}
</style>