From 2ed657b7630bebea438b9f786be78ec439a8c643 Mon Sep 17 00:00:00 2001 From: Feynman Date: Thu, 2 Apr 2026 17:05:19 +0800 Subject: [PATCH 1/6] refactor: simplify initNodeType function by removing syncType parameter and using isSyncTask for condition checks --- packages/dag/src/EditorView.vue | 4 ++-- packages/dag/src/composables/useCanvasOperation.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/dag/src/EditorView.vue b/packages/dag/src/EditorView.vue index 1d55e5174..c7eb66260 100644 --- a/packages/dag/src/EditorView.vue +++ b/packages/dag/src/EditorView.vue @@ -82,8 +82,8 @@ const init = async () => { await dataflowStore.initPdkProperties() if (taskId) { + await initNodeType() await dataflowStore.fetchDataflow(taskId) - await initNodeType(dataflowStore.dataflow.syncType) // nextTick(() => { // setTimeout(() => { // canvasRef.value.fitViewWithOffset({ duration: 0, maxZoom: 1 }) @@ -100,7 +100,7 @@ const init = async () => { syncType = 'migrate' targetRoute = 'MigrateEditor' } - await initNodeType(syncType!) + await initNodeType() await dataflowStore.createDataflow(syncType) router.push({ name: targetRoute, diff --git a/packages/dag/src/composables/useCanvasOperation.ts b/packages/dag/src/composables/useCanvasOperation.ts index 305697799..5f21d94d7 100644 --- a/packages/dag/src/composables/useCanvasOperation.ts +++ b/packages/dag/src/composables/useCanvasOperation.ts @@ -243,10 +243,10 @@ export function useCanvasOperation() { } */, ] - const initNodeType = async (syncType: string) => { - let nodes = syncType === 'sync' ? syncProcessor : migrateProcessor + const initNodeType = async () => { + let nodes = isSyncTask.value ? syncProcessor : migrateProcessor //仅企业版有的节点 - if (isDaas && syncType === 'sync') { + if (isDaas && isSyncTask.value) { const isDaasNode = [ { name: t('packages_dag_src_editor_join'), @@ -258,7 +258,7 @@ export function useCanvasOperation() { dataflowStore.addProcessorNode(nodes.filter((item) => !item.hidden)) // dataflowStore.addResourceIns(allResourceIns) - if (syncType === 'sync' && hasFeature('customProcessor')) { + if (isSyncTask.value && hasFeature('customProcessor')) { await dataflowStore.loadCustomNode() } } From db4f3fbf97e738fe9a98c057e52bfb8eaba99b5f Mon Sep 17 00:00:00 2001 From: Feynman Date: Fri, 3 Apr 2026 12:35:28 +0800 Subject: [PATCH 2/6] feat: integrate AggregatePanel component into node-design --- packages/form/package.json | 1 + .../components/aggregate/AggregateFields.tsx | 156 +++++++++ .../components/aggregate/AggregatePanel.tsx | 280 ++++++++++++++++ .../src/components/aggregate/GroupFields.tsx | 127 ++++++++ .../src/components/aggregate/MatchFilter.tsx | 173 ++++++++++ .../components/aggregate/PipelineEditor.tsx | 294 +++++++++++++++++ .../components/aggregate/PipelinePreview.tsx | 65 ++++ .../src/components/aggregate/buildPipeline.ts | 114 +++++++ .../form/src/components/aggregate/index.tsx | 17 + .../components/aggregate/resolveSourceInfo.ts | 133 ++++++++ .../form/src/components/aggregate/style.scss | 308 ++++++++++++++++++ .../form/src/components/aggregate/types.ts | 5 + packages/form/src/components/index.ts | 1 + packages/form/src/locale/lang/en.js | 68 ++++ packages/form/src/locale/lang/zh-CN.js | 68 ++++ packages/form/src/locale/lang/zh-TW.js | 68 ++++ packages/node-design/src/Editor.vue | 4 +- .../widgets/NodeTitleWidget/index.tsx | 7 +- .../node-design/src/hooks/useNodeIdProps.ts | 2 +- .../components/AggregatePanel/index.tsx | 2 + .../components/AggregatePanel/preview.tsx | 47 +++ .../src/sources/components/Field/preview.tsx | 11 +- .../src/sources/components/index.tsx | 1 + .../src/sources/locales/AggregatePanel.tsx | 21 ++ .../node-design/src/sources/locales/all.tsx | 1 + packages/types/src/daas-auto-imports.d.ts | 4 + packages/types/src/daas-components.d.ts | 8 + pnpm-lock.yaml | 3 + 28 files changed, 1984 insertions(+), 5 deletions(-) create mode 100644 packages/form/src/components/aggregate/AggregateFields.tsx create mode 100644 packages/form/src/components/aggregate/AggregatePanel.tsx create mode 100644 packages/form/src/components/aggregate/GroupFields.tsx create mode 100644 packages/form/src/components/aggregate/MatchFilter.tsx create mode 100644 packages/form/src/components/aggregate/PipelineEditor.tsx create mode 100644 packages/form/src/components/aggregate/PipelinePreview.tsx create mode 100644 packages/form/src/components/aggregate/buildPipeline.ts create mode 100644 packages/form/src/components/aggregate/index.tsx create mode 100644 packages/form/src/components/aggregate/resolveSourceInfo.ts create mode 100644 packages/form/src/components/aggregate/style.scss create mode 100644 packages/form/src/components/aggregate/types.ts create mode 100644 packages/node-design/src/sources/components/AggregatePanel/index.tsx create mode 100644 packages/node-design/src/sources/components/AggregatePanel/preview.tsx create mode 100644 packages/node-design/src/sources/locales/AggregatePanel.tsx diff --git a/packages/form/package.json b/packages/form/package.json index 1caae031c..e5a66dbc8 100644 --- a/packages/form/package.json +++ b/packages/form/package.json @@ -32,6 +32,7 @@ "element-plus": "catalog:", "highlight.js": "^11.9.0", "lodash": "catalog:", + "monaco-editor": "catalog:", "resize-observer-polyfill": "catalog:", "tiny-emitter": "catalog:", "vue": "catalog:", diff --git a/packages/form/src/components/aggregate/AggregateFields.tsx b/packages/form/src/components/aggregate/AggregateFields.tsx new file mode 100644 index 000000000..3d361ae4f --- /dev/null +++ b/packages/form/src/components/aggregate/AggregateFields.tsx @@ -0,0 +1,156 @@ +import { useI18n } from '@tap/i18n' +import { defineComponent, type PropType } from 'vue' +import { BaseFieldSelect } from '../field-select' + +export interface AggregateField { + id: string + outputField: string + operator: string + sourceField: string +} + +const AGG_OPERATORS = [ + { label: '$sum', value: '$sum' }, + { label: '$avg', value: '$avg' }, + { label: '$min', value: '$min' }, + { label: '$max', value: '$max' }, + { label: '$count', value: '$count' }, + { label: '$first', value: '$first' }, + { label: '$last', value: '$last' }, + { label: '$push', value: '$push' }, + { label: '$addToSet', value: '$addToSet' }, +] + +let aggIdCounter = 0 +function genId() { + return `agg_${++aggIdCounter}_${Date.now()}` +} + +export const AggregateFields = defineComponent({ + name: 'AggregateFields', + props: { + fields: { + type: Array as PropType, + default: () => [], + }, + fieldOptions: { + type: Array as PropType, + default: () => [], + }, + loading: Boolean, + }, + emits: ['update:fields'], + setup(props, { emit }) { + const { t } = useI18n() + + const addField = () => { + const newField: AggregateField = { + id: genId(), + outputField: '', + operator: '$sum', + sourceField: '', + } + emit('update:fields', [...props.fields, newField]) + } + + const removeField = (index: number) => { + const next = [...props.fields] + next.splice(index, 1) + emit('update:fields', next) + } + + const updateField = (index: number, patch: Partial) => { + const next = props.fields.map((f, i) => + i === index ? { ...f, ...patch } : f, + ) + emit('update:fields', next) + } + + const onDragStart = (e: DragEvent, index: number) => { + e.dataTransfer?.setData('text/plain', String(index)) + } + + const onDrop = (e: DragEvent, toIndex: number) => { + e.preventDefault() + const fromIndex = Number(e.dataTransfer?.getData('text/plain')) + if (isNaN(fromIndex) || fromIndex === toIndex) return + const next = [...props.fields] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved!) + emit('update:fields', next) + } + + return () => ( +
+ {props.fields.map((af, index) => ( +
onDragStart(e, index)} + onDragover={(e: DragEvent) => e.preventDefault()} + onDrop={(e: DragEvent) => onDrop(e, index)} + > +
+ + + +
+ + + updateField(index, { outputField: val }) + } + placeholder={t('packages_form_aggregate_output_field')} + /> + + + updateField(index, { operator: val }) + } + style={{ width: '120px' }} + > + {AGG_OPERATORS.map((op) => ( + + ))} + + + + updateField(index, { sourceField: val }), + } as any)} + /> + + removeField(index)} + icon={IconLucideTrash2} + size="small" + /> +
+ ))} + + + + + + {t('packages_form_aggregate_add_agg_field')} + +
+ ) + }, +}) diff --git a/packages/form/src/components/aggregate/AggregatePanel.tsx b/packages/form/src/components/aggregate/AggregatePanel.tsx new file mode 100644 index 000000000..3195ec8e8 --- /dev/null +++ b/packages/form/src/components/aggregate/AggregatePanel.tsx @@ -0,0 +1,280 @@ +import { SchemaExpressionScopeSymbol, useForm } from '@formily/vue' +import { + getNodeSchema, + getNodeSchemaPage, +} from '@tap/api/src/core/metadata-instances' +import { useI18n } from '@tap/i18n' +import { + computed, + defineComponent, + inject, + onMounted, + ref, + type PropType, +} from 'vue' +import { mapFieldsData } from '../field-select/FieldSelect' +import { AggregateFields, type AggregateField } from './AggregateFields' +import { buildPipelineJSON } from './buildPipeline' +import { GroupFields, type GroupField } from './GroupFields' +import { MatchFilter, type MatchCondition } from './MatchFilter' +import { PipelineEditor, type FieldItem } from './PipelineEditor' +import { PipelinePreview } from './PipelinePreview' +import { resolveSourceInfo } from './resolveSourceInfo' +import './style.scss' + +export const AggregatePanel = defineComponent({ + name: 'AggregatePanel', + props: { + value: { + type: Object as PropType<{ + useRawPipeline: boolean + rawPipeline: string + matchConditions: MatchCondition[] + groupFields: GroupField[] + aggregateFields: AggregateField[] + // 源节点信息(自动加载) + connectionName: string + databaseName: string + tableName: string + connectionId: string + databaseType: string + }>, + default: () => ({ + useRawPipeline: false, + rawPipeline: '[\n \n]', + matchConditions: [], + groupFields: [], + aggregateFields: [], + connectionName: '', + databaseName: '', + tableName: '', + connectionId: '', + databaseType: '', + }), + }, + }, + emits: ['change'], + setup(props, { emit }) { + const activeCollapse = ref(['match', 'group', 'aggregate']) + const fieldOptions = ref([]) + const rawFields = ref([]) + const fieldsLoading = ref(false) + + const { t } = useI18n() + + // Load fields via formily form context + let form: any + let nodeId: string | undefined + let findNodeById: ((id: string) => any) | undefined + try { + const formRef = useForm() + form = formRef.value + nodeId = form?.values?.id + + // 从 formily scope 中获取 findNodeById + const scopeRef = inject(SchemaExpressionScopeSymbol, null) + findNodeById = scopeRef?.value?.findNodeById + } catch { + // Not inside a formily context + } + + const loadFields = async () => { + if (!nodeId) return + fieldsLoading.value = true + try { + let fields: any[] = [] + if (form?.values?.type?.includes?.('migrate')) { + const result = await getNodeSchemaPage({ + nodeId, + fields: [ + 'original_name', + 'fields', + 'qualified_name', + 'name', + 'indices', + ], + page: 1, + pageSize: 1, + }) + const { fields: mapped } = mapFieldsData(result?.items?.[0]) + fields = mapped + } else { + const data = await getNodeSchema(nodeId) + const { fields: mapped } = mapFieldsData({ + fields: data?.[0]?.fields || [], + }) + fields = mapped + } + fieldOptions.value = fields + rawFields.value = fields.map((f: any) => ({ + field_name: f.field_name || f.value, + data_type: f.type || f.data_type, + })) + } catch (error) { + console.error('AggregatePanel loadFields error', error) + } finally { + fieldsLoading.value = false + } + } + + const loadSourceInfo = async () => { + if (!nodeId || !findNodeById) return + try { + const info = await resolveSourceInfo(nodeId, findNodeById) + if (info) { + emitChange({ + ...props.value, + connectionName: info.connectionName, + databaseName: info.databaseName, + tableName: info.tableName, + connectionId: info.connectionId, + databaseType: info.databaseType, + }) + } + } catch (error) { + console.error('AggregatePanel loadSourceInfo error', error) + } + } + + onMounted(() => { + loadFields() + loadSourceInfo() + }) + + const emitChange = (val: any) => emit('change', val) + + const config = computed({ + get: () => props.value, + set: (val) => emitChange(val), + }) + + const useRawPipeline = computed({ + get: () => config.value.useRawPipeline, + set: (val) => { + emitChange({ ...config.value, useRawPipeline: val }) + }, + }) + + const rawPipeline = computed({ + get: () => config.value.rawPipeline, + set: (val) => { + emitChange({ ...config.value, rawPipeline: val }) + }, + }) + + const pipelineJson = computed(() => buildPipelineJSON(config.value)) + + return () => ( +
+
+ {t('packages_form_aggregate_mode')} + + (useRawPipeline.value = !!val)} + active-text={t('packages_form_aggregate_raw_pipeline')} + inactive-text={t('packages_form_aggregate_visual')} + /> + +
+ + {useRawPipeline.value ? ( +
+ (rawPipeline.value = val)} + /> +
+ ) : ( + + {/* $match */} + + {{ + title: () => + renderCollapseTitle( + t('packages_form_aggregate_match_title'), + config.value.matchConditions.length, + t('packages_form_aggregate_match_tip'), + ), + default: () => ( + + emitChange({ ...config.value, matchConditions: val }) + } + /> + ), + }} + + {/* $group */} + + {{ + title: () => + renderCollapseTitle( + t('packages_form_aggregate_group_title'), + config.value.groupFields.length, + t('packages_form_aggregate_group_tip'), + ), + default: () => ( + + emitChange({ ...config.value, groupFields: val }) + } + /> + ), + }} + + {/* Aggregations */} + + {{ + title: () => + renderCollapseTitle( + t('packages_form_aggregate_fields_title'), + config.value.aggregateFields.length, + t('packages_form_aggregate_fields_tip'), + ), + default: () => ( + + emitChange({ ...config.value, aggregateFields: val }) + } + /> + ), + }} + + + )} + + {!useRawPipeline.value && } +
+ ) + }, +}) + +function renderCollapseTitle(title: string, count: number, tooltip: string) { + return ( +
+ {title} + {count > 0 && } + + + + + +
+ ) +} diff --git a/packages/form/src/components/aggregate/GroupFields.tsx b/packages/form/src/components/aggregate/GroupFields.tsx new file mode 100644 index 000000000..4d64352f5 --- /dev/null +++ b/packages/form/src/components/aggregate/GroupFields.tsx @@ -0,0 +1,127 @@ +import { useI18n } from '@tap/i18n' +import { defineComponent, type PropType } from 'vue' +import { BaseFieldSelect } from '../field-select' + +export interface GroupField { + id: string + field: string + alias: string +} + +let groupIdCounter = 0 +function genId() { + return `group_${++groupIdCounter}_${Date.now()}` +} + +export const GroupFields = defineComponent({ + name: 'GroupFields', + props: { + fields: { + type: Array as PropType, + default: () => [], + }, + fieldOptions: { + type: Array as PropType, + default: () => [], + }, + loading: Boolean, + }, + emits: ['update:fields'], + setup(props, { emit }) { + const { t } = useI18n() + + const addField = () => { + const newField: GroupField = { + id: genId(), + field: '', + alias: '', + } + emit('update:fields', [...props.fields, newField]) + } + + const removeField = (index: number) => { + const next = [...props.fields] + next.splice(index, 1) + emit('update:fields', next) + } + + const updateField = (index: number, patch: Partial) => { + const next = props.fields.map((f, i) => + i === index ? { ...f, ...patch } : f, + ) + emit('update:fields', next) + } + + const onDragStart = (e: DragEvent, index: number) => { + e.dataTransfer?.setData('text/plain', String(index)) + } + + const onDrop = (e: DragEvent, toIndex: number) => { + e.preventDefault() + const fromIndex = Number(e.dataTransfer?.getData('text/plain')) + if (isNaN(fromIndex) || fromIndex === toIndex) return + const next = [...props.fields] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved!) + emit('update:fields', next) + } + + return () => ( +
+ {props.fields.map((gf, index) => ( +
onDragStart(e, index)} + onDragover={(e: DragEvent) => e.preventDefault()} + onDrop={(e: DragEvent) => onDrop(e, index)} + > +
+ + + +
+ + updateField(index, { field: val }), + } as any)} + /> + + + updateField(index, { alias: val }) + } + placeholder={t('packages_form_aggregate_alias_optional')} + /> + + removeField(index)} + icon={IconLucideTrash2} + size="small" + /> +
+ ))} + + + + + + {t('packages_form_aggregate_add_group_field')} + +
+ ) + }, +}) diff --git a/packages/form/src/components/aggregate/MatchFilter.tsx b/packages/form/src/components/aggregate/MatchFilter.tsx new file mode 100644 index 000000000..fade24494 --- /dev/null +++ b/packages/form/src/components/aggregate/MatchFilter.tsx @@ -0,0 +1,173 @@ +import { useI18n } from '@tap/i18n' +import { defineComponent, type PropType } from 'vue' +import { BaseFieldSelect } from '../field-select' + +export interface MatchCondition { + id: string + logic: 'AND' | 'OR' + field: string + operator: string + value: string +} + +const OPERATORS = [ + { label: '=', value: '=' }, + { label: '≠', value: '≠' }, + { label: '>', value: '>' }, + { label: '≥', value: '≥' }, + { label: '<', value: '<' }, + { label: '≤', value: '≤' }, + { label: 'IN', value: 'IN' }, + { label: 'NOT IN', value: 'NOT IN' }, + { label: 'REGEX', value: 'REGEX' }, +] + +let matchIdCounter = 0 +function genId() { + return `match_${++matchIdCounter}_${Date.now()}` +} + +export const MatchFilter = defineComponent({ + name: 'MatchFilter', + props: { + conditions: { + type: Array as PropType, + default: () => [], + }, + fieldOptions: { + type: Array as PropType, + default: () => [], + }, + loading: Boolean, + }, + emits: ['update:conditions'], + setup(props, { emit }) { + const { t } = useI18n() + + const addCondition = () => { + const newCondition: MatchCondition = { + id: genId(), + logic: 'AND', + field: '', + operator: '=', + value: '', + } + emit('update:conditions', [...props.conditions, newCondition]) + } + + const removeCondition = (index: number) => { + const next = [...props.conditions] + next.splice(index, 1) + emit('update:conditions', next) + } + + const updateCondition = (index: number, patch: Partial) => { + const next = props.conditions.map((c, i) => + i === index ? { ...c, ...patch } : c, + ) + emit('update:conditions', next) + } + + const onDragStart = (e: DragEvent, index: number) => { + e.dataTransfer?.setData('text/plain', String(index)) + } + + const onDrop = (e: DragEvent, toIndex: number) => { + e.preventDefault() + const fromIndex = Number(e.dataTransfer?.getData('text/plain')) + if (isNaN(fromIndex) || fromIndex === toIndex) return + const next = [...props.conditions] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved!) + emit('update:conditions', next) + } + + return () => ( +
+ {props.conditions.map((cond, index) => ( +
onDragStart(e, index)} + onDragover={(e: DragEvent) => e.preventDefault()} + onDrop={(e: DragEvent) => onDrop(e, index)} + > +
+ + + +
+ + {index > 0 ? ( + + updateCondition(index, { logic: val as 'AND' | 'OR' }) + } + style={{ width: '80px' }} + > + + + + ) : ( + WHERE + )} + + + updateCondition(index, { field: val }), + } as any)} + /> + + + updateCondition(index, { operator: val }) + } + style={{ width: '100px' }} + > + {OPERATORS.map((op) => ( + + ))} + + + + updateCondition(index, { value: val }) + } + placeholder={t('packages_form_aggregate_input_value')} + /> + + removeCondition(index)} + icon={IconLucideTrash2} + size="small" + /> +
+ ))} + + + + + + {t('packages_form_aggregate_add_condition')} + +
+ ) + }, +}) diff --git a/packages/form/src/components/aggregate/PipelineEditor.tsx b/packages/form/src/components/aggregate/PipelineEditor.tsx new file mode 100644 index 000000000..5f31fc94f --- /dev/null +++ b/packages/form/src/components/aggregate/PipelineEditor.tsx @@ -0,0 +1,294 @@ +import { useI18n } from '@tap/i18n' +import { useFullscreen } from '@vueuse/core' +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker' +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker' +import { + defineComponent, + nextTick, + onBeforeUnmount, + onMounted, + ref, + watch, + type PropType, +} from 'vue' +import 'monaco-editor/esm/vs/language/json/monaco.contribution' +import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution' +import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController' +import 'monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution' +import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching' +import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding' +import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController' +import 'monaco-editor/esm/vs/editor/contrib/format/browser/formatActions' +import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/browser/wordHighlighter' + +// Monaco worker setup (idempotent) +if (!(self as any).__pipelineEditorWorkerSetup) { + ;(self as any).__pipelineEditorWorkerSetup = true + ;(self as any).MonacoEnvironment = { + getWorker(_: string, label: string) { + if (label === 'json') return new jsonWorker() + return new editorWorker() + }, + } +} + +/** MongoDB aggregation stage operators — detail keys for i18n */ +const MONGO_AGG_OPERATORS = [ + { label: '$match', detailKey: 'packages_form_aggregate_op_match' }, + { label: '$group', detailKey: 'packages_form_aggregate_op_group' }, + { label: '$project', detailKey: 'packages_form_aggregate_op_project' }, + { label: '$sort', detailKey: 'packages_form_aggregate_op_sort' }, + { label: '$limit', detailKey: 'packages_form_aggregate_op_limit' }, + { label: '$skip', detailKey: 'packages_form_aggregate_op_skip' }, + { label: '$unwind', detailKey: 'packages_form_aggregate_op_unwind' }, + { label: '$lookup', detailKey: 'packages_form_aggregate_op_lookup' }, + { label: '$addFields', detailKey: 'packages_form_aggregate_op_addFields' }, + { label: '$set', detailKey: 'packages_form_aggregate_op_set' }, + { label: '$unset', detailKey: 'packages_form_aggregate_op_unset' }, + { + label: '$replaceRoot', + detailKey: 'packages_form_aggregate_op_replaceRoot', + }, + { label: '$count', detailKey: 'packages_form_aggregate_op_count' }, + { label: '$out', detailKey: 'packages_form_aggregate_op_out' }, + { label: '$merge', detailKey: 'packages_form_aggregate_op_merge' }, + // Accumulator / expression operators + { label: '$sum', detailKey: 'packages_form_aggregate_op_sum' }, + { label: '$avg', detailKey: 'packages_form_aggregate_op_avg' }, + { label: '$min', detailKey: 'packages_form_aggregate_op_min' }, + { label: '$max', detailKey: 'packages_form_aggregate_op_max' }, + { label: '$first', detailKey: 'packages_form_aggregate_op_first' }, + { label: '$last', detailKey: 'packages_form_aggregate_op_last' }, + { label: '$push', detailKey: 'packages_form_aggregate_op_push' }, + { label: '$addToSet', detailKey: 'packages_form_aggregate_op_addToSet' }, + // Comparison / logical + { label: '$eq', detailKey: 'packages_form_aggregate_op_eq' }, + { label: '$ne', detailKey: 'packages_form_aggregate_op_ne' }, + { label: '$gt', detailKey: 'packages_form_aggregate_op_gt' }, + { label: '$gte', detailKey: 'packages_form_aggregate_op_gte' }, + { label: '$lt', detailKey: 'packages_form_aggregate_op_lt' }, + { label: '$lte', detailKey: 'packages_form_aggregate_op_lte' }, + { label: '$in', detailKey: 'packages_form_aggregate_op_in' }, + { label: '$nin', detailKey: 'packages_form_aggregate_op_nin' }, + { label: '$and', detailKey: 'packages_form_aggregate_op_and' }, + { label: '$or', detailKey: 'packages_form_aggregate_op_or' }, + { label: '$not', detailKey: 'packages_form_aggregate_op_not' }, + { label: '$nor', detailKey: 'packages_form_aggregate_op_nor' }, + { label: '$exists', detailKey: 'packages_form_aggregate_op_exists' }, + { label: '$type', detailKey: 'packages_form_aggregate_op_type' }, + { label: '$regex', detailKey: 'packages_form_aggregate_op_regex' }, + { label: '$all', detailKey: 'packages_form_aggregate_op_all' }, + { label: '$elemMatch', detailKey: 'packages_form_aggregate_op_elemMatch' }, + { label: '$size', detailKey: 'packages_form_aggregate_op_size' }, +] + +export interface FieldItem { + field_name: string + data_type?: string +} + +export const PipelineEditor = defineComponent({ + name: 'PipelineEditor', + props: { + modelValue: { type: String, default: '[\n \n]' }, + fields: { type: Array as PropType, default: () => [] }, + height: { type: [Number, String], default: 260 }, + disabled: Boolean, + }, + emits: ['update:modelValue', 'change'], + setup(props, { emit }) { + const { t } = useI18n() + const containerRef = ref() + let editor: monaco.editor.IStandaloneCodeEditor | null = null + let completionDisposable: monaco.IDisposable | null = null + + const registerCompletion = () => { + completionDisposable?.dispose() + completionDisposable = monaco.languages.registerCompletionItemProvider( + 'json', + { + triggerCharacters: ['$', '"', '{'], + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position) + const lineContent = model.getLineContent(position.lineNumber) + const textBefore = lineContent.slice( + 0, + Math.max(0, position.column - 1), + ) + const lastQuote = Math.max( + textBefore.lastIndexOf('"'), + textBefore.lastIndexOf("'"), + ) + const inQuotes = + lastQuote > -1 && !textBefore.slice(lastQuote + 1).includes('"') + + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + } + + const suggestions: monaco.languages.CompletionItem[] = [] + + // Operator suggestions + MONGO_AGG_OPERATORS.filter((op) => + op.label.toLowerCase().startsWith(word.word.toLowerCase()), + ).forEach((op) => { + suggestions.push({ + label: op.label, + kind: monaco.languages.CompletionItemKind.Operator, + detail: t(op.detailKey), + insertText: inQuotes ? op.label : `"${op.label}"`, + range, + sortText: `2${op.label}`, + }) + }) + + // Field suggestions + if (props.fields.length) { + props.fields + .filter((f) => + f.field_name + ?.toLowerCase() + .startsWith(word.word.toLowerCase()), + ) + .forEach((f) => { + suggestions.push({ + label: f.field_name, + kind: monaco.languages.CompletionItemKind.Field, + detail: f.data_type || 'field', + insertText: inQuotes ? f.field_name : `"${f.field_name}"`, + range, + sortText: `1${f.field_name}`, + }) + }) + } + + return { suggestions } + }, + }, + ) + } + + onMounted(async () => { + await nextTick() + if (!containerRef.value) return + + editor = monaco.editor.create(containerRef.value, { + value: props.modelValue, + language: 'json', + theme: 'vs', + automaticLayout: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + readOnly: props.disabled, + lineNumbers: 'on', + lineNumbersMinChars: 3, + glyphMargin: false, + fontSize: 12, + tabSize: 2, + suggestOnTriggerCharacters: true, + quickSuggestions: true, + wordBasedSuggestions: 'off', + formatOnPaste: true, + }) + + editor.onDidChangeModelContent(() => { + const val = editor!.getValue() + emit('update:modelValue', val) + emit('change', val) + }) + + registerCompletion() + }) + + watch( + () => props.modelValue, + (val) => { + if (editor && editor.getValue() !== val) { + editor.setValue(val || '') + } + }, + ) + + watch(() => props.fields, registerCompletion) + + watch( + () => props.disabled, + (val) => editor?.updateOptions({ readOnly: val }), + ) + + onBeforeUnmount(() => { + completionDisposable?.dispose() + editor?.dispose() + editor = null + }) + + const wrapRef = ref() + const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(wrapRef) + + // 全屏切换后重新布局编辑器 + watch(isFullscreen, () => nextTick(() => editor?.layout())) + + const formatCode = async () => { + if (!editor) return + const formatAction = editor.getAction('editor.action.formatDocument') + if (formatAction) { + await formatAction.run() + } + } + + const heightPx = + typeof props.height === 'number' ? `${props.height}px` : props.height + + return () => ( +
+
+ + + + + + +
+
+
+ ) + }, +}) diff --git a/packages/form/src/components/aggregate/PipelinePreview.tsx b/packages/form/src/components/aggregate/PipelinePreview.tsx new file mode 100644 index 000000000..d898eddf5 --- /dev/null +++ b/packages/form/src/components/aggregate/PipelinePreview.tsx @@ -0,0 +1,65 @@ +import Highlight from '@tap/component/src/base/Highlight' +import { useI18n } from '@tap/i18n' +import { copyToClipboard } from '@tap/shared' +import { useDark } from '@vueuse/core' +import { defineComponent, nextTick, ref } from 'vue' + +export const PipelinePreview = defineComponent({ + name: 'PipelinePreview', + props: { + code: { + type: String, + default: '[]', + }, + }, + setup(props) { + const isDark = useDark() + const { t } = useI18n() + const tooltipRef = ref(null) + const tooltipContent = ref(t('public_button_copy')) + + const onCopy = async () => { + await copyToClipboard(props.code) + tooltipContent.value = t('public_message_copied') + nextTick(() => { + tooltipRef.value?.updatePopper?.() + }) + } + + const onMouseleave = () => { + setTimeout(() => { + tooltipContent.value = t('public_button_copy') + }, 200) + } + + return () => ( +
+
+ + {t('packages_form_aggregate_pipeline_preview')} + + + + +
+
+ {/* @ts-expect-error Highlight lacks proper type declarations */} + +
+
+ ) + }, +}) diff --git a/packages/form/src/components/aggregate/buildPipeline.ts b/packages/form/src/components/aggregate/buildPipeline.ts new file mode 100644 index 000000000..09c76038e --- /dev/null +++ b/packages/form/src/components/aggregate/buildPipeline.ts @@ -0,0 +1,114 @@ +import type { AggregateField } from './AggregateFields' +import type { GroupField } from './GroupFields' +import type { MatchCondition } from './MatchFilter' + +export interface AggregatePanelValue { + useRawPipeline: boolean + rawPipeline: string + matchConditions: MatchCondition[] + groupFields: GroupField[] + aggregateFields: AggregateField[] + // 源节点信息(自动加载) + connectionName?: string + databaseName?: string + tableName?: string + connectionId?: string + databaseType?: string +} + +const OP_MAP: Record = { + '=': '$eq', + '≠': '$ne', + '>': '$gt', + '≥': '$gte', + '<': '$lt', + '≤': '$lte', + IN: '$in', + 'NOT IN': '$nin', + REGEX: '$regex', +} + +/** + * 根据 AggregatePanel 的值生成 MongoDB 聚合管道数组 + */ +export function buildPipelineStages(value: AggregatePanelValue): any[] | null { + if (value.useRawPipeline) { + try { + return JSON.parse(value.rawPipeline) + } catch { + return null + } + } + + const stages: any[] = [] + + // $match + if (value.matchConditions.length > 0) { + const matchObj: Record = {} + const conditions = value.matchConditions.map((c) => { + const mongoOp = OP_MAP[c.operator] || '$eq' + let val: any = c.value + if (c.operator === 'IN' || c.operator === 'NOT IN') { + val = val.split(',').map((s: string) => s.trim()) + } + return { [c.field]: { [mongoOp]: val } } + }) + + if (conditions.length === 1) { + Object.assign(matchObj, conditions[0]) + } else { + const hasOr = value.matchConditions.some((c) => c.logic === 'OR') + if (hasOr) { + matchObj.$or = conditions + } else { + conditions.forEach((c) => Object.assign(matchObj, c)) + } + } + stages.push({ $match: matchObj }) + } + + // $group + if (value.groupFields.length > 0 || value.aggregateFields.length > 0) { + const groupObj: Record = {} + + if (value.groupFields.length === 1 && !value.groupFields[0]?.alias) { + groupObj._id = `$${value.groupFields[0]?.field}` + } else if (value.groupFields.length > 0) { + groupObj._id = {} + value.groupFields.forEach((g) => { + const key = g.alias || g.field + groupObj._id[key] = `$${g.field}` + }) + } else { + groupObj._id = null + } + + value.aggregateFields.forEach((a) => { + const opLower = a.operator.toLowerCase() + if (a.operator === '$count') { + groupObj[a.outputField] = { $sum: 1 } + } else { + groupObj[a.outputField] = { [opLower]: `$${a.sourceField}` } + } + }) + + stages.push({ $group: groupObj }) + } + + return stages +} + +/** + * 根据 AggregatePanel 的值生成 MongoDB 聚合管道 JSON 字符串 + * @param value AggregatePanel 组件的 value + * @param indent JSON 缩进空格数,默认 2 + */ +export function buildPipelineJSON( + value: AggregatePanelValue, + indent = 2, +): string { + const stages = buildPipelineStages(value) + if (stages === null) return value.rawPipeline || '[]' + return JSON.stringify(stages, null, indent) +} + diff --git a/packages/form/src/components/aggregate/index.tsx b/packages/form/src/components/aggregate/index.tsx new file mode 100644 index 000000000..56a566eb1 --- /dev/null +++ b/packages/form/src/components/aggregate/index.tsx @@ -0,0 +1,17 @@ +export { AggregatePanel } from './AggregatePanel' +export { buildPipelineJSON, buildPipelineStages } from './buildPipeline' +export { resolveSourceInfo } from './resolveSourceInfo' +export { MatchFilter } from './MatchFilter' +export { GroupFields } from './GroupFields' +export { AggregateFields } from './AggregateFields' +export { PipelineEditor } from './PipelineEditor' +export { PipelinePreview } from './PipelinePreview' + +export type { AggregatePanelValue } from './buildPipeline' +export type { SourceInfo } from './resolveSourceInfo' +export type { MatchCondition } from './MatchFilter' +export type { GroupField } from './GroupFields' +export type { AggregateField } from './AggregateFields' +export type { FieldItem } from './PipelineEditor' +export type { FieldOption } from './types' + diff --git a/packages/form/src/components/aggregate/resolveSourceInfo.ts b/packages/form/src/components/aggregate/resolveSourceInfo.ts new file mode 100644 index 000000000..18f9871f6 --- /dev/null +++ b/packages/form/src/components/aggregate/resolveSourceInfo.ts @@ -0,0 +1,133 @@ +import { getConnectionNoSchema } from '@tap/api/src/core/connections' + +export interface SourceInfo { + /** 连接名称 */ + connectionName: string + /** 数据库名称 */ + databaseName: string + /** 表名 */ + tableName: string + /** 连接ID */ + connectionId: string + /** 数据库类型,如 MongoDB */ + databaseType: string +} + +/** + * 从 MongoDB URI 中解析数据库名称 + * 格式: mongodb://user:pass@host:port/database?params + */ +function parseDatabaseFromUri(uri: string): string { + try { + // 去掉协议前缀 mongodb:// 或 mongodb+srv:// + const withoutProtocol = uri.replace(/^mongodb(\+srv)?:\/\//, '') + // 去掉认证信息 user:pass@ + const afterAuth = withoutProtocol.includes('@') + ? withoutProtocol.slice(withoutProtocol.indexOf('@') + 1) + : withoutProtocol + // 去掉 host:port(可能有多个逗号分隔的 host) + // 找到第一个 / 后面就是 database?params + const slashIndex = afterAuth.indexOf('/') + if (slashIndex === -1) return '' + const afterSlash = afterAuth.slice(slashIndex + 1) + // 去掉查询参数 + const qIndex = afterSlash.indexOf('?') + return qIndex === -1 ? afterSlash : afterSlash.slice(0, qIndex) + } catch { + return '' + } +} + +/** + * 从连接详情中解析数据库名称 + */ +function resolveDatabaseName(connection: any): string { + // 优先取顶层 database_name(后端可能已经解析好了) + if (connection.database_name) { + return connection.database_name + } + + const config = connection.config || {} + + // isUri 模式:从 URI 中解析 + if (config.isUri && config.uri) { + return parseDatabaseFromUri(config.uri) || '' + } + + // 非 URI 模式:直接取 config.database + return config.database || '' +} + +/** + * 沿 DAG 向上遍历,找到第一个 type=table 的源节点 + * @param currentNodeId 当前节点 ID + * @param findNodeById 根据 ID 查找节点的函数(从 dataflowStore 或 formScope 获取) + */ +function findSourceTableNode( + currentNodeId: string, + findNodeById: (id: string) => any, +): any | null { + const visited = new Set() + + const walk = (nodeId: string): any | null => { + if (visited.has(nodeId)) return null + visited.add(nodeId) + + const node = findNodeById(nodeId) + if (!node) return null + + if (node.type === 'table') return node + + const parentIds: string[] = node.$inputs || [] + for (const pid of parentIds) { + const found = walk(pid) + if (found) return found + } + return null + } + + return walk(currentNodeId) +} + +/** + * 解析当前聚合节点对应的源数据节点信息 + * + * @param currentNodeId 当前聚合节点 ID + * @param findNodeById 根据 ID 查找 DAG 节点的函数 + * @returns 源节点信息,包含连接名称、数据库名称、表名等 + * + * @example + * ```ts + * import { resolveSourceInfo } from '@tap/form' + * + * const info = await resolveSourceInfo(nodeId, dataflowStore.findNodeById) + * // info.connectionName → 'mongodb_source' + * // info.databaseName → 'source' + * // info.tableName → 'orders' + * // info.databaseType → 'MongoDB' + * ``` + */ +export async function resolveSourceInfo( + currentNodeId: string, + findNodeById: (id: string) => any, +): Promise { + const sourceNode = findSourceTableNode(currentNodeId, findNodeById) + if (!sourceNode) return null + + const connectionId: string = sourceNode.connectionId + const tableName: string = sourceNode.tableName || '' + + if (!connectionId) return null + + const connection = await getConnectionNoSchema(connectionId) + if (!connection) return null + + return { + connectionName: connection.name || '', + databaseName: resolveDatabaseName(connection), + tableName, + connectionId, + databaseType: connection.database_type || '', + } +} + diff --git a/packages/form/src/components/aggregate/style.scss b/packages/form/src/components/aggregate/style.scss new file mode 100644 index 000000000..f64dde1e8 --- /dev/null +++ b/packages/form/src/components/aggregate/style.scss @@ -0,0 +1,308 @@ +.aggregate-panel { + color: var(--el-text-color-regular, #606266); + + &__mode { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 0 12px; + + .el-switch { + --el-switch-on-color: var(--el-color-primary, #409eff); + } + } + + &__raw { + margin-bottom: 12px; + } + + .el-switch__label { + font-weight: 400; + &:not(.is-active) { + color: var(--el-text-color-regular, #606266); + } + } + + // Collapse overrides for Element Plus + .el-collapse { + border-top: none; + border-bottom: none; + } + + .el-collapse-item__header { + height: 40px; + line-height: 40px; + font-size: 13px; + font-weight: 500; + color: var(--el-text-color-primary, #303133); + background-color: var(--el-fill-color-lighter, #fafafa); + border-radius: 10px; + padding: 0 12px; + border-bottom: none; + margin-bottom: 4px; + + .el-badge { + margin-left: 4px; + vertical-align: middle; + } + + .el-badge__content--primary { + background-color: var(--el-color-primary, #409eff); + } + } + + .el-collapse-item__wrap { + border-bottom: none; + } + + .el-collapse-item__content { + padding: 8px 4px 12px; + } +} + +// Shared drag-item row styles +@mixin drag-item-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: var(--el-border-radius-base, 4px); + border: 1px solid transparent; + transition: + background-color 0.2s, + border-color 0.2s; + + &:hover { + background-color: var(--el-fill-color-light, #f5f7fa); + border-color: var(--el-border-color-lighter, #ebeef5); + + .match-filter__delete, + .group-fields__delete, + .aggregate-fields__delete { + opacity: 1; + } + } + + & + & { + margin-top: 4px; + } + + // compact inputs inside rows + .el-input, + .el-select, + .field-select { + --el-input-height: 28px; + font-size: 12px; + } +} + +@mixin drag-handle { + cursor: grab; + color: var(--el-text-color-placeholder, #c0c4cc); + display: flex; + align-items: center; + flex-shrink: 0; + padding: 2px; + border-radius: 2px; + transition: color 0.15s; + + &:hover { + color: var(--el-text-color-secondary, #909399); + } + + &:active { + cursor: grabbing; + color: var(--el-color-primary, #409eff); + } +} + +@mixin delete-btn { + opacity: 0; + transition: opacity 0.15s; + flex-shrink: 0; + + &.el-button { + --el-button-hover-text-color: var(--el-color-danger, #f56c6c); + padding: 4px; + } +} + +// MatchFilter +.match-filter { + &__item { + @include drag-item-row; + } + + &__drag-handle { + @include drag-handle; + } + + &__logic-placeholder { + display: inline-flex; + align-items: center; + justify-content: center; + width: 80px; + height: 28px; + font-size: 11px; + font-weight: 600; + color: var(--el-color-primary, #409eff); + background-color: var(--el-color-primary-light-9, #ecf5ff); + border-radius: var(--el-border-radius-base, 4px); + flex-shrink: 0; + letter-spacing: 1px; + } + + &__field { + flex: 1; + min-width: 0; + } + + &__operator { + flex-shrink: 0; + } + + &__value { + flex: 1; + min-width: 0; + } + + &__delete { + @include delete-btn; + } +} + +// GroupFields +.group-fields { + &__item { + @include drag-item-row; + } + + &__drag-handle { + @include drag-handle; + } + + &__field { + flex: 1; + min-width: 0; + } + + &__alias { + flex: 0.6; + min-width: 0; + } + + &__delete { + @include delete-btn; + } +} + +// AggregateFields +.aggregate-fields { + &__item { + @include drag-item-row; + } + + &__drag-handle { + @include drag-handle; + } + + &__output { + flex: 1; + min-width: 0; + } + + &__operator { + flex-shrink: 0; + } + + &__source { + flex: 1; + min-width: 0; + } + + &__delete { + @include delete-btn; + } +} + +// PipelinePreview +.pipeline-preview { + border-top: 1px solid var(--el-border-color-lighter, #ebeef5); + padding-top: 12px; + margin-top: 8px; + + &__code { + border-radius: 10px; + border: 1px solid var(--el-border-color-lighter, #ebeef5); + overflow: hidden; + font-size: 12px; + background-color: var(--el-fill-color-lighter, #fafafa); + + pre { + margin: 0; + padding: 12px 16px; + max-height: 300px; + overflow: auto; + + code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, + Courier, monospace; + font-size: 12px; + line-height: 1.6; + } + } + } +} + +// PipelineEditor +.pipeline-editor-wrap { + border: 1px solid var(--el-border-color, #dcdfe6); + border-radius: var(--el-border-radius-base, 4px); + overflow: hidden; + transition: border-color 0.2s; + position: relative; + + &:focus-within { + border-color: var(--el-color-primary, #409eff); + } + + // 全屏模式(浏览器原生 Fullscreen API) + &.is-fullscreen, + &:fullscreen { + border-radius: 0; + border: none; + background: var(--el-bg-color, #fff); + + .pipeline-editor { + height: calc(100vh - 36px) !important; + } + + .pipeline-editor-toolbar { + border-bottom: 1px solid var(--el-border-color-lighter, #ebeef5); + background-color: var(--el-bg-color, #fff); + } + } +} + +.pipeline-editor-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + padding: 2px 4px; + background-color: var(--el-fill-color-lighter, #fafafa); + border-bottom: 1px solid var(--el-border-color-extra-light, #f2f6fc); + + .el-button { + color: var(--el-text-color-secondary, #909399); + padding: 4px; + + &:hover { + color: var(--el-color-primary, #409eff); + } + } +} + +.pipeline-editor { + width: 100%; +} diff --git a/packages/form/src/components/aggregate/types.ts b/packages/form/src/components/aggregate/types.ts new file mode 100644 index 000000000..2354503fe --- /dev/null +++ b/packages/form/src/components/aggregate/types.ts @@ -0,0 +1,5 @@ +export interface FieldOption { + label: string + value: string + type?: string // e.g. 'String', 'Number', 'ObjectId', etc. +} diff --git a/packages/form/src/components/index.ts b/packages/form/src/components/index.ts index 4f34c7329..18084774d 100644 --- a/packages/form/src/components/index.ts +++ b/packages/form/src/components/index.ts @@ -49,4 +49,5 @@ export * from './verify-fields-dialog' export * from './infinite-select' // export * from './switch' // 为了扩展开关的二次确认 +export * from './aggregate' export * from './ElementPlus' diff --git a/packages/form/src/locale/lang/en.js b/packages/form/src/locale/lang/en.js index a3520c6e5..c5c42dd5a 100644 --- a/packages/form/src/locale/lang/en.js +++ b/packages/form/src/locale/lang/en.js @@ -222,4 +222,72 @@ export default { packages_form_batch_add_field_type: 'Type', packages_form_batch_add_field_count: 'Count', packages_form_batch_add_field_start: 'Start Number', + + // AggregatePanel + packages_form_aggregate_mode: 'Aggregate Mode', + packages_form_aggregate_mode_tip: 'Switch to raw pipeline mode to directly edit MongoDB aggregation pipeline JSON', + packages_form_aggregate_raw_pipeline: 'Raw Pipeline', + packages_form_aggregate_visual: 'Visual Config', + packages_form_aggregate_match_title: '$match Filter', + packages_form_aggregate_match_tip: 'Filter documents, only pass matching documents to the next stage', + packages_form_aggregate_group_title: '$group Fields', + packages_form_aggregate_group_tip: 'Specify fields for grouping, similar to SQL GROUP BY', + packages_form_aggregate_fields_title: 'Aggregate Fields', + packages_form_aggregate_fields_tip: 'Configure aggregate expressions, such as $sum, $avg, etc.', + packages_form_aggregate_add_condition: 'Add Condition', + packages_form_aggregate_add_group_field: 'Add Group Field', + packages_form_aggregate_add_agg_field: 'Add Aggregate Field', + packages_form_aggregate_select_field: 'Select Field', + packages_form_aggregate_select_group_field: 'Select Group Field', + packages_form_aggregate_select_source_field: 'Select Source Field', + packages_form_aggregate_input_value: 'Enter Value', + packages_form_aggregate_alias_optional: 'Alias (optional)', + packages_form_aggregate_output_field: 'Output Field Name', + packages_form_aggregate_pipeline_preview: 'Pipeline Preview', + packages_form_aggregate_format: 'Format (Alt+Shift+F)', + packages_form_aggregate_fullscreen: 'Fullscreen', + packages_form_aggregate_exit_fullscreen: 'Exit Fullscreen (Esc)', + + // PipelineEditor operators + packages_form_aggregate_op_match: 'Filter Documents', + packages_form_aggregate_op_group: 'Group', + packages_form_aggregate_op_project: 'Projection', + packages_form_aggregate_op_sort: 'Sort', + packages_form_aggregate_op_limit: 'Limit', + packages_form_aggregate_op_skip: 'Skip', + packages_form_aggregate_op_unwind: 'Unwind Array', + packages_form_aggregate_op_lookup: 'Lookup', + packages_form_aggregate_op_addFields: 'Add Fields', + packages_form_aggregate_op_set: 'Set Fields ($addFields alias)', + packages_form_aggregate_op_unset: 'Remove Fields', + packages_form_aggregate_op_replaceRoot: 'Replace Root', + packages_form_aggregate_op_count: 'Count', + packages_form_aggregate_op_out: 'Output to Collection', + packages_form_aggregate_op_merge: 'Merge to Collection', + packages_form_aggregate_op_sum: 'Sum', + packages_form_aggregate_op_avg: 'Average', + packages_form_aggregate_op_min: 'Min', + packages_form_aggregate_op_max: 'Max', + packages_form_aggregate_op_first: 'First Value', + packages_form_aggregate_op_last: 'Last Value', + packages_form_aggregate_op_push: 'Push to Array', + packages_form_aggregate_op_addToSet: 'Add to Set (deduplicate)', + packages_form_aggregate_op_eq: 'Equal', + packages_form_aggregate_op_ne: 'Not Equal', + packages_form_aggregate_op_gt: 'Greater Than', + packages_form_aggregate_op_gte: 'Greater Than or Equal', + packages_form_aggregate_op_lt: 'Less Than', + packages_form_aggregate_op_lte: 'Less Than or Equal', + packages_form_aggregate_op_in: 'In', + packages_form_aggregate_op_nin: 'Not In', + packages_form_aggregate_op_and: 'Logical AND', + packages_form_aggregate_op_or: 'Logical OR', + packages_form_aggregate_op_not: 'Logical NOT', + packages_form_aggregate_op_nor: 'Logical NOR', + packages_form_aggregate_op_exists: 'Exists', + packages_form_aggregate_op_type: 'Type Check', + packages_form_aggregate_op_regex: 'Regex Match', + packages_form_aggregate_op_all: 'Array Contains All', + packages_form_aggregate_op_elemMatch: 'Element Match', + packages_form_aggregate_op_size: 'Array Size', } diff --git a/packages/form/src/locale/lang/zh-CN.js b/packages/form/src/locale/lang/zh-CN.js index b90abc9e2..c89eed6b2 100644 --- a/packages/form/src/locale/lang/zh-CN.js +++ b/packages/form/src/locale/lang/zh-CN.js @@ -191,4 +191,72 @@ export default { packages_form_batch_add_field_type: '字段类型', packages_form_batch_add_field_count: '字段数量', packages_form_batch_add_field_start: '开始数字', + + // AggregatePanel + packages_form_aggregate_mode: '聚合模式', + packages_form_aggregate_mode_tip: '切换到原始管道模式可直接编辑 MongoDB 聚合管道 JSON', + packages_form_aggregate_raw_pipeline: '原始管道', + packages_form_aggregate_visual: '可视化配置', + packages_form_aggregate_match_title: '$match 筛选条件', + packages_form_aggregate_match_tip: '用于过滤文档,只传递满足条件的文档到下一阶段', + packages_form_aggregate_group_title: '$group 分组字段', + packages_form_aggregate_group_tip: '指定用于分组的字段,类似 SQL 的 GROUP BY', + packages_form_aggregate_fields_title: '聚合计算字段', + packages_form_aggregate_fields_tip: '配置聚合计算表达式,如 $sum、$avg 等', + packages_form_aggregate_add_condition: '添加条件', + packages_form_aggregate_add_group_field: '添加分组字段', + packages_form_aggregate_add_agg_field: '添加聚合字段', + packages_form_aggregate_select_field: '选择字段', + packages_form_aggregate_select_group_field: '选择分组字段', + packages_form_aggregate_select_source_field: '选择源字段', + packages_form_aggregate_input_value: '输入值', + packages_form_aggregate_alias_optional: '别名(可选)', + packages_form_aggregate_output_field: '输出字段名', + packages_form_aggregate_pipeline_preview: '管道预览', + packages_form_aggregate_format: '格式化 (Alt+Shift+F)', + packages_form_aggregate_fullscreen: '全屏编辑', + packages_form_aggregate_exit_fullscreen: '退出全屏 (Esc)', + + // PipelineEditor operators + packages_form_aggregate_op_match: '筛选文档', + packages_form_aggregate_op_group: '分组聚合', + packages_form_aggregate_op_project: '字段投影', + packages_form_aggregate_op_sort: '排序', + packages_form_aggregate_op_limit: '限制数量', + packages_form_aggregate_op_skip: '跳过文档', + packages_form_aggregate_op_unwind: '展开数组', + packages_form_aggregate_op_lookup: '关联查询', + packages_form_aggregate_op_addFields: '添加字段', + packages_form_aggregate_op_set: '设置字段($addFields 别名)', + packages_form_aggregate_op_unset: '移除字段', + packages_form_aggregate_op_replaceRoot: '替换根文档', + packages_form_aggregate_op_count: '计数', + packages_form_aggregate_op_out: '输出到集合', + packages_form_aggregate_op_merge: '合并到集合', + packages_form_aggregate_op_sum: '求和', + packages_form_aggregate_op_avg: '平均值', + packages_form_aggregate_op_min: '最小值', + packages_form_aggregate_op_max: '最大值', + packages_form_aggregate_op_first: '第一个值', + packages_form_aggregate_op_last: '最后一个值', + packages_form_aggregate_op_push: '推入数组', + packages_form_aggregate_op_addToSet: '添加到集合(去重)', + packages_form_aggregate_op_eq: '等于', + packages_form_aggregate_op_ne: '不等于', + packages_form_aggregate_op_gt: '大于', + packages_form_aggregate_op_gte: '大于等于', + packages_form_aggregate_op_lt: '小于', + packages_form_aggregate_op_lte: '小于等于', + packages_form_aggregate_op_in: '包含于', + packages_form_aggregate_op_nin: '不包含于', + packages_form_aggregate_op_and: '逻辑与', + packages_form_aggregate_op_or: '逻辑或', + packages_form_aggregate_op_not: '逻辑非', + packages_form_aggregate_op_nor: '逻辑或非', + packages_form_aggregate_op_exists: '字段存在', + packages_form_aggregate_op_type: '类型判断', + packages_form_aggregate_op_regex: '正则匹配', + packages_form_aggregate_op_all: '数组全包含', + packages_form_aggregate_op_elemMatch: '数组元素匹配', + packages_form_aggregate_op_size: '数组长度', } diff --git a/packages/form/src/locale/lang/zh-TW.js b/packages/form/src/locale/lang/zh-TW.js index 2f38fb22e..5d40ad253 100644 --- a/packages/form/src/locale/lang/zh-TW.js +++ b/packages/form/src/locale/lang/zh-TW.js @@ -191,4 +191,72 @@ export default { packages_form_batch_add_field_type: '字段類型', packages_form_batch_add_field_count: '字段數量', packages_form_batch_add_field_start: '開始數字', + + // AggregatePanel + packages_form_aggregate_mode: '聚合模式', + packages_form_aggregate_mode_tip: '切換到原始管道模式可直接編輯 MongoDB 聚合管道 JSON', + packages_form_aggregate_raw_pipeline: '原始管道', + packages_form_aggregate_visual: '可視化配置', + packages_form_aggregate_match_title: '$match 篩選條件', + packages_form_aggregate_match_tip: '用於過濾文檔,只傳遞滿足條件的文檔到下一階段', + packages_form_aggregate_group_title: '$group 分組字段', + packages_form_aggregate_group_tip: '指定用於分組的字段,類似 SQL 的 GROUP BY', + packages_form_aggregate_fields_title: '聚合計算字段', + packages_form_aggregate_fields_tip: '配置聚合計算表達式,如 $sum、$avg 等', + packages_form_aggregate_add_condition: '添加條件', + packages_form_aggregate_add_group_field: '添加分組字段', + packages_form_aggregate_add_agg_field: '添加聚合字段', + packages_form_aggregate_select_field: '選擇字段', + packages_form_aggregate_select_group_field: '選擇分組字段', + packages_form_aggregate_select_source_field: '選擇源字段', + packages_form_aggregate_input_value: '輸入值', + packages_form_aggregate_alias_optional: '別名(可選)', + packages_form_aggregate_output_field: '輸出字段名', + packages_form_aggregate_pipeline_preview: '管道預覽', + packages_form_aggregate_format: '格式化 (Alt+Shift+F)', + packages_form_aggregate_fullscreen: '全屏編輯', + packages_form_aggregate_exit_fullscreen: '退出全屏 (Esc)', + + // PipelineEditor operators + packages_form_aggregate_op_match: '篩選文檔', + packages_form_aggregate_op_group: '分組聚合', + packages_form_aggregate_op_project: '字段投影', + packages_form_aggregate_op_sort: '排序', + packages_form_aggregate_op_limit: '限制數量', + packages_form_aggregate_op_skip: '跳過文檔', + packages_form_aggregate_op_unwind: '展開數組', + packages_form_aggregate_op_lookup: '關聯查詢', + packages_form_aggregate_op_addFields: '添加字段', + packages_form_aggregate_op_set: '設置字段($addFields 別名)', + packages_form_aggregate_op_unset: '移除字段', + packages_form_aggregate_op_replaceRoot: '替換根文檔', + packages_form_aggregate_op_count: '計數', + packages_form_aggregate_op_out: '輸出到集合', + packages_form_aggregate_op_merge: '合併到集合', + packages_form_aggregate_op_sum: '求和', + packages_form_aggregate_op_avg: '平均值', + packages_form_aggregate_op_min: '最小值', + packages_form_aggregate_op_max: '最大值', + packages_form_aggregate_op_first: '第一個值', + packages_form_aggregate_op_last: '最後一個值', + packages_form_aggregate_op_push: '推入數組', + packages_form_aggregate_op_addToSet: '添加到集合(去重)', + packages_form_aggregate_op_eq: '等於', + packages_form_aggregate_op_ne: '不等於', + packages_form_aggregate_op_gt: '大於', + packages_form_aggregate_op_gte: '大於等於', + packages_form_aggregate_op_lt: '小於', + packages_form_aggregate_op_lte: '小於等於', + packages_form_aggregate_op_in: '包含於', + packages_form_aggregate_op_nin: '不包含於', + packages_form_aggregate_op_and: '邏輯與', + packages_form_aggregate_op_or: '邏輯或', + packages_form_aggregate_op_not: '邏輯非', + packages_form_aggregate_op_nor: '邏輯或非', + packages_form_aggregate_op_exists: '字段存在', + packages_form_aggregate_op_type: '類型判斷', + packages_form_aggregate_op_regex: '正則匹配', + packages_form_aggregate_op_all: '數組全包含', + packages_form_aggregate_op_elemMatch: '數組元素匹配', + packages_form_aggregate_op_size: '數組長度', } diff --git a/packages/node-design/src/Editor.vue b/packages/node-design/src/Editor.vue index 09555e297..34133fcda 100644 --- a/packages/node-design/src/Editor.vue +++ b/packages/node-design/src/Editor.vue @@ -39,6 +39,7 @@ import { import { GlobalRegistry } from './core' import * as icons from './icons' import { + AggregatePanel, Checkbox, Field, FieldSelect, @@ -98,7 +99,7 @@ export default { data() { return { sources: [Input, Select, InputNumber, Checkbox, Radio], - businessSources: [FieldSelect], + businessSources: [FieldSelect, AggregatePanel], components: markRaw({ Field, Input, @@ -109,6 +110,7 @@ export default { Checkbox, Radio, FieldSelect, + AggregatePanel, }), settingsFormComponents: markRaw({ SizeInput, diff --git a/packages/node-design/src/components/widgets/NodeTitleWidget/index.tsx b/packages/node-design/src/components/widgets/NodeTitleWidget/index.tsx index e4a4f0618..3107abc1b 100644 --- a/packages/node-design/src/components/widgets/NodeTitleWidget/index.tsx +++ b/packages/node-design/src/components/widgets/NodeTitleWidget/index.tsx @@ -1,5 +1,5 @@ import { observer } from '@formily/reactive-vue' -import { defineComponent, type PropType } from 'vue' +import { defineComponent, isRef, type PropType } from 'vue' import type { TreeNode } from '@designable/core' const NodeTitleWidgetComponent = defineComponent({ @@ -9,7 +9,10 @@ const NodeTitleWidgetComponent = defineComponent({ }, setup(props) { const takeNode = () => { - const node = props.node! + let node = props.node! + if (isRef(node)) { + node = node.value + } if (node.componentName === '$$ResourceNode$$') { return node.children[0] } diff --git a/packages/node-design/src/hooks/useNodeIdProps.ts b/packages/node-design/src/hooks/useNodeIdProps.ts index 13c93bdee..db88734b2 100644 --- a/packages/node-design/src/hooks/useNodeIdProps.ts +++ b/packages/node-design/src/hooks/useNodeIdProps.ts @@ -5,6 +5,6 @@ export const useNodeIdProps = (node) => { const target = useTreeNode() const designer = useDesigner() return { - [designer.props.nodeIdAttrName]: node ? node.id : target.id, + [designer.value.props.nodeIdAttrName]: node ? node.id : target.id, } } diff --git a/packages/node-design/src/sources/components/AggregatePanel/index.tsx b/packages/node-design/src/sources/components/AggregatePanel/index.tsx new file mode 100644 index 000000000..34b6d0162 --- /dev/null +++ b/packages/node-design/src/sources/components/AggregatePanel/index.tsx @@ -0,0 +1,2 @@ +export * from './preview' + diff --git a/packages/node-design/src/sources/components/AggregatePanel/preview.tsx b/packages/node-design/src/sources/components/AggregatePanel/preview.tsx new file mode 100644 index 000000000..2a4dafa6b --- /dev/null +++ b/packages/node-design/src/sources/components/AggregatePanel/preview.tsx @@ -0,0 +1,47 @@ +import { AggregatePanel } from '@tap/form' +import { createBehavior, createResource } from '../../../core' +import { AllLocales } from '../../locales' +import { AllSchemas } from '../../schemas' +import { createFieldSchema } from '../Field' + +export { AggregatePanel } + +AggregatePanel.Behavior = createBehavior({ + name: 'AggregatePanel', + extends: ['Field'], + selector: (node: any) => node.props?.['x-component'] === 'AggregatePanel', + designerProps: { + droppable: false, + propsSchema: createFieldSchema( + AllSchemas.AggregatePanel, + undefined, + undefined, + ), + }, + designerLocales: AllLocales.AggregatePanel, +}) + +AggregatePanel.Resource = createResource({ + icon: 'ObjectSource', + elements: [ + { + componentName: 'Field', + props: { + type: 'object', + 'x-component': 'AggregatePanel', + default: { + useRawPipeline: false, + rawPipeline: '[\n \n]', + matchConditions: [], + groupFields: [], + aggregateFields: [], + connectionName: '', + databaseName: '', + tableName: '', + connectionId: '', + databaseType: '', + }, + }, + }, + ], +}) diff --git a/packages/node-design/src/sources/components/Field/preview.tsx b/packages/node-design/src/sources/components/Field/preview.tsx index 45c7d9962..8368522d5 100644 --- a/packages/node-design/src/sources/components/Field/preview.tsx +++ b/packages/node-design/src/sources/components/Field/preview.tsx @@ -77,7 +77,12 @@ const filterExpression = (val) => { return val } -const toDesignableFieldProps = (schema, components, nodeIdAttrName, id) => { +export const toDesignableFieldProps = ( + schema, + components, + nodeIdAttrName, + id, +) => { const props = {} each(SchemaStateMap, (fieldKey, schemaKey) => { const value = schema[schemaKey] @@ -143,6 +148,10 @@ export const Field = observer( ) if (attrs.type === 'object') { + // 如果组件声明了 droppable: false,直接用 InternalField 渲染,不包 Container + if (node.designerProps?.droppable === false) { + return + } return ( diff --git a/packages/node-design/src/sources/components/index.tsx b/packages/node-design/src/sources/components/index.tsx index b02cac3bc..0be635094 100644 --- a/packages/node-design/src/sources/components/index.tsx +++ b/packages/node-design/src/sources/components/index.tsx @@ -26,3 +26,4 @@ export * from './InputNumber' // export * from './FormGrid' export * from './FormLayout' export * from './FieldSelect' +export * from './AggregatePanel' diff --git a/packages/node-design/src/sources/locales/AggregatePanel.tsx b/packages/node-design/src/sources/locales/AggregatePanel.tsx new file mode 100644 index 000000000..a2f07cd6c --- /dev/null +++ b/packages/node-design/src/sources/locales/AggregatePanel.tsx @@ -0,0 +1,21 @@ +export const AggregatePanel = { + 'zh-CN': { + title: '聚合面板', + settings: { + 'x-component-props': {}, + }, + }, + 'zh-TW': { + title: '聚合面板', + settings: { + 'x-component-props': {}, + }, + }, + 'en-US': { + title: 'Aggregate Panel', + settings: { + 'x-component-props': {}, + }, + }, +} + diff --git a/packages/node-design/src/sources/locales/all.tsx b/packages/node-design/src/sources/locales/all.tsx index a81e19a98..7e38d27a8 100644 --- a/packages/node-design/src/sources/locales/all.tsx +++ b/packages/node-design/src/sources/locales/all.tsx @@ -8,3 +8,4 @@ export * from './FormLayout' export * from './InputNumber' export * from './Checkbox' export * from './Radio' +export * from './AggregatePanel' diff --git a/packages/types/src/daas-auto-imports.d.ts b/packages/types/src/daas-auto-imports.d.ts index cde8fec0b..0743db7b4 100644 --- a/packages/types/src/daas-auto-imports.d.ts +++ b/packages/types/src/daas-auto-imports.d.ts @@ -22,12 +22,16 @@ declare global { const ElRadioButton: typeof import('element-plus/es').ElRadioButton const ElRadioGroup: typeof import('element-plus/es').ElRadioGroup const ElSwitch: typeof import('element-plus/es').ElSwitch + const IconLucideAlignLeft: typeof import('~icons/lucide/align-left').default const IconLucideArrowDownAZ: typeof import('~icons/lucide/arrow-down-a-z').default const IconLucideArrowUpZA: typeof import('~icons/lucide/arrow-up-z-a').default const IconLucideClock: typeof import('~icons/lucide/clock').default + const IconLucideCopy: typeof import('~icons/lucide/copy').default const IconLucideFileText: typeof import('~icons/lucide/file-text').default const IconLucideHash: typeof import('~icons/lucide/hash').default const IconLucideList: typeof import('~icons/lucide/list').default + const IconLucideMaximize2: typeof import('~icons/lucide/maximize2').default + const IconLucideMinimize2: typeof import('~icons/lucide/minimize2').default const IconLucideSettings2: typeof import('~icons/lucide/settings2').default const IconLucideTrash2: typeof import('~icons/lucide/trash2').default const IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert').default diff --git a/packages/types/src/daas-components.d.ts b/packages/types/src/daas-components.d.ts index 4e371e93b..15fd628b7 100644 --- a/packages/types/src/daas-components.d.ts +++ b/packages/types/src/daas-components.d.ts @@ -91,6 +91,7 @@ declare module 'vue' { ElUpload: typeof import('element-plus/es')['ElUpload'] IFluentFolderLink16Regular: typeof import('~icons/fluent/folder-link16-regular')['default'] ILucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default'] + ILucideAlignLeft: typeof import('~icons/lucide/align-left')['default'] ILucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] ILucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] ILucideBadgeAlert: typeof import('~icons/lucide/badge-alert')['default'] @@ -127,6 +128,7 @@ declare module 'vue' { ILucideGitBranch: typeof import('~icons/lucide/git-branch')['default'] ILucideGitCompareArrows: typeof import('~icons/lucide/git-compare-arrows')['default'] ILucideGithub: typeof import('~icons/lucide/github')['default'] + ILucideGripVertical: typeof import('~icons/lucide/grip-vertical')['default'] ILucideHardDrive: typeof import('~icons/lucide/hard-drive')['default'] ILucideHardDriveDownload: typeof import('~icons/lucide/hard-drive-download')['default'] ILucideInbox: typeof import('~icons/lucide/inbox')['default'] @@ -135,7 +137,9 @@ declare module 'vue' { ILucideLink2: typeof import('~icons/lucide/link2')['default'] ILucideList: typeof import('~icons/lucide/list')['default'] ILucideLoader: typeof import('~icons/lucide/loader')['default'] + ILucideMaximize2: typeof import('~icons/lucide/maximize2')['default'] ILucideMemoryStick: typeof import('~icons/lucide/memory-stick')['default'] + ILucideMinimize2: typeof import('~icons/lucide/minimize2')['default'] ILucideMinus: typeof import('~icons/lucide/minus')['default'] ILucideMonitor: typeof import('~icons/lucide/monitor')['default'] ILucideMonitorDown: typeof import('~icons/lucide/monitor-down')['default'] @@ -288,6 +292,7 @@ declare global { const ElUpload: typeof import('element-plus/es')['ElUpload'] const IFluentFolderLink16Regular: typeof import('~icons/fluent/folder-link16-regular')['default'] const ILucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default'] + const ILucideAlignLeft: typeof import('~icons/lucide/align-left')['default'] const ILucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] const ILucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] const ILucideBadgeAlert: typeof import('~icons/lucide/badge-alert')['default'] @@ -324,6 +329,7 @@ declare global { const ILucideGitBranch: typeof import('~icons/lucide/git-branch')['default'] const ILucideGitCompareArrows: typeof import('~icons/lucide/git-compare-arrows')['default'] const ILucideGithub: typeof import('~icons/lucide/github')['default'] + const ILucideGripVertical: typeof import('~icons/lucide/grip-vertical')['default'] const ILucideHardDrive: typeof import('~icons/lucide/hard-drive')['default'] const ILucideHardDriveDownload: typeof import('~icons/lucide/hard-drive-download')['default'] const ILucideInbox: typeof import('~icons/lucide/inbox')['default'] @@ -332,7 +338,9 @@ declare global { const ILucideLink2: typeof import('~icons/lucide/link2')['default'] const ILucideList: typeof import('~icons/lucide/list')['default'] const ILucideLoader: typeof import('~icons/lucide/loader')['default'] + const ILucideMaximize2: typeof import('~icons/lucide/maximize2')['default'] const ILucideMemoryStick: typeof import('~icons/lucide/memory-stick')['default'] + const ILucideMinimize2: typeof import('~icons/lucide/minimize2')['default'] const ILucideMinus: typeof import('~icons/lucide/minus')['default'] const ILucideMonitor: typeof import('~icons/lucide/monitor')['default'] const ILucideMonitorDown: typeof import('~icons/lucide/monitor-down')['default'] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5245c6d06..1e905bbb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1033,6 +1033,9 @@ importers: lodash: specifier: 'catalog:' version: 4.17.21 + monaco-editor: + specifier: 'catalog:' + version: 0.52.2 resize-observer-polyfill: specifier: 'catalog:' version: 1.5.1 From be8b62ca6ae40304ee4bdc183ce76a91e78f9d67 Mon Sep 17 00:00:00 2001 From: Feynman Date: Fri, 3 Apr 2026 15:53:02 +0800 Subject: [PATCH 3/6] feat: add 'custom_processor' to log tags filter in NodeLog component --- packages/business/src/components/logs/NodeLog.vue | 1 + packages/types/src/daas-components.d.ts | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/business/src/components/logs/NodeLog.vue b/packages/business/src/components/logs/NodeLog.vue index aaee42df9..2da676c4d 100644 --- a/packages/business/src/components/logs/NodeLog.vue +++ b/packages/business/src/components/logs/NodeLog.vue @@ -420,6 +420,7 @@ function addLogTagsFilter(params: any) { if ( node && [ + 'custom_processor', 'js_processor', 'migrate_js_processor', 'standard_js_processor', diff --git a/packages/types/src/daas-components.d.ts b/packages/types/src/daas-components.d.ts index 15fd628b7..9d31fd0f4 100644 --- a/packages/types/src/daas-components.d.ts +++ b/packages/types/src/daas-components.d.ts @@ -91,7 +91,6 @@ declare module 'vue' { ElUpload: typeof import('element-plus/es')['ElUpload'] IFluentFolderLink16Regular: typeof import('~icons/fluent/folder-link16-regular')['default'] ILucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default'] - ILucideAlignLeft: typeof import('~icons/lucide/align-left')['default'] ILucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] ILucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] ILucideBadgeAlert: typeof import('~icons/lucide/badge-alert')['default'] @@ -137,9 +136,7 @@ declare module 'vue' { ILucideLink2: typeof import('~icons/lucide/link2')['default'] ILucideList: typeof import('~icons/lucide/list')['default'] ILucideLoader: typeof import('~icons/lucide/loader')['default'] - ILucideMaximize2: typeof import('~icons/lucide/maximize2')['default'] ILucideMemoryStick: typeof import('~icons/lucide/memory-stick')['default'] - ILucideMinimize2: typeof import('~icons/lucide/minimize2')['default'] ILucideMinus: typeof import('~icons/lucide/minus')['default'] ILucideMonitor: typeof import('~icons/lucide/monitor')['default'] ILucideMonitorDown: typeof import('~icons/lucide/monitor-down')['default'] @@ -292,7 +289,6 @@ declare global { const ElUpload: typeof import('element-plus/es')['ElUpload'] const IFluentFolderLink16Regular: typeof import('~icons/fluent/folder-link16-regular')['default'] const ILucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default'] - const ILucideAlignLeft: typeof import('~icons/lucide/align-left')['default'] const ILucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] const ILucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] const ILucideBadgeAlert: typeof import('~icons/lucide/badge-alert')['default'] @@ -338,9 +334,7 @@ declare global { const ILucideLink2: typeof import('~icons/lucide/link2')['default'] const ILucideList: typeof import('~icons/lucide/list')['default'] const ILucideLoader: typeof import('~icons/lucide/loader')['default'] - const ILucideMaximize2: typeof import('~icons/lucide/maximize2')['default'] const ILucideMemoryStick: typeof import('~icons/lucide/memory-stick')['default'] - const ILucideMinimize2: typeof import('~icons/lucide/minimize2')['default'] const ILucideMinus: typeof import('~icons/lucide/minus')['default'] const ILucideMonitor: typeof import('~icons/lucide/monitor')['default'] const ILucideMonitorDown: typeof import('~icons/lucide/monitor-down')['default'] From 77f8a0605525861473fc716dfc40f2bd1236d505 Mon Sep 17 00:00:00 2001 From: Feynman Date: Fri, 3 Apr 2026 16:18:39 +0800 Subject: [PATCH 4/6] refactor: clean up MonitorView by removing unused watch and simplifying initNodeType call in data fetching --- packages/dag/src/MonitorView.vue | 11 ++--------- packages/dag/src/composables/useCanvasOperation.ts | 7 ++++++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/dag/src/MonitorView.vue b/packages/dag/src/MonitorView.vue index 40dde8d07..84b182108 100644 --- a/packages/dag/src/MonitorView.vue +++ b/packages/dag/src/MonitorView.vue @@ -10,7 +10,7 @@ import { TextEditable } from '@tap/component/src/base/text-editable' import Time from '@tap/shared/src/time' import { useDark } from '@vueuse/core' import { debounce } from 'lodash-es' -import { computed, onUnmounted, provide, ref, watch } from 'vue' +import { computed, onUnmounted, provide, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import Canvas from './Canvas.vue' import ConsolePanel from './components/migration/ConsolePanel.vue' @@ -561,13 +561,6 @@ const initMonitor = debounce(() => { startLoadData() }, 200) -watch( - () => dataflowStore.stateIsReadonly, - (v) => { - console.trace('stateIsReadonly', v) - }, -) - function handleOpenDetail(node: any) { if (['mem_cache'].includes(node.type)) return nodeDetailDialogId.value = node.id @@ -599,8 +592,8 @@ const init = async () => { await dataflowStore.initPdkProperties() if (taskId) { + await initNodeType() await dataflowStore.fetchDataflow(taskId) - await initNodeType(dataflowStore.dataflow.syncType) } initMonitor() initWS() diff --git a/packages/dag/src/composables/useCanvasOperation.ts b/packages/dag/src/composables/useCanvasOperation.ts index 5f21d94d7..1c09b5038 100644 --- a/packages/dag/src/composables/useCanvasOperation.ts +++ b/packages/dag/src/composables/useCanvasOperation.ts @@ -113,7 +113,12 @@ export function useCanvasOperation() { ) const isSyncTask = computed(() => { - return ['DataflowNew', 'DataflowEditor'].includes(route.name) + return [ + 'DataflowNew', + 'DataflowEditor', + 'TaskMonitor', + 'MigrationMonitorViewer', // 任务记录也加载自定节点 + ].includes(route.name) }) const monitorRoute = computed(() => { From 659c64844e70e2a1c14447d15f7de3434d8e6b39 Mon Sep 17 00:00:00 2001 From: Feynman Date: Fri, 3 Apr 2026 16:26:51 +0800 Subject: [PATCH 5/6] feat: add 'disabled' prop to aggregate components for better control over user interactions --- .../src/components/aggregate/AggregateFields.tsx | 13 ++++++++++++- .../src/components/aggregate/AggregatePanel.tsx | 9 +++++++++ .../form/src/components/aggregate/GroupFields.tsx | 12 +++++++++++- .../form/src/components/aggregate/MatchFilter.tsx | 14 +++++++++++++- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/form/src/components/aggregate/AggregateFields.tsx b/packages/form/src/components/aggregate/AggregateFields.tsx index 3d361ae4f..f5144da80 100644 --- a/packages/form/src/components/aggregate/AggregateFields.tsx +++ b/packages/form/src/components/aggregate/AggregateFields.tsx @@ -29,6 +29,7 @@ function genId() { export const AggregateFields = defineComponent({ name: 'AggregateFields', props: { + disabled: Boolean, fields: { type: Array as PropType, default: () => [], @@ -98,6 +99,7 @@ export const AggregateFields = defineComponent({
@@ -107,6 +109,7 @@ export const AggregateFields = defineComponent({ /> @@ -120,6 +123,7 @@ export const AggregateFields = defineComponent({ ))} - + diff --git a/packages/form/src/components/aggregate/AggregatePanel.tsx b/packages/form/src/components/aggregate/AggregatePanel.tsx index 3195ec8e8..1e9fbef4f 100644 --- a/packages/form/src/components/aggregate/AggregatePanel.tsx +++ b/packages/form/src/components/aggregate/AggregatePanel.tsx @@ -25,6 +25,10 @@ import './style.scss' export const AggregatePanel = defineComponent({ name: 'AggregatePanel', props: { + disabled: { + type: Boolean, + default: false, + }, value: { type: Object as PropType<{ useRawPipeline: boolean @@ -173,6 +177,7 @@ export const AggregatePanel = defineComponent({ placement="top" > (useRawPipeline.value = !!val)} @@ -185,6 +190,7 @@ export const AggregatePanel = defineComponent({ {useRawPipeline.value ? (
( ( ( , default: () => [], @@ -84,6 +85,7 @@ export const GroupFields = defineComponent({
@@ -105,6 +108,7 @@ export const GroupFields = defineComponent({ /> ))} - + diff --git a/packages/form/src/components/aggregate/MatchFilter.tsx b/packages/form/src/components/aggregate/MatchFilter.tsx index fade24494..f8de3fa84 100644 --- a/packages/form/src/components/aggregate/MatchFilter.tsx +++ b/packages/form/src/components/aggregate/MatchFilter.tsx @@ -30,6 +30,7 @@ function genId() { export const MatchFilter = defineComponent({ name: 'MatchFilter', props: { + disabled: Boolean, conditions: { type: Array as PropType, default: () => [], @@ -101,6 +102,7 @@ export const MatchFilter = defineComponent({ {index > 0 ? ( @@ -116,6 +118,7 @@ export const MatchFilter = defineComponent({ )} @@ -142,6 +146,7 @@ export const MatchFilter = defineComponent({ @@ -151,6 +156,7 @@ export const MatchFilter = defineComponent({ /> ))} - + From 53c1467ab710ea8fd9c682b21b222631074cb782 Mon Sep 17 00:00:00 2001 From: Feynman Date: Fri, 3 Apr 2026 20:02:40 +0800 Subject: [PATCH 6/6] feat: implement AI aggregation pipeline generation with new dialog component --- packages/api/src/core/ai.ts | 54 +++++-- .../aggregate/AiAggregateDialog.tsx | 150 ++++++++++++++++++ .../components/aggregate/PipelineEditor.tsx | 30 ++++ .../form/src/components/aggregate/index.tsx | 1 + .../form/src/components/aggregate/style.scss | 37 +++++ packages/form/src/locale/lang/en.js | 10 ++ packages/form/src/locale/lang/zh-CN.js | 10 ++ packages/form/src/locale/lang/zh-TW.js | 10 ++ packages/types/src/daas-auto-imports.d.ts | 1 + 9 files changed, 293 insertions(+), 10 deletions(-) create mode 100644 packages/form/src/components/aggregate/AiAggregateDialog.tsx diff --git a/packages/api/src/core/ai.ts b/packages/api/src/core/ai.ts index 390f6afd5..477ed073a 100644 --- a/packages/api/src/core/ai.ts +++ b/packages/api/src/core/ai.ts @@ -45,19 +45,29 @@ export async function generateAiCode( return response.data } +export interface AiAggregateRequest { + prompt: string + fields?: Array<{ + name: string + type: string + primaryKey?: boolean + nullable?: boolean + comment?: string + }> + existingCode?: string +} + /** - * Generate JavaScript code using AI with SSE streaming - * @param data - The request data containing prompt and optional fields - * @param callbacks - Callbacks for handling SSE events - * @returns AbortController to cancel the request + * Internal helper: create an SSE streaming request */ -export function generateAiCodeStream( - data: AiGenerateRequest, +function createSSEStream( + url: string, + data: any, callbacks: SSECallbacks, ): AbortController { const controller = new AbortController() - fetch(`${AI_BASE_URL}/generate/stream`, { + fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -90,9 +100,8 @@ export function generateAiCodeStream( buffer += decoder.decode(value, { stream: true }) - // Parse SSE events from buffer const lines = buffer.split('\n') - buffer = lines.pop() || '' // Keep incomplete line in buffer + buffer = lines.pop() || '' let currentEvent = '' let currentData = '' @@ -103,7 +112,6 @@ export function generateAiCodeStream( } else if (line.startsWith('data: ')) { currentData = line.slice(6) } else if (line === '' && currentEvent && currentData) { - // Empty line signals end of event try { const parsed = JSON.parse(currentData) if (currentEvent === 'chunk' && parsed.content) { @@ -130,3 +138,29 @@ export function generateAiCodeStream( return controller } + +/** + * Generate MongoDB aggregation pipeline using AI with SSE streaming + * @param data - The request data containing prompt, fields and optional existing pipeline + * @param callbacks - Callbacks for handling SSE events + * @returns AbortController to cancel the request + */ +export function generateAiAggregateStream( + data: AiAggregateRequest, + callbacks: SSECallbacks, +): AbortController { + return createSSEStream(`${AI_BASE_URL}/aggregate/stream`, data, callbacks) +} + +/** + * Generate JavaScript code using AI with SSE streaming + * @param data - The request data containing prompt and optional fields + * @param callbacks - Callbacks for handling SSE events + * @returns AbortController to cancel the request + */ +export function generateAiCodeStream( + data: AiGenerateRequest, + callbacks: SSECallbacks, +): AbortController { + return createSSEStream(`${AI_BASE_URL}/generate/stream`, data, callbacks) +} diff --git a/packages/form/src/components/aggregate/AiAggregateDialog.tsx b/packages/form/src/components/aggregate/AiAggregateDialog.tsx new file mode 100644 index 000000000..0209ae600 --- /dev/null +++ b/packages/form/src/components/aggregate/AiAggregateDialog.tsx @@ -0,0 +1,150 @@ +import { + generateAiAggregateStream, + type AiAggregateRequest, +} from '@tap/api/src/core/ai' +import { useI18n } from '@tap/i18n' +import { + defineComponent, + nextTick, + onBeforeUnmount, + ref, + type PropType, +} from 'vue' +import type { FieldItem } from './PipelineEditor' + +export const AiAggregateDialog = defineComponent({ + name: 'AiAggregateDialog', + props: { + visible: Boolean, + fields: { type: Array as PropType, default: () => [] }, + existingCode: { type: String, default: '' }, + }, + emits: ['update:visible', 'apply'], + setup(props, { emit }) { + const { t } = useI18n() + const prompt = ref('') + const generatedCode = ref('') + const isGenerating = ref(false) + const errorMsg = ref('') + const resultRef = ref() + let abortController: AbortController | null = null + + const doGenerate = () => { + if (!prompt.value.trim()) return + isGenerating.value = true + errorMsg.value = '' + generatedCode.value = '' + const data: AiAggregateRequest = { + prompt: prompt.value, + fields: props.fields.map((f) => ({ + name: f.field_name, + type: f.data_type || 'String', + })), + } + if (props.existingCode?.trim()) data.existingCode = props.existingCode + abortController = generateAiAggregateStream(data, { + onChunk(content) { + generatedCode.value += content + nextTick(() => { + if (resultRef.value) + resultRef.value.scrollTop = resultRef.value.scrollHeight + }) + }, + onDone(code) { + generatedCode.value = code + isGenerating.value = false + }, + onError(err) { + errorMsg.value = err || t('packages_form_aggregate_ai_error') + isGenerating.value = false + }, + }) + } + const stopGenerate = () => { + abortController?.abort() + abortController = null + isGenerating.value = false + } + const applyCode = () => { + emit('apply', generatedCode.value) + closeDialog() + } + const closeDialog = () => { + stopGenerate() + emit('update:visible', false) + } + onBeforeUnmount(stopGenerate) + + return () => ( + emit('update:visible', val)} + title={t('packages_form_aggregate_ai_title')} + width="640px" + destroyOnClose + onClose={closeDialog} + v-slots={{ + footer: () => ( +
+ {isGenerating.value ? ( + + {t('packages_form_aggregate_ai_stop')} + + ) : ( + <> + + {generatedCode.value + ? t('packages_form_aggregate_ai_retry') + : t('packages_form_aggregate_ai_btn')} + + {generatedCode.value && ( + + {t('packages_form_aggregate_ai_apply')} + + )} + + )} +
+ ), + }} + > +
+ (prompt.value = val)} + placeholder={t('packages_form_aggregate_ai_placeholder')} + autosize={{ minRows: 3, maxRows: 6 }} + disabled={isGenerating.value} + onKeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + doGenerate() + } + }} + /> + {(generatedCode.value || isGenerating.value) && ( +
+              {generatedCode.value}
+              {isGenerating.value && (
+                
+              )}
+            
+ )} + {errorMsg.value && ( + + )} +
+
+ ) + }, +}) diff --git a/packages/form/src/components/aggregate/PipelineEditor.tsx b/packages/form/src/components/aggregate/PipelineEditor.tsx index 5f31fc94f..f3e212b8a 100644 --- a/packages/form/src/components/aggregate/PipelineEditor.tsx +++ b/packages/form/src/components/aggregate/PipelineEditor.tsx @@ -12,6 +12,7 @@ import { watch, type PropType, } from 'vue' +import { AiAggregateDialog } from './AiAggregateDialog' import 'monaco-editor/esm/vs/language/json/monaco.contribution' import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution' import 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestController' @@ -240,6 +241,16 @@ export const PipelineEditor = defineComponent({ } } + const showAiDialog = ref(false) + + const onAiApply = (code: string) => { + if (editor) { + editor.setValue(code) + } + emit('update:modelValue', code) + emit('change', code) + } + const heightPx = typeof props.height === 'number' ? `${props.height}px` : props.height @@ -252,6 +263,18 @@ export const PipelineEditor = defineComponent({ ]} >
+ + (showAiDialog.value = true)} + icon={IconLucideSparkles} + /> + + (showAiDialog.value = val)} + fields={props.fields} + existingCode={props.modelValue} + onApply={onAiApply} + />
) }, diff --git a/packages/form/src/components/aggregate/index.tsx b/packages/form/src/components/aggregate/index.tsx index 56a566eb1..8bf399312 100644 --- a/packages/form/src/components/aggregate/index.tsx +++ b/packages/form/src/components/aggregate/index.tsx @@ -1,3 +1,4 @@ +export { AiAggregateDialog } from './AiAggregateDialog' export { AggregatePanel } from './AggregatePanel' export { buildPipelineJSON, buildPipelineStages } from './buildPipeline' export { resolveSourceInfo } from './resolveSourceInfo' diff --git a/packages/form/src/components/aggregate/style.scss b/packages/form/src/components/aggregate/style.scss index f64dde1e8..d561394d3 100644 --- a/packages/form/src/components/aggregate/style.scss +++ b/packages/form/src/components/aggregate/style.scss @@ -306,3 +306,40 @@ .pipeline-editor { width: 100%; } + +// AI Aggregate Dialog +.ai-aggregate-dialog { + &__result { + margin-top: 12px; + padding: 12px 16px; + max-height: 360px; + overflow: auto; + border-radius: var(--el-border-radius-base, 4px); + border: 1px solid var(--el-border-color-lighter, #ebeef5); + background-color: var(--el-fill-color-lighter, #fafafa); + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; + + code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, + Courier, monospace; + } + } + + &__cursor { + animation: ai-cursor-blink 1s step-end infinite; + color: var(--el-color-primary, #409eff); + } +} + +@keyframes ai-cursor-blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} diff --git a/packages/form/src/locale/lang/en.js b/packages/form/src/locale/lang/en.js index c5c42dd5a..62011cffc 100644 --- a/packages/form/src/locale/lang/en.js +++ b/packages/form/src/locale/lang/en.js @@ -290,4 +290,14 @@ export default { packages_form_aggregate_op_all: 'Array Contains All', packages_form_aggregate_op_elemMatch: 'Element Match', packages_form_aggregate_op_size: 'Array Size', + + // AggregatePanel AI + packages_form_aggregate_ai_btn: 'AI Generate', + packages_form_aggregate_ai_title: 'AI Generate Aggregation Pipeline', + packages_form_aggregate_ai_placeholder: 'Describe the aggregation you want, e.g.: group by status, count each group and calculate average amount', + packages_form_aggregate_ai_generating: 'Generating...', + packages_form_aggregate_ai_apply: 'Apply', + packages_form_aggregate_ai_retry: 'Regenerate', + packages_form_aggregate_ai_stop: 'Stop', + packages_form_aggregate_ai_error: 'Generation failed, please retry', } diff --git a/packages/form/src/locale/lang/zh-CN.js b/packages/form/src/locale/lang/zh-CN.js index c89eed6b2..ea13720ee 100644 --- a/packages/form/src/locale/lang/zh-CN.js +++ b/packages/form/src/locale/lang/zh-CN.js @@ -259,4 +259,14 @@ export default { packages_form_aggregate_op_all: '数组全包含', packages_form_aggregate_op_elemMatch: '数组元素匹配', packages_form_aggregate_op_size: '数组长度', + + // AggregatePanel AI + packages_form_aggregate_ai_btn: 'AI 生成', + packages_form_aggregate_ai_title: 'AI 生成聚合管道', + packages_form_aggregate_ai_placeholder: '请描述你想要的聚合操作,例如:按 status 分组,统计每组的数量和平均金额', + packages_form_aggregate_ai_generating: '正在生成...', + packages_form_aggregate_ai_apply: '应用', + packages_form_aggregate_ai_retry: '重新生成', + packages_form_aggregate_ai_stop: '停止', + packages_form_aggregate_ai_error: '生成失败,请重试', } diff --git a/packages/form/src/locale/lang/zh-TW.js b/packages/form/src/locale/lang/zh-TW.js index 5d40ad253..f22274e24 100644 --- a/packages/form/src/locale/lang/zh-TW.js +++ b/packages/form/src/locale/lang/zh-TW.js @@ -259,4 +259,14 @@ export default { packages_form_aggregate_op_all: '數組全包含', packages_form_aggregate_op_elemMatch: '數組元素匹配', packages_form_aggregate_op_size: '數組長度', + + // AggregatePanel AI + packages_form_aggregate_ai_btn: 'AI 生成', + packages_form_aggregate_ai_title: 'AI 生成聚合管道', + packages_form_aggregate_ai_placeholder: '請描述你想要的聚合操作,例如:按 status 分組,統計每組的數量和平均金額', + packages_form_aggregate_ai_generating: '正在生成...', + packages_form_aggregate_ai_apply: '應用', + packages_form_aggregate_ai_retry: '重新生成', + packages_form_aggregate_ai_stop: '停止', + packages_form_aggregate_ai_error: '生成失敗,請重試', } diff --git a/packages/types/src/daas-auto-imports.d.ts b/packages/types/src/daas-auto-imports.d.ts index 0743db7b4..2cff433f6 100644 --- a/packages/types/src/daas-auto-imports.d.ts +++ b/packages/types/src/daas-auto-imports.d.ts @@ -33,6 +33,7 @@ declare global { const IconLucideMaximize2: typeof import('~icons/lucide/maximize2').default const IconLucideMinimize2: typeof import('~icons/lucide/minimize2').default const IconLucideSettings2: typeof import('~icons/lucide/settings2').default + const IconLucideSparkles: typeof import('~icons/lucide/sparkles').default const IconLucideTrash2: typeof import('~icons/lucide/trash2').default const IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert').default const IconMingcuteCheckCircleFill: typeof import('~icons/mingcute/check-circle-fill').default