Merge remote-tracking branch 'origin/feature/workflow' into feature/workflow
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"grill-me": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/grill-me/SKILL.md",
|
||||
"computedHash": "67e31b63e6e9e454cc2f51cd85b9cf1396ee46379a87c07a1450f33c47c1276a"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
/** 字段定义 */
|
||||
export interface FieldDefine {
|
||||
id?: number | string;
|
||||
fieldCode: string;
|
||||
fieldName: string;
|
||||
fieldType: 'STRING' | 'INT' | 'FLOAT' | 'BIGINT' | 'DECIMAL' | 'DATE' | 'DATETIME' | 'BOOLEAN';
|
||||
dataType?: string;
|
||||
fieldRole: 'DIMENSION' | 'INDICATOR';
|
||||
isAggregatable?: boolean;
|
||||
isFilterable?: boolean;
|
||||
isQueryable?: boolean;
|
||||
isSortable?: boolean;
|
||||
defaultAggregate?: string;
|
||||
groupName?: string;
|
||||
sortOrder: number;
|
||||
unit?: string;
|
||||
expression?: string;
|
||||
status?: string;
|
||||
validAggregates?: string[];
|
||||
}
|
||||
|
||||
/** 报表+字段保存请求参数 */
|
||||
export interface ReportWithFieldsData {
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
reportName: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
statTableName: string;
|
||||
statTableComment?: string;
|
||||
dateField?: string;
|
||||
primaryKeys?: string[];
|
||||
conflictKeys?: string[];
|
||||
fields: FieldDefine[];
|
||||
}
|
||||
|
||||
export interface ExtractConfigSaveData {
|
||||
id?: number;
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
extractCode: string;
|
||||
extractName: string;
|
||||
sourceTableName: string;
|
||||
sourceTableAlias?: string;
|
||||
targetTableName: string;
|
||||
isEnabled: boolean;
|
||||
extractType: string;
|
||||
extractMode: string;
|
||||
extractKeyField?: string;
|
||||
batchSize?: number;
|
||||
status?: string;
|
||||
groupByFields?: string[];
|
||||
filterExpression?: string;
|
||||
operator?: string;
|
||||
}
|
||||
|
||||
// --- 业务 Business ---
|
||||
|
||||
export function listBusinesses() {
|
||||
return request({
|
||||
url: '/data-engine/report/businesses',
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export function getBusiness(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/business',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveBusiness(data: { id?: number; businessCode: string; businessName: string }) {
|
||||
return request({
|
||||
url: '/data-engine/report/business/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteBusiness(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/business',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// --- 报表 Report ---
|
||||
|
||||
export function listReports(businessCode: string, pageNum: number, pageSize: number, reportName?: string) {
|
||||
const params: any = { businessCode, pageNum, pageSize };
|
||||
if (reportName) params.reportName = reportName;
|
||||
return request({
|
||||
url: '/data-engine/report/reports',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getReport(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/report',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveReport(data: {
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
reportName: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
statTableName: string;
|
||||
statTableComment?: string;
|
||||
dateField?: string;
|
||||
primaryKeys?: string[];
|
||||
conflictKeys?: string[];
|
||||
}) {
|
||||
return request({
|
||||
url: '/data-engine/report/report/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存报表及字段(新建 / 编辑统一入口,全量替换字段) */
|
||||
export function saveReportWithFields(data: ReportWithFieldsData) {
|
||||
return request({
|
||||
url: '/data-engine/report/report/saveWithFields',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteReport(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/report',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取某业务下所有报表(不分页,供下拉选择) */
|
||||
export function listAllReports(businessCode: string) {
|
||||
return request({
|
||||
url: '/data-engine/report/reports',
|
||||
method: 'get',
|
||||
params: { businessCode, pageNum: 1, pageSize: 9999 },
|
||||
});
|
||||
}
|
||||
|
||||
// --- 查询相关类型 ---
|
||||
|
||||
export interface ReportFilter {
|
||||
fieldCode: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ReportIndicator {
|
||||
fieldCode: string;
|
||||
aggregate: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export interface ReportOrderBy {
|
||||
fieldCode: string;
|
||||
direction: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface ReportQueryParams {
|
||||
businessCode: string;
|
||||
reportCode: string;
|
||||
dimensions: string[];
|
||||
indicators: ReportIndicator[];
|
||||
filters: ReportFilter[];
|
||||
timeRange: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
timeGroup: string;
|
||||
orderBy: ReportOrderBy[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** 数据查询 */
|
||||
export function reportQuery(data: ReportQueryParams) {
|
||||
return request({
|
||||
url: '/data-engine/report/query',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 按天回刷(支持单天或日期范围) */
|
||||
export function execDailyBackfill(data: { businessCode: string; reportCode: string; startDate: string; endDate: string }) {
|
||||
return request({
|
||||
url: '/data-engine/report/backfill',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// --- 抽取配置 ExtractConfig ---
|
||||
|
||||
export function getExtractConfig(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/extractConfig',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveExtractConfig(data: Record<string, any>) {
|
||||
return request({
|
||||
url: '/data-engine/report/extractConfig/save',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteExtractConfig(id: number) {
|
||||
return request({
|
||||
url: '/data-engine/report/extractConfig',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
<template>
|
||||
<div class="report-query-container">
|
||||
<!-- 选择业务和报表 -->
|
||||
<el-card shadow="hover" class="mb15">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="业务">
|
||||
<el-select v-model="queryForm.businessCode" placeholder="请选择业务" style="width: 200px" @change="onBusinessChange">
|
||||
<el-option v-for="biz in businesses" :key="biz.businessCode" :label="biz.businessName" :value="biz.businessCode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="报表">
|
||||
<el-select v-model="queryForm.reportCode" placeholder="请选择报表" style="width: 260px" @change="onReportChange" :loading="reportLoading">
|
||||
<el-option v-for="r in reports" :key="r.reportCode" :label="r.reportName" :value="r.reportCode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 查询配置区 -->
|
||||
<template v-if="reportLoaded">
|
||||
<el-card shadow="hover" class="mb15">
|
||||
<template #header><span>查询配置</span></template>
|
||||
<el-form :model="queryForm" label-width="100px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="时间范围" required>
|
||||
<el-date-picker
|
||||
v-model="timeRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="时间粒度">
|
||||
<el-select v-model="queryForm.timeGroup" style="width: 100%">
|
||||
<el-option label="按天" value="day" />
|
||||
<el-option label="按周" value="week" />
|
||||
<el-option label="按月" value="month" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 维度 -->
|
||||
<el-form-item label="统计维度">
|
||||
<el-checkbox-group v-model="queryForm.dimensions" class="field-checkbox-group">
|
||||
<el-checkbox
|
||||
v-for="f in dimensionFields"
|
||||
:key="f.fieldCode"
|
||||
:label="f.fieldCode"
|
||||
>
|
||||
{{ f.fieldName || f.fieldCode }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<div v-if="dimensionFields.length === 0" class="field-empty-hint">该报表无可用的维度字段</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 指标 -->
|
||||
<el-form-item label="统计指标">
|
||||
<div class="indicator-list" v-if="indicatorFields.length > 0">
|
||||
<div v-for="(f, idx) in indicatorFields" :key="f.fieldCode" class="indicator-row">
|
||||
<el-checkbox
|
||||
v-model="indicatorChecked[f.fieldCode]"
|
||||
:label="f.fieldName || f.fieldCode"
|
||||
@change="(val: boolean) => onIndicatorCheck(f.fieldCode, val)"
|
||||
style="min-width: 120px"
|
||||
/>
|
||||
<template v-if="indicatorChecked[f.fieldCode]">
|
||||
<el-select
|
||||
v-model="indicatorAggs[f.fieldCode].aggregate"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="聚合方式"
|
||||
>
|
||||
<el-option v-for="agg in aggregateOptions" :key="agg.value" :label="agg.label" :value="agg.value" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="indicatorAggs[f.fieldCode].alias"
|
||||
style="width: 130px; margin-left: 8px"
|
||||
placeholder="显示别名"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="field-empty-hint">该报表无可用的指标字段</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<el-form-item label="筛选条件">
|
||||
<div class="filter-list">
|
||||
<div v-for="(f, idx) in filters" :key="idx" class="filter-row">
|
||||
<el-select v-model="f.fieldCode" style="width: 150px" placeholder="字段" @change="onFilterFieldChange(idx)">
|
||||
<el-option v-for="qf in queryableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
|
||||
</el-select>
|
||||
<el-select v-model="f.operator" style="width: 100px; margin-left: 6px" placeholder="运算符">
|
||||
<el-option v-for="op in filterOperators" :key="op.value" :label="op.label" :value="op.value" />
|
||||
</el-select>
|
||||
<el-input v-model="f.value" style="width: 160px; margin-left: 6px" placeholder="值" />
|
||||
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeFilter(idx)" />
|
||||
</div>
|
||||
<el-button size="small" @click="addFilter" :disabled="queryableFields.length === 0">+ 添加筛选</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 排序 -->
|
||||
<el-form-item label="排序">
|
||||
<div class="order-list">
|
||||
<div v-for="(o, idx) in queryForm.orderBy" :key="idx" class="order-row">
|
||||
<el-select v-model="o.fieldCode" style="width: 150px" placeholder="排序字段">
|
||||
<el-option v-for="qf in sortableFields" :key="qf.fieldCode" :label="qf.fieldName || qf.fieldCode" :value="qf.fieldCode" />
|
||||
</el-select>
|
||||
<el-select v-model="o.direction" style="width: 100px; margin-left: 6px">
|
||||
<el-option label="降序" value="DESC" />
|
||||
<el-option label="升序" value="ASC" />
|
||||
</el-select>
|
||||
<el-button type="danger" :icon="Delete" circle size="small" style="margin-left: 6px" @click="removeOrder(idx)" />
|
||||
</div>
|
||||
<el-button size="small" @click="addOrder" :disabled="sortableFields.length === 0">+ 添加排序</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" size="default" @click="handleQuery" :loading="queryLoading">
|
||||
<el-icon><ele-Search /></el-icon> 查询
|
||||
</el-button>
|
||||
<el-button size="default" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 查询结果 -->
|
||||
<el-card shadow="hover" v-if="queryDone">
|
||||
<template #header>
|
||||
<div class="result-header">
|
||||
<span>查询结果(共 {{ queryResult.total }} 条)</span>
|
||||
<el-radio-group v-model="viewMode" size="small">
|
||||
<el-radio-button value="table">数据列表</el-radio-button>
|
||||
<el-radio-button value="chart">图表展示</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 数据列表 -->
|
||||
<template v-if="viewMode === 'table'">
|
||||
<el-table :data="queryResult.list" border stripe v-loading="queryLoading" style="width: 100%" empty-text="暂无数据">
|
||||
<el-table-column v-for="col in queryResult.columns" :key="col" :prop="col" :label="col" show-overflow-tooltip min-width="120" />
|
||||
</el-table>
|
||||
<pagination
|
||||
v-show="queryResult.total > 0"
|
||||
:total="queryResult.total"
|
||||
v-model:page="queryForm.page"
|
||||
v-model:limit="queryForm.pageSize"
|
||||
@pagination="handleQuery"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 图表展示 -->
|
||||
<template v-if="viewMode === 'chart'">
|
||||
<div class="chart-tip" v-if="!chartReady">
|
||||
<el-alert title="图表展示需要至少选择一个维度和一个指标" type="info" show-icon :closable="false" />
|
||||
</div>
|
||||
<div ref="chartRef" class="chart-container" v-loading="queryLoading"></div>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default { name: 'reportEngineQuery' };
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Delete } from '@element-plus/icons-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import {
|
||||
listBusinesses,
|
||||
listAllReports,
|
||||
getReport,
|
||||
reportQuery,
|
||||
} from '/@/api/report-engine/report';
|
||||
import type {
|
||||
FieldDefine,
|
||||
ReportFilter,
|
||||
ReportOrderBy,
|
||||
} from '/@/api/report-engine/report';
|
||||
|
||||
// ========== 常量 ==========
|
||||
|
||||
const AGGREGATE_OPTIONS = [
|
||||
{ value: 'SUM', label: 'SUM (求和)' },
|
||||
{ value: 'COUNT', label: 'COUNT (计数)' },
|
||||
{ value: 'AVG', label: 'AVG (平均)' },
|
||||
{ value: 'MAX', label: 'MAX (最大)' },
|
||||
{ value: 'MIN', label: 'MIN (最小)' },
|
||||
];
|
||||
|
||||
const FILTER_OPERATORS = [
|
||||
{ value: '=', label: '=' },
|
||||
{ value: '!=', label: '!=' },
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '<=', label: '<=' },
|
||||
{ value: 'LIKE', label: 'LIKE' },
|
||||
{ value: 'IN', label: 'IN' },
|
||||
];
|
||||
|
||||
// ========== 状态 ==========
|
||||
|
||||
const businesses = ref<any[]>([]);
|
||||
const reports = ref<any[]>([]);
|
||||
const reportLoading = ref(false);
|
||||
const reportLoaded = ref(false);
|
||||
const queryLoading = ref(false);
|
||||
const queryDone = ref(false);
|
||||
const viewMode = ref<'table' | 'chart'>('table');
|
||||
const chartRef = ref<HTMLDivElement>();
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
/** 当前选中报表的所有字段 */
|
||||
const reportFields = ref<FieldDefine[]>([]);
|
||||
|
||||
// 维度字段(role === DIMENSION)
|
||||
const dimensionFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.fieldRole === 'DIMENSION')
|
||||
);
|
||||
|
||||
// 指标字段(role === INDICATOR)
|
||||
const indicatorFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.fieldRole === 'INDICATOR')
|
||||
);
|
||||
|
||||
// 可筛选字段
|
||||
const queryableFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.isFilterable !== false || f.isQueryable)
|
||||
);
|
||||
|
||||
// 可排序字段
|
||||
const sortableFields = computed(() =>
|
||||
reportFields.value.filter((f) => f.isSortable !== false || f.fieldRole === 'DIMENSION')
|
||||
);
|
||||
|
||||
const aggregateOptions = AGGREGATE_OPTIONS;
|
||||
const filterOperators = FILTER_OPERATORS;
|
||||
|
||||
/** 指标选中状态 */
|
||||
const indicatorChecked = reactive<Record<string, boolean>>({});
|
||||
/** 指标聚合配置 */
|
||||
const indicatorAggs = reactive<Record<string, { aggregate: string; alias: string }>>({});
|
||||
|
||||
/** 筛选条件列表 */
|
||||
const filters = ref<ReportFilter[]>([]);
|
||||
|
||||
/** 时间范围绑定(内部用数组) */
|
||||
const timeRange = ref<string[]>([]);
|
||||
|
||||
/** 查询表单 */
|
||||
const queryForm = reactive({
|
||||
businessCode: '',
|
||||
reportCode: '',
|
||||
dimensions: [] as string[],
|
||||
timeGroup: 'day',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
orderBy: [] as ReportOrderBy[],
|
||||
});
|
||||
|
||||
/** 查询结果 */
|
||||
const queryResult = reactive({
|
||||
list: [] as Record<string, any>[],
|
||||
columns: [] as string[],
|
||||
total: 0,
|
||||
});
|
||||
|
||||
/** 图表数据是否可用 */
|
||||
const chartReady = computed(() => {
|
||||
return queryForm.dimensions.length > 0 && Object.values(indicatorChecked).some(Boolean);
|
||||
});
|
||||
|
||||
// ========== 初始化 ==========
|
||||
|
||||
const initBusinesses = async () => {
|
||||
try {
|
||||
const res = await listBusinesses();
|
||||
businesses.value = res.data?.list || res.data || [];
|
||||
} catch (_e) {
|
||||
ElMessage.error('获取业务列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
initBusinesses();
|
||||
|
||||
// ========== 业务/报表切换 ==========
|
||||
|
||||
const onBusinessChange = async (code: string) => {
|
||||
queryForm.reportCode = '';
|
||||
reportLoaded.value = false;
|
||||
queryDone.value = false;
|
||||
reports.value = [];
|
||||
if (!code) return;
|
||||
|
||||
reportLoading.value = true;
|
||||
try {
|
||||
const res = await listAllReports(code);
|
||||
const raw = res.data;
|
||||
reports.value = Array.isArray(raw) ? raw : raw?.list || raw?.records || [];
|
||||
} catch (_e) {
|
||||
reports.value = [];
|
||||
ElMessage.error('获取报表列表失败');
|
||||
} finally {
|
||||
reportLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onReportChange = async (code: string) => {
|
||||
reportLoaded.value = false;
|
||||
queryDone.value = false;
|
||||
if (!code) return;
|
||||
|
||||
// 从报表列表中找到该报表的 id
|
||||
const row = reports.value.find((r) => r.reportCode === code);
|
||||
if (!row?.id) {
|
||||
ElMessage.warning('未找到报表信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getReport(row.id);
|
||||
const raw = res.data || {};
|
||||
const detail = raw.report || raw.data || raw;
|
||||
reportFields.value = detail.fields || [];
|
||||
|
||||
// 重置查询配置
|
||||
resetQueryState();
|
||||
|
||||
// 默认选中所有维度
|
||||
queryForm.dimensions = dimensionFields.value.map((f) => f.fieldCode);
|
||||
|
||||
// 默认选中所有指标
|
||||
indicatorFields.value.forEach((f) => {
|
||||
indicatorChecked[f.fieldCode] = true;
|
||||
indicatorAggs[f.fieldCode] = {
|
||||
aggregate: f.defaultAggregate || 'SUM',
|
||||
alias: f.fieldName || f.fieldCode,
|
||||
};
|
||||
});
|
||||
|
||||
reportLoaded.value = true;
|
||||
} catch (_e) {
|
||||
ElMessage.error('获取报表字段信息失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 指标勾选 ==========
|
||||
|
||||
const onIndicatorCheck = (fieldCode: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
const f = reportFields.value.find((r) => r.fieldCode === fieldCode);
|
||||
indicatorAggs[fieldCode] = {
|
||||
aggregate: f?.defaultAggregate || 'SUM',
|
||||
alias: f?.fieldName || fieldCode,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 筛选条件 ==========
|
||||
|
||||
const addFilter = () => {
|
||||
filters.value.push({ fieldCode: '', operator: '=', value: '' });
|
||||
};
|
||||
|
||||
const removeFilter = (idx: number) => {
|
||||
filters.value.splice(idx, 1);
|
||||
};
|
||||
|
||||
const onFilterFieldChange = (idx: number) => {
|
||||
// 清空值,方便用户输入
|
||||
filters.value[idx].value = '';
|
||||
};
|
||||
|
||||
// ========== 排序 ==========
|
||||
|
||||
const addOrder = () => {
|
||||
queryForm.orderBy.push({ fieldCode: '', direction: 'DESC' });
|
||||
};
|
||||
|
||||
const removeOrder = (idx: number) => {
|
||||
queryForm.orderBy.splice(idx, 1);
|
||||
};
|
||||
|
||||
// ========== 查询 ==========
|
||||
|
||||
const buildQueryParams = () => {
|
||||
const dimensions = [...queryForm.dimensions];
|
||||
|
||||
// 收集已勾选的指标
|
||||
const indicators: { fieldCode: string; aggregate: string; alias: string }[] = [];
|
||||
indicatorFields.value.forEach((f) => {
|
||||
if (indicatorChecked[f.fieldCode]) {
|
||||
indicators.push({
|
||||
fieldCode: f.fieldCode,
|
||||
aggregate: indicatorAggs[f.fieldCode]?.aggregate || 'SUM',
|
||||
alias: indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const validFilters = filters.value.filter((f) => f.fieldCode && f.value);
|
||||
const validOrderBy = queryForm.orderBy.filter((o) => o.fieldCode);
|
||||
|
||||
return {
|
||||
businessCode: queryForm.businessCode,
|
||||
reportCode: queryForm.reportCode,
|
||||
dimensions,
|
||||
indicators,
|
||||
filters: validFilters,
|
||||
timeRange: {
|
||||
startDate: timeRange.value?.[0] || '',
|
||||
endDate: timeRange.value?.[1] || '',
|
||||
},
|
||||
timeGroup: queryForm.timeGroup,
|
||||
orderBy: validOrderBy,
|
||||
page: queryForm.page,
|
||||
pageSize: queryForm.pageSize,
|
||||
};
|
||||
};
|
||||
|
||||
const handleQuery = async () => {
|
||||
if (!queryForm.businessCode || !queryForm.reportCode) {
|
||||
ElMessage.warning('请选择业务和报表');
|
||||
return;
|
||||
}
|
||||
if (!timeRange.value || timeRange.value.length !== 2) {
|
||||
ElMessage.warning('请选择时间范围');
|
||||
return;
|
||||
}
|
||||
|
||||
queryLoading.value = true;
|
||||
queryDone.value = true;
|
||||
try {
|
||||
const params = buildQueryParams();
|
||||
const res = await reportQuery(params);
|
||||
const raw = res.data || {};
|
||||
|
||||
// 适配多种返回格式
|
||||
const list = raw.list || raw.records || raw.rows || [];
|
||||
const total = raw.total ?? list.length;
|
||||
|
||||
// 从接口获取列信息,或从数据推断
|
||||
const columns = raw.columns || raw.header || [];
|
||||
if (columns.length === 0 && list.length > 0) {
|
||||
queryResult.columns = Object.keys(list[0]);
|
||||
} else {
|
||||
queryResult.columns = columns;
|
||||
}
|
||||
|
||||
queryResult.list = list;
|
||||
queryResult.total = total;
|
||||
|
||||
// 切换到图表时渲染
|
||||
if (viewMode.value === 'chart') {
|
||||
nextTick(() => renderChart());
|
||||
}
|
||||
} catch (_e) {
|
||||
queryResult.list = [];
|
||||
queryResult.columns = [];
|
||||
queryResult.total = 0;
|
||||
ElMessage.error('查询失败');
|
||||
} finally {
|
||||
queryLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
resetQueryState();
|
||||
timeRange.value = [];
|
||||
queryForm.page = 1;
|
||||
queryDone.value = false;
|
||||
};
|
||||
|
||||
const resetQueryState = () => {
|
||||
queryForm.dimensions = [];
|
||||
queryForm.orderBy = [];
|
||||
queryForm.timeGroup = 'day';
|
||||
queryForm.pageSize = 20;
|
||||
queryForm.page = 1;
|
||||
filters.value = [];
|
||||
// 清空指标状态
|
||||
Object.keys(indicatorChecked).forEach((k) => delete indicatorChecked[k]);
|
||||
Object.keys(indicatorAggs).forEach((k) => delete indicatorAggs[k]);
|
||||
};
|
||||
|
||||
// ========== 图表渲染 ==========
|
||||
|
||||
watch(viewMode, (mode) => {
|
||||
if (mode === 'chart' && queryDone.value && queryResult.list.length > 0) {
|
||||
nextTick(() => renderChart());
|
||||
}
|
||||
if (mode === 'table') {
|
||||
disposeChart();
|
||||
}
|
||||
});
|
||||
|
||||
const renderChart = () => {
|
||||
if (!chartRef.value || queryResult.list.length === 0) return;
|
||||
if (!chartReady.value) {
|
||||
disposeChart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
}
|
||||
|
||||
const dimensions = queryForm.dimensions;
|
||||
const dimField = dimensions[0] || queryResult.columns[0];
|
||||
|
||||
// x 轴:第一个维度
|
||||
const xData = queryResult.list.map((row) => row[dimField] ?? '');
|
||||
|
||||
// 系列:已勾选的指标
|
||||
// 注意:API 返回的数据中,指标字段的 key 使用的是 alias,而非 fieldCode
|
||||
const series: echarts.SeriesOption[] = [];
|
||||
indicatorFields.value.forEach((f) => {
|
||||
if (indicatorChecked[f.fieldCode]) {
|
||||
const alias = indicatorAggs[f.fieldCode]?.alias || f.fieldName || f.fieldCode;
|
||||
series.push({
|
||||
name: alias,
|
||||
type: 'bar',
|
||||
data: queryResult.list.map((row) => {
|
||||
const val = Number(row[alias]);
|
||||
return isNaN(val) ? row[alias] : val;
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
chartInstance.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { type: 'scroll', bottom: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '20%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
axisLabel: { rotate: xData.length > 10 ? 45 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series,
|
||||
});
|
||||
|
||||
chartInstance.resize();
|
||||
};
|
||||
|
||||
const disposeChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose();
|
||||
chartInstance = null;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 窗口 resize ==========
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (chartInstance) chartInstance.resize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.report-query-container {
|
||||
.field-checkbox-group {
|
||||
.el-checkbox {
|
||||
margin-right: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.field-empty-hint {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.indicator-list {
|
||||
width: 100%;
|
||||
|
||||
.indicator-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-list,
|
||||
.order-list {
|
||||
width: 100%;
|
||||
|
||||
.filter-row,
|
||||
.order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
}
|
||||
|
||||
.chart-tip {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user