feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
@@ -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>
@@ -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>
@@ -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,
}
: {},
};
}