Преглед на файлове

feat: 添加运行弹窗内容

jiaxing.liao преди 1 месец
родител
ревизия
9cb100ea80

+ 75 - 73
apps/web/src/features/RunWorkflow/components/InputTab.vue

@@ -34,80 +34,82 @@ defineEmits<{
 				:required="variable.is_require"
 				:error="validationErrors[variable.name]"
 			>
-				<el-input
-					v-if="variable.formType === 'text-input'"
-					v-model="inputValues[variable.name]"
-					:maxlength="variable.max_length"
-					:placeholder="`请输入${variable.label || variable.name}`"
-					clearable
-				/>
-
-				<el-input
-					v-else-if="variable.formType === 'text-area'"
-					v-model="inputValues[variable.name]"
-					type="textarea"
-					:rows="4"
-					:maxlength="variable.max_length"
-					:placeholder="`请输入${variable.label || variable.name}`"
-				/>
-
-				<el-select
-					v-else-if="variable.formType === 'select'"
-					v-model="inputValues[variable.name]"
-					class="w-full"
-					:placeholder="`请选择${variable.label || variable.name}`"
-				>
-					<el-option
-						v-for="option in variable.options || []"
-						:key="option"
-						:label="option"
-						:value="option"
+				<div class="w-full">
+					<el-input
+						v-if="variable.formType === 'text-input'"
+						v-model="inputValues[variable.name]"
+						:maxlength="variable.max_length"
+						:placeholder="`请输入${variable.label || variable.name}`"
+						clearable
+					/>
+
+					<el-input
+						v-else-if="variable.formType === 'text-area'"
+						v-model="inputValues[variable.name]"
+						type="textarea"
+						:rows="4"
+						:maxlength="variable.max_length"
+						:placeholder="`请输入${variable.label || variable.name}`"
+					/>
+
+					<el-select
+						v-else-if="variable.formType === 'select'"
+						v-model="inputValues[variable.name]"
+						class="w-full"
+						:placeholder="`请选择${variable.label || variable.name}`"
+					>
+						<el-option
+							v-for="option in variable.options || []"
+							:key="option"
+							:label="option"
+							:value="option"
+						/>
+					</el-select>
+
+					<el-input-number
+						v-else-if="variable.formType === 'number'"
+						v-model="inputValues[variable.name]"
+						:controls-position="'right'"
+						class="w-full"
+					/>
+
+					<div v-else-if="variable.formType === 'checkbox'" class="switch-line">
+						<el-switch v-model="inputValues[variable.name]" />
+						<span>{{ inputValues[variable.name] ? 'true' : 'false' }}</span>
+					</div>
+
+					<FileUploadInput
+						v-else-if="variable.formType === 'file'"
+						v-model="inputValues[variable.name]"
+						:file-types="variable.file_types || []"
+						:file-extensions="variable.file_extensions || []"
+						:allow-link-input="false"
 					/>
-				</el-select>
-
-				<el-input-number
-					v-else-if="variable.formType === 'number'"
-					v-model="inputValues[variable.name]"
-					:controls-position="'right'"
-					class="w-full"
-				/>
-
-				<div v-else-if="variable.formType === 'checkbox'" class="switch-line">
-					<el-switch v-model="inputValues[variable.name]" />
-					<span>{{ inputValues[variable.name] ? 'true' : 'false' }}</span>
-				</div>
 
-				<FileUploadInput
-					v-else-if="variable.formType === 'file'"
-					v-model="inputValues[variable.name]"
-					:file-types="variable.file_types || []"
-					:file-extensions="variable.file_extensions || []"
-					:allow-link-input="variable.allow_link_input"
-				/>
-
-				<FileUploadInput
-					v-else-if="variable.formType === 'file-list'"
-					v-model="inputValues[variable.name]"
-					multiple
-					:file-types="variable.file_types || []"
-					:file-extensions="variable.file_extensions || []"
-					:allow-link-input="variable.allow_link_input"
-				/>
-
-				<CodeEditor
-					v-else-if="variable.formType === 'json_object'"
-					v-model="jsonDrafts[variable.name]"
-					:tools="false"
-					language="json"
-					:height="180"
-				/>
-
-				<el-input
-					v-else
-					v-model="inputValues[variable.name]"
-					:placeholder="`请输入${variable.label || variable.name}`"
-					clearable
-				/>
+					<FileUploadInput
+						v-else-if="variable.formType === 'file-list'"
+						v-model="inputValues[variable.name]"
+						multiple
+						:file-types="variable.file_types || []"
+						:file-extensions="variable.file_extensions || []"
+						:allow-link-input="false"
+					/>
+
+					<CodeEditor
+						v-else-if="variable.formType === 'json_object'"
+						v-model="jsonDrafts[variable.name]"
+						:tools="false"
+						language="json"
+						:height="180"
+					/>
+
+					<el-input
+						v-else
+						v-model="inputValues[variable.name]"
+						:placeholder="`请输入${variable.label || variable.name}`"
+						clearable
+					/>
+				</div>
 			</el-form-item>
 		</el-form>
 
@@ -142,7 +144,7 @@ defineEmits<{
 }
 
 .action-bar {
-	margin-top: 16px;
+	margin: 16px 0;
 }
 
 .run-button {

+ 202 - 4
apps/web/src/features/RunWorkflow/components/ResultTab.vue

@@ -14,14 +14,34 @@ const editorValue = computed(() => props.finalResultText || '-')
 
 <template>
 	<div class="tab-pane tab-pane--scroll">
-		<div v-if="!hasExecutionData" class="empty-state">暂无运行结果</div>
-		<div v-else-if="isRunning && !finalResultText" class="empty-state">工作流运行中...</div>
-		<div v-else class="panel">
+		<div v-if="!hasExecutionData" class="empty-state">无结果</div>
+		<div v-else-if="isRunning && !finalResultText" class="running-state">
+			<div class="running-state__header">
+				<div class="running-state__signal">
+					<span></span>
+					<span></span>
+					<span></span>
+				</div>
+				<div>
+					<div class="running-state__title">Workflow Running</div>
+					<div class="running-state__desc">正则执行,请等待...</div>
+				</div>
+			</div>
+			<div class="running-state__skeleton">
+				<div class="skeleton-line skeleton-line--lg"></div>
+				<div class="skeleton-line"></div>
+				<div class="skeleton-line"></div>
+				<div class="skeleton-line skeleton-line--sm"></div>
+			</div>
+		</div>
+		<div v-else class="panel" :class="{ 'panel--running': isRunning }">
 			<div class="panel-header">
-				<div class="section-title">输出结果</div>
+				<div class="section-title">输出</div>
+				<div v-if="isRunning" class="running-badge">运行中...</div>
 				<el-button text @click="$emit('copy', finalResultValue)">复制</el-button>
 			</div>
 			<div class="editor-shell">
+				<div v-if="isRunning" class="editor-shell__scanner"></div>
 				<CodeEditor
 					:model-value="editorValue"
 					language="json"
@@ -55,6 +75,117 @@ const editorValue = computed(() => props.finalResultText || '-')
 	line-height: 1.6;
 }
 
+.running-state {
+	position: relative;
+	padding: 18px 16px;
+	border-radius: 16px;
+	overflow: hidden;
+	background:
+		radial-gradient(circle at top left, rgba(59, 130, 246, 0.16), transparent 38%),
+		linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
+	border: 1px solid #cfe0ff;
+	box-shadow:
+		0 14px 28px rgba(37, 99, 235, 0.08),
+		inset 0 1px 0 rgba(255, 255, 255, 0.9);
+}
+
+.running-state::after {
+	content: '';
+	position: absolute;
+	inset: 0;
+	background: linear-gradient(
+		110deg,
+		transparent 0%,
+		rgba(255, 255, 255, 0.55) 45%,
+		transparent 70%
+	);
+	transform: translateX(-100%);
+	animation: shimmer 2s linear infinite;
+	pointer-events: none;
+}
+
+.running-state__header {
+	display: flex;
+	align-items: center;
+	gap: 14px;
+	margin-bottom: 18px;
+}
+
+.running-state__signal {
+	display: flex;
+	align-items: flex-end;
+	gap: 5px;
+	height: 28px;
+}
+
+.running-state__signal span {
+	width: 6px;
+	border-radius: 999px;
+	background: linear-gradient(180deg, #2563eb 0%, #60a5fa 100%);
+	animation: bounce 0.9s ease-in-out infinite;
+}
+
+.running-state__signal span:nth-child(1) {
+	height: 12px;
+}
+
+.running-state__signal span:nth-child(2) {
+	height: 22px;
+	animation-delay: 0.15s;
+}
+
+.running-state__signal span:nth-child(3) {
+	height: 16px;
+	animation-delay: 0.3s;
+}
+
+.running-state__title {
+	font-size: 15px;
+	font-weight: 600;
+	color: #1d4ed8;
+}
+
+.running-state__desc {
+	margin-top: 4px;
+	font-size: 13px;
+	color: #5b6b8a;
+}
+
+.running-state__skeleton {
+	display: grid;
+	gap: 10px;
+}
+
+.skeleton-line {
+	position: relative;
+	height: 12px;
+	border-radius: 999px;
+	background: rgba(148, 163, 184, 0.18);
+	overflow: hidden;
+}
+
+.skeleton-line::after {
+	content: '';
+	position: absolute;
+	inset: 0;
+	background: linear-gradient(
+		90deg,
+		transparent 0%,
+		rgba(255, 255, 255, 0.92) 50%,
+		transparent 100%
+	);
+	transform: translateX(-100%);
+	animation: shimmer 1.8s ease-in-out infinite;
+}
+
+.skeleton-line--lg {
+	width: 92%;
+}
+
+.skeleton-line--sm {
+	width: 58%;
+}
+
 .panel {
 	padding: 14px;
 	border-radius: 16px;
@@ -65,6 +196,13 @@ const editorValue = computed(() => props.finalResultText || '-')
 		inset 0 1px 0 rgba(255, 255, 255, 0.75);
 }
 
+.panel--running {
+	border-color: #bfdbfe;
+	box-shadow:
+		0 10px 30px rgba(37, 99, 235, 0.08),
+		inset 0 1px 0 rgba(255, 255, 255, 0.8);
+}
+
 .panel-header {
 	display: flex;
 	align-items: center;
@@ -80,10 +218,70 @@ const editorValue = computed(() => props.finalResultText || '-')
 	color: #344054;
 }
 
+.running-badge {
+	padding: 2px 10px;
+	border-radius: 999px;
+	background: rgba(37, 99, 235, 0.1);
+	color: #1d4ed8;
+	font-size: 12px;
+	font-weight: 600;
+	animation: pulse 1.6s ease-in-out infinite;
+}
+
 .editor-shell {
+	position: relative;
 	border-radius: 14px;
 	overflow: hidden;
 	background: #fff;
 	border: 1px solid #d0d5dd;
 }
+
+.editor-shell__scanner {
+	position: absolute;
+	top: 0;
+	left: 0;
+	right: 0;
+	height: 56px;
+	background: linear-gradient(180deg, rgba(96, 165, 250, 0.18) 0%, rgba(96, 165, 250, 0) 100%);
+	transform: translateY(-100%);
+	animation: scan 2.4s linear infinite;
+	pointer-events: none;
+	z-index: 1;
+}
+
+@keyframes shimmer {
+	100% {
+		transform: translateX(100%);
+	}
+}
+
+@keyframes bounce {
+	0%,
+	100% {
+		transform: scaleY(0.75);
+		opacity: 0.6;
+	}
+
+	50% {
+		transform: scaleY(1);
+		opacity: 1;
+	}
+}
+
+@keyframes pulse {
+	0%,
+	100% {
+		opacity: 0.55;
+	}
+
+	50% {
+		opacity: 1;
+	}
+}
+
+@keyframes scan {
+	100% {
+		transform: translateY(320px);
+	}
+}
 </style>

+ 4 - 0
apps/web/src/features/RunWorkflow/components/TraceTab.vue

@@ -245,6 +245,10 @@ function formatDisplayValue(value: unknown) {
 	padding: 0;
 }
 
+:deep(.el-card__body) {
+	padding: 4px 8px;
+}
+
 :deep(.el-collapse-item__wrap) {
 	border: none;
 }

+ 16 - 7
apps/web/src/features/RunWorkflow/index.vue

@@ -1,4 +1,4 @@
-<script lang="ts" setup>
+<script lang="ts" setup>
 import { computed, reactive, ref, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import { Icon } from '@repo/ui'
@@ -22,14 +22,19 @@ import type { StartVariable } from '@/nodes/src/start'
 interface Props {
 	workflow: IWorkflow
 	visible: boolean
+	closeOnRun?: boolean
+	inputOnly?: boolean
 }
 
 const props = withDefaults(defineProps<Props>(), {
-	visible: false
+	visible: false,
+	closeOnRun: false,
+	inputOnly: false
 })
 
 const emit = defineEmits<{
 	'update:visible': [visible: boolean]
+	'run-started': [nodeId: string]
 }>()
 
 const runnerStore = useRunnerStore()
@@ -230,8 +235,11 @@ const handleRunWorkflow = async () => {
 	}
 
 	submittedParams.value = params
-	activeTab.value = 'result'
+	activeTab.value = props.inputOnly ? 'input' : 'result'
 	executing.value = true
+	if (props.closeOnRun) {
+		closeDrawer()
+	}
 
 	try {
 		const response = await agent.postAgentDoExecute({
@@ -245,6 +253,7 @@ const handleRunWorkflow = async () => {
 		const agentRunnerKey = response?.result
 		if (agentRunnerKey) {
 			runnerStore.startRunner(agentRunnerKey)
+			emit('run-started', startNode.value.id)
 			return
 		}
 
@@ -409,7 +418,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 							@run="handleRunWorkflow"
 						/>
 					</el-tab-pane>
-					<el-tab-pane label="结果" name="result">
+					<el-tab-pane v-if="!props.inputOnly" label="结果" name="result">
 						<ResultTab
 							:has-execution-data="hasExecutionData"
 							:is-running="isRunning"
@@ -417,7 +426,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 							:final-result-value="finalResultValue"
 						/>
 					</el-tab-pane>
-					<el-tab-pane label="详情" name="detail">
+					<el-tab-pane v-if="!props.inputOnly" label="详情" name="detail">
 						<DetailTab
 							:has-execution-data="hasExecutionData"
 							:status-class-name="detailStatusClassName"
@@ -431,7 +440,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 							:detail-output-text="detailOutputText"
 						/>
 					</el-tab-pane>
-					<el-tab-pane label="追踪" name="trace">
+					<el-tab-pane v-if="!props.inputOnly" label="追踪" name="trace">
 						<TraceTab :trace-nodes="traceNodes" />
 					</el-tab-pane>
 				</el-tabs>
@@ -498,7 +507,7 @@ function statusClass(status?: NodeStatus | RunnerStatus | null) {
 	}
 
 	:deep(.el-tab-pane) {
-		height: 100%;
+		height: 98%;
 	}
 }
 </style>

+ 15 - 4
apps/web/src/features/setter/index.vue

@@ -21,10 +21,12 @@ interface Props {
 	id: string
 	workflow: IWorkflow
 	visible: boolean
+	activeTab?: 'setting' | 'last-run'
 }
 const props = withDefaults(defineProps<Props>(), {
 	visible: false,
-	id: ''
+	id: '',
+	activeTab: 'setting'
 })
 const emit = defineEmits<{
 	'update:visible': [value: boolean]
@@ -70,6 +72,7 @@ const onUpdateData = useDebounceFn((data: Record<string, unknown>) => {
 const name = ref(node.value?.name || '')
 const remark = ref(node.value?.remark || '')
 const nodeVars = ref<NodeVar[]>([])
+const currentTab = ref<'setting' | 'last-run'>(props.activeTab || 'setting')
 
 const onUpdateName = () => {
 	if (name.value !== node.value?.name && name.value.trim() !== '') {
@@ -85,6 +88,7 @@ watch(
 	async () => {
 		name.value = node.value?.name || ''
 		remark.value = node.value?.remark || ''
+		currentTab.value = props.activeTab || 'setting'
 
 		if (props.id && props.visible) {
 			const response = await agent.postAgentGetPrevNodeOutVariableList({
@@ -96,6 +100,13 @@ watch(
 	}
 )
 
+watch(
+	() => props.activeTab,
+	(value) => {
+		currentTab.value = value || 'setting'
+	}
+)
+
 provide('nodeVars', nodeVars)
 </script>
 
@@ -149,8 +160,8 @@ provide('nodeVars', nodeVars)
 			</header>
 
 			<div class="content">
-				<el-tabs>
-					<el-tab-pane label="设置">
+				<el-tabs v-model="currentTab">
+					<el-tab-pane label="设置" name="setting">
 						<div class="tab-pane tab-pane--fill">
 							<component
 								:is="setter"
@@ -161,7 +172,7 @@ provide('nodeVars', nodeVars)
 							></component>
 						</div>
 					</el-tab-pane>
-					<el-tab-pane label="上次运行">
+					<el-tab-pane label="上次运行" name="last-run">
 						<div class="tab-pane tab-pane--scroll">
 							<NodeLog :node="node" />
 						</div>

+ 60 - 17
apps/web/src/views/editor/NodeView.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
 	<div class="h-full w-full" ref="workflowWrapperRef" @drop="onDrop">
 		<Workflow
 			ref="workflowRef"
@@ -29,10 +29,17 @@
 			/>
 		</Workflow>
 	</div>
-	<RunWorkflow v-model:visible="runVisible" :workflow="workflow" />
+	<RunWorkflow
+		v-model:visible="runVisible"
+		:workflow="workflow"
+		:close-on-run="closeRunWorkflowOnSubmit"
+		:input-only="runWorkflowInputOnly"
+		@run-started="handleWorkflowRunStarted"
+	/>
 	<Setter
 		:id="nodeID"
 		:workflow="workflow"
+		:active-tab="setterActiveTab"
 		@update:node:data="handleUpdateNode"
 		@run-node="handleRunNode"
 		v-model:visible="setterVisible"
@@ -102,8 +109,11 @@ const workflowRef = ref<InstanceType<typeof Workflow>>()
 const showNodeLibary = ref(false)
 const libaryRefferenceRef = ref<HTMLElement>()
 const runVisible = ref(false)
+const closeRunWorkflowOnSubmit = ref(false)
+const runWorkflowInputOnly = ref(false)
 const setterVisible = ref(false)
 const nodeID = ref('')
+const setterActiveTab = ref<'setting' | 'last-run'>('setting')
 const pendingSetterInit = new Set<string>()
 const displayNodeExecutionStatus = ref<Record<string, CanvasExecutionStatus>>({})
 const peddingHandlePayload = ref<{
@@ -300,9 +310,10 @@ const normalizeConnectionEndpoints = (connection: Connection) => {
 	return normalizeEdgeEndpoints(connection, props.workflow?.nodes || [])
 }
 
-// 节点仍处于临时创建阶段时,跳过持久化更新。
-const isPendingCreate = (node: any) => {
-	return !!(node as any)?.__pendingCreate
+const openSetter = (id: string, tab: 'setting' | 'last-run' = 'setting') => {
+	nodeID.value = id
+	setterActiveTab.value = tab
+	setterVisible.value = true
 }
 
 // 从节点级操作入口直接运行指定节点。
@@ -312,17 +323,34 @@ const handleRunNode = async (id: string) => {
 		return
 	}
 
+	const targetNode = props.workflow?.nodes?.find((node) => node.id === id)
+	const nodeType = (targetNode as any)?.nodeType || (targetNode as any)?.data?.nodeType
+
+	if (nodeType === 'start') {
+		closeRunWorkflowOnSubmit.value = true
+		runWorkflowInputOnly.value = true
+		runVisible.value = false
+		await nextTick()
+		runVisible.value = true
+		return
+	}
+
+	closeRunWorkflowOnSubmit.value = false
+	runWorkflowInputOnly.value = false
+
 	try {
 		const response = await agent.postAgentDoExecute({
 			appAgentId: props.workflow.id,
 			start_node_id: id,
 			is_debugger: true,
 			responseType: 'ws',
+			// 如果是start用户输入则传入数据
 			params: {}
 		})
 		const agentRunnerKey = response?.result
 		if (agentRunnerKey) {
 			runnerStore.startRunner(agentRunnerKey)
+			openSetter(id, 'last-run')
 		}
 	} catch (error) {
 		console.error('postDoTestNodeRunner error', error)
@@ -344,6 +372,8 @@ const handleRunAgent = () => {
 		return
 	}
 
+	closeRunWorkflowOnSubmit.value = false
+	runWorkflowInputOnly.value = false
 	runVisible.value = true
 }
 
@@ -444,9 +474,15 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
 
 // 双击节点时打开对应的配置面板。
 const handleNodeClick = (id: string, _position: XYPosition) => {
-	nodeID.value = id
-	pendingSetterInit.add(id)
-	setterVisible.value = true
+	openSetter(id, 'setting')
+}
+
+const handleWorkflowRunStarted = (id: string) => {
+	if (!closeRunWorkflowOnSubmit.value) {
+		return
+	}
+
+	openSetter(id, 'last-run')
 }
 
 // 将拖拽落点转换成统一的节点创建流程。
@@ -492,7 +528,7 @@ const onCreateConnection = async (connection: Connection) => {
 const handleUpdateNodesPosition = (events: { id: string; position: XYPosition }[]) => {
 	events?.forEach(({ id, position }) => {
 		const node = props.workflow?.nodes.find((item) => item.id === id)
-		if (node && !isPendingCreate(node)) {
+		if (node) {
 			if (node.position?.x === position.x && node.position?.y === position.y) {
 				return
 			}
@@ -522,20 +558,27 @@ const handleSelectNode = (id: string) => {
 
 // 持久化配置面板提交的节点数据变更。
 const handleUpdateNode = (node: IWorkflowNode) => {
-	if (node && !isPendingCreate(node)) {
-		if (pendingSetterInit.has(node.id)) {
-			pendingSetterInit.delete(node.id)
-			return
-		}
-
+	if (node) {
 		if (node.nodeType === 'if-else') {
 			const cases = node.data?.cases || []
 			const offsetHeight = (cases.length > 1 ? cases.length - 1 : 0) * 32
 			node.height = 96 + offsetHeight
 		}
 
+		const workflowNode = props.workflow?.nodes.find((item) => item.id === node.id)
+		const syncedNode = workflowNode || node
+
+		if (workflowNode) {
+			const mergedData = {
+				...(workflowNode.data || {}),
+				...(node.data || {})
+			}
+
+			Object.assign(workflowNode, node)
+			workflowNode.data = mergedData
+		}
 		agent
-			.postAgentDoUpdateAgentNode(buildUpdateNodePayload(node))
+			.postAgentDoUpdateAgentNode(buildUpdateNodePayload(syncedNode))
 			.then((response) => {
 				handleApiResult(response, undefined, '更新节点失败')
 			})
@@ -548,7 +591,7 @@ const handleUpdateNode = (node: IWorkflowNode) => {
 // 持久化画布自身抛出的轻量节点属性更新。
 const handleUpdateNodeProps = (id: string, attrs: Record<string, unknown>) => {
 	const node = props.workflow?.nodes.find((item) => item.id === id)
-	if (node && !isPendingCreate(node)) {
+	if (node) {
 		const keys = Object.keys(attrs || {})
 		const meaningfulKeys = keys.filter((key) => !['selected', 'dragging'].includes(key))
 		if (meaningfulKeys.length === 0) {

+ 3 - 1
packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue

@@ -74,7 +74,9 @@ const warningInfo = computed(() => {
 			</div>
 		</el-tooltip>
 
-		<div className="absolute w-full bottom--24px text-12px text-center text-#333">
+		<div
+			className="absolute w-[150%] break-all -translate-x-1/2 left-1/2 bottom-auto mt-10px text-12px text-center text-#333"
+		>
 			<div>{{ nodeData?.name || nodeType?.displayName || '节点标题' }}</div>
 			<div className="text-12px text-center text-#999 truncate">
 				{{ nodeSubtitle }}