feat: restore source parity and harden agent runtime
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 预览时合并后端 chartData 到 float/cell 图表配置(对齐 JNPF useReport.getRealEchart)
|
||||
*/
|
||||
export interface ChartDataItem {
|
||||
drawingId: string;
|
||||
field: {
|
||||
classifyNameField?: string[];
|
||||
seriesNameField?: string[];
|
||||
seriesDataField?: string[][];
|
||||
maxField?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
type EchartStore = Record<
|
||||
string,
|
||||
{ drawingId?: string; echartType: string; option: Record<string, any> }
|
||||
> | null;
|
||||
|
||||
function getColor(colorList: any[], index: number) {
|
||||
const item = colorList?.[index];
|
||||
if (!item) return undefined;
|
||||
return item.color1 || item.color2 || undefined;
|
||||
}
|
||||
|
||||
function getPieData(pieOpt: Record<string, any>, list: { name: string; value: string }[]) {
|
||||
let data = [...list];
|
||||
if (pieOpt?.showZero) {
|
||||
data = data.filter((item) => String(item.value) !== '0');
|
||||
}
|
||||
if (pieOpt?.sortable) {
|
||||
data = [...data].sort((a, b) => Number(a.value) - Number(b.value));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function applyChartDataToEcharts(
|
||||
echarts: EchartStore,
|
||||
chartData: ChartDataItem[] | null | undefined,
|
||||
): EchartStore {
|
||||
if (!echarts || !chartData?.length) return echarts;
|
||||
const next = JSON.parse(JSON.stringify(echarts)) as NonNullable<EchartStore>;
|
||||
Object.keys(next).forEach((key) => {
|
||||
const chart = next[key];
|
||||
if (!chart?.option) return;
|
||||
const option = chart.option;
|
||||
const styleType = option.styleType;
|
||||
const colorList = option.color?.list || [];
|
||||
const dataList = chartData.filter((o) => o.drawingId === key);
|
||||
if (!dataList.length) return;
|
||||
const data = dataList[0].field;
|
||||
const {
|
||||
classifyNameField = [],
|
||||
seriesDataField = [],
|
||||
seriesNameField = [],
|
||||
maxField = [],
|
||||
} = data;
|
||||
|
||||
if (['bar', 'line'].includes(chart.echartType)) {
|
||||
const series = seriesDataField.map((o, index) => ({
|
||||
name: seriesNameField[index],
|
||||
data: o,
|
||||
type: chart.echartType,
|
||||
itemStyle: { color: getColor(colorList, index) },
|
||||
...(chart.echartType === 'line'
|
||||
? {
|
||||
smooth: styleType === 2,
|
||||
step: styleType === 3,
|
||||
stack: styleType === 4 ? 'total' : '',
|
||||
lineStyle: { width: option.line?.width },
|
||||
symbolSize: option.line?.symbolSize,
|
||||
}
|
||||
: {}),
|
||||
...(chart.echartType === 'line' && option.areaStyle
|
||||
? { areaStyle: option.areaStyle }
|
||||
: {}),
|
||||
...(chart.echartType === 'bar'
|
||||
? {
|
||||
showBackground: styleType === 4,
|
||||
stack:
|
||||
styleType === 5
|
||||
? seriesNameField[index]
|
||||
: styleType === 2 || styleType === 6
|
||||
? 'total'
|
||||
: '',
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
option.series = series;
|
||||
option.legend = { ...option.legend, data: seriesNameField };
|
||||
option.xAxis = { ...option.xAxis, data: classifyNameField };
|
||||
}
|
||||
|
||||
if (chart.echartType === 'pie') {
|
||||
option.series = seriesDataField.map((o, index) => {
|
||||
const pieData = o.map((item, sIndex) => ({
|
||||
value: item,
|
||||
name: classifyNameField[sIndex],
|
||||
}));
|
||||
return {
|
||||
name: seriesNameField[index],
|
||||
type: 'pie',
|
||||
radius: styleType === 2 ? ['30%', '60%'] : '50%',
|
||||
center: [
|
||||
`${option.seriesCenter?.seriesCenterLeft ?? 50}%`,
|
||||
`${option.seriesCenter?.seriesCenterTop ?? 50}%`,
|
||||
],
|
||||
roseType: option.pie?.roseType ? 'area' : '',
|
||||
data: getPieData(option.pie || {}, pieData),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (chart.echartType === 'radar' && maxField.length) {
|
||||
const indicator = maxField.map((o, sIndex) => ({
|
||||
max: o,
|
||||
name: classifyNameField[sIndex],
|
||||
}));
|
||||
option.radar = {
|
||||
...option.radar,
|
||||
indicator,
|
||||
shape: styleType === 1 ? 'polygon' : 'circle',
|
||||
};
|
||||
option.series = [
|
||||
{
|
||||
type: 'radar',
|
||||
data: seriesDataField.map((element, index) => ({
|
||||
value: element,
|
||||
name: seriesNameField[index],
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseCellsMeta(cells: Record<string, any> | string | undefined) {
|
||||
if (!cells) return {};
|
||||
if (typeof cells === 'string') {
|
||||
try {
|
||||
return JSON.parse(cells);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
|
||||
import { computed, watch, type MaybeRefOrGetter, toValue } from 'vue';
|
||||
|
||||
import {
|
||||
buildFieldOptions,
|
||||
buildFieldTree,
|
||||
datasetFieldsCache,
|
||||
datasetFieldsLoadingMap,
|
||||
fetchDatasetFields,
|
||||
normalizeDataset,
|
||||
type FieldOption,
|
||||
type FieldTreeNode,
|
||||
} from '../utils/dataset-fields';
|
||||
|
||||
export function useReportDatasetFields(
|
||||
datasetsSource: MaybeRefOrGetter<ReportDatasetItem[]>,
|
||||
) {
|
||||
const fieldsCache = datasetFieldsCache;
|
||||
const loadingMap = datasetFieldsLoadingMap;
|
||||
|
||||
const normalizedDatasets = computed(() =>
|
||||
(toValue(datasetsSource) || [])
|
||||
.map(normalizeDataset)
|
||||
.filter((item) => item.data_source_id),
|
||||
);
|
||||
|
||||
watch(
|
||||
normalizedDatasets,
|
||||
(list) => {
|
||||
const idSet = new Set(list.map((d) => d.data_source_id));
|
||||
for (const key of Object.keys(fieldsCache.value)) {
|
||||
if (!idSet.has(key)) {
|
||||
delete fieldsCache.value[key];
|
||||
delete loadingMap.value[key];
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
async function loadFields(ds: ReportDatasetItem, force = false) {
|
||||
const normalized = normalizeDataset(ds);
|
||||
const id = normalized.data_source_id;
|
||||
if (!id) return [];
|
||||
if (!force && fieldsCache.value[id] !== undefined) {
|
||||
return fieldsCache.value[id];
|
||||
}
|
||||
if (loadingMap.value[id]) {
|
||||
return fieldsCache.value[id] || [];
|
||||
}
|
||||
|
||||
loadingMap.value[id] = true;
|
||||
try {
|
||||
const fields = await fetchDatasetFields(normalized);
|
||||
fieldsCache.value[id] = fields;
|
||||
return fields;
|
||||
} catch {
|
||||
fieldsCache.value[id] = [];
|
||||
return [];
|
||||
} finally {
|
||||
loadingMap.value[id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAll(force = false) {
|
||||
await Promise.all(
|
||||
normalizedDatasets.value.map((ds) => loadFields(ds, force)),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureByAlias(alias: string, force = false) {
|
||||
const ds = normalizedDatasets.value.find((item) => item.alias === alias);
|
||||
if (!ds) return [];
|
||||
return loadFields(ds, force);
|
||||
}
|
||||
|
||||
async function ensureByDataSourceId(dataSourceId: string, force = false) {
|
||||
const ds = normalizedDatasets.value.find(
|
||||
(item) => item.data_source_id === dataSourceId,
|
||||
);
|
||||
if (!ds) return [];
|
||||
return loadFields(ds, force);
|
||||
}
|
||||
|
||||
const allFieldOptions = computed<FieldOption[]>(() =>
|
||||
buildFieldOptions(normalizedDatasets.value, fieldsCache.value, {
|
||||
withAlias: true,
|
||||
}),
|
||||
);
|
||||
|
||||
function getFieldOptions(
|
||||
alias?: string,
|
||||
withAlias = true,
|
||||
currentValue?: string,
|
||||
) {
|
||||
const options = buildFieldOptions(
|
||||
normalizedDatasets.value,
|
||||
fieldsCache.value,
|
||||
{ alias, withAlias },
|
||||
);
|
||||
return appendIfMissing(options, currentValue);
|
||||
}
|
||||
|
||||
function getFieldTree(
|
||||
alias?: string,
|
||||
withAlias = true,
|
||||
currentValue?: string,
|
||||
): FieldTreeNode[] {
|
||||
return buildFieldTree(normalizedDatasets.value, fieldsCache.value, {
|
||||
alias,
|
||||
withAlias,
|
||||
currentValue,
|
||||
});
|
||||
}
|
||||
|
||||
function appendIfMissing(options: FieldOption[], value?: string) {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed || options.some((opt) => opt.value === trimmed)) {
|
||||
return options;
|
||||
}
|
||||
return [{ label: trimmed, value: trimmed }, ...options];
|
||||
}
|
||||
|
||||
return {
|
||||
fieldsCache,
|
||||
loadingMap,
|
||||
normalizedDatasets,
|
||||
allFieldOptions,
|
||||
loadFields,
|
||||
ensureAll,
|
||||
ensureByAlias,
|
||||
ensureByDataSourceId,
|
||||
getFieldOptions,
|
||||
getFieldTree,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
/** 报表查询条件项(与 JNPF queryList 子集兼容) */
|
||||
export interface ReportQueryField {
|
||||
field: string;
|
||||
label?: string;
|
||||
component?: string;
|
||||
defaultValue?: any;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
showTime?: boolean;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
}
|
||||
|
||||
export interface SheetQueryBlock {
|
||||
sheet: string;
|
||||
queryList: any[];
|
||||
}
|
||||
|
||||
function isSheetWrappedQueryList(queryList: any[]): queryList is SheetQueryBlock[] {
|
||||
if (!queryList?.length) return false;
|
||||
const first = queryList[0];
|
||||
return (
|
||||
typeof first === 'object' &&
|
||||
first !== null &&
|
||||
'queryList' in first &&
|
||||
Array.isArray(first.queryList)
|
||||
);
|
||||
}
|
||||
|
||||
function flattenQueryList(queryList: any[]): any[] {
|
||||
if (!queryList?.length) return [];
|
||||
if (isSheetWrappedQueryList(queryList)) {
|
||||
const flat: any[] = [];
|
||||
for (const block of queryList) {
|
||||
for (const item of block.queryList || []) {
|
||||
if (item && typeof item === 'object') flat.push(item);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
return queryList.filter((item) => item && typeof item === 'object');
|
||||
}
|
||||
|
||||
function queryListForSheet(queryList: any[], sheetId: string): any[] {
|
||||
if (!queryList?.length) return [];
|
||||
if (isSheetWrappedQueryList(queryList)) {
|
||||
const block = queryList.find((b) => String(b.sheet || '') === String(sheetId));
|
||||
if (block) return block.queryList || [];
|
||||
return flattenQueryList(queryList);
|
||||
}
|
||||
return queryList;
|
||||
}
|
||||
|
||||
function normalizeComponent(raw: string) {
|
||||
const c = (raw || 'input').toLowerCase();
|
||||
if (c.includes('select')) return 'select';
|
||||
if (c.includes('range')) return 'dateRange';
|
||||
if (c.includes('date') || c.includes('time')) return 'date';
|
||||
return 'input';
|
||||
}
|
||||
|
||||
function parseOptions(raw: any): Array<{ label: string; value: any }> | undefined {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return {
|
||||
label: String(item.label ?? item.fullName ?? item.text ?? item.id ?? ''),
|
||||
value: item.value ?? item.id,
|
||||
};
|
||||
}
|
||||
return { label: String(item), value: item };
|
||||
});
|
||||
}
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return raw.split(',').map((part) => {
|
||||
const [label, value] = part.split(':');
|
||||
const v = (value ?? label).trim();
|
||||
return { label: (label || v).trim(), value: v };
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseQueryFields(queryList: any[]) {
|
||||
const fields: ReportQueryField[] = [];
|
||||
const defaults: Record<string, any> = {};
|
||||
for (const raw of queryList || []) {
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const field = raw.field || raw.vModel || raw.prop;
|
||||
if (!field) continue;
|
||||
fields.push({
|
||||
field,
|
||||
label: raw.label || raw.__config__?.label || field,
|
||||
component: raw.component || raw.__config__?.tag || 'input',
|
||||
defaultValue: raw.defaultValue ?? raw.value,
|
||||
required: raw.required,
|
||||
placeholder: raw.placeholder,
|
||||
showTime: raw.showTime === true || raw.__config__?.showTime === true,
|
||||
options: parseOptions(raw.options ?? raw.__config__?.options),
|
||||
});
|
||||
if (raw.defaultValue !== undefined && raw.defaultValue !== null) {
|
||||
defaults[field] = raw.defaultValue;
|
||||
} else if (raw.value !== undefined) {
|
||||
defaults[field] = raw.value;
|
||||
}
|
||||
}
|
||||
return { fields, defaults };
|
||||
}
|
||||
|
||||
export function useReportQuery() {
|
||||
const activeSheetId = ref('');
|
||||
const rawQueryList = ref<any[]>([]);
|
||||
const isSheetWrapped = ref(false);
|
||||
|
||||
const state = reactive({
|
||||
queryFields: [] as ReportQueryField[],
|
||||
formValues: {} as Record<string, any>,
|
||||
});
|
||||
|
||||
const searchSchemas = computed(() =>
|
||||
state.queryFields.map((item) => {
|
||||
const component = normalizeComponent(item.component || 'input');
|
||||
const schema: Record<string, any> = {
|
||||
fieldName: item.field,
|
||||
label: item.label || item.field,
|
||||
component,
|
||||
componentProps: {
|
||||
placeholder: item.placeholder || item.label || item.field,
|
||||
showTime: item.showTime === true,
|
||||
},
|
||||
rules: item.required
|
||||
? [{ required: true, message: $t('report-manager.query.required') }]
|
||||
: undefined,
|
||||
};
|
||||
if (component === 'select' && item.options?.length) {
|
||||
schema.componentProps.options = item.options;
|
||||
}
|
||||
return schema;
|
||||
}),
|
||||
);
|
||||
|
||||
function applyQueryFields(queryList: any[]) {
|
||||
const { fields, defaults } = parseQueryFields(queryList);
|
||||
state.queryFields = fields;
|
||||
state.formValues = { ...defaults };
|
||||
}
|
||||
|
||||
function setQueryList(queryList: any[]) {
|
||||
rawQueryList.value = Array.isArray(queryList) ? [...queryList] : [];
|
||||
isSheetWrapped.value = isSheetWrappedQueryList(rawQueryList.value);
|
||||
if (isSheetWrapped.value && !activeSheetId.value) {
|
||||
activeSheetId.value = String(rawQueryList.value[0]?.sheet || '');
|
||||
}
|
||||
const effective = isSheetWrapped.value
|
||||
? queryListForSheet(rawQueryList.value, activeSheetId.value)
|
||||
: rawQueryList.value;
|
||||
applyQueryFields(effective);
|
||||
}
|
||||
|
||||
function setQueryListForSheet(queryList: any[], sheetId?: string) {
|
||||
if (sheetId !== undefined) {
|
||||
activeSheetId.value = sheetId;
|
||||
}
|
||||
if (isSheetWrapped.value && activeSheetId.value) {
|
||||
applyQueryFields(queryListForSheet(rawQueryList.value, activeSheetId.value));
|
||||
return;
|
||||
}
|
||||
setQueryList(queryList);
|
||||
}
|
||||
|
||||
function onActiveSheetChange(sheetId: string) {
|
||||
if (!sheetId || sheetId === activeSheetId.value) return;
|
||||
activeSheetId.value = sheetId;
|
||||
if (isSheetWrapped.value) {
|
||||
applyQueryFields(queryListForSheet(rawQueryList.value, sheetId));
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultParams() {
|
||||
const params: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(state.formValues)) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
params[key] = val;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function setFormValues(values: Record<string, any>) {
|
||||
state.formValues = { ...state.formValues, ...values };
|
||||
}
|
||||
|
||||
function clear() {
|
||||
rawQueryList.value = [];
|
||||
isSheetWrapped.value = false;
|
||||
activeSheetId.value = '';
|
||||
state.queryFields = [];
|
||||
state.formValues = {};
|
||||
}
|
||||
|
||||
return {
|
||||
activeSheetId,
|
||||
isSheetWrapped,
|
||||
searchSchemas,
|
||||
formValues: computed(() => state.formValues),
|
||||
setQueryList,
|
||||
setQueryListForSheet,
|
||||
onActiveSheetChange,
|
||||
getDefaultParams,
|
||||
setFormValues,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import '@zq/univer/style';
|
||||
import UniverHost from './univer-host.vue';
|
||||
|
||||
import type { ZqTabItem } from '#/components/zq-tabs';
|
||||
import { ZqTabs } from '#/components/zq-tabs';
|
||||
|
||||
import { getAllDataSourceApi, type DataSourceSimple } from '#/api/core/data-source';
|
||||
import { type ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
import { useAppContextStore } from '#/store/app-context';
|
||||
|
||||
import CellChartPanel from './modules/cell-chart-panel.vue';
|
||||
import type { CellChartSelection } from './modules/cell-chart-panel.vue';
|
||||
import CellPropertyPanel from './modules/cell-property-panel.vue';
|
||||
import type { CellSelection } from './modules/cell-property-panel.vue';
|
||||
import FloatEchartPanel from './modules/float-echart-panel.vue';
|
||||
import type { FloatEchartSelection } from './modules/float-echart-panel.vue';
|
||||
import FloatImagePanel from './modules/float-image-panel.vue';
|
||||
import type { FloatImageSelection } from './modules/float-image-panel.vue';
|
||||
import DatasetPanel from './modules/dataset-panel.vue';
|
||||
import QueryConfigDialog from './modules/query-config-dialog.vue';
|
||||
import PreviewDialog from './modules/preview-dialog.vue';
|
||||
import SortConfigDialog from './modules/sort-config-dialog.vue';
|
||||
import ColumnConfigDialog from './modules/column-config-dialog.vue';
|
||||
import ConvertConfigDialog from './modules/convert-config-dialog.vue';
|
||||
import ReportConfigFeaturesPanel from './modules/report-config-features-panel.vue';
|
||||
import ReportSettingsPanel from './modules/report-settings-panel.vue';
|
||||
import { useReportQuery } from './hooks/useReportQuery';
|
||||
|
||||
const props = defineProps<{
|
||||
templateId: string;
|
||||
versionId: string;
|
||||
reportName: string;
|
||||
reportCode?: string;
|
||||
snapshot: Record<string, any>;
|
||||
cells: Record<string, any>;
|
||||
queryList: any[];
|
||||
sortList?: any[];
|
||||
columnList?: any[];
|
||||
convertConfig?: any[];
|
||||
datasets: ReportDatasetItem[];
|
||||
allowExport?: boolean;
|
||||
allowPrint?: boolean;
|
||||
allowWatermark?: boolean;
|
||||
watermarkConfig?: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'settings-saved': [
|
||||
{
|
||||
allow_export: boolean;
|
||||
allow_print: boolean;
|
||||
allow_watermark: boolean;
|
||||
watermark_text: string;
|
||||
watermark_show_time: boolean;
|
||||
watermark_time_format: string;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
|
||||
const appContextStore = useAppContextStore();
|
||||
const univerRef = ref<InstanceType<typeof UniverHost> | null>(null);
|
||||
const dataSources = ref<DataSourceSimple[]>([]);
|
||||
const localDatasets = ref<ReportDatasetItem[]>([]);
|
||||
const localQueryList = ref<any[]>([]);
|
||||
const localSortList = ref<any[]>([]);
|
||||
const localColumnList = ref<any[]>([]);
|
||||
const localConvertConfig = ref<any[]>([]);
|
||||
const showQueryDialog = ref(false);
|
||||
const showSortDialog = ref(false);
|
||||
const showColumnDialog = ref(false);
|
||||
const showConvertDialog = ref(false);
|
||||
const showPreviewDialog = ref(false);
|
||||
const leftTab = ref('dataSource');
|
||||
const leftTabItems = computed<ZqTabItem[]>(() => [
|
||||
{
|
||||
key: 'dataSource',
|
||||
label: $t('report-manager.leftPanel.dataSource'),
|
||||
},
|
||||
{
|
||||
key: 'reportProperties',
|
||||
label: $t('report-manager.leftPanel.reportProperties'),
|
||||
},
|
||||
]);
|
||||
const rightTab = ref('cellProperties');
|
||||
const rightTabItems = computed<ZqTabItem[]>(() => [
|
||||
{
|
||||
key: 'cellProperties',
|
||||
label: $t('report-manager.properties.title'),
|
||||
},
|
||||
]);
|
||||
const cellSelection = ref<CellSelection | null>(null);
|
||||
const floatImageSelection = ref<FloatImageSelection | null>(null);
|
||||
const floatEchartSelection = ref<FloatEchartSelection | null>(null);
|
||||
const cellChartSelection = ref<CellChartSelection | null>(null);
|
||||
const propertyMode = ref<'cell' | 'image' | 'chart' | 'cellChart'>('cell');
|
||||
|
||||
const { setQueryList } = useReportQuery();
|
||||
|
||||
function normalizeConvertConfig(raw: any): any[] {
|
||||
if (Array.isArray(raw)) return [...raw];
|
||||
if (raw && Array.isArray(raw.list)) return [...raw.list];
|
||||
return [];
|
||||
}
|
||||
|
||||
function syncLocalState() {
|
||||
localDatasets.value = [...(props.datasets || [])];
|
||||
localQueryList.value = [...(props.queryList || [])];
|
||||
localSortList.value = [...(props.sortList || [])];
|
||||
localColumnList.value = [...(props.columnList || [])];
|
||||
localConvertConfig.value = normalizeConvertConfig(props.convertConfig);
|
||||
setQueryList(localQueryList.value);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncLocalState();
|
||||
try {
|
||||
dataSources.value = await getAllDataSourceApi(
|
||||
appContextStore.currentApp?.id,
|
||||
);
|
||||
} catch {
|
||||
dataSources.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.datasets, props.queryList, props.sortList, props.columnList, props.convertConfig, props.versionId],
|
||||
() => syncLocalState(),
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function getSavePayload() {
|
||||
const data = univerRef.value?.getData() || {};
|
||||
return {
|
||||
id: props.templateId,
|
||||
versionId: props.versionId,
|
||||
snapshot: data.snapshot,
|
||||
cells: data.cells,
|
||||
queryList: localQueryList.value,
|
||||
sortList: localSortList.value,
|
||||
columnList: localColumnList.value,
|
||||
fenceList: localColumnList.value,
|
||||
convertConfig: localConvertConfig.value,
|
||||
dataSetList: localDatasets.value.map((d) => ({
|
||||
dataSourceId: d.data_source_id,
|
||||
alias: d.alias,
|
||||
fieldMapping: d.field_mapping || {},
|
||||
convertConfig: d.convert_config || {},
|
||||
sort: d.sort ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function onCellChange(payload: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
cellData: Record<string, any>;
|
||||
sheetId?: string;
|
||||
}) {
|
||||
floatImageSelection.value = null;
|
||||
floatEchartSelection.value = null;
|
||||
const custom = payload.cellData?.custom || {};
|
||||
if (custom.type === 'chart') {
|
||||
cellSelection.value = null;
|
||||
cellChartSelection.value = {
|
||||
startRow: payload.startRow,
|
||||
startColumn: payload.startColumn,
|
||||
sheetId: payload.sheetId,
|
||||
cellData: payload.cellData,
|
||||
};
|
||||
propertyMode.value = 'cellChart';
|
||||
return;
|
||||
}
|
||||
cellChartSelection.value = null;
|
||||
propertyMode.value = 'cell';
|
||||
cellSelection.value = {
|
||||
startRow: payload.startRow,
|
||||
startColumn: payload.startColumn,
|
||||
sheetId: payload.sheetId,
|
||||
cellData: payload.cellData,
|
||||
};
|
||||
}
|
||||
|
||||
function onFocusFloatImage(payload: {
|
||||
drawingId: string;
|
||||
imageType: 'BASE64' | 'URL';
|
||||
option: Record<string, any>;
|
||||
}) {
|
||||
cellSelection.value = null;
|
||||
floatEchartSelection.value = null;
|
||||
cellChartSelection.value = null;
|
||||
propertyMode.value = 'image';
|
||||
floatImageSelection.value = {
|
||||
drawingId: payload.drawingId,
|
||||
imageType: payload.imageType,
|
||||
option: payload.option || {},
|
||||
};
|
||||
}
|
||||
|
||||
function onFocusFloatEchart(payload: {
|
||||
drawingId: string;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
}) {
|
||||
cellSelection.value = null;
|
||||
floatImageSelection.value = null;
|
||||
cellChartSelection.value = null;
|
||||
propertyMode.value = 'chart';
|
||||
floatEchartSelection.value = {
|
||||
drawingId: payload.drawingId,
|
||||
echartType: payload.echartType,
|
||||
option: payload.option || {},
|
||||
};
|
||||
}
|
||||
|
||||
function onApplyFloatImage(config: FloatImageSelection) {
|
||||
univerRef.value?.updateFloatImageConfig?.(config);
|
||||
}
|
||||
|
||||
function onApplyFloatEchart(config: FloatEchartSelection) {
|
||||
univerRef.value?.updateFloatEchartConfig?.(config);
|
||||
}
|
||||
|
||||
function onApplyCellChart(payload: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
preserveCustom: Record<string, any>;
|
||||
}) {
|
||||
univerRef.value?.applyCellChart?.(payload);
|
||||
}
|
||||
|
||||
function onApplyCellBinding(binding: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
type: string;
|
||||
v: string;
|
||||
custom: Record<string, any>;
|
||||
}) {
|
||||
univerRef.value?.applyCellBinding?.(binding);
|
||||
}
|
||||
|
||||
function onSettingsSaved(form: {
|
||||
allow_export: boolean;
|
||||
allow_print: boolean;
|
||||
allow_watermark: boolean;
|
||||
watermark_text: string;
|
||||
watermark_show_time: boolean;
|
||||
watermark_time_format: string;
|
||||
}) {
|
||||
emit('settings-saved', form);
|
||||
}
|
||||
|
||||
defineExpose({ getSavePayload });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background-deep flex h-full min-h-0 w-full">
|
||||
<aside
|
||||
class="bg-background my-3 ml-3 flex w-56 shrink-0 flex-col rounded-[8px]"
|
||||
>
|
||||
<div class="shrink-0 p-2">
|
||||
<ZqTabs v-model="leftTab" :items="leftTabItems" />
|
||||
</div>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<DatasetPanel
|
||||
v-if="leftTab === 'dataSource'"
|
||||
class="min-h-0 flex-1"
|
||||
:datasets="localDatasets"
|
||||
:data-sources="dataSources"
|
||||
@update="localDatasets = $event"
|
||||
/>
|
||||
<div
|
||||
v-else-if="leftTab === 'reportProperties'"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-auto"
|
||||
>
|
||||
<ReportConfigFeaturesPanel
|
||||
:query-list="localQueryList"
|
||||
:sort-list="localSortList"
|
||||
:column-list="localColumnList"
|
||||
:convert-config="localConvertConfig"
|
||||
@open-query="showQueryDialog = true"
|
||||
@open-sort="showSortDialog = true"
|
||||
@open-column="showColumnDialog = true"
|
||||
@open-convert="showConvertDialog = true"
|
||||
/>
|
||||
<!-- <div class="border-t border-[var(--el-border-color)]"> -->
|
||||
<ReportSettingsPanel
|
||||
:template-id="templateId"
|
||||
:report-name="reportName"
|
||||
:allow-export="allowExport"
|
||||
:allow-print="allowPrint"
|
||||
:allow-watermark="allowWatermark"
|
||||
:watermark-config="watermarkConfig"
|
||||
@saved="onSettingsSaved"
|
||||
/>
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
<section
|
||||
class="bg-background m-3 flex min-w-0 flex-1 flex-col overflow-hidden rounded-[8px]"
|
||||
>
|
||||
<UniverHost
|
||||
ref="univerRef"
|
||||
mode="design"
|
||||
class="h-full w-full"
|
||||
:snapshot="snapshot"
|
||||
:cells="cells"
|
||||
@change-cell="onCellChange"
|
||||
@focus-float-image="onFocusFloatImage"
|
||||
@focus-float-echart="onFocusFloatEchart"
|
||||
@preview="showPreviewDialog = true"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside
|
||||
class="bg-background my-3 mr-3 flex w-52 shrink-0 flex-col overflow-hidden rounded-[8px]"
|
||||
>
|
||||
<div class="shrink-0 p-2">
|
||||
<ZqTabs v-model="rightTab" :items="rightTabItems" />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-3 pt-0">
|
||||
<FloatImagePanel
|
||||
v-if="propertyMode === 'image'"
|
||||
:selection="floatImageSelection"
|
||||
@apply="onApplyFloatImage"
|
||||
/>
|
||||
<FloatEchartPanel
|
||||
v-else-if="propertyMode === 'chart'"
|
||||
:selection="floatEchartSelection"
|
||||
:datasets="localDatasets"
|
||||
@apply="onApplyFloatEchart"
|
||||
/>
|
||||
<CellChartPanel
|
||||
v-else-if="propertyMode === 'cellChart'"
|
||||
:selection="cellChartSelection"
|
||||
:datasets="localDatasets"
|
||||
@apply="onApplyCellChart"
|
||||
/>
|
||||
<CellPropertyPanel
|
||||
v-else
|
||||
:selection="cellSelection"
|
||||
:datasets="localDatasets"
|
||||
@apply="onApplyCellBinding"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<QueryConfigDialog
|
||||
v-model:visible="showQueryDialog"
|
||||
:query-list="localQueryList"
|
||||
:datasets="localDatasets"
|
||||
@confirm="localQueryList = $event"
|
||||
/>
|
||||
<SortConfigDialog
|
||||
v-model:visible="showSortDialog"
|
||||
:sort-list="localSortList"
|
||||
:datasets="localDatasets"
|
||||
@confirm="localSortList = $event"
|
||||
/>
|
||||
<ColumnConfigDialog
|
||||
v-model:visible="showColumnDialog"
|
||||
:column-list="localColumnList"
|
||||
@confirm="localColumnList = $event"
|
||||
/>
|
||||
<ConvertConfigDialog
|
||||
v-model:visible="showConvertDialog"
|
||||
:convert-config="localConvertConfig"
|
||||
:datasets="localDatasets"
|
||||
@confirm="localConvertConfig = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PreviewDialog
|
||||
v-model:visible="showPreviewDialog"
|
||||
:version-id="versionId"
|
||||
:report-code="reportCode"
|
||||
:report-name="reportName"
|
||||
:query-list="localQueryList"
|
||||
:get-draft-payload="getSavePayload"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
|
||||
import ChartBindForm, {
|
||||
type ChartBindFormState,
|
||||
} from './chart-bind-form.vue';
|
||||
import { parseChartOptionToFormState } from './chart-bind-utils';
|
||||
|
||||
export interface CellChartSelection {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
sheetId?: string;
|
||||
cellData?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
selection: CellChartSelection | null;
|
||||
datasets: ReportDatasetItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [payload: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
preserveCustom: Record<string, any>;
|
||||
}];
|
||||
}>();
|
||||
|
||||
const bindFormRef = ref<InstanceType<typeof ChartBindForm> | null>(null);
|
||||
const formState = ref<ChartBindFormState | null>(null);
|
||||
|
||||
const positionLabel = computed(() => {
|
||||
if (!props.selection) return '';
|
||||
return $t('report-manager.properties.position', {
|
||||
row: props.selection.startRow + 1,
|
||||
col: props.selection.startColumn + 1,
|
||||
});
|
||||
});
|
||||
|
||||
function customToFormState(
|
||||
custom: Record<string, any>,
|
||||
datasets: ReportDatasetItem[],
|
||||
): ChartBindFormState {
|
||||
const { chartType, drawingId, height, width, type, ...restOption } = custom;
|
||||
return parseChartOptionToFormState(
|
||||
custom.chartType || 'bar',
|
||||
restOption,
|
||||
datasets,
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selection,
|
||||
(sel) => {
|
||||
if (!sel) {
|
||||
formState.value = null;
|
||||
return;
|
||||
}
|
||||
formState.value = customToFormState(
|
||||
sel.cellData?.custom || {},
|
||||
props.datasets || [],
|
||||
);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function onFormChange() {
|
||||
if (!props.selection) return;
|
||||
const custom = props.selection.cellData?.custom || {};
|
||||
const { chartType, drawingId, height, width, type, ...restOption } = custom;
|
||||
const option = bindFormRef.value?.buildOption(restOption) || {};
|
||||
emit('apply', {
|
||||
startRow: props.selection.startRow,
|
||||
startColumn: props.selection.startColumn,
|
||||
echartType: formState.value?.echartType || custom.chartType || 'bar',
|
||||
option,
|
||||
preserveCustom: {
|
||||
drawingId,
|
||||
height,
|
||||
width,
|
||||
type: 'chart',
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.chart.cellEmpty') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ positionLabel }}
|
||||
</p>
|
||||
<p class="mb-2 text-xs font-medium">
|
||||
{{ $t('report-manager.chart.cellTitle') }}
|
||||
</p>
|
||||
<ChartBindForm
|
||||
ref="bindFormRef"
|
||||
:datasets="datasets"
|
||||
:model-value="formState"
|
||||
@change="onFormChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,675 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
|
||||
import ReportFieldSelect from './report-field-select.vue';
|
||||
|
||||
export interface CellSelection {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
sheetId?: string;
|
||||
cellData?: Record<string, any>;
|
||||
}
|
||||
|
||||
const DEFAULT_QR_OPTION = {
|
||||
type: 'static',
|
||||
color: { dark: '#000000', light: '#f4f5f6' },
|
||||
errorCorrectionLevel: 'M',
|
||||
};
|
||||
|
||||
const DEFAULT_BARCODE_OPTION = {
|
||||
type: 'static',
|
||||
format: 'code128',
|
||||
displayValue: false,
|
||||
lineColor: '#000000',
|
||||
background: '#f4f5f6',
|
||||
width: 4,
|
||||
margin: 15,
|
||||
};
|
||||
|
||||
const QR_LEVELS = ['L', 'M', 'Q', 'H'] as const;
|
||||
const PARENT_CELL_TYPES = ['none', 'default', 'custom'] as const;
|
||||
|
||||
function colIndexToLetter(col: number): string {
|
||||
let n = col;
|
||||
let s = '';
|
||||
while (n >= 0) {
|
||||
s = String.fromCharCode(65 + (n % 26)) + s;
|
||||
n = Math.floor(n / 26) - 1;
|
||||
}
|
||||
return s || 'A';
|
||||
}
|
||||
const BARCODE_FORMATS = [
|
||||
'code128',
|
||||
'ean13',
|
||||
'ean8',
|
||||
'upc',
|
||||
'code39',
|
||||
] as const;
|
||||
|
||||
const props = defineProps<{
|
||||
selection: CellSelection | null;
|
||||
datasets: ReportDatasetItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [payload: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
type: string;
|
||||
v: string;
|
||||
custom: Record<string, any>;
|
||||
}];
|
||||
}>();
|
||||
|
||||
const { ensureByAlias } = useReportDatasetFields(
|
||||
() => props.datasets,
|
||||
);
|
||||
|
||||
const form = reactive({
|
||||
type: 'text',
|
||||
dataSetName: '',
|
||||
field: '',
|
||||
expand: 'none',
|
||||
fillEmptyRows: false,
|
||||
fillEmptyNum: 1,
|
||||
paramField: '',
|
||||
expressionFormula: '',
|
||||
codeValue: '',
|
||||
qrLevel: 'M' as string,
|
||||
barcodeFormat: 'code128' as string,
|
||||
polymerizationType: '1' as string,
|
||||
summaryType: 'sum' as string,
|
||||
groupType: 'default' as string,
|
||||
mergeCell: true,
|
||||
displayType: 'default' as string,
|
||||
leftParentCellType: 'default' as string,
|
||||
topParentCellType: 'default' as string,
|
||||
leftParentCellCustomRowName: 'A',
|
||||
leftParentCellCustomColName: 1,
|
||||
topParentCellCustomRowName: 'A',
|
||||
topParentCellCustomColName: 1,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => form.dataSetName,
|
||||
(alias) => {
|
||||
if (alias) {
|
||||
ensureByAlias(alias);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.selection,
|
||||
(sel) => {
|
||||
if (!sel) return;
|
||||
const custom = sel.cellData?.custom || {};
|
||||
const cellV = sel.cellData?.v;
|
||||
form.type = custom.type || 'text';
|
||||
form.dataSetName = custom.dataSetName || custom.alias || '';
|
||||
form.field = custom.field || custom.bindField || '';
|
||||
form.expand = custom.expand || custom.expandDirection || 'none';
|
||||
const poly = custom.polymerizationType;
|
||||
form.polymerizationType =
|
||||
poly !== undefined && poly !== null ? String(poly) : '1';
|
||||
form.summaryType = custom.summaryType || 'sum';
|
||||
form.groupType = custom.groupType || 'default';
|
||||
const mergeRaw = custom.mergeCell;
|
||||
form.mergeCell =
|
||||
mergeRaw === undefined || mergeRaw === null
|
||||
? true
|
||||
: mergeRaw !== false && mergeRaw !== '0' && mergeRaw !== 0;
|
||||
const fd = (custom.fillDirection || '').toLowerCase();
|
||||
if (!custom.expand && !custom.expandDirection) {
|
||||
if (fd === 'portrait' || fd === 'vertical') form.expand = 'down';
|
||||
else if (fd === 'landscape' || fd === 'horizontal') form.expand = 'right';
|
||||
else if (form.polymerizationType === '1' || form.polymerizationType === '2') {
|
||||
form.expand = 'down';
|
||||
}
|
||||
}
|
||||
form.fillEmptyRows = !!custom.fillEmptyRows;
|
||||
form.fillEmptyNum = Number(custom.fillEmptyNum) > 0 ? Number(custom.fillEmptyNum) : 1;
|
||||
form.displayType = custom.displayType || 'default';
|
||||
form.qrLevel =
|
||||
custom.qrCodeOption?.errorCorrectionLevel ||
|
||||
DEFAULT_QR_OPTION.errorCorrectionLevel;
|
||||
form.barcodeFormat =
|
||||
custom.jsbarcodeOption?.format || DEFAULT_BARCODE_OPTION.format;
|
||||
form.leftParentCellType = custom.leftParentCellType || 'default';
|
||||
form.topParentCellType = custom.topParentCellType || 'default';
|
||||
form.leftParentCellCustomRowName =
|
||||
custom.leftParentCellCustomRowName || colIndexToLetter(sel.startColumn);
|
||||
form.leftParentCellCustomColName =
|
||||
Number(custom.leftParentCellCustomColName) || sel.startRow + 1;
|
||||
form.topParentCellCustomRowName =
|
||||
custom.topParentCellCustomRowName || colIndexToLetter(sel.startColumn);
|
||||
form.topParentCellCustomColName =
|
||||
Number(custom.topParentCellCustomColName) || sel.startRow;
|
||||
form.paramField =
|
||||
custom.value?.replace(/^#\{|\}$/g, '') || custom.field || '';
|
||||
form.expressionFormula =
|
||||
custom.formula ||
|
||||
custom.field ||
|
||||
custom.value ||
|
||||
sel.cellData?.f ||
|
||||
(typeof cellV === 'string' ? cellV : '') ||
|
||||
'';
|
||||
if (form.type === 'qrCode' || form.type === 'jsbarcode') {
|
||||
form.codeValue =
|
||||
custom.field ||
|
||||
(typeof cellV === 'string' ? cellV.replace(/^#\{|\}$/g, '') : '') ||
|
||||
'';
|
||||
form.qrLevel =
|
||||
custom.qrCodeOption?.errorCorrectionLevel ||
|
||||
DEFAULT_QR_OPTION.errorCorrectionLevel;
|
||||
form.barcodeFormat =
|
||||
custom.jsbarcodeOption?.format || DEFAULT_BARCODE_OPTION.format;
|
||||
}
|
||||
if (form.dataSetName) {
|
||||
ensureByAlias(form.dataSetName);
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.polymerizationType,
|
||||
(poly) => {
|
||||
if (poly === '3') {
|
||||
form.leftParentCellType = 'none';
|
||||
form.topParentCellType = 'none';
|
||||
} else if (poly === '2' || poly === '1') {
|
||||
if (form.expand === 'none') {
|
||||
form.expand = 'down';
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function handleApply() {
|
||||
if (!props.selection) return;
|
||||
const { startRow, startColumn } = props.selection;
|
||||
let type = form.type;
|
||||
let v = '';
|
||||
const custom: Record<string, any> = { type };
|
||||
|
||||
if (type === 'dataSource') {
|
||||
if (!form.dataSetName || !form.field) return;
|
||||
v = form.field;
|
||||
custom.dataSetName = form.dataSetName;
|
||||
custom.field = form.field;
|
||||
custom.displayType = form.displayType || 'default';
|
||||
if (form.displayType === 'qrCode') {
|
||||
custom.qrCodeOption = {
|
||||
...DEFAULT_QR_OPTION,
|
||||
...(props.selection.cellData?.custom?.qrCodeOption || {}),
|
||||
type: 'static',
|
||||
errorCorrectionLevel: form.qrLevel,
|
||||
};
|
||||
delete custom.jsbarcodeOption;
|
||||
} else if (form.displayType === 'jsbarcode') {
|
||||
custom.jsbarcodeOption = {
|
||||
...DEFAULT_BARCODE_OPTION,
|
||||
...(props.selection.cellData?.custom?.jsbarcodeOption || {}),
|
||||
type: 'static',
|
||||
format: form.barcodeFormat,
|
||||
};
|
||||
delete custom.qrCodeOption;
|
||||
} else {
|
||||
delete custom.qrCodeOption;
|
||||
delete custom.jsbarcodeOption;
|
||||
}
|
||||
custom.polymerizationType = form.polymerizationType;
|
||||
if (form.polymerizationType === '3') {
|
||||
custom.summaryType = form.summaryType;
|
||||
custom.expand = 'none';
|
||||
custom.leftParentCellType = form.leftParentCellType;
|
||||
custom.topParentCellType = form.topParentCellType;
|
||||
delete custom.fillDirection;
|
||||
} else {
|
||||
delete custom.summaryType;
|
||||
custom.expand = form.expand;
|
||||
custom.fillDirection =
|
||||
form.expand === 'right' ? 'landscape' : form.expand === 'down' ? 'portrait' : undefined;
|
||||
if (form.polymerizationType === '2') {
|
||||
custom.groupType = form.groupType;
|
||||
custom.mergeCell = form.mergeCell;
|
||||
} else {
|
||||
delete custom.groupType;
|
||||
delete custom.mergeCell;
|
||||
}
|
||||
custom.leftParentCellType = form.leftParentCellType;
|
||||
custom.topParentCellType = form.topParentCellType;
|
||||
}
|
||||
if (form.polymerizationType !== '3' && (form.expand === 'down' || form.expand === 'right')) {
|
||||
custom.fillEmptyRows = form.fillEmptyRows;
|
||||
if (form.fillEmptyRows) {
|
||||
custom.fillEmptyNum = Math.max(1, form.fillEmptyNum || 1);
|
||||
}
|
||||
} else if (form.polymerizationType !== '3') {
|
||||
delete custom.fillEmptyRows;
|
||||
delete custom.fillEmptyNum;
|
||||
}
|
||||
if (form.leftParentCellType === 'custom') {
|
||||
custom.leftParentCellCustomRowName = form.leftParentCellCustomRowName;
|
||||
custom.leftParentCellCustomColName = form.leftParentCellCustomColName;
|
||||
}
|
||||
if (form.topParentCellType === 'custom') {
|
||||
custom.topParentCellCustomRowName = form.topParentCellCustomRowName;
|
||||
custom.topParentCellCustomColName = form.topParentCellCustomColName;
|
||||
}
|
||||
} else if (type === 'parameter') {
|
||||
if (!form.paramField) return;
|
||||
v = `#{${form.paramField}}`;
|
||||
custom.value = v;
|
||||
custom.field = form.paramField;
|
||||
} else if (type === 'qrCode') {
|
||||
if (!form.codeValue.trim()) return;
|
||||
v = form.codeValue;
|
||||
custom.field = form.codeValue;
|
||||
custom.displayType = 'qrCode';
|
||||
custom.qrCodeOption = {
|
||||
...DEFAULT_QR_OPTION,
|
||||
...(props.selection.cellData?.custom?.qrCodeOption || {}),
|
||||
type: 'static',
|
||||
errorCorrectionLevel: form.qrLevel,
|
||||
};
|
||||
} else if (type === 'expression') {
|
||||
if (!form.expressionFormula.trim()) return;
|
||||
const raw = form.expressionFormula.trim();
|
||||
const formula = raw.startsWith('=') ? raw : `=${raw}`;
|
||||
v = formula;
|
||||
custom.field = formula.startsWith('=') ? formula.slice(1) : formula;
|
||||
custom.formula = formula;
|
||||
} else if (type === 'jsbarcode') {
|
||||
if (!form.codeValue.trim()) return;
|
||||
v = form.codeValue;
|
||||
custom.field = form.codeValue;
|
||||
custom.displayType = 'jsbarcode';
|
||||
custom.jsbarcodeOption = {
|
||||
...DEFAULT_BARCODE_OPTION,
|
||||
...(props.selection.cellData?.custom?.jsbarcodeOption || {}),
|
||||
type: 'static',
|
||||
format: form.barcodeFormat,
|
||||
};
|
||||
} else {
|
||||
type = 'text';
|
||||
custom.type = 'text';
|
||||
}
|
||||
|
||||
custom.type = type;
|
||||
emit('apply', { startRow, startColumn, type, v, custom });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.properties.empty') }}
|
||||
</div>
|
||||
<ElForm v-else label-position="top" size="small" class="cell-property-form">
|
||||
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{
|
||||
$t('report-manager.properties.position', {
|
||||
row: selection.startRow + 1,
|
||||
col: selection.startColumn + 1,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<ElFormItem :label="$t('report-manager.properties.cellType')">
|
||||
<ElSelect v-model="form.type" class="w-full">
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeText')"
|
||||
value="text"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeDataSource')"
|
||||
value="dataSource"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeParameter')"
|
||||
value="parameter"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeExpression')"
|
||||
value="expression"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeQrCode')"
|
||||
value="qrCode"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeBarcode')"
|
||||
value="jsbarcode"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<template v-if="form.type === 'dataSource'">
|
||||
<ElFormItem :label="$t('report-manager.properties.dataset')">
|
||||
<ElSelect v-model="form.dataSetName" class="w-full" filterable>
|
||||
<ElOption
|
||||
v-for="ds in datasets"
|
||||
:key="ds.alias"
|
||||
:label="`${ds.alias} (${ds.data_source_name || ds.data_source_id})`"
|
||||
:value="ds.alias"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.properties.field')">
|
||||
<ReportFieldSelect
|
||||
v-model="form.field"
|
||||
:datasets="datasets"
|
||||
:alias="form.dataSetName"
|
||||
:with-alias="true"
|
||||
:placeholder="$t('report-manager.properties.fieldPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.properties.displayType')">
|
||||
<ElSelect v-model="form.displayType" class="w-full">
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.displayDefault')"
|
||||
value="default"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeQrCode')"
|
||||
value="qrCode"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.typeBarcode')"
|
||||
value="jsbarcode"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.displayType === 'qrCode'"
|
||||
:label="$t('report-manager.code.qrLevel')"
|
||||
>
|
||||
<ElSelect v-model="form.qrLevel" class="w-full">
|
||||
<ElOption v-for="lv in QR_LEVELS" :key="lv" :label="lv" :value="lv" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.displayType === 'jsbarcode'"
|
||||
:label="$t('report-manager.code.barcodeFormat')"
|
||||
>
|
||||
<ElSelect v-model="form.barcodeFormat" class="w-full">
|
||||
<ElOption
|
||||
v-for="fmt in BARCODE_FORMATS"
|
||||
:key="fmt"
|
||||
:label="fmt"
|
||||
:value="fmt"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.properties.polymerizationType')">
|
||||
<ElSelect v-model="form.polymerizationType" class="w-full">
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.polyList')"
|
||||
value="1"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.polyGroup')"
|
||||
value="2"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.polySummary')"
|
||||
value="3"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.polymerizationType === '3'"
|
||||
:label="$t('report-manager.properties.summaryType')"
|
||||
>
|
||||
<ElSelect v-model="form.summaryType" class="w-full">
|
||||
<ElOption
|
||||
v-for="s in ['sum', 'avg', 'max', 'min', 'count']"
|
||||
:key="s"
|
||||
:label="$t(`report-manager.chart.summary.${s}`)"
|
||||
:value="s"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.polymerizationType === '2'"
|
||||
:label="$t('report-manager.properties.groupType')"
|
||||
>
|
||||
<ElSelect v-model="form.groupType" class="w-full">
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.groupDefault')"
|
||||
value="default"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.groupAdjacent')"
|
||||
value="adjacent"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.polymerizationType === '2' && form.expand === 'down'"
|
||||
:label="$t('report-manager.properties.mergeCell')"
|
||||
>
|
||||
<ElSwitch v-model="form.mergeCell" />
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.polymerizationType !== '3'"
|
||||
:label="$t('report-manager.properties.expand')"
|
||||
>
|
||||
<ElSelect v-model="form.expand" class="w-full">
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.expandNone')"
|
||||
value="none"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.expandDown')"
|
||||
value="down"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.properties.expandRight')"
|
||||
value="right"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<template
|
||||
v-if="
|
||||
form.polymerizationType !== '3' &&
|
||||
(form.expand === 'down' || form.expand === 'right')
|
||||
"
|
||||
>
|
||||
<ElFormItem :label="$t('report-manager.properties.fillEmptyRows')">
|
||||
<ElSwitch v-model="form.fillEmptyRows" />
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.fillEmptyRows"
|
||||
:label="$t('report-manager.properties.fillEmptyNum')"
|
||||
>
|
||||
<ElInputNumber
|
||||
v-model="form.fillEmptyNum"
|
||||
:min="1"
|
||||
:max="99"
|
||||
class="w-full"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
form.polymerizationType === '3' ||
|
||||
(form.polymerizationType !== '3' &&
|
||||
(form.expand === 'down' || form.expand === 'right'))
|
||||
"
|
||||
>
|
||||
<ElFormItem>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ $t('report-manager.properties.leftParent') }}</span>
|
||||
<ElTooltip
|
||||
placement="top"
|
||||
:content="$t('report-manager.properties.leftParentHint')"
|
||||
>
|
||||
<CircleHelp
|
||||
class="h-3.5 w-3.5 cursor-help text-[var(--el-text-color-secondary)]"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<ElSelect v-model="form.leftParentCellType" class="w-full">
|
||||
<ElOption
|
||||
v-for="t in PARENT_CELL_TYPES"
|
||||
:key="t"
|
||||
:label="$t(`report-manager.properties.parentType.${t}`)"
|
||||
:value="t"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.leftParentCellType === 'custom'"
|
||||
:label="$t('report-manager.properties.leftParentCustom')"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<ElInput
|
||||
v-model="form.leftParentCellCustomRowName"
|
||||
class="w-1/3"
|
||||
placeholder="A"
|
||||
/>
|
||||
<ElInputNumber
|
||||
v-model="form.leftParentCellCustomColName"
|
||||
:min="1"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem>
|
||||
<template #label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ $t('report-manager.properties.topParent') }}</span>
|
||||
<ElTooltip
|
||||
placement="top"
|
||||
:content="$t('report-manager.properties.topParentHint')"
|
||||
>
|
||||
<CircleHelp
|
||||
class="h-3.5 w-3.5 cursor-help text-[var(--el-text-color-secondary)]"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<ElSelect v-model="form.topParentCellType" class="w-full">
|
||||
<ElOption
|
||||
v-for="t in PARENT_CELL_TYPES"
|
||||
:key="t"
|
||||
:label="$t(`report-manager.properties.parentType.${t}`)"
|
||||
:value="t"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="form.topParentCellType === 'custom'"
|
||||
:label="$t('report-manager.properties.topParentCustom')"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<ElInput
|
||||
v-model="form.topParentCellCustomRowName"
|
||||
class="w-1/3"
|
||||
placeholder="A"
|
||||
/>
|
||||
<ElInputNumber
|
||||
v-model="form.topParentCellCustomColName"
|
||||
:min="1"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'text'">
|
||||
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.properties.textParamHint') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'expression'">
|
||||
<ElFormItem :label="$t('report-manager.properties.expressionFormula')">
|
||||
<ElInput
|
||||
v-model="form.expressionFormula"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('report-manager.properties.expressionPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.properties.expressionHint') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'parameter'">
|
||||
<ElFormItem :label="$t('report-manager.properties.paramField')">
|
||||
<ElInput v-model="form.paramField" placeholder="userName" />
|
||||
</ElFormItem>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'qrCode'">
|
||||
<ElFormItem :label="$t('report-manager.code.content')">
|
||||
<ElInput
|
||||
v-model="form.codeValue"
|
||||
:placeholder="$t('report-manager.code.contentPlaceholder')"
|
||||
maxlength="256"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.code.paramHint') }}
|
||||
</p>
|
||||
<ElFormItem :label="$t('report-manager.code.qrLevel')">
|
||||
<ElSelect v-model="form.qrLevel" class="w-full">
|
||||
<ElOption v-for="lv in QR_LEVELS" :key="lv" :label="lv" :value="lv" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'jsbarcode'">
|
||||
<ElFormItem :label="$t('report-manager.code.content')">
|
||||
<ElInput
|
||||
v-model="form.codeValue"
|
||||
:placeholder="$t('report-manager.code.contentPlaceholder')"
|
||||
maxlength="128"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
<p class="mb-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.code.paramHint') }}
|
||||
</p>
|
||||
<ElFormItem :label="$t('report-manager.code.barcodeFormat')">
|
||||
<ElSelect v-model="form.barcodeFormat" class="w-full">
|
||||
<ElOption
|
||||
v-for="fmt in BARCODE_FORMATS"
|
||||
:key="fmt"
|
||||
:label="fmt"
|
||||
:value="fmt"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
|
||||
<ElButton type="primary" size="small" class="w-full" @click="handleApply">
|
||||
{{ $t('report-manager.properties.apply') }}
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</template>
|
||||
@@ -0,0 +1,545 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { Plus, Trash2 } from '@vben/icons';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElColorPicker,
|
||||
ElDivider,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
|
||||
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
|
||||
import ReportFieldSelect from './report-field-select.vue';
|
||||
|
||||
export interface ChartBindFormState {
|
||||
echartType: string;
|
||||
title: string;
|
||||
dataSetAlias: string;
|
||||
classifyNameField: string;
|
||||
seriesNameField: string;
|
||||
seriesDataField: string;
|
||||
maxField: string;
|
||||
summaryType: string;
|
||||
legendShow: boolean;
|
||||
legendOrient: string;
|
||||
legendFontSize: number;
|
||||
styleType: number;
|
||||
lineAreaStyle: boolean;
|
||||
pieRoseType: boolean;
|
||||
pieShowZero: boolean;
|
||||
gridTop: number;
|
||||
gridLeft: number;
|
||||
gridRight: number;
|
||||
gridBottom: number;
|
||||
legendLeft: number;
|
||||
legendTop: number;
|
||||
colorListText: string;
|
||||
chartColors: string[];
|
||||
seriesCenterLeft: number;
|
||||
seriesCenterTop: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
datasets: ReportDatasetItem[];
|
||||
modelValue: ChartBindFormState | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [ChartBindFormState];
|
||||
change: [ChartBindFormState];
|
||||
}>();
|
||||
|
||||
const CHART_TYPES = ['bar', 'line', 'pie', 'radar'] as const;
|
||||
const SUMMARY_TYPES = ['none', 'sum', 'avg', 'max', 'min', 'count'] as const;
|
||||
|
||||
const form = reactive<ChartBindFormState>({
|
||||
echartType: 'bar',
|
||||
title: '',
|
||||
dataSetAlias: '',
|
||||
classifyNameField: '',
|
||||
seriesNameField: '',
|
||||
seriesDataField: '',
|
||||
maxField: '',
|
||||
summaryType: 'sum',
|
||||
legendShow: true,
|
||||
legendOrient: 'horizontal',
|
||||
legendFontSize: 12,
|
||||
styleType: 1,
|
||||
lineAreaStyle: false,
|
||||
pieRoseType: false,
|
||||
pieShowZero: false,
|
||||
gridTop: 60,
|
||||
gridLeft: 30,
|
||||
gridRight: 10,
|
||||
gridBottom: 50,
|
||||
legendLeft: 40,
|
||||
legendTop: 90,
|
||||
colorListText: '',
|
||||
chartColors: [] as string[],
|
||||
seriesCenterLeft: 50,
|
||||
seriesCenterTop: 50,
|
||||
});
|
||||
|
||||
const datasetOptions = computed(() =>
|
||||
(props.datasets || []).map((d) => ({
|
||||
label: `${d.alias}${d.data_source_name ? ` (${d.data_source_name})` : ''}`,
|
||||
value: d.alias,
|
||||
})),
|
||||
);
|
||||
|
||||
const { ensureByAlias } = useReportDatasetFields(
|
||||
() => props.datasets,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => form.dataSetAlias,
|
||||
(alias) => {
|
||||
if (alias) {
|
||||
ensureByAlias(alias);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const styleTypeOptions = computed(() => {
|
||||
const t = form.echartType;
|
||||
if (t === 'line') {
|
||||
return [
|
||||
{ value: 1, labelKey: 'lineDefault' },
|
||||
{ value: 2, labelKey: 'lineSmooth' },
|
||||
{ value: 4, labelKey: 'lineStack' },
|
||||
];
|
||||
}
|
||||
if (t === 'bar') {
|
||||
return [
|
||||
{ value: 1, labelKey: 'barDefault' },
|
||||
{ value: 2, labelKey: 'barStack' },
|
||||
];
|
||||
}
|
||||
if (t === 'pie') {
|
||||
return [
|
||||
{ value: 1, labelKey: 'pieDefault' },
|
||||
{ value: 2, labelKey: 'pieRing' },
|
||||
];
|
||||
}
|
||||
if (t === 'radar') {
|
||||
return [
|
||||
{ value: 1, labelKey: 'radarPolygon' },
|
||||
{ value: 2, labelKey: 'radarCircle' },
|
||||
];
|
||||
}
|
||||
return [{ value: 1, labelKey: 'barDefault' }];
|
||||
});
|
||||
|
||||
const showMaxField = computed(() => form.echartType === 'radar');
|
||||
const showGridLayout = computed(() =>
|
||||
['bar', 'line', 'radar'].includes(form.echartType),
|
||||
);
|
||||
function parseColorList(text: string): string[] {
|
||||
return (text || '')
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(s));
|
||||
}
|
||||
|
||||
function colorsFromState(state: ChartBindFormState): string[] {
|
||||
if (state.chartColors?.length) {
|
||||
return state.chartColors.filter((c) =>
|
||||
/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(c),
|
||||
);
|
||||
}
|
||||
return parseColorList(state.colorListText);
|
||||
}
|
||||
|
||||
function buildColorList(colors: string[]) {
|
||||
if (!colors.length) return undefined;
|
||||
return colors.map((color1) => ({ color1 }));
|
||||
}
|
||||
|
||||
function syncColorListText() {
|
||||
form.colorListText = colorsFromState(form).join(', ');
|
||||
}
|
||||
|
||||
function addChartColor() {
|
||||
form.chartColors = [...(form.chartColors || []), '#5470c6'];
|
||||
syncColorListText();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function removeChartColor(index: number) {
|
||||
form.chartColors = (form.chartColors || []).filter((_, i) => i !== index);
|
||||
syncColorListText();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function onColorChange() {
|
||||
syncColorListText();
|
||||
emitChange();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (!val) return;
|
||||
Object.assign(form, val);
|
||||
if (!form.chartColors?.length && form.colorListText) {
|
||||
form.chartColors = parseColorList(form.colorListText);
|
||||
}
|
||||
if (!form.chartColors) {
|
||||
form.chartColors = [];
|
||||
}
|
||||
if (!form.dataSetAlias && datasetOptions.value.length) {
|
||||
form.dataSetAlias = datasetOptions.value[0].value;
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
datasetOptions,
|
||||
(opts) => {
|
||||
if (!form.dataSetAlias && opts.length) {
|
||||
form.dataSetAlias = opts[0].value;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function fieldWithAlias(field: string) {
|
||||
if (!field) return field;
|
||||
if (field.includes('.')) return field;
|
||||
const alias = form.dataSetAlias || datasetOptions.value[0]?.value || '';
|
||||
return alias ? `${alias}.${field}` : field;
|
||||
}
|
||||
|
||||
function buildOption(baseOption: Record<string, any> = {}) {
|
||||
const styleType = form.styleType || 1;
|
||||
const option: Record<string, any> = {
|
||||
...baseOption,
|
||||
chartType: form.echartType,
|
||||
classifyNameField: fieldWithAlias(form.classifyNameField),
|
||||
seriesNameField: fieldWithAlias(form.seriesNameField),
|
||||
seriesDataField: fieldWithAlias(form.seriesDataField),
|
||||
maxField: fieldWithAlias(form.maxField),
|
||||
summaryType: form.summaryType,
|
||||
styleType,
|
||||
title: {
|
||||
...(baseOption.title || {}),
|
||||
text: form.title,
|
||||
show: true,
|
||||
},
|
||||
legend: {
|
||||
...(baseOption.legend || {}),
|
||||
show: form.legendShow,
|
||||
orient: form.legendOrient,
|
||||
left: `${form.legendLeft}%`,
|
||||
top: `${form.legendTop}%`,
|
||||
textStyle: {
|
||||
...(baseOption.legend?.textStyle || {}),
|
||||
fontSize: form.legendFontSize,
|
||||
},
|
||||
},
|
||||
legendLeft: form.legendLeft,
|
||||
legendTop: form.legendTop,
|
||||
};
|
||||
|
||||
if (showGridLayout.value) {
|
||||
option.grid = {
|
||||
...(baseOption.grid || {}),
|
||||
top: form.gridTop,
|
||||
left: form.gridLeft,
|
||||
right: form.gridRight,
|
||||
bottom: form.gridBottom,
|
||||
};
|
||||
}
|
||||
|
||||
const colorList = buildColorList(colorsFromState(form));
|
||||
if (colorList) {
|
||||
option.color = {
|
||||
...(baseOption.color || {}),
|
||||
list: colorList,
|
||||
};
|
||||
}
|
||||
|
||||
if (form.echartType === 'line') {
|
||||
option.areaStyle = form.lineAreaStyle ? { opacity: 0.3 } : false;
|
||||
option.line = {
|
||||
...(baseOption.line || {}),
|
||||
smooth: styleType === 2,
|
||||
};
|
||||
}
|
||||
|
||||
if (form.echartType === 'pie') {
|
||||
option.pie = {
|
||||
...(baseOption.pie || {}),
|
||||
roseType: form.pieRoseType,
|
||||
showZero: form.pieShowZero,
|
||||
};
|
||||
option.seriesCenter = {
|
||||
...(baseOption.seriesCenter || {}),
|
||||
seriesCenterLeft: form.seriesCenterLeft,
|
||||
seriesCenterTop: form.seriesCenterTop,
|
||||
};
|
||||
}
|
||||
|
||||
return option;
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const state = { ...form };
|
||||
emit('update:modelValue', state);
|
||||
emit('change', state);
|
||||
}
|
||||
|
||||
defineExpose({ buildOption, form });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElForm label-position="top" size="small" @submit.prevent="emitChange">
|
||||
<ElFormItem :label="$t('report-manager.chart.type')">
|
||||
<ElSelect v-model="form.echartType" class="w-full" @change="emitChange">
|
||||
<ElOption
|
||||
v-for="t in CHART_TYPES"
|
||||
:key="t"
|
||||
:label="$t(`report-manager.chart.types.${t}`)"
|
||||
:value="t"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.title')">
|
||||
<ElInput v-model="form.title" clearable @change="emitChange" />
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="datasetOptions.length"
|
||||
:label="$t('report-manager.chart.dataSet')"
|
||||
>
|
||||
<ElSelect
|
||||
v-model="form.dataSetAlias"
|
||||
class="w-full"
|
||||
filterable
|
||||
@change="emitChange"
|
||||
>
|
||||
<ElOption
|
||||
v-for="d in datasetOptions"
|
||||
:key="d.value"
|
||||
:label="d.label"
|
||||
:value="d.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.classifyField')">
|
||||
<ReportFieldSelect
|
||||
v-model="form.classifyNameField"
|
||||
:datasets="datasets"
|
||||
:alias="form.dataSetAlias"
|
||||
:with-alias="false"
|
||||
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.seriesNameField')">
|
||||
<ReportFieldSelect
|
||||
v-model="form.seriesNameField"
|
||||
:datasets="datasets"
|
||||
:alias="form.dataSetAlias"
|
||||
:with-alias="false"
|
||||
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.seriesDataField')">
|
||||
<ReportFieldSelect
|
||||
v-model="form.seriesDataField"
|
||||
:datasets="datasets"
|
||||
:alias="form.dataSetAlias"
|
||||
:with-alias="false"
|
||||
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
v-if="showMaxField"
|
||||
:label="$t('report-manager.chart.maxField')"
|
||||
>
|
||||
<ReportFieldSelect
|
||||
v-model="form.maxField"
|
||||
:datasets="datasets"
|
||||
:alias="form.dataSetAlias"
|
||||
:with-alias="false"
|
||||
:placeholder="$t('report-manager.chart.fieldPlaceholder')"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.summaryType')">
|
||||
<ElSelect v-model="form.summaryType" class="w-full" @change="emitChange">
|
||||
<ElOption
|
||||
v-for="s in SUMMARY_TYPES"
|
||||
:key="s"
|
||||
:label="$t(`report-manager.chart.summary.${s}`)"
|
||||
:value="s"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.legendShow')">
|
||||
<ElSwitch v-model="form.legendShow" @change="emitChange" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.legendOrient')">
|
||||
<ElSelect v-model="form.legendOrient" class="w-full" @change="emitChange">
|
||||
<ElOption
|
||||
:label="$t('report-manager.chart.legendOrientHorizontal')"
|
||||
value="horizontal"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.chart.legendOrientVertical')"
|
||||
value="vertical"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.legendFontSize')">
|
||||
<ElInputNumber
|
||||
v-model="form.legendFontSize"
|
||||
:min="12"
|
||||
:max="25"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.styleType')">
|
||||
<ElSelect v-model="form.styleType" class="w-full" @change="emitChange">
|
||||
<ElOption
|
||||
v-for="opt in styleTypeOptions"
|
||||
:key="opt.value"
|
||||
:label="$t(`report-manager.chart.styleTypes.${opt.labelKey}`)"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<template v-if="form.echartType === 'line'">
|
||||
<ElFormItem :label="$t('report-manager.chart.lineArea')">
|
||||
<ElSwitch v-model="form.lineAreaStyle" @change="emitChange" />
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<template v-if="form.echartType === 'pie'">
|
||||
<ElFormItem :label="$t('report-manager.chart.pieRose')">
|
||||
<ElSwitch v-model="form.pieRoseType" @change="emitChange" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.pieShowZero')">
|
||||
<ElSwitch v-model="form.pieShowZero" @change="emitChange" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.seriesCenterLeft')">
|
||||
<ElInputNumber
|
||||
v-model="form.seriesCenterLeft"
|
||||
:min="0"
|
||||
:max="100"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.seriesCenterTop')">
|
||||
<ElInputNumber
|
||||
v-model="form.seriesCenterTop"
|
||||
:min="0"
|
||||
:max="100"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
|
||||
<ElDivider content-position="left">
|
||||
{{ $t('report-manager.chart.layoutSection') }}
|
||||
</ElDivider>
|
||||
<ElFormItem :label="$t('report-manager.chart.legendLeft')">
|
||||
<ElInputNumber
|
||||
v-model="form.legendLeft"
|
||||
:min="0"
|
||||
:max="100"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.legendTop')">
|
||||
<ElInputNumber
|
||||
v-model="form.legendTop"
|
||||
:min="0"
|
||||
:max="100"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<template v-if="showGridLayout">
|
||||
<ElFormItem :label="$t('report-manager.chart.gridTop')">
|
||||
<ElInputNumber
|
||||
v-model="form.gridTop"
|
||||
:min="0"
|
||||
:max="200"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.gridLeft')">
|
||||
<ElInputNumber
|
||||
v-model="form.gridLeft"
|
||||
:min="0"
|
||||
:max="200"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.gridRight')">
|
||||
<ElInputNumber
|
||||
v-model="form.gridRight"
|
||||
:min="0"
|
||||
:max="200"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.chart.gridBottom')">
|
||||
<ElInputNumber
|
||||
v-model="form.gridBottom"
|
||||
:min="0"
|
||||
:max="200"
|
||||
class="w-full"
|
||||
@change="emitChange"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
<ElFormItem :label="$t('report-manager.chart.colorList')">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div
|
||||
v-for="(color, idx) in form.chartColors"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<ElColorPicker
|
||||
v-model="form.chartColors[idx]"
|
||||
color-format="hex"
|
||||
@change="onColorChange"
|
||||
/>
|
||||
<ElButton
|
||||
type="danger"
|
||||
link
|
||||
:icon="Trash2"
|
||||
@click="removeChartColor(idx)"
|
||||
/>
|
||||
</div>
|
||||
<ElButton type="primary" link :icon="Plus" @click="addChartColor">
|
||||
{{ $t('report-manager.chart.addColor') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ChartBindFormState } from './chart-bind-form.vue';
|
||||
|
||||
function stripAlias(field: string) {
|
||||
if (!field || !field.includes('.')) return field;
|
||||
const parts = field.split('.');
|
||||
return parts.length > 1 ? parts.slice(1).join('.') : field;
|
||||
}
|
||||
|
||||
function colorListToText(list: unknown): string {
|
||||
if (!Array.isArray(list)) return '';
|
||||
return list
|
||||
.map((item: any) => item?.color1 || item?.color2 || '')
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** 从图表 option 解析为属性面板表单(悬浮/单元格图表共用) */
|
||||
export function parseChartOptionToFormState(
|
||||
echartType: string,
|
||||
option: Record<string, any>,
|
||||
datasets: { alias: string }[],
|
||||
): ChartBindFormState {
|
||||
const opt = option || {};
|
||||
const guessAlias = () => {
|
||||
for (const key of [
|
||||
'classifyNameField',
|
||||
'seriesNameField',
|
||||
'seriesDataField',
|
||||
'maxField',
|
||||
] as const) {
|
||||
const raw = opt[key];
|
||||
if (typeof raw === 'string' && raw.includes('.')) {
|
||||
return raw.split('.')[0];
|
||||
}
|
||||
}
|
||||
return datasets[0]?.alias || '';
|
||||
};
|
||||
const grid = opt.grid || {};
|
||||
return {
|
||||
echartType: echartType || opt.chartType || 'bar',
|
||||
title: opt.title?.text || '',
|
||||
dataSetAlias: guessAlias(),
|
||||
classifyNameField: stripAlias(opt.classifyNameField || ''),
|
||||
seriesNameField: stripAlias(opt.seriesNameField || ''),
|
||||
seriesDataField: stripAlias(opt.seriesDataField || ''),
|
||||
maxField: stripAlias(opt.maxField || ''),
|
||||
summaryType: opt.summaryType || 'sum',
|
||||
legendShow: opt.legend?.show !== false,
|
||||
legendOrient: opt.legend?.orient || 'horizontal',
|
||||
legendFontSize: Number(opt.legend?.textStyle?.fontSize ?? 12),
|
||||
styleType: Number(opt.styleType) || 1,
|
||||
lineAreaStyle: !!opt.areaStyle,
|
||||
pieRoseType: !!opt.pie?.roseType,
|
||||
pieShowZero: !!opt.pie?.showZero,
|
||||
gridTop: Number(grid.top ?? 60),
|
||||
gridLeft: Number(grid.left ?? 30),
|
||||
gridRight: Number(grid.right ?? 10),
|
||||
gridBottom: Number(grid.bottom ?? 50),
|
||||
legendLeft: Number(opt.legendLeft ?? 40),
|
||||
legendTop: Number(opt.legendTop ?? 90),
|
||||
colorListText: colorListToText(opt.color?.list),
|
||||
chartColors: (opt.color?.list || [])
|
||||
.map((item: any) => item?.color1 || item?.color2 || '')
|
||||
.filter((c: string) => /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(c)),
|
||||
seriesCenterLeft: Number(
|
||||
opt.seriesCenter?.seriesCenterLeft ?? 50,
|
||||
),
|
||||
seriesCenterTop: Number(opt.seriesCenter?.seriesCenterTop ?? 50),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
export interface ColumnListConfig {
|
||||
columnState: boolean;
|
||||
columnStyle: 'col' | 'row';
|
||||
columnType: '1' | '2';
|
||||
maxCol: number;
|
||||
rowCount: number;
|
||||
maxRow: number;
|
||||
colCount: number;
|
||||
columnData: string;
|
||||
copyCol: string;
|
||||
copyRow: string;
|
||||
fillEmptyRows: boolean;
|
||||
}
|
||||
|
||||
function defaultColumnConfig(): ColumnListConfig {
|
||||
return {
|
||||
columnState: false,
|
||||
columnStyle: 'col',
|
||||
columnType: '1',
|
||||
maxCol: 0,
|
||||
rowCount: 0,
|
||||
maxRow: 0,
|
||||
colCount: 0,
|
||||
columnData: '',
|
||||
copyCol: '',
|
||||
copyRow: '',
|
||||
fillEmptyRows: false,
|
||||
};
|
||||
}
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
columnList: any[];
|
||||
defaultSheetId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [any[]];
|
||||
}>();
|
||||
|
||||
const sheetId = ref('sheet1');
|
||||
const form = reactive<ColumnListConfig>(defaultColumnConfig());
|
||||
|
||||
watch(
|
||||
() => props.columnList,
|
||||
(list) => {
|
||||
const first = list?.[0];
|
||||
sheetId.value = first?.sheet || props.defaultSheetId || 'sheet1';
|
||||
const cfg = first?.columnList;
|
||||
Object.assign(form, defaultColumnConfig(), cfg || {});
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm', [
|
||||
{
|
||||
sheet: sheetId.value,
|
||||
sheetName: sheetId.value,
|
||||
columnList: { ...form },
|
||||
},
|
||||
]);
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
v-model="visible"
|
||||
:title="$t('report-manager.column.title')"
|
||||
width="720px"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<p class="mb-3 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.column.previewNote') }}
|
||||
</p>
|
||||
<ElForm label-position="top" size="small">
|
||||
<ElFormItem :label="$t('report-manager.column.enable')">
|
||||
<ElSwitch v-model="form.columnState" />
|
||||
</ElFormItem>
|
||||
|
||||
<template v-if="form.columnState">
|
||||
<ElFormItem :label="$t('report-manager.column.style')">
|
||||
<ElRadioGroup v-model="form.columnStyle">
|
||||
<ElRadio value="col">
|
||||
{{ $t('report-manager.column.styleCol') }}
|
||||
</ElRadio>
|
||||
<ElRadio value="row">
|
||||
{{ $t('report-manager.column.styleRow') }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<template v-if="form.columnStyle === 'col'">
|
||||
<ElFormItem :label="$t('report-manager.column.type')">
|
||||
<ElRadioGroup v-model="form.columnType">
|
||||
<ElRadio value="1">
|
||||
{{ $t('report-manager.column.overRows') }}
|
||||
<ElInputNumber
|
||||
v-model="form.maxCol"
|
||||
:min="0"
|
||||
size="small"
|
||||
class="mx-2 w-24"
|
||||
/>
|
||||
{{ $t('report-manager.column.splitCols') }}
|
||||
</ElRadio>
|
||||
<ElRadio value="2" class="mt-2 block">
|
||||
{{ $t('report-manager.column.splitInto') }}
|
||||
<ElInputNumber
|
||||
v-model="form.rowCount"
|
||||
:min="0"
|
||||
size="small"
|
||||
class="mx-2 w-24"
|
||||
/>
|
||||
{{ $t('report-manager.column.colsUnit') }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.column.copyColNo')">
|
||||
<ElInput
|
||||
v-model="form.copyCol"
|
||||
:placeholder="$t('report-manager.column.rangeHint')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<ElFormItem :label="$t('report-manager.column.type')">
|
||||
<ElRadioGroup v-model="form.columnType">
|
||||
<ElRadio value="1">
|
||||
{{ $t('report-manager.column.overCols') }}
|
||||
<ElInputNumber
|
||||
v-model="form.maxRow"
|
||||
:min="0"
|
||||
size="small"
|
||||
class="mx-2 w-24"
|
||||
/>
|
||||
{{ $t('report-manager.column.splitRows') }}
|
||||
</ElRadio>
|
||||
<ElRadio value="2" class="mt-2 block">
|
||||
{{ $t('report-manager.column.splitInto') }}
|
||||
<ElInputNumber
|
||||
v-model="form.colCount"
|
||||
:min="0"
|
||||
size="small"
|
||||
class="mx-2 w-24"
|
||||
/>
|
||||
{{ $t('report-manager.column.rowsUnit') }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.column.copyRowNo')">
|
||||
<ElInput
|
||||
v-model="form.copyRow"
|
||||
:placeholder="$t('report-manager.column.rangeHint')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
</template>
|
||||
|
||||
<ElFormItem :label="$t('report-manager.column.dataRange')">
|
||||
<ElInput
|
||||
v-model="form.columnData"
|
||||
:placeholder="$t('report-manager.column.dataRangePlaceholder')"
|
||||
clearable
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.column.fillEmpty')">
|
||||
<ElSwitch v-model="form.fillEmptyRows" />
|
||||
</ElFormItem>
|
||||
</template>
|
||||
</ElForm>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Plus, Settings2, Trash2 } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
|
||||
import ReportFieldSelect from './report-field-select.vue';
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
convertConfig: any[];
|
||||
datasets: ReportDatasetItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [any[]];
|
||||
}>();
|
||||
|
||||
const rows = ref<any[]>([]);
|
||||
const extraVisible = ref(false);
|
||||
const extraIndex = ref(-1);
|
||||
const extraForm = ref<any>({ type: '', config: {} });
|
||||
|
||||
const { ensureAll } = useReportDatasetFields(() => props.datasets);
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open) {
|
||||
ensureAll();
|
||||
}
|
||||
});
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ label: $t('report-manager.convert.types.select'), value: 'select' },
|
||||
{ label: $t('report-manager.convert.types.date'), value: 'date' },
|
||||
{ label: $t('report-manager.convert.types.number'), value: 'number' },
|
||||
{ label: $t('report-manager.convert.types.user'), value: 'user' },
|
||||
{ label: $t('report-manager.convert.types.department'), value: 'department' },
|
||||
{ label: $t('report-manager.convert.types.organize'), value: 'organize' },
|
||||
{ label: $t('report-manager.convert.types.role'), value: 'role' },
|
||||
{ label: $t('report-manager.convert.types.dictionary'), value: 'dictionary' },
|
||||
]);
|
||||
|
||||
const dateFormatOptions = [
|
||||
'yyyy',
|
||||
'yyyy-MM',
|
||||
'yyyy-MM-dd',
|
||||
'yyyy-MM-dd HH:mm',
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
];
|
||||
|
||||
const fieldHints = computed(() =>
|
||||
(props.datasets || []).map((d) => d.alias).filter(Boolean),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.convertConfig,
|
||||
(list) => {
|
||||
rows.value = (list || []).map((item) => ({
|
||||
field: item.field || '',
|
||||
type: item.type || '',
|
||||
config: { ...(item.config || {}) },
|
||||
}));
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
function addRow() {
|
||||
rows.value.push({
|
||||
field: '',
|
||||
type: 'select',
|
||||
config: {
|
||||
options: [],
|
||||
format: 'yyyy-MM-dd',
|
||||
precision: 0,
|
||||
thousands: false,
|
||||
names: {},
|
||||
dictionaryType: '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
rows.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function openExtra(row: any, index: number) {
|
||||
extraIndex.value = index;
|
||||
extraForm.value = {
|
||||
type: row.type,
|
||||
config: {
|
||||
options: [],
|
||||
format: 'yyyy-MM-dd',
|
||||
precision: 0,
|
||||
thousands: false,
|
||||
names: {},
|
||||
dictionaryType: '',
|
||||
...(row.config || {}),
|
||||
},
|
||||
};
|
||||
if (!Array.isArray(extraForm.value.config.options)) {
|
||||
extraForm.value.config.options = [];
|
||||
}
|
||||
extraVisible.value = true;
|
||||
}
|
||||
|
||||
function addSelectOption() {
|
||||
extraForm.value.config.options.push({
|
||||
id: String(extraForm.value.config.options.length + 1),
|
||||
fullName: `${$t('report-manager.convert.option')} ${extraForm.value.config.options.length + 1}`,
|
||||
});
|
||||
}
|
||||
|
||||
function removeSelectOption(index: number) {
|
||||
extraForm.value.config.options.splice(index, 1);
|
||||
}
|
||||
|
||||
function saveExtra() {
|
||||
if (extraIndex.value >= 0) {
|
||||
rows.value[extraIndex.value] = {
|
||||
...rows.value[extraIndex.value],
|
||||
config: { ...extraForm.value.config },
|
||||
};
|
||||
}
|
||||
extraVisible.value = false;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit(
|
||||
'confirm',
|
||||
rows.value
|
||||
.filter((r) => r.field && r.type)
|
||||
.map((r) => ({
|
||||
field: r.field,
|
||||
type: r.type,
|
||||
config: r.config || {},
|
||||
})),
|
||||
);
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function canConfigure(type: string) {
|
||||
return [
|
||||
'select',
|
||||
'date',
|
||||
'number',
|
||||
'user',
|
||||
'department',
|
||||
'organize',
|
||||
'role',
|
||||
'dictionary',
|
||||
].includes(type);
|
||||
}
|
||||
|
||||
const lookupTypes = ['user', 'department', 'organize', 'role'];
|
||||
|
||||
const namesText = computed({
|
||||
get: () => {
|
||||
const names = extraForm.value.config?.names;
|
||||
if (!names || typeof names !== 'object') return '';
|
||||
return Object.entries(names)
|
||||
.map(([k, v]) => `${k}:${v}`)
|
||||
.join(',');
|
||||
},
|
||||
set: (text: string) => {
|
||||
const names: Record<string, string> = {};
|
||||
for (const part of (text || '').split(',')) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
const [k, v] = trimmed.split(':');
|
||||
const key = (k || v || '').trim();
|
||||
if (key) names[key] = (v ?? k).trim();
|
||||
}
|
||||
extraForm.value.config.names = names;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
v-model="visible"
|
||||
:title="$t('report-manager.convert.title')"
|
||||
width="760px"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{{ $t('report-manager.convert.hint') }}
|
||||
<template v-if="fieldHints.length">
|
||||
({{ fieldHints.join(', ') }})
|
||||
</template>
|
||||
</span>
|
||||
<ElButton type="primary" :icon="Plus" @click="addRow">
|
||||
{{ $t('report-manager.convert.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTable :data="rows" border max-height="360">
|
||||
<ElTableColumn :label="$t('report-manager.convert.field')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<ReportFieldSelect
|
||||
v-model="row.field"
|
||||
size="small"
|
||||
:datasets="datasets"
|
||||
:with-alias="true"
|
||||
:placeholder="$t('report-manager.convert.fieldPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.convert.type')" width="160">
|
||||
<template #default="{ row }">
|
||||
<ElSelect v-model="row.type" size="small" class="w-full" filterable>
|
||||
<ElOption
|
||||
v-for="opt in typeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.convert.configCol')" width="100" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<ElButton
|
||||
v-if="canConfigure(row.type)"
|
||||
link
|
||||
type="primary"
|
||||
:icon="Settings2"
|
||||
@click="openExtra(row, $index)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn width="60" align="center">
|
||||
<template #default="{ $index }">
|
||||
<ElButton link type="danger" :icon="Trash2" @click="removeRow($index)" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ZqDialog>
|
||||
|
||||
<ZqDialog
|
||||
v-model="extraVisible"
|
||||
:title="$t('report-manager.convert.extraTitle')"
|
||||
width="520px"
|
||||
@confirm="saveExtra"
|
||||
>
|
||||
<template v-if="extraForm.type === 'select'">
|
||||
<div class="mb-2 flex justify-end">
|
||||
<ElButton type="primary" size="small" :icon="Plus" @click="addSelectOption">
|
||||
{{ $t('report-manager.convert.addOption') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTable :data="extraForm.config.options" border size="small" max-height="280">
|
||||
<ElTableColumn :label="$t('report-manager.convert.optionId')" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model="row.id" size="small" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.convert.optionLabel')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model="row.fullName" size="small" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn width="50" align="center">
|
||||
<template #default="{ $index }">
|
||||
<ElButton link type="danger" :icon="Trash2" @click="removeSelectOption($index)" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</template>
|
||||
|
||||
<ElForm v-else-if="extraForm.type === 'date'" label-width="120px">
|
||||
<ElFormItem :label="$t('report-manager.convert.dateFormat')">
|
||||
<ElSelect v-model="extraForm.config.format" class="w-full">
|
||||
<ElOption v-for="fmt in dateFormatOptions" :key="fmt" :label="fmt" :value="fmt" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<ElForm v-else-if="extraForm.type === 'number'" label-width="120px">
|
||||
<ElFormItem :label="$t('report-manager.convert.precision')">
|
||||
<ElInputNumber v-model="extraForm.config.precision" :min="0" :max="8" class="w-full" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('report-manager.convert.thousands')">
|
||||
<ElSwitch v-model="extraForm.config.thousands" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<ElForm v-else-if="lookupTypes.includes(extraForm.type)" label-width="120px">
|
||||
<ElFormItem :label="$t('report-manager.convert.namesMap')">
|
||||
<ElInput
|
||||
v-model="namesText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('report-manager.convert.namesPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<ElForm v-else-if="extraForm.type === 'dictionary'" label-width="120px">
|
||||
<ElFormItem :label="$t('report-manager.convert.dictionaryType')">
|
||||
<ElInput
|
||||
v-model="extraForm.config.dictionaryType"
|
||||
:placeholder="$t('report-manager.convert.dictionaryTypePlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Plus, Trash2 } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElTable, ElTableColumn } from 'element-plus';
|
||||
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
|
||||
import ReportFieldSelect from './report-field-select.vue';
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
alias?: string;
|
||||
dataSourceId?: string;
|
||||
datasets?: ReportDatasetItem[];
|
||||
fieldMapping?: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [Record<string, string>];
|
||||
}>();
|
||||
|
||||
const rows = ref<Array<{ key: string; value: string }>>([]);
|
||||
|
||||
const { ensureByDataSourceId } = useReportDatasetFields(
|
||||
() => props.datasets || [],
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.fieldMapping, visible.value, props.dataSourceId] as const,
|
||||
([mapping, open, dataSourceId]) => {
|
||||
if (!open) return;
|
||||
if (dataSourceId) {
|
||||
ensureByDataSourceId(dataSourceId);
|
||||
}
|
||||
const entries = Object.entries(mapping || {});
|
||||
rows.value = entries.length
|
||||
? entries.map(([key, value]) => ({ key, value: String(value ?? '') }))
|
||||
: [{ key: '', value: '' }];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function addRow() {
|
||||
rows.value.push({ key: '', value: '' });
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
rows.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const row of rows.value) {
|
||||
const key = row.key.trim();
|
||||
if (!key) continue;
|
||||
mapping[key] = row.value;
|
||||
}
|
||||
emit('confirm', mapping);
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
v-model="visible"
|
||||
:title="$t('report-manager.fieldMapping.title')"
|
||||
width="640px"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<p class="text-muted-foreground mb-3 text-xs">
|
||||
{{ $t('report-manager.fieldMapping.hint') }}
|
||||
<template v-if="alias">({{ alias }})</template>
|
||||
</p>
|
||||
<div class="mb-3 flex justify-end">
|
||||
<ElButton type="primary" :icon="Plus" @click="addRow">
|
||||
{{ $t('report-manager.fieldMapping.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTable :data="rows" border max-height="360">
|
||||
<ElTableColumn :label="$t('report-manager.fieldMapping.sourceField')" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<ReportFieldSelect
|
||||
v-model="row.key"
|
||||
size="small"
|
||||
:datasets="datasets || []"
|
||||
:alias="alias"
|
||||
:with-alias="false"
|
||||
:placeholder="$t('report-manager.fieldMapping.sourcePlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.fieldMapping.targetField')" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<ReportFieldSelect
|
||||
v-model="row.value"
|
||||
size="small"
|
||||
:datasets="datasets || []"
|
||||
:alias="alias"
|
||||
:with-alias="false"
|
||||
:placeholder="$t('report-manager.fieldMapping.targetPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn width="60" align="center">
|
||||
<template #default="{ $index }">
|
||||
<ElButton link type="danger" :icon="Trash2" @click="removeRow($index)" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
import type { DataSourceSimple } from '#/api/core/data-source';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Plus, Settings2, Trash2 } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElEmpty,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTree,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getDataSourceDetailApi,
|
||||
previewDataSourceApi,
|
||||
} from '#/api/core/data-source';
|
||||
|
||||
import DatasetFieldMappingDialog from './dataset-field-mapping-dialog.vue';
|
||||
|
||||
interface DatasetTreeNode {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeType: 'dataset' | 'empty' | 'field' | 'loading';
|
||||
data?: ReportDatasetItem;
|
||||
fieldName?: string;
|
||||
isLeaf?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
datasets: ReportDatasetItem[];
|
||||
dataSources: DataSourceSimple[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [ReportDatasetItem[]];
|
||||
}>();
|
||||
|
||||
const selectedSourceId = ref('');
|
||||
const mappingVisible = ref(false);
|
||||
const mappingTarget = ref<ReportDatasetItem | null>(null);
|
||||
const fieldsCache = ref<Record<string, string[]>>({});
|
||||
const fieldsLoading = ref<Record<string, boolean>>({});
|
||||
const treeKey = ref(0);
|
||||
|
||||
const treeProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
isLeaf: 'isLeaf',
|
||||
};
|
||||
|
||||
function normalizeDataset(raw: ReportDatasetItem): ReportDatasetItem {
|
||||
const dataSourceId =
|
||||
raw.data_source_id || (raw as any).dataSourceId || '';
|
||||
return {
|
||||
...raw,
|
||||
data_source_id: dataSourceId,
|
||||
data_source_code:
|
||||
raw.data_source_code || (raw as any).dataSourceCode || '',
|
||||
data_source_name:
|
||||
raw.data_source_name || (raw as any).dataSourceName || '',
|
||||
alias: raw.alias || raw.data_source_code || (raw as any).dataSourceCode || '',
|
||||
field_mapping: raw.field_mapping || (raw as any).fieldMapping || {},
|
||||
convert_config: raw.convert_config || (raw as any).convertConfig || {},
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedDatasets = ref<ReportDatasetItem[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.datasets,
|
||||
(list) => {
|
||||
normalizedDatasets.value = (list || [])
|
||||
.map(normalizeDataset)
|
||||
.filter((item) => item.data_source_id);
|
||||
const idSet = new Set(normalizedDatasets.value.map((d) => d.data_source_id));
|
||||
for (const key of Object.keys(fieldsCache.value)) {
|
||||
if (!idSet.has(key)) {
|
||||
delete fieldsCache.value[key];
|
||||
delete fieldsLoading.value[key];
|
||||
}
|
||||
}
|
||||
treeKey.value += 1;
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
function normalizePreviewRows(data: any): any[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === 'object') return [data];
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractFieldsFromRows(rows: any[]): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const row of rows || []) {
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key) names.add(key);
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
function mergeDatasetFields(ds: ReportDatasetItem, rows: any[]) {
|
||||
const names = new Set(extractFieldsFromRows(rows));
|
||||
const mapping = ds.field_mapping || {};
|
||||
for (const key of Object.keys(mapping)) {
|
||||
if (key) names.add(key);
|
||||
}
|
||||
for (const value of Object.values(mapping)) {
|
||||
if (value) names.add(String(value));
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
async function loadStaticFields(dataSourceId: string) {
|
||||
try {
|
||||
const detail = await getDataSourceDetailApi(dataSourceId);
|
||||
return extractFieldsFromRows(normalizePreviewRows(detail?.static_data));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFieldsForDataset(ds: ReportDatasetItem) {
|
||||
const id = ds.data_source_id;
|
||||
if (fieldsCache.value[id] !== undefined || fieldsLoading.value[id]) {
|
||||
return fieldsCache.value[id] || [];
|
||||
}
|
||||
fieldsLoading.value[id] = true;
|
||||
try {
|
||||
const result = await previewDataSourceApi(id, { params: {}, limit: 5 });
|
||||
let fields = mergeDatasetFields(ds, normalizePreviewRows(result?.data));
|
||||
if (!fields.length) {
|
||||
fields = mergeDatasetFields(
|
||||
ds,
|
||||
normalizePreviewRows(await loadStaticFields(id)),
|
||||
);
|
||||
}
|
||||
fieldsCache.value[id] = fields;
|
||||
return fields;
|
||||
} catch (error: any) {
|
||||
const fallback = mergeDatasetFields(
|
||||
ds,
|
||||
normalizePreviewRows(await loadStaticFields(id)),
|
||||
);
|
||||
if (fallback.length) {
|
||||
fieldsCache.value[id] = fallback;
|
||||
return fallback;
|
||||
}
|
||||
ElMessage.error(
|
||||
error?.message || $t('report-manager.dataset.loadFieldsFailed'),
|
||||
);
|
||||
fieldsCache.value[id] = [];
|
||||
return [];
|
||||
} finally {
|
||||
fieldsLoading.value[id] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildFieldNodes(ds: ReportDatasetItem, fields: string[]): DatasetTreeNode[] {
|
||||
if (!fields.length) {
|
||||
return [
|
||||
{
|
||||
id: `${ds.data_source_id}::__empty`,
|
||||
label: $t('report-manager.dataset.noFields'),
|
||||
nodeType: 'empty',
|
||||
isLeaf: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
return fields.map((field) => ({
|
||||
id: `${ds.data_source_id}::${field}`,
|
||||
label: `${ds.alias}.${field}`,
|
||||
nodeType: 'field',
|
||||
fieldName: field,
|
||||
isLeaf: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildDatasetNodes(): DatasetTreeNode[] {
|
||||
return normalizedDatasets.value.map((ds) => ({
|
||||
id: ds.data_source_id,
|
||||
label: `${ds.alias} (${ds.data_source_code || ds.data_source_id})`,
|
||||
nodeType: 'dataset',
|
||||
data: ds,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTreeNode(node: any, resolve: (data: DatasetTreeNode[]) => void) {
|
||||
if (node.level === 0) {
|
||||
resolve(buildDatasetNodes());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = node.data as DatasetTreeNode;
|
||||
if (data.nodeType !== 'dataset' || !data.data) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ds = data.data;
|
||||
const cached = fieldsCache.value[ds.data_source_id];
|
||||
if (cached !== undefined) {
|
||||
resolve(buildFieldNodes(ds, cached));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldsLoading.value[ds.data_source_id]) {
|
||||
resolve([
|
||||
{
|
||||
id: `${ds.data_source_id}::__loading`,
|
||||
label: $t('report-manager.dataset.loadingFields'),
|
||||
nodeType: 'loading',
|
||||
isLeaf: true,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = await loadFieldsForDataset(ds);
|
||||
resolve(buildFieldNodes(ds, fields));
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
if (!selectedSourceId.value) return;
|
||||
const source = props.dataSources.find((s) => s.id === selectedSourceId.value);
|
||||
if (!source) return;
|
||||
const exists = normalizedDatasets.value.some(
|
||||
(d) => d.data_source_id === source.id,
|
||||
);
|
||||
if (exists) return;
|
||||
const next: ReportDatasetItem[] = [
|
||||
...props.datasets,
|
||||
{
|
||||
data_source_id: source.id,
|
||||
data_source_code: source.code,
|
||||
data_source_name: source.name,
|
||||
alias: source.code,
|
||||
field_mapping: {},
|
||||
convert_config: {},
|
||||
sort: props.datasets.length,
|
||||
},
|
||||
];
|
||||
emit('update', next);
|
||||
selectedSourceId.value = '';
|
||||
}
|
||||
|
||||
function handleRemove(dataSourceId: string) {
|
||||
emit(
|
||||
'update',
|
||||
props.datasets.filter(
|
||||
(d) => (d.data_source_id || (d as any).dataSourceId) !== dataSourceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function openFieldMapping(ds: ReportDatasetItem) {
|
||||
mappingTarget.value = ds;
|
||||
mappingVisible.value = true;
|
||||
}
|
||||
|
||||
function onMappingConfirm(mapping: Record<string, string>) {
|
||||
if (!mappingTarget.value) return;
|
||||
const targetId = mappingTarget.value.data_source_id;
|
||||
const next = props.datasets.map((d) =>
|
||||
(d.data_source_id || (d as any).dataSourceId) === targetId
|
||||
? { ...d, field_mapping: mapping }
|
||||
: d,
|
||||
);
|
||||
emit('update', next);
|
||||
mappingVisible.value = false;
|
||||
delete fieldsCache.value[targetId];
|
||||
treeKey.value += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col gap-3 p-3">
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<ElSelect
|
||||
v-model="selectedSourceId"
|
||||
class="min-w-0 flex-1"
|
||||
size="small"
|
||||
filterable
|
||||
:placeholder="$t('report-manager.dataset.selectSource')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="s in dataSources"
|
||||
:key="s.id"
|
||||
:label="`${s.name} (${s.code})`"
|
||||
:value="s.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton type="primary" size="small" :icon="Plus" @click="handleAdd" />
|
||||
</div>
|
||||
<div
|
||||
v-if="normalizedDatasets.length"
|
||||
class="min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
<ElTree
|
||||
:key="treeKey"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
lazy
|
||||
:load="loadTreeNode"
|
||||
:empty-text="$t('report-manager.dataset.empty')"
|
||||
class="w-full p-1"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div
|
||||
class="flex w-full min-w-0 items-center justify-between gap-1 pr-2"
|
||||
:class="data.nodeType === 'field' ? 'pl-1' : ''"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
:class="
|
||||
data.nodeType === 'field'
|
||||
? 'text-muted-foreground text-xs'
|
||||
: data.nodeType === 'dataset'
|
||||
? 'text-sm'
|
||||
: 'text-muted-foreground text-xs italic'
|
||||
"
|
||||
>
|
||||
{{ data.label }}
|
||||
</span>
|
||||
<div
|
||||
v-if="data.nodeType === 'dataset'"
|
||||
class="flex shrink-0 items-center gap-1"
|
||||
>
|
||||
<ElButton
|
||||
link
|
||||
:icon="Settings2"
|
||||
:title="$t('report-manager.dataset.fieldMapping')"
|
||||
@click.stop="openFieldMapping(data.data!)"
|
||||
/>
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
:icon="Trash2"
|
||||
@click.stop="handleRemove(data.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-else
|
||||
class="min-h-0 flex-1"
|
||||
:description="$t('report-manager.dataset.empty')"
|
||||
/>
|
||||
|
||||
<DatasetFieldMappingDialog
|
||||
v-model:visible="mappingVisible"
|
||||
:alias="mappingTarget?.alias"
|
||||
:data-source-id="mappingTarget?.data_source_id"
|
||||
:datasets="datasets"
|
||||
:field-mapping="mappingTarget?.field_mapping"
|
||||
@confirm="onMappingConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
|
||||
import ChartBindForm, {
|
||||
type ChartBindFormState,
|
||||
} from './chart-bind-form.vue';
|
||||
import { parseChartOptionToFormState } from './chart-bind-utils';
|
||||
|
||||
export interface FloatEchartSelection {
|
||||
drawingId: string;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
selection: FloatEchartSelection | null;
|
||||
datasets: ReportDatasetItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [config: FloatEchartSelection];
|
||||
}>();
|
||||
|
||||
const bindFormRef = ref<InstanceType<typeof ChartBindForm> | null>(null);
|
||||
const formState = ref<ChartBindFormState | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.selection,
|
||||
(sel) => {
|
||||
if (!sel) {
|
||||
formState.value = null;
|
||||
return;
|
||||
}
|
||||
formState.value = parseChartOptionToFormState(
|
||||
sel.echartType,
|
||||
sel.option || {},
|
||||
props.datasets || [],
|
||||
);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function onFormChange() {
|
||||
if (!props.selection) return;
|
||||
const option =
|
||||
bindFormRef.value?.buildOption(props.selection.option || {}) || {};
|
||||
emit('apply', {
|
||||
drawingId: props.selection.drawingId,
|
||||
echartType: formState.value?.echartType || 'bar',
|
||||
option,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.chart.empty') }}
|
||||
</div>
|
||||
<ChartBindForm
|
||||
v-else
|
||||
ref="bindFormRef"
|
||||
:datasets="datasets"
|
||||
:model-value="formState"
|
||||
@change="onFormChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElRadioButton,
|
||||
ElRadioGroup,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
downloadReportImageApi,
|
||||
uploadReportFileApi,
|
||||
} from '#/api/online-dev/report-manager';
|
||||
|
||||
export interface FloatImageSelection {
|
||||
drawingId: string;
|
||||
imageType: 'BASE64' | 'URL';
|
||||
option: {
|
||||
source?: number;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
selection: FloatImageSelection | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [config: FloatImageSelection];
|
||||
}>();
|
||||
|
||||
const form = reactive({
|
||||
source: 1,
|
||||
src: '',
|
||||
imageType: 'URL' as 'BASE64' | 'URL',
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.selection,
|
||||
(sel) => {
|
||||
if (!sel) return;
|
||||
form.source = sel.option?.source ?? (sel.imageType === 'URL' ? 2 : 1);
|
||||
form.src = sel.option?.src || '';
|
||||
form.imageType = sel.imageType || 'URL';
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const isUrlSource = computed(() => form.source === 2);
|
||||
|
||||
function emitApply() {
|
||||
if (!props.selection) return;
|
||||
emit('apply', {
|
||||
drawingId: props.selection.drawingId,
|
||||
imageType: form.imageType,
|
||||
option: {
|
||||
...props.selection.option,
|
||||
source: form.source,
|
||||
src: form.src,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
try {
|
||||
const res = await uploadReportFileApi(file);
|
||||
form.src = res.url;
|
||||
form.imageType = 'URL';
|
||||
emitApply();
|
||||
ElMessage.success($t('report-manager.image.uploadSuccess'));
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('report-manager.image.uploadFailed'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleUrlBlur() {
|
||||
if (!form.src?.trim() || !props.selection) return;
|
||||
const val = form.src.trim();
|
||||
if (!/^https?:\/\//i.test(val)) {
|
||||
emitApply();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await downloadReportImageApi(val, 'URL');
|
||||
form.src = res.url;
|
||||
form.imageType = 'URL';
|
||||
emitApply();
|
||||
} catch {
|
||||
form.imageType = 'URL';
|
||||
emitApply();
|
||||
}
|
||||
}
|
||||
|
||||
function onSourceChange() {
|
||||
form.src = '';
|
||||
form.imageType = form.source === 2 ? 'URL' : 'BASE64';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!selection" class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.image.empty') }}
|
||||
</div>
|
||||
<ElForm v-else label-position="top" size="small">
|
||||
<ElFormItem :label="$t('report-manager.image.source')">
|
||||
<ElRadioGroup v-model="form.source" size="small" @change="onSourceChange">
|
||||
<ElRadioButton :value="1">
|
||||
{{ $t('report-manager.image.sourceLocal') }}
|
||||
</ElRadioButton>
|
||||
<ElRadioButton :value="2">
|
||||
{{ $t('report-manager.image.sourceUrl') }}
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-if="isUrlSource" :label="$t('report-manager.image.url')">
|
||||
<ElInput
|
||||
v-model="form.src"
|
||||
:placeholder="$t('report-manager.image.urlPlaceholder')"
|
||||
@blur="handleUrlBlur"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-else :label="$t('report-manager.image.upload')">
|
||||
<ElUpload
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:before-upload="handleUpload as any"
|
||||
>
|
||||
<ElButton type="primary" link>
|
||||
{{ $t('report-manager.image.selectFile') }}
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
<p
|
||||
v-if="form.src"
|
||||
class="text-muted-foreground mt-1 truncate text-xs"
|
||||
:title="form.src"
|
||||
>
|
||||
{{ form.src }}
|
||||
</p>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</template>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Download, Printer } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { ReportDialogHeader, ZqUniverPrint } from '@zq/univer';
|
||||
import '@zq/univer/style';
|
||||
|
||||
import UniverHost from '../univer-host.vue';
|
||||
|
||||
import { ElButton, ElDialog, ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
import {
|
||||
exportReportDesignExcelApi,
|
||||
previewReportDesignApi,
|
||||
} from '#/api/online-dev/report-manager';
|
||||
import { useReportQuery } from '#/components/report-design/hooks/useReportQuery';
|
||||
import ReportQueryForm from '#/components/report-design/modules/report-query-form.vue';
|
||||
import { countSnapshotImages } from '#/components/report-design/utils/report-media-url';
|
||||
import { showPreviewWarnings } from '#/components/report-design/utils/preview-warnings';
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
versionId: string;
|
||||
reportCode?: string;
|
||||
reportName?: string;
|
||||
queryList: any[];
|
||||
getDraftPayload?: () => Record<string, any> | null | undefined;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const exportLoading = ref(false);
|
||||
const allowExport = ref(true);
|
||||
const allowPrint = ref(true);
|
||||
const printRef = ref<InstanceType<typeof ZqUniverPrint> | null>(null);
|
||||
const previewReady = ref(false);
|
||||
const previewSessionKey = ref(0);
|
||||
const previewSnapshot = ref<Record<string, any>>({});
|
||||
const previewCells = ref<Record<string, any>>({});
|
||||
const previewChartData = ref<any[]>([]);
|
||||
const previewWatermark = ref<{ show?: boolean; config?: Record<string, any> }>({
|
||||
show: false,
|
||||
config: {},
|
||||
});
|
||||
|
||||
const { searchSchemas, formValues, setQueryList, getDefaultParams, setFormValues } =
|
||||
useReportQuery();
|
||||
|
||||
watch(
|
||||
() => props.queryList,
|
||||
(list) => setQueryList(list || []),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function resetPreviewState() {
|
||||
previewReady.value = false;
|
||||
allowExport.value = true;
|
||||
allowPrint.value = true;
|
||||
previewSnapshot.value = {};
|
||||
previewCells.value = {};
|
||||
previewChartData.value = [];
|
||||
previewWatermark.value = { show: false, config: {} };
|
||||
}
|
||||
|
||||
watch(visible, async (v) => {
|
||||
if (!v) {
|
||||
resetPreviewState();
|
||||
return;
|
||||
}
|
||||
if (!props.versionId) return;
|
||||
|
||||
resetPreviewState();
|
||||
await runPreview();
|
||||
previewSessionKey.value += 1;
|
||||
previewReady.value = true;
|
||||
});
|
||||
|
||||
function buildDraftPayload() {
|
||||
const draft = props.getDraftPayload?.();
|
||||
if (!draft) return undefined;
|
||||
return {
|
||||
snapshot: draft.snapshot,
|
||||
cells: draft.cells,
|
||||
queryList: draft.queryList,
|
||||
sortList: draft.sortList,
|
||||
columnList: draft.columnList,
|
||||
fenceList: draft.fenceList,
|
||||
convertConfig: draft.convertConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
if (!props.versionId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await previewReportDesignApi(
|
||||
props.versionId,
|
||||
getDefaultParams(),
|
||||
buildDraftPayload(),
|
||||
);
|
||||
previewSnapshot.value = res.snapshot || {};
|
||||
previewCells.value = res.cells || {};
|
||||
previewChartData.value = Array.isArray(res.chartData)
|
||||
? res.chartData
|
||||
: [];
|
||||
previewWatermark.value =
|
||||
res.watermark && typeof res.watermark === 'object'
|
||||
? res.watermark
|
||||
: {
|
||||
show: !!res.allowWatermark,
|
||||
config: res.watermarkConfig || {},
|
||||
};
|
||||
allowExport.value = res.allowExport !== false;
|
||||
allowPrint.value = res.allowPrint !== false;
|
||||
showPreviewWarnings(Array.isArray(res.warnings) ? res.warnings : []);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportExcel() {
|
||||
if (exportLoading.value || !props.versionId) return;
|
||||
if (!allowExport.value) {
|
||||
ElMessage.warning($t('report-manager.export.notAllowed'));
|
||||
return;
|
||||
}
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const blob = await exportReportDesignExcelApi(
|
||||
props.versionId,
|
||||
getDefaultParams(),
|
||||
buildDraftPayload(),
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${props.reportCode || props.reportName || 'report'}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
ElMessage.success($t('report-manager.export.success'));
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('report-manager.export.failed'));
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePrint() {
|
||||
if (!allowPrint.value) {
|
||||
ElMessage.warning($t('report-manager.print.notAllowed'));
|
||||
return;
|
||||
}
|
||||
if (!previewSnapshot.value || !Object.keys(previewSnapshot.value).length) {
|
||||
ElMessage.warning($t('report-manager.render.empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
const imageCount = countSnapshotImages(previewSnapshot.value, previewCells.value);
|
||||
if (imageCount > 100) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
$t('report-manager.print.imageWarn', { count: imageCount }),
|
||||
$t('report-manager.print.title'),
|
||||
{ type: 'warning' },
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
printRef.value?.handleCreatePrintUnit?.({
|
||||
snapshot: previewSnapshot.value as any,
|
||||
watermarkConfig: previewWatermark.value,
|
||||
reportName: props.reportName,
|
||||
reportCode: props.reportCode,
|
||||
});
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
class="report-preview-dialog"
|
||||
fullscreen
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:show-close="false"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="bg-background flex h-full min-h-0 flex-col">
|
||||
<ReportDialogHeader
|
||||
:report-name="reportName"
|
||||
:report-code="reportCode"
|
||||
:fallback-title="$t('report-manager.preview.title')"
|
||||
>
|
||||
<template #actions>
|
||||
<ElButton
|
||||
v-if="allowExport"
|
||||
:icon="Download"
|
||||
:loading="exportLoading"
|
||||
@click="handleExportExcel"
|
||||
>
|
||||
{{ $t('report-manager.export.title') }}
|
||||
</ElButton>
|
||||
<ElButton v-if="allowPrint" :icon="Printer" @click="handlePrint">
|
||||
{{ $t('report-manager.print.title') }}
|
||||
</ElButton>
|
||||
<ElButton @click="handleClose">
|
||||
{{ $t('common.close') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ReportDialogHeader>
|
||||
|
||||
<main
|
||||
v-loading="loading"
|
||||
class="report-preview-dialog__content m-3 min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg"
|
||||
>
|
||||
<ReportQueryForm
|
||||
:schemas="searchSchemas"
|
||||
:model-value="formValues"
|
||||
:loading="loading"
|
||||
@update:model-value="setFormValues"
|
||||
@search="runPreview"
|
||||
/>
|
||||
<div class="report-preview-dialog__sheet">
|
||||
<UniverHost
|
||||
v-if="previewReady"
|
||||
:key="previewSessionKey"
|
||||
mode="preview"
|
||||
readonly
|
||||
class="h-full w-full"
|
||||
:snapshot="previewSnapshot"
|
||||
:cells="previewCells"
|
||||
:chart-data="previewChartData"
|
||||
:watermark="previewWatermark"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ElDialog>
|
||||
|
||||
<Teleport to="body">
|
||||
<ZqUniverPrint ref="printRef" />
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.report-preview-dialog.el-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.report-preview-dialog__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.report-preview-dialog__sheet {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Plus, Trash2 } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
|
||||
import ReportFieldSelect from './report-field-select.vue';
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
queryList: any[];
|
||||
datasets?: ReportDatasetItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [any[]];
|
||||
}>();
|
||||
|
||||
const rows = ref<any[]>([]);
|
||||
|
||||
const { ensureAll } = useReportDatasetFields(() => props.datasets || []);
|
||||
|
||||
const paramFieldOptions = computed(() => {
|
||||
const names = new Set<string>();
|
||||
for (const row of rows.value) {
|
||||
const field = String(row.field || '').trim();
|
||||
if (field) names.add(field);
|
||||
}
|
||||
for (const item of props.queryList || []) {
|
||||
const field = String(item.field || item.vModel || '').trim();
|
||||
if (field) names.add(field);
|
||||
}
|
||||
return [...names].sort();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.queryList,
|
||||
(list) => {
|
||||
rows.value = (list || []).map((item) => ({
|
||||
...item,
|
||||
optionsText: Array.isArray(item.options)
|
||||
? item.options
|
||||
.map((o: any) => `${o.label ?? o.fullName ?? o.id}:${o.value ?? o.id}`)
|
||||
.join(',')
|
||||
: typeof item.options === 'string'
|
||||
? item.options
|
||||
: '',
|
||||
}));
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open) {
|
||||
ensureAll();
|
||||
}
|
||||
});
|
||||
|
||||
function getParamFieldOptions(currentValue?: string) {
|
||||
const options = [...paramFieldOptions.value];
|
||||
const trimmed = (currentValue || '').trim();
|
||||
if (trimmed && !options.includes(trimmed)) {
|
||||
options.unshift(trimmed);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
rows.value.push({
|
||||
field: `param_${rows.value.length + 1}`,
|
||||
label: '',
|
||||
component: 'input',
|
||||
defaultValue: '',
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
rows.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit(
|
||||
'confirm',
|
||||
rows.value
|
||||
.filter((r) => r.field)
|
||||
.map(({ optionsText, ...rest }) => ({
|
||||
...rest,
|
||||
options: rest.component === 'select' ? optionsText || '' : undefined,
|
||||
})),
|
||||
);
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
v-model="visible"
|
||||
:title="$t('report-manager.query.title')"
|
||||
width="720px"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="mb-3 flex justify-end">
|
||||
<ElButton type="primary" :icon="Plus" @click="addRow">
|
||||
{{ $t('report-manager.query.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTable :data="rows" border max-height="360">
|
||||
<ElTableColumn :label="$t('report-manager.query.field')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<ElSelect
|
||||
v-if="!(datasets || []).length"
|
||||
v-model="row.field"
|
||||
size="small"
|
||||
class="w-full"
|
||||
filterable
|
||||
clearable
|
||||
allow-create
|
||||
default-first-option
|
||||
:placeholder="$t('report-manager.query.fieldPlaceholder')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="name in getParamFieldOptions(row.field)"
|
||||
:key="name"
|
||||
:label="name"
|
||||
:value="name"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ReportFieldSelect
|
||||
v-else
|
||||
v-model="row.field"
|
||||
size="small"
|
||||
:datasets="datasets || []"
|
||||
:with-alias="true"
|
||||
:placeholder="$t('report-manager.query.fieldPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.query.label')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model="row.label" size="small" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.query.component')" width="120">
|
||||
<template #default="{ row }">
|
||||
<ElSelect v-model="row.component" size="small" filterable>
|
||||
<ElOption :label="$t('report-manager.query.input')" value="input" />
|
||||
<ElOption :label="$t('report-manager.query.select')" value="select" />
|
||||
<ElOption
|
||||
:label="$t('report-manager.query.date')"
|
||||
value="date"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.query.dateRange')"
|
||||
value="dateRange"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('report-manager.query.showTime')"
|
||||
width="90"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElSwitch
|
||||
v-if="row.component === 'date' || row.component === 'dateRange'"
|
||||
v-model="row.showTime"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('report-manager.query.options')"
|
||||
min-width="140"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElInput
|
||||
v-if="row.component === 'select'"
|
||||
v-model="row.optionsText"
|
||||
size="small"
|
||||
:placeholder="$t('report-manager.query.optionsPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('report-manager.query.defaultValue')"
|
||||
min-width="100"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<ElInput v-model="row.defaultValue" size="small" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.query.requiredLabel')" width="80">
|
||||
<template #default="{ row }">
|
||||
<ElSwitch v-model="row.required" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn width="60" align="center">
|
||||
<template #default="{ $index }">
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
:icon="Trash2"
|
||||
@click="removeRow($index)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import ReportConfigToggleItem from './report-config-toggle-item.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
queryList: any[];
|
||||
sortList: any[];
|
||||
columnList: any[];
|
||||
convertConfig: any[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-query': [];
|
||||
'open-sort': [];
|
||||
'open-column': [];
|
||||
'open-convert': [];
|
||||
}>();
|
||||
|
||||
const queryEnabled = ref(false);
|
||||
const sortEnabled = ref(false);
|
||||
const columnEnabled = ref(false);
|
||||
const convertEnabled = ref(false);
|
||||
|
||||
function getSortRows() {
|
||||
const first = props.sortList?.[0];
|
||||
return first?.sortList || [];
|
||||
}
|
||||
|
||||
function getColumnConfig() {
|
||||
return props.columnList?.[0]?.columnList || null;
|
||||
}
|
||||
|
||||
function syncEnabledFromData() {
|
||||
if ((props.queryList?.length || 0) > 0) {
|
||||
queryEnabled.value = true;
|
||||
}
|
||||
if (getSortRows().length > 0) {
|
||||
sortEnabled.value = true;
|
||||
}
|
||||
if (getColumnConfig()?.columnState) {
|
||||
columnEnabled.value = true;
|
||||
}
|
||||
if ((props.convertConfig?.length || 0) > 0) {
|
||||
convertEnabled.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.queryList, props.sortList, props.columnList, props.convertConfig],
|
||||
() => syncEnabledFromData(),
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
const querySummary = computed(() => {
|
||||
const count = props.queryList?.length || 0;
|
||||
if (!count) return '';
|
||||
return $t('report-manager.leftPanel.configuredCount', { count });
|
||||
});
|
||||
|
||||
const queryDetail = computed(() => {
|
||||
const labels = (props.queryList || [])
|
||||
.map((item) => item.label || item.field || item.vModel)
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
if (!labels.length) return '';
|
||||
const suffix =
|
||||
(props.queryList?.length || 0) > 3
|
||||
? ` · +${(props.queryList?.length || 0) - 3}`
|
||||
: '';
|
||||
return `${labels.join('、')}${suffix}`;
|
||||
});
|
||||
|
||||
const sortSummary = computed(() => {
|
||||
const rows = getSortRows();
|
||||
if (!rows.length) return '';
|
||||
return $t('report-manager.leftPanel.configuredCount', { count: rows.length });
|
||||
});
|
||||
|
||||
const sortDetail = computed(() => {
|
||||
return getSortRows()
|
||||
.map((item: any) => {
|
||||
const field = item.vModel || item.field || '';
|
||||
const order =
|
||||
item.type === 'desc'
|
||||
? $t('report-manager.sort.desc')
|
||||
: $t('report-manager.sort.asc');
|
||||
return field ? `${field} (${order})` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('、');
|
||||
});
|
||||
|
||||
const columnSummary = computed(() => {
|
||||
const cfg = getColumnConfig();
|
||||
if (!cfg?.columnState) return '';
|
||||
return $t('report-manager.leftPanel.columnEnabled');
|
||||
});
|
||||
|
||||
const columnDetail = computed(() => {
|
||||
const cfg = getColumnConfig();
|
||||
if (!cfg?.columnState) return '';
|
||||
const styleLabel =
|
||||
cfg.columnStyle === 'row'
|
||||
? $t('report-manager.column.styleRow')
|
||||
: $t('report-manager.column.styleCol');
|
||||
const splitCount =
|
||||
cfg.columnStyle === 'row' ? cfg.rowCount : cfg.colCount;
|
||||
const range = cfg.columnData ? ` · ${cfg.columnData}` : '';
|
||||
return `${styleLabel} · ${splitCount || 0}${range}`;
|
||||
});
|
||||
|
||||
const convertSummary = computed(() => {
|
||||
const count = props.convertConfig?.length || 0;
|
||||
if (!count) return '';
|
||||
return $t('report-manager.leftPanel.configuredCount', { count });
|
||||
});
|
||||
|
||||
const convertDetail = computed(() => {
|
||||
return (props.convertConfig || [])
|
||||
.map((item) => item.field)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('、');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 px-2 pb-2">
|
||||
<ReportConfigToggleItem
|
||||
:label="$t('report-manager.query.title')"
|
||||
:enabled="queryEnabled"
|
||||
:summary="querySummary"
|
||||
:detail="queryDetail"
|
||||
:empty-text="$t('report-manager.leftPanel.queryEmpty')"
|
||||
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
|
||||
@update:enabled="queryEnabled = $event"
|
||||
@open="emit('open-query')"
|
||||
/>
|
||||
<ReportConfigToggleItem
|
||||
:label="$t('report-manager.sort.config')"
|
||||
:enabled="sortEnabled"
|
||||
:summary="sortSummary"
|
||||
:detail="sortDetail"
|
||||
:empty-text="$t('report-manager.leftPanel.sortEmpty')"
|
||||
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
|
||||
@update:enabled="sortEnabled = $event"
|
||||
@open="emit('open-sort')"
|
||||
/>
|
||||
<ReportConfigToggleItem
|
||||
:label="$t('report-manager.column.config')"
|
||||
:enabled="columnEnabled"
|
||||
:summary="columnSummary"
|
||||
:detail="columnDetail"
|
||||
:empty-text="$t('report-manager.leftPanel.columnEmpty')"
|
||||
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
|
||||
@update:enabled="columnEnabled = $event"
|
||||
@open="emit('open-column')"
|
||||
/>
|
||||
<ReportConfigToggleItem
|
||||
:label="$t('report-manager.convert.config')"
|
||||
:enabled="convertEnabled"
|
||||
:summary="convertSummary"
|
||||
:detail="convertDetail"
|
||||
:empty-text="$t('report-manager.leftPanel.convertEmpty')"
|
||||
:action-text="$t('report-manager.leftPanel.clickToConfigure')"
|
||||
@update:enabled="convertEnabled = $event"
|
||||
@open="emit('open-convert')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ChevronRight } from '@vben/icons';
|
||||
|
||||
import { ElSwitch } from 'element-plus';
|
||||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
summary?: string;
|
||||
detail?: string;
|
||||
emptyText: string;
|
||||
actionText: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean];
|
||||
open: [];
|
||||
}>();
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (props.summary && props.detail) {
|
||||
return `${props.summary} · ${props.detail}`;
|
||||
}
|
||||
return props.summary || props.detail || props.emptyText;
|
||||
});
|
||||
|
||||
function handleToggle(value: boolean | string | number) {
|
||||
emit('update:enabled', !!value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-foreground truncate text-[13px]">{{ label }}</span>
|
||||
<ElSwitch :model-value="enabled" size="small" @change="handleToggle" />
|
||||
</div>
|
||||
|
||||
<Transition name="report-config-expand">
|
||||
<div v-if="enabled" class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">{{ actionText }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="report-side-field group"
|
||||
@click="emit('open')"
|
||||
>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-xs leading-relaxed"
|
||||
:class="
|
||||
summary || detail
|
||||
? 'text-[var(--el-text-color-regular)]'
|
||||
: 'text-[var(--el-text-color-placeholder)]'
|
||||
"
|
||||
>
|
||||
{{ displayText }}
|
||||
</span>
|
||||
<ChevronRight
|
||||
class="h-3.5 w-3.5 shrink-0 text-[var(--el-text-color-placeholder)] transition-transform group-hover:translate-x-0.5 group-hover:text-[var(--el-color-primary)]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.report-side-field {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 11px;
|
||||
min-height: 24px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-bg-color);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.report-side-field:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.report-config-expand-enter-active,
|
||||
.report-config-expand-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.report-config-expand-enter-from,
|
||||
.report-config-expand-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { ElTreeSelect } from 'element-plus';
|
||||
|
||||
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
datasets: ReportDatasetItem[];
|
||||
alias?: string;
|
||||
withAlias?: boolean;
|
||||
placeholder?: string;
|
||||
size?: 'default' | 'large' | 'small';
|
||||
}>(),
|
||||
{
|
||||
withAlias: true,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [string];
|
||||
}>();
|
||||
|
||||
const { ensureAll, ensureByAlias, getFieldTree, loadingMap, normalizedDatasets } =
|
||||
useReportDatasetFields(() => props.datasets);
|
||||
|
||||
const treeData = computed(() =>
|
||||
getFieldTree(props.alias, props.withAlias, modelValue.value),
|
||||
);
|
||||
|
||||
const loading = computed(() => {
|
||||
if (props.alias) {
|
||||
const ds = normalizedDatasets.value.find((item) => item.alias === props.alias);
|
||||
return !!loadingMap.value[ds?.data_source_id || ''];
|
||||
}
|
||||
return Object.values(loadingMap.value).some(Boolean);
|
||||
});
|
||||
|
||||
async function loadTreeFields() {
|
||||
if (props.alias) {
|
||||
await ensureByAlias(props.alias);
|
||||
return;
|
||||
}
|
||||
await ensureAll();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.alias, props.datasets] as const,
|
||||
() => {
|
||||
loadTreeFields();
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTreeSelect
|
||||
v-model="modelValue"
|
||||
:data="treeData"
|
||||
node-key="value"
|
||||
class="w-full"
|
||||
filterable
|
||||
clearable
|
||||
default-expand-all
|
||||
:render-after-expand="false"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder"
|
||||
:size="size"
|
||||
@change="$emit('change', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElDatePicker,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
export interface ReportQuerySchema {
|
||||
fieldName: string;
|
||||
label: string;
|
||||
component: string;
|
||||
componentProps?: Record<string, any>;
|
||||
rules?: any[];
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
schemas: ReportQuerySchema[];
|
||||
modelValue: Record<string, any>;
|
||||
inline?: boolean;
|
||||
showSearch?: boolean;
|
||||
showReset?: boolean;
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
inline: true,
|
||||
showSearch: true,
|
||||
showReset: false,
|
||||
loading: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<string, any>];
|
||||
search: [];
|
||||
reset: [];
|
||||
}>();
|
||||
|
||||
const formModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
function updateField(field: string, value: any) {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value });
|
||||
}
|
||||
|
||||
function normalizeComponent(component: string) {
|
||||
const c = (component || 'input').toLowerCase();
|
||||
if (c.includes('select')) return 'select';
|
||||
if (c.includes('range')) return 'dateRange';
|
||||
if (c.includes('date') || c.includes('time')) return 'date';
|
||||
return 'input';
|
||||
}
|
||||
|
||||
function isDateTime(schema: ReportQuerySchema) {
|
||||
return schema.componentProps?.showTime === true;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
const cleared: Record<string, any> = {};
|
||||
for (const schema of props.schemas) {
|
||||
cleared[schema.fieldName] = undefined;
|
||||
}
|
||||
emit('update:modelValue', cleared);
|
||||
emit('reset');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElForm
|
||||
v-if="schemas.length"
|
||||
:inline="inline"
|
||||
class="bg-background-deep rounded-lg p-4"
|
||||
@submit.prevent="emit('search')"
|
||||
>
|
||||
<ElFormItem
|
||||
v-for="schema in schemas"
|
||||
:key="schema.fieldName"
|
||||
:label="schema.label"
|
||||
:required="!!schema.rules?.length"
|
||||
>
|
||||
<ElSelect
|
||||
v-if="normalizeComponent(schema.component) === 'select'"
|
||||
:model-value="formModel[schema.fieldName]"
|
||||
clearable
|
||||
filterable
|
||||
class="min-w-[160px]"
|
||||
:placeholder="schema.componentProps?.placeholder"
|
||||
@update:model-value="updateField(schema.fieldName, $event)"
|
||||
>
|
||||
<ElOption
|
||||
v-for="opt in schema.componentProps?.options || []"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
|
||||
<ElDatePicker
|
||||
v-else-if="normalizeComponent(schema.component) === 'dateRange'"
|
||||
:model-value="formModel[schema.fieldName]"
|
||||
clearable
|
||||
class="!w-[280px]"
|
||||
:type="isDateTime(schema) ? 'datetimerange' : 'daterange'"
|
||||
:value-format="isDateTime(schema) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'"
|
||||
:start-placeholder="$t('report-manager.query.startDate')"
|
||||
:end-placeholder="$t('report-manager.query.endDate')"
|
||||
@update:model-value="updateField(schema.fieldName, $event)"
|
||||
/>
|
||||
|
||||
<ElDatePicker
|
||||
v-else-if="normalizeComponent(schema.component) === 'date'"
|
||||
:model-value="formModel[schema.fieldName]"
|
||||
clearable
|
||||
class="!w-[200px]"
|
||||
:type="isDateTime(schema) ? 'datetime' : 'date'"
|
||||
:value-format="isDateTime(schema) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'"
|
||||
:placeholder="schema.componentProps?.placeholder"
|
||||
@update:model-value="updateField(schema.fieldName, $event)"
|
||||
/>
|
||||
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="formModel[schema.fieldName]"
|
||||
clearable
|
||||
class="min-w-[160px]"
|
||||
:placeholder="schema.componentProps?.placeholder"
|
||||
@update:model-value="updateField(schema.fieldName, $event)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-if="showSearch || showReset">
|
||||
<ElButton
|
||||
v-if="showSearch"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="emit('search')"
|
||||
>
|
||||
{{ $t('report-manager.query.search') }}
|
||||
</ElButton>
|
||||
<ElButton v-if="showReset" @click="handleReset">
|
||||
{{ $t('report-manager.query.reset') }}
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</template>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
ElTooltip,
|
||||
} from 'element-plus';
|
||||
|
||||
import { updateReportApi } from '#/api/online-dev/report-manager';
|
||||
|
||||
import {
|
||||
buildReportSettingsPayload,
|
||||
parseReportSettings,
|
||||
type ReportSettingsForm,
|
||||
} from '../utils/report-settings';
|
||||
|
||||
const props = defineProps<{
|
||||
templateId: string;
|
||||
reportName: string;
|
||||
allowExport?: boolean;
|
||||
allowPrint?: boolean;
|
||||
allowWatermark?: boolean;
|
||||
watermarkConfig?: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [ReportSettingsForm];
|
||||
}>();
|
||||
|
||||
const timeFormatOptions = [
|
||||
'yyyy-MM-dd',
|
||||
'yyyy-MM-dd HH:mm',
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
];
|
||||
|
||||
const saving = ref(false);
|
||||
const form = ref<ReportSettingsForm>(
|
||||
parseReportSettings(props.reportName, props),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.templateId,
|
||||
props.reportName,
|
||||
props.allowExport,
|
||||
props.allowPrint,
|
||||
props.allowWatermark,
|
||||
props.watermarkConfig,
|
||||
],
|
||||
() => {
|
||||
form.value = parseReportSettings(props.reportName, props);
|
||||
},
|
||||
);
|
||||
|
||||
async function persistSettings() {
|
||||
if (!props.templateId || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateReportApi(
|
||||
props.templateId,
|
||||
buildReportSettingsPayload(form.value, props.reportName),
|
||||
);
|
||||
emit('saved', { ...form.value });
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || $t('common.saveFailed'));
|
||||
form.value = parseReportSettings(props.reportName, props);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitchChange() {
|
||||
void persistSettings();
|
||||
}
|
||||
|
||||
let textSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function handleTextChange() {
|
||||
if (textSaveTimer) clearTimeout(textSaveTimer);
|
||||
textSaveTimer = setTimeout(() => {
|
||||
void persistSettings();
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 px-2 pb-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<span class="text-foreground truncate text-[13px]">
|
||||
{{ $t('report-manager.settings.allowExport') }}
|
||||
</span>
|
||||
<ElTooltip
|
||||
placement="top"
|
||||
:content="$t('report-manager.settings.allowExportTip')"
|
||||
>
|
||||
<CircleHelp
|
||||
class="h-3.5 w-3.5 shrink-0 cursor-help text-[var(--el-text-color-secondary)]"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<ElSwitch
|
||||
v-model="form.allow_export"
|
||||
size="small"
|
||||
:disabled="saving"
|
||||
@change="handleSwitchChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<span class="text-foreground truncate text-[13px]">
|
||||
{{ $t('report-manager.settings.allowPrint') }}
|
||||
</span>
|
||||
<ElTooltip
|
||||
placement="top"
|
||||
:content="$t('report-manager.settings.allowPrintTip')"
|
||||
>
|
||||
<CircleHelp
|
||||
class="h-3.5 w-3.5 shrink-0 cursor-help text-[var(--el-text-color-secondary)]"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<ElSwitch
|
||||
v-model="form.allow_print"
|
||||
size="small"
|
||||
:disabled="saving"
|
||||
@change="handleSwitchChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<span class="text-foreground truncate text-[13px]">
|
||||
{{ $t('report-manager.settings.allowWatermark') }}
|
||||
</span>
|
||||
<ElTooltip
|
||||
placement="top"
|
||||
:content="$t('report-manager.settings.allowWatermarkTip')"
|
||||
>
|
||||
<CircleHelp
|
||||
class="h-3.5 w-3.5 shrink-0 cursor-help text-[var(--el-text-color-secondary)]"
|
||||
/>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<ElSwitch
|
||||
v-model="form.allow_watermark"
|
||||
size="small"
|
||||
:disabled="saving"
|
||||
@change="handleSwitchChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="form.allow_watermark">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{{ $t('report-manager.settings.watermarkText') }}
|
||||
</span>
|
||||
<ElInput
|
||||
v-model="form.watermark_text"
|
||||
size="small"
|
||||
:disabled="saving"
|
||||
:placeholder="$t('report-manager.settings.watermarkPlaceholder')"
|
||||
@input="handleTextChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{{ $t('report-manager.settings.watermarkShowTime') }}
|
||||
</span>
|
||||
<ElSwitch
|
||||
v-model="form.watermark_show_time"
|
||||
size="small"
|
||||
:disabled="saving"
|
||||
@change="handleSwitchChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.watermark_show_time" class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground text-xs">
|
||||
{{ $t('report-manager.settings.watermarkTimeFormat') }}
|
||||
</span>
|
||||
<ElSelect
|
||||
v-model="form.watermark_time_format"
|
||||
size="small"
|
||||
class="w-full"
|
||||
:disabled="saving"
|
||||
@change="handleSwitchChange"
|
||||
>
|
||||
<ElOption
|
||||
v-for="fmt in timeFormatOptions"
|
||||
:key="fmt"
|
||||
:label="fmt"
|
||||
:value="fmt"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Plus, Trash2 } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElFormItem,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
} from 'element-plus';
|
||||
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
import { ZqDialog } from '#/components/zq-dialog';
|
||||
|
||||
import { useReportDatasetFields } from '../hooks/useReportDatasetFields';
|
||||
import ReportFieldSelect from './report-field-select.vue';
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
sortList: any[];
|
||||
datasets: ReportDatasetItem[];
|
||||
defaultSheetId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [any[]];
|
||||
}>();
|
||||
|
||||
const rows = ref<any[]>([]);
|
||||
const sheetId = ref('sheet1');
|
||||
|
||||
const { ensureAll } = useReportDatasetFields(() => props.datasets);
|
||||
|
||||
watch(
|
||||
() => props.sortList,
|
||||
(list) => {
|
||||
const first = list?.[0];
|
||||
if (first?.sortList?.length) {
|
||||
sheetId.value = first.sheet || props.defaultSheetId || 'sheet1';
|
||||
rows.value = first.sortList.map((r: any) => ({ ...r }));
|
||||
} else {
|
||||
sheetId.value = props.defaultSheetId || 'sheet1';
|
||||
rows.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open) {
|
||||
ensureAll();
|
||||
}
|
||||
});
|
||||
|
||||
function addRow() {
|
||||
rows.value.push({
|
||||
id: `sort_${Date.now()}_${rows.value.length}`,
|
||||
vModel: '',
|
||||
type: 'asc',
|
||||
});
|
||||
}
|
||||
|
||||
function removeRow(index: number) {
|
||||
rows.value.splice(index, 1);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
const valid = rows.value.filter((r) => r.vModel?.trim());
|
||||
emit('confirm', [
|
||||
{
|
||||
sheet: sheetId.value,
|
||||
sheetName: sheetId.value,
|
||||
sortList: valid,
|
||||
},
|
||||
]);
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ZqDialog
|
||||
v-model="visible"
|
||||
:title="$t('report-manager.sort.title')"
|
||||
width="640px"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<p class="mb-3 text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ $t('report-manager.sort.hint') }}
|
||||
</p>
|
||||
<div class="mb-3 flex justify-end">
|
||||
<ElButton type="primary" :icon="Plus" @click="addRow">
|
||||
{{ $t('report-manager.sort.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTable :data="rows" border max-height="320">
|
||||
<ElTableColumn :label="$t('report-manager.sort.field')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<ReportFieldSelect
|
||||
v-model="row.vModel"
|
||||
size="small"
|
||||
:datasets="datasets"
|
||||
:with-alias="true"
|
||||
:placeholder="$t('report-manager.sort.fieldPlaceholder')"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('report-manager.sort.order')" width="140">
|
||||
<template #default="{ row }">
|
||||
<ElSelect v-model="row.type" size="small" class="w-full" filterable>
|
||||
<ElOption
|
||||
:label="$t('report-manager.sort.asc')"
|
||||
value="asc"
|
||||
/>
|
||||
<ElOption
|
||||
:label="$t('report-manager.sort.desc')"
|
||||
value="desc"
|
||||
/>
|
||||
</ElSelect>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn width="56" align="center">
|
||||
<template #default="{ $index }">
|
||||
<ElButton
|
||||
link
|
||||
type="danger"
|
||||
:icon="Trash2"
|
||||
@click="removeRow($index)"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElFormItem
|
||||
v-if="(props.datasets || []).length"
|
||||
class="mt-3"
|
||||
:label="$t('report-manager.sort.datasetHint')"
|
||||
>
|
||||
<span class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
{{ (props.datasets || []).map((d) => d.alias).join(', ') }}
|
||||
</span>
|
||||
</ElFormItem>
|
||||
</ZqDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,321 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
import { ZqUniver } from '@zq/univer';
|
||||
|
||||
import {
|
||||
applyChartDataToEcharts,
|
||||
parseCellsMeta,
|
||||
type ChartDataItem,
|
||||
} from './hooks/useReportChart';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mode?: 'design' | 'preview' | 'print';
|
||||
readonly?: boolean;
|
||||
snapshot?: Record<string, any>;
|
||||
cells?: Record<string, any>;
|
||||
chartData?: ChartDataItem[];
|
||||
watermark?: { show?: boolean; config?: Record<string, any> };
|
||||
}>(),
|
||||
{
|
||||
mode: 'design',
|
||||
readonly: false,
|
||||
snapshot: () => ({}),
|
||||
cells: () => ({}),
|
||||
chartData: () => [],
|
||||
watermark: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
const { locale } = usePreferences();
|
||||
const appLocale = computed(() => locale.value || 'zh-CN');
|
||||
|
||||
const univerRef = ref<InstanceType<typeof ZqUniver> | null>(null);
|
||||
const ready = ref(false);
|
||||
const activeSheetId = ref('sheet1');
|
||||
let sheetPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let initToken = 0;
|
||||
|
||||
function getSnapshotFingerprint(snap?: Record<string, any>) {
|
||||
if (!snap || !Object.keys(snap).length) return '';
|
||||
const sheetOrder = Array.isArray(snap.sheetOrder) ? snap.sheetOrder.join(',') : '';
|
||||
return `${snap.id || ''}:${sheetOrder}:${Object.keys(snap.sheets || {}).length}`;
|
||||
}
|
||||
|
||||
function syncActiveSheetId() {
|
||||
const id = univerRef.value?.getActiveWorksheetId?.();
|
||||
if (id && id !== activeSheetId.value) {
|
||||
activeSheetId.value = id;
|
||||
emit('sheetChange', id);
|
||||
} else if (id) {
|
||||
activeSheetId.value = id;
|
||||
}
|
||||
}
|
||||
|
||||
function startSheetPolling() {
|
||||
stopSheetPolling();
|
||||
if (props.mode !== 'preview') return;
|
||||
sheetPollTimer = setInterval(syncActiveSheetId, 500);
|
||||
}
|
||||
|
||||
function stopSheetPolling() {
|
||||
if (sheetPollTimer) {
|
||||
clearInterval(sheetPollTimer);
|
||||
sheetPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
changeCell: [payload: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
cellData: Record<string, any>;
|
||||
sheetId?: string;
|
||||
}];
|
||||
focusFloatImage: [payload: {
|
||||
drawingId: string;
|
||||
imageType: 'BASE64' | 'URL';
|
||||
option: Record<string, any>;
|
||||
}];
|
||||
focusFloatEchart: [payload: {
|
||||
drawingId: string;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
}];
|
||||
sheetChange: [sheetId: string];
|
||||
preview: [];
|
||||
}>();
|
||||
|
||||
function resolveEchartStores() {
|
||||
const meta = parseCellsMeta(props.cells);
|
||||
let floatEcharts = meta.floatEcharts || {};
|
||||
let cellEcharts = meta.cellEcharts || {};
|
||||
const floatImages = meta.floatImages || {};
|
||||
if (props.mode === 'preview' && props.chartData?.length) {
|
||||
floatEcharts =
|
||||
applyChartDataToEcharts(floatEcharts, props.chartData) || floatEcharts;
|
||||
cellEcharts =
|
||||
applyChartDataToEcharts(cellEcharts, props.chartData) || cellEcharts;
|
||||
}
|
||||
return { floatEcharts, cellEcharts, floatImages };
|
||||
}
|
||||
|
||||
function onChangeCell(payload: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
cellData: Record<string, any>;
|
||||
}) {
|
||||
activeSheetId.value =
|
||||
univerRef.value?.getActiveWorksheetId?.() || activeSheetId.value;
|
||||
emit('changeCell', {
|
||||
...payload,
|
||||
sheetId: activeSheetId.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function initUniver() {
|
||||
const token = ++initToken;
|
||||
await nextTick();
|
||||
if (token !== initToken) return;
|
||||
|
||||
const snap = props.snapshot;
|
||||
if (!snap || !Object.keys(snap).length) {
|
||||
ready.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
stopSheetPolling();
|
||||
univerRef.value?.handleDisposeUnit?.();
|
||||
if (token !== initToken) return;
|
||||
|
||||
const { floatEcharts, cellEcharts, floatImages } = resolveEchartStores();
|
||||
await univerRef.value?.handleCreateDesignUnit?.({
|
||||
mode: props.mode,
|
||||
readonly: props.readonly,
|
||||
snapshot: snap as any,
|
||||
floatEcharts,
|
||||
cellEcharts,
|
||||
floatImages,
|
||||
uiHeader: props.mode === 'design',
|
||||
uiFooter: props.mode === 'design',
|
||||
uiContextMenu: props.mode === 'design' && !props.readonly,
|
||||
watermark: props.watermark,
|
||||
appLocale: appLocale.value,
|
||||
});
|
||||
|
||||
if (token !== initToken) {
|
||||
univerRef.value?.handleDisposeUnit?.();
|
||||
return;
|
||||
}
|
||||
|
||||
ready.value = true;
|
||||
syncActiveSheetId();
|
||||
startSheetPolling();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
getSnapshotFingerprint(props.snapshot),
|
||||
props.mode,
|
||||
props.readonly,
|
||||
appLocale.value,
|
||||
props.mode === 'preview' ? props.chartData : null,
|
||||
],
|
||||
() => {
|
||||
void initUniver();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
initToken += 1;
|
||||
stopSheetPolling();
|
||||
univerRef.value?.handleDisposeUnit?.();
|
||||
});
|
||||
|
||||
function getData() {
|
||||
const pack = univerRef.value?.getDesignWorkbookData?.() || {};
|
||||
const snapshotStr = pack.snapshot ? JSON.stringify(pack.snapshot) : '{}';
|
||||
const floatEcharts = pack.floatEcharts || {};
|
||||
const floatImages = pack.floatImages || {};
|
||||
const cellEcharts = pack.cellEcharts || {};
|
||||
const customs = pack.customs || [];
|
||||
const customCells = customs.length
|
||||
? customs.map((o: any) => ({
|
||||
col: o.col,
|
||||
row: o.row,
|
||||
sheet: o.sheetId,
|
||||
type: o.cellData?.custom?.type,
|
||||
custom: o.cellData?.custom || {},
|
||||
}))
|
||||
: [];
|
||||
return {
|
||||
snapshot: snapshotStr,
|
||||
cells: JSON.stringify({
|
||||
cells: customCells,
|
||||
floatEcharts,
|
||||
cellEcharts,
|
||||
floatImages,
|
||||
}),
|
||||
queryList: '[]',
|
||||
sortList: '[]',
|
||||
columnList: '[]',
|
||||
fenceList: '[]',
|
||||
convertConfig: '{}',
|
||||
dataSetList: [],
|
||||
};
|
||||
}
|
||||
|
||||
function onFocusFloatImage(payload: {
|
||||
drawingId: string;
|
||||
imageType: 'BASE64' | 'URL';
|
||||
option: Record<string, any>;
|
||||
}) {
|
||||
emit('focusFloatImage', payload);
|
||||
}
|
||||
|
||||
function onFocusFloatEchart(payload: {
|
||||
drawingId: string;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
}) {
|
||||
emit('focusFloatEchart', payload);
|
||||
}
|
||||
|
||||
function onPreview() {
|
||||
emit('preview');
|
||||
}
|
||||
|
||||
function applyCellBinding(binding: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
type: string;
|
||||
v: string;
|
||||
custom: Record<string, any>;
|
||||
}) {
|
||||
univerRef.value?.updateCellsData?.([
|
||||
{
|
||||
startRow: binding.startRow,
|
||||
startColumn: binding.startColumn,
|
||||
cellData: {
|
||||
v: binding.v,
|
||||
custom: binding.custom,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function applyCellChart(payload: {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
preserveCustom: Record<string, any>;
|
||||
}) {
|
||||
const { startRow, startColumn, echartType, option, preserveCustom } = payload;
|
||||
univerRef.value?.updateCellsData?.([
|
||||
{
|
||||
startRow,
|
||||
startColumn,
|
||||
cellData: {
|
||||
custom: {
|
||||
...preserveCustom,
|
||||
type: 'chart',
|
||||
chartType: echartType,
|
||||
...option,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function updateFloatImageConfig(config: {
|
||||
drawingId: string;
|
||||
imageType: 'BASE64' | 'URL';
|
||||
option: Record<string, any>;
|
||||
}) {
|
||||
univerRef.value?.updateFloatImageConfig?.(config);
|
||||
}
|
||||
|
||||
function updateFloatEchartConfig(config: {
|
||||
drawingId: string;
|
||||
echartType: string;
|
||||
option: Record<string, any>;
|
||||
}) {
|
||||
univerRef.value?.updateFloatEchartConfig?.(config);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getData,
|
||||
initUniver,
|
||||
applyCellBinding,
|
||||
applyCellChart,
|
||||
updateFloatImageConfig,
|
||||
updateFloatEchartConfig,
|
||||
getActiveWorksheetId: () =>
|
||||
univerRef.value?.getActiveWorksheetId?.() || activeSheetId.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="zq-univer-host h-full w-full min-h-[400px]">
|
||||
<ZqUniver
|
||||
ref="univerRef"
|
||||
class="h-full w-full"
|
||||
@change-cell="onChangeCell"
|
||||
@focus-float-image="onFocusFloatImage"
|
||||
@focus-float-echart="onFocusFloatEchart"
|
||||
@preview="onPreview"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.zq-univer-host :deep(.zq-univer-design-content),
|
||||
.zq-univer-host :deep(.univer-design-container) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { ReportDatasetItem } from '#/api/online-dev/report-manager';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
getDataSourceDetailApi,
|
||||
previewDataSourceApi,
|
||||
} from '#/api/core/data-source';
|
||||
|
||||
export interface FieldOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface FieldTreeNode {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
children?: FieldTreeNode[];
|
||||
}
|
||||
|
||||
/** 跨组件共享字段缓存,避免重复请求预览接口 */
|
||||
export const datasetFieldsCache = ref<Record<string, string[]>>({});
|
||||
export const datasetFieldsLoadingMap = ref<Record<string, boolean>>({});
|
||||
|
||||
export function normalizeDataset(raw: ReportDatasetItem): ReportDatasetItem {
|
||||
const dataSourceId =
|
||||
raw.data_source_id || (raw as any).dataSourceId || '';
|
||||
return {
|
||||
...raw,
|
||||
data_source_id: dataSourceId,
|
||||
data_source_code:
|
||||
raw.data_source_code || (raw as any).dataSourceCode || '',
|
||||
data_source_name:
|
||||
raw.data_source_name || (raw as any).dataSourceName || '',
|
||||
alias:
|
||||
raw.alias || raw.data_source_code || (raw as any).dataSourceCode || '',
|
||||
field_mapping: raw.field_mapping || (raw as any).fieldMapping || {},
|
||||
convert_config: raw.convert_config || (raw as any).convertConfig || {},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePreviewRows(data: any): any[] {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === 'object') return [data];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function extractFieldsFromRows(rows: any[]): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const row of rows || []) {
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
|
||||
for (const key of Object.keys(row)) {
|
||||
if (key) names.add(key);
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
export function mergeDatasetFields(
|
||||
ds: ReportDatasetItem,
|
||||
rows: any[],
|
||||
): string[] {
|
||||
const names = new Set(extractFieldsFromRows(rows));
|
||||
const mapping = ds.field_mapping || {};
|
||||
for (const key of Object.keys(mapping)) {
|
||||
if (key) names.add(key);
|
||||
}
|
||||
for (const value of Object.values(mapping)) {
|
||||
if (value) names.add(String(value));
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
async function loadStaticFields(dataSourceId: string) {
|
||||
try {
|
||||
const detail = await getDataSourceDetailApi(dataSourceId);
|
||||
return extractFieldsFromRows(normalizePreviewRows(detail?.static_data));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDatasetFields(
|
||||
ds: ReportDatasetItem,
|
||||
): Promise<string[]> {
|
||||
const normalized = normalizeDataset(ds);
|
||||
const id = normalized.data_source_id;
|
||||
if (!id) return mergeDatasetFields(normalized, []);
|
||||
|
||||
try {
|
||||
const result = await previewDataSourceApi(id, { params: {}, limit: 5 });
|
||||
let fields = mergeDatasetFields(
|
||||
normalized,
|
||||
normalizePreviewRows(result?.data),
|
||||
);
|
||||
if (!fields.length) {
|
||||
fields = mergeDatasetFields(
|
||||
normalized,
|
||||
normalizePreviewRows(await loadStaticFields(id)),
|
||||
);
|
||||
}
|
||||
return fields;
|
||||
} catch {
|
||||
return mergeDatasetFields(
|
||||
normalized,
|
||||
normalizePreviewRows(await loadStaticFields(id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFieldOptions(
|
||||
datasets: ReportDatasetItem[],
|
||||
fieldsCache: Record<string, string[]>,
|
||||
options?: {
|
||||
alias?: string;
|
||||
withAlias?: boolean;
|
||||
},
|
||||
): FieldOption[] {
|
||||
const list = (datasets || []).map(normalizeDataset).filter((d) => d.alias);
|
||||
const filtered = options?.alias
|
||||
? list.filter((d) => d.alias === options.alias)
|
||||
: list;
|
||||
|
||||
const opts: FieldOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const ds of filtered) {
|
||||
const fields = fieldsCache[ds.data_source_id] || [];
|
||||
for (const field of fields) {
|
||||
const value = options?.withAlias === false ? field : `${ds.alias}.${field}`;
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
opts.push({ label: value, value });
|
||||
}
|
||||
}
|
||||
|
||||
return opts.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
export function buildFieldTree(
|
||||
datasets: ReportDatasetItem[],
|
||||
fieldsCache: Record<string, string[]>,
|
||||
options?: {
|
||||
alias?: string;
|
||||
withAlias?: boolean;
|
||||
currentValue?: string;
|
||||
},
|
||||
): FieldTreeNode[] {
|
||||
const withAlias = options?.withAlias !== false;
|
||||
const list = (datasets || []).map(normalizeDataset).filter((d) => d.alias);
|
||||
const filtered = options?.alias
|
||||
? list.filter((d) => d.alias === options.alias)
|
||||
: list;
|
||||
|
||||
const tree: FieldTreeNode[] = [];
|
||||
const valueSet = new Set<string>();
|
||||
|
||||
for (const ds of filtered) {
|
||||
const fields = fieldsCache[ds.data_source_id] || [];
|
||||
const children: FieldTreeNode[] = fields.map((field) => {
|
||||
const value = withAlias ? `${ds.alias}.${field}` : field;
|
||||
valueSet.add(value);
|
||||
return {
|
||||
label: withAlias ? `${ds.alias}.${field}` : field,
|
||||
value,
|
||||
};
|
||||
});
|
||||
|
||||
tree.push({
|
||||
label: ds.data_source_name
|
||||
? `${ds.alias} (${ds.data_source_name})`
|
||||
: ds.alias,
|
||||
value: `__dataset__:${ds.alias}`,
|
||||
disabled: true,
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmed = (options?.currentValue || '').trim();
|
||||
if (trimmed && !valueSet.has(trimmed)) {
|
||||
tree.unshift({
|
||||
label: trimmed,
|
||||
value: trimmed,
|
||||
});
|
||||
}
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
export function appendCustomFieldOption(
|
||||
options: FieldOption[],
|
||||
value?: string,
|
||||
): FieldOption[] {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) return options;
|
||||
if (options.some((opt) => opt.value === trimmed)) return options;
|
||||
return [{ label: trimmed, value: trimmed }, ...options];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
/** 展示预览/导出返回的 warning 码(与后端 preview_guard 对齐) */
|
||||
export function showPreviewWarnings(warnings: string[]) {
|
||||
for (const code of warnings) {
|
||||
if (code === 'expression_cycle') {
|
||||
ElMessage.warning($t('report-manager.preview.expressionCycle'));
|
||||
} else if (code === 'snapshot_large') {
|
||||
ElMessage.warning($t('report-manager.preview.snapshotLarge'));
|
||||
} else if (code.startsWith('dataset_row_warn:')) {
|
||||
const alias = code.slice('dataset_row_warn:'.length);
|
||||
ElMessage.warning($t('report-manager.preview.datasetRowWarn', { alias }));
|
||||
} else if (code.startsWith('dataset_row_limit:')) {
|
||||
const alias = code.slice('dataset_row_limit:'.length);
|
||||
ElMessage.warning($t('report-manager.preview.datasetRowLimit', { alias }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/** 解析报表媒体路径为可访问 URL(悬浮图片等) */
|
||||
export function resolveReportMediaUrl(path: string, baseUrl?: string): string {
|
||||
if (!path) return '';
|
||||
const trimmed = path.trim();
|
||||
if (/^data:image\//i.test(trimmed)) return trimmed;
|
||||
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||
|
||||
const apiPrefix = (baseUrl || import.meta.env.VITE_GLOB_API_URL || '/basic-api').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
|
||||
if (trimmed.startsWith('/basic-api')) return trimmed;
|
||||
if (trimmed.startsWith('/api/')) return `${apiPrefix}${trimmed}`;
|
||||
|
||||
const normalized = trimmed.startsWith('/') ? trimmed.slice(1) : trimmed;
|
||||
if (normalized.startsWith('api/')) return `${apiPrefix}/${normalized}`;
|
||||
|
||||
return `${apiPrefix}/api/file_manager/file/download?path=${encodeURIComponent(trimmed)}`;
|
||||
}
|
||||
|
||||
function resolveMediaInObject(obj: Record<string, any>, baseUrl?: string) {
|
||||
if (typeof obj.src === 'string' && obj.src) {
|
||||
obj.src = resolveReportMediaUrl(obj.src, baseUrl);
|
||||
}
|
||||
if (typeof obj.source === 'string' && obj.source && !obj.source.startsWith('data:')) {
|
||||
obj.source = resolveReportMediaUrl(obj.source, baseUrl);
|
||||
}
|
||||
if (typeof obj.url === 'string' && obj.url) {
|
||||
obj.url = resolveReportMediaUrl(obj.url, baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 cells 中 floatImages 的媒体 URL */
|
||||
export function resolveReportMediaUrlsInCells(
|
||||
cells: Record<string, any>,
|
||||
baseUrl?: string,
|
||||
): Record<string, any> {
|
||||
if (!cells || typeof cells !== 'object') return cells;
|
||||
const next = JSON.parse(JSON.stringify(cells)) as Record<string, any>;
|
||||
const floatImages = next.floatImages;
|
||||
if (floatImages && typeof floatImages === 'object') {
|
||||
for (const item of Object.values(floatImages) as any[]) {
|
||||
if (item?.option) resolveMediaInObject(item.option, baseUrl);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 解析 snapshot 内 SHEET_DRAWING_PLUGIN 等资源中的图片 URL */
|
||||
export function resolveReportMediaUrlsInSnapshot(
|
||||
snapshot: Record<string, any>,
|
||||
baseUrl?: string,
|
||||
): Record<string, any> {
|
||||
if (!snapshot || typeof snapshot !== 'object') return snapshot;
|
||||
const next = JSON.parse(JSON.stringify(snapshot)) as Record<string, any>;
|
||||
const resources = next.resources;
|
||||
if (!Array.isArray(resources)) return next;
|
||||
|
||||
for (const resource of resources) {
|
||||
if (resource?.name !== 'SHEET_DRAWING_PLUGIN' || !resource.data) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(resource.data);
|
||||
for (const sheetBlock of Object.values(parsed) as any[]) {
|
||||
const data = sheetBlock?.data;
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
for (const drawing of Object.values(data) as any[]) {
|
||||
if (typeof drawing?.source === 'string' && drawing.source) {
|
||||
drawing.source = resolveReportMediaUrl(drawing.source, baseUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
resource.data = JSON.stringify(parsed);
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 合并解析 snapshot 与 cells 中的媒体 URL */
|
||||
export function resolveReportMediaUrls(
|
||||
payload: { snapshot?: Record<string, any>; cells?: Record<string, any> },
|
||||
baseUrl?: string,
|
||||
) {
|
||||
return {
|
||||
snapshot: payload.snapshot
|
||||
? resolveReportMediaUrlsInSnapshot(payload.snapshot, baseUrl)
|
||||
: payload.snapshot,
|
||||
cells: payload.cells
|
||||
? resolveReportMediaUrlsInCells(payload.cells, baseUrl)
|
||||
: payload.cells,
|
||||
};
|
||||
}
|
||||
|
||||
/** 统计 snapshot / cells 中的图片数量(用于打印前告警) */
|
||||
export function countSnapshotImages(
|
||||
snapshot?: Record<string, any>,
|
||||
cells?: Record<string, any>,
|
||||
): number {
|
||||
let count = 0;
|
||||
|
||||
const floatImages = cells?.floatImages;
|
||||
if (floatImages && typeof floatImages === 'object') {
|
||||
count += Object.keys(floatImages).length;
|
||||
}
|
||||
|
||||
const resources = snapshot?.resources;
|
||||
if (Array.isArray(resources)) {
|
||||
for (const resource of resources) {
|
||||
if (resource?.name !== 'SHEET_DRAWING_PLUGIN' || !resource.data) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(resource.data);
|
||||
for (const sheetBlock of Object.values(parsed) as any[]) {
|
||||
const data = sheetBlock?.data;
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
for (const drawing of Object.values(data) as any[]) {
|
||||
if (drawing?.source || drawing?.imageSourceType) count += 1;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sheets = snapshot?.sheets;
|
||||
if (sheets && typeof sheets === 'object') {
|
||||
for (const sheet of Object.values(sheets) as any[]) {
|
||||
const cellData = sheet?.cellData;
|
||||
if (!cellData || typeof cellData !== 'object') continue;
|
||||
for (const row of Object.values(cellData) as any[]) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
for (const cell of Object.values(row) as any[]) {
|
||||
if (cell?.p?.drawings) {
|
||||
count += Object.keys(cell.p.drawings).length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ReportSettingsForm {
|
||||
allow_export: boolean;
|
||||
allow_print: boolean;
|
||||
allow_watermark: boolean;
|
||||
watermark_text: string;
|
||||
watermark_show_time: boolean;
|
||||
watermark_time_format: string;
|
||||
}
|
||||
|
||||
export function parseReportSettings(
|
||||
reportName: string,
|
||||
options: {
|
||||
allowExport?: boolean;
|
||||
allowPrint?: boolean;
|
||||
allowWatermark?: boolean;
|
||||
watermarkConfig?: Record<string, any>;
|
||||
},
|
||||
): ReportSettingsForm {
|
||||
const wc = options.watermarkConfig || {};
|
||||
return {
|
||||
allow_export: options.allowExport !== false,
|
||||
allow_print: options.allowPrint !== false,
|
||||
allow_watermark: !!options.allowWatermark,
|
||||
watermark_text: (wc.content as string) || reportName,
|
||||
watermark_show_time: !!wc.showTime,
|
||||
watermark_time_format: (wc.timeFormat as string) || 'yyyy-MM-dd',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildReportSettingsPayload(
|
||||
form: ReportSettingsForm,
|
||||
reportName: string,
|
||||
) {
|
||||
return {
|
||||
allow_export: form.allow_export,
|
||||
allow_print: form.allow_print,
|
||||
allow_watermark: form.allow_watermark,
|
||||
watermark_config: form.allow_watermark
|
||||
? {
|
||||
content: form.watermark_text || reportName,
|
||||
opacity: 0.12,
|
||||
rotate: -30,
|
||||
repeat: true,
|
||||
showTime: form.watermark_show_time,
|
||||
timeFormat: form.watermark_time_format,
|
||||
}
|
||||
: {},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user