Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,926 @@
<script setup lang="ts">
import type {
Agent,
AgentChatEvent,
AgentMessage,
ReasoningStep,
} from '#/api/ai-platform/ai-platform';
import type { ChatMessage } from '#/components/ChatBox/index';
/**
* Agent 对话面板组件
* 可复用于 Agent 编辑页面和 Agent 对话页面
*/
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElMessage } from 'element-plus';
import {
feedbackAgentMessageApi,
getAgentMessagesApi,
sendAgentMessageStream,
} from '#/api/ai-platform/ai-platform';
import { ChatBox } from '#/components/ChatBox/index';
import { AppDesignPanel, DesignEditorPanel } from '#/components/form-editor';
import AiWorkingAnimation from '../../../../components/ai-loading/AiWorkingAnimation.vue';
const props = defineProps<{
/** Agent 详情(可选,用于显示名称和欢迎配置) */
agent?: Agent | null;
/** Agent ID */
agentId: string;
/** 当前对话 ID(用于加载历史消息) */
currentConversationId?: null | string;
/** 是否禁用输入 */
disabled?: boolean;
/** 是否启用附件 */
enableAttachment?: boolean;
/** 是否启用图片 */
enableImage?: boolean;
/** 是否启用语音 */
enableVoice?: boolean;
/** 占位符文本 */
placeholder?: string;
/** 是否显示清空按钮 */
showClearButton?: boolean;
}>();
const emit = defineEmits<{
(e: 'conversation-created', conversationId: string): void;
}>();
// 设计面板显示状态
const showDesignPanel = ref(false);
const showAppDesignPanel = ref(false);
// 对话状态
const chatMessages = ref<AgentMessage[]>([]);
const sending = ref(false);
const streamingContent = ref('');
const streamingSteps = ref<ReasoningStep[]>([]);
const currentStep = ref('');
const conversationId = ref<null | string>(null);
const chatBoxRef = ref<InstanceType<typeof ChatBox> | null>(null);
// 对话流交互状态
const waitingForInput = ref(false);
const waitingConfig = ref<any>(null);
// 设计面板状态
const currentDesign = ref<null | {
data: any;
form_fields?: any[];
nodeId: string;
table_configs?: any[];
title: string;
type: string;
}>(null);
// 应用设计面板状态
const currentAppDesign = ref<null | {
data: any;
nodeId: string;
title: string;
type: string;
}>(null);
// 取消流式请求
let cancelStream: (() => void) | null = null;
// 生成消息 ID
const generateId = () =>
`msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// 转换消息格式为 ChatMessage
const formattedMessages = computed<ChatMessage[]>(() => {
return chatMessages.value.map((msg) => ({
id: msg.id,
role: msg.role as 'assistant' | 'user',
content: msg.content,
status: msg.status as any,
timestamp: msg.created_at,
elapsed_time: msg.elapsed_time,
tokens_used: msg.total_tokens,
error_message: msg.error_message,
feedback: (msg.feedback as any) || null,
reasoning_steps: msg.reasoning_steps?.map((step) => ({
type: step.type as any,
content: step.content || '',
tool: step.tool,
params: step.params,
})),
interaction: msg.interaction,
voice: msg.voice as any,
}));
});
// 欢迎配置
const welcomeConfig = computed(() => {
if (!props.agent) return undefined;
return {
title: props.agent.name,
description:
props.agent.status === 'published'
? props.agent.welcome_message ||
$t('ai-platform.agent.chatPanel.sendToStart')
: $t('ai-platform.agent.chatPanel.publishFirst'),
suggestions:
props.agent.status === 'published' ? props.agent.suggested_questions : [],
};
});
// 监听 agentId 变化,清空对话
watch(
() => props.agentId,
() => {
clearChat();
},
);
// 监听 currentConversationId 变化,加载历史消息
watch(
() => props.currentConversationId,
async (newId) => {
if (newId) {
await loadMessages(newId);
} else {
clearChat();
}
},
{ immediate: true },
);
// 加载历史消息
async function loadMessages(convId: string) {
// 如果正在发送消息或等待用户输入,跳过加载以避免覆盖当前状态
if (sending.value || waitingForInput.value) {
conversationId.value = convId;
return;
}
try {
const res = await getAgentMessagesApi(convId, { pageSize: 100 });
chatMessages.value = res.items;
conversationId.value = convId;
await nextTick();
scrollToBottom();
} catch (error) {
console.error('加载消息失败:', error);
}
}
// 发送消息 (由 ChatBox 触发)
async function handleSendMessage(message: string) {
if (!message.trim() || sending.value) return;
// 如果正在等待用户输入(对话流模式),处理恢复逻辑
if (waitingForInput.value) {
// 移除最后一条助手消息的交互配置
const lastAssistantMsg = chatMessages.value.findLast(
(m) => m.role === 'assistant',
);
if (lastAssistantMsg && lastAssistantMsg.interaction) {
const msgIndex = chatMessages.value.findIndex(
(m) => m.id === lastAssistantMsg.id,
);
if (msgIndex !== -1) {
chatMessages.value[msgIndex] = {
...lastAssistantMsg,
interaction: undefined,
};
}
}
// 添加用户消息
const userMsg: AgentMessage = {
id: generateId(),
role: 'user',
content: message,
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(userMsg);
// 重置等待状态并继续工作流
waitingForInput.value = false;
waitingConfig.value = null;
doSendMessage(message, false);
return;
}
doSendMessage(message, true);
}
// 实际发送消息的函数
async function doSendMessage(message: string, addUserMessage: boolean = true) {
sending.value = true;
streamingContent.value = '';
streamingSteps.value = [];
currentStep.value = '';
// 如果工作流类型是 "application",立即显示右侧面板(加载状态)
if (props.agent?.workflow_type === 'application') {
showDesignPanel.value = true;
currentDesign.value = {
type: 'loading',
title: $t('ai-platform.agent.chatPanel.aiDeveloping'),
data: null,
nodeId: '',
};
}
// 添加用户消息
if (addUserMessage) {
const userMessage: AgentMessage = {
id: generateId(),
role: 'user',
content: message,
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(userMessage);
}
// 添加助手消息占位
const assistantMessage: AgentMessage = {
id: generateId(),
role: 'assistant',
content: '',
status: 'pending',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(assistantMessage);
await nextTick();
scrollToBottom();
// 发送流式请求
// 系统变量(application_id 等)由 API 层自动注入
cancelStream = sendAgentMessageStream(
props.agentId,
{
message,
conversation_id: conversationId.value || undefined,
},
(event: AgentChatEvent) => handleStreamEvent(event, assistantMessage),
(error) => {
console.error('Stream error:', error);
assistantMessage.status = 'failed';
assistantMessage.error_message = error.message;
sending.value = false;
},
() => {
sending.value = false;
},
);
}
// 处理流式事件
function handleStreamEvent(
event: AgentChatEvent,
assistantMessage: AgentMessage,
) {
const msgIndex = chatMessages.value.findIndex(
(m) => m.id === assistantMessage.id,
);
switch (event.type) {
case 'action': {
currentStep.value = 'action';
streamingSteps.value.push({
type: 'action',
content: '',
tool: event.tool,
params: event.params,
timestamp: new Date().toISOString(),
});
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'annotation_reply':
case 'knowledge_retrieval': {
currentStep.value = event.type;
streamingSteps.value.push({
type: event.type as 'annotation_reply' | 'knowledge_retrieval',
content: event.content || '',
status: 'completed',
timestamp: new Date().toISOString(),
});
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'answer': {
currentStep.value = 'answering';
streamingContent.value = event.content || '';
assistantMessage.content = streamingContent.value;
break;
}
case 'complete': {
assistantMessage.status = 'completed';
assistantMessage.total_tokens = event.tokens_used || 0;
assistantMessage.elapsed_time = event.elapsed_time || 0;
currentStep.value = '';
break;
}
case 'error': {
assistantMessage.status = 'failed';
assistantMessage.error_message =
event.content || $t('ai-platform.agent.chatPanel.executionFailed');
currentStep.value = '';
break;
}
case 'node_complete': {
// 查找已存在的 node_start 步骤并更新为 node_complete
const existingIndex = streamingSteps.value.findIndex(
(s) => s.node_id === event.node_id && s.type === 'node_start',
);
if (existingIndex === -1) {
// 如果没有找到对应的 start,添加新的 complete 步骤
streamingSteps.value.push({
type: 'node_complete',
content: event.node_label || event.node_type || '',
node_id: event.node_id,
node_type: event.node_type,
output: event.outputs,
status: 'completed',
timestamp: new Date().toISOString(),
});
} else {
const existingStep = streamingSteps.value[existingIndex]!;
// 更新已存在的步骤
streamingSteps.value[existingIndex] = {
...existingStep,
type: 'node_complete',
content: event.node_label || event.node_type || '',
output: event.outputs,
status: 'completed',
timestamp: existingStep.timestamp || new Date().toISOString(),
};
}
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'node_event': {
// 节点事件(如消息节点发送消息)
const nodeEvent = event.event;
if (nodeEvent?.type === 'message') {
// 从消息列表中查找初始消息,检查其内容是否为空
const initialMsg = chatMessages.value.find(
(m) => m.id === assistantMessage.id,
);
if (initialMsg && !initialMsg.content?.trim()) {
// 更新初始消息(typing indicator -> 实际内容)
initialMsg.content = nodeEvent.content || '';
initialMsg.status = 'completed';
} else {
// 已有内容,创建新消息
const newMsg: AgentMessage = {
id: generateId(),
role: 'assistant',
content: nodeEvent.content || '',
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(newMsg);
}
}
break;
}
case 'node_start': {
// 检查是否已存在相同 node_id 的步骤(避免重复)
const existingIndex = streamingSteps.value.findIndex(
(s) => s.node_id === event.node_id,
);
if (existingIndex === -1) {
// 添加新的执行步骤
streamingSteps.value.push({
type: 'node_start',
content: `${event.node_label || event.node_type}`,
node_id: event.node_id,
node_type: event.node_type,
status: 'running',
timestamp: new Date().toISOString(),
});
}
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'observation': {
currentStep.value = 'observation';
streamingSteps.value.push({
type: 'observation',
content: event.content || '',
timestamp: new Date().toISOString(),
});
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'start': {
if (event.conversation_id && !conversationId.value) {
conversationId.value = event.conversation_id;
emit('conversation-created', event.conversation_id);
}
if (event.message_id) {
assistantMessage.id = event.message_id;
}
break;
}
case 'thought': {
currentStep.value = 'thinking';
streamingSteps.value.push({
type: 'thought',
content: event.content || '',
timestamp: new Date().toISOString(),
});
assistantMessage.reasoning_steps = [...streamingSteps.value];
break;
}
case 'waiting_input': {
assistantMessage.status = 'completed';
currentStep.value = '';
waitingForInput.value = true;
waitingConfig.value = event.config;
const configData = event.config as any;
// 检查是否是设计预览类型
if (configData?.type === 'design_preview') {
const previewType = configData.preview_type;
// 应用设计方案使用 AppDesignPanel(富文本编辑器)
if (previewType === 'app_design') {
currentAppDesign.value = {
type: previewType,
title:
configData.title ||
$t('ai-platform.agent.chatPanel.appDesignTitle'),
data: configData.data || {},
nodeId: event.node_id || '',
};
showAppDesignPanel.value = true;
} else {
// 其他设计类型使用 DesignEditorPanel(表单编辑器)
currentDesign.value = {
type: previewType,
title:
configData.title ||
$t('ai-platform.agent.chatPanel.designPreview'),
data: configData.data || {},
nodeId: event.node_id || '',
form_fields: configData.form_fields || [],
table_configs: configData.table_configs || [],
};
showDesignPanel.value = true;
}
// 显示提示消息
const waitingContent = `**${configData.title}**\n\n${configData.message || $t('ai-platform.agent.chatPanel.designPanelHint')}`;
const newMsg: AgentMessage = {
id: generateId(),
role: 'assistant',
content: waitingContent,
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(newMsg);
} else {
// 普通对话流交互
const newMsg: AgentMessage = {
id: generateId(),
role: 'assistant',
content: getWaitingPrompt(event.config),
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
interaction: event.config,
};
chatMessages.value.push(newMsg);
}
sending.value = false;
break;
}
}
if (msgIndex !== -1) {
chatMessages.value[msgIndex] = { ...assistantMessage };
}
scrollToBottom();
}
// 获取等待提示文本
function getWaitingPrompt(config: any) {
if (!config) return $t('ai-platform.agent.chatPanel.waitingInput');
switch (config.type) {
case 'choice': {
return config.question || $t('ai-platform.agent.chatPanel.selectPrompt');
}
case 'confirm': {
return `**${config.title || $t('ai-platform.agent.chatPanel.confirm')}**\n\n${config.content || $t('ai-platform.agent.chatPanel.confirmContinue')}`;
}
case 'question': {
return config.question || $t('ai-platform.agent.chatPanel.inputPrompt');
}
default: {
return $t('ai-platform.agent.chatPanel.waitingInput');
}
}
}
// 处理交互提交
function handleInteractionSubmit(messageId: string, value: any) {
const msgIndex = chatMessages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
const msg = chatMessages.value[msgIndex];
if (msg) {
chatMessages.value[msgIndex] = { ...msg, interaction: undefined };
}
}
const userMsg: AgentMessage = {
id: generateId(),
role: 'user',
content: formatInteractionValue(value),
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(userMsg);
waitingForInput.value = false;
waitingConfig.value = null;
doSendMessage(String(value), false);
}
// 处理交互取消
function handleInteractionCancel(messageId: string) {
const msgIndex = chatMessages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
const msg = chatMessages.value[msgIndex];
if (msg) {
chatMessages.value[msgIndex] = { ...msg, interaction: undefined };
}
}
const userMsg: AgentMessage = {
id: generateId(),
role: 'user',
content:
waitingConfig.value?.cancel_text ||
$t('ai-platform.agent.chatPanel.cancelText'),
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(userMsg);
waitingForInput.value = false;
waitingConfig.value = null;
doSendMessage('false', false);
}
// 格式化交互值显示
function formatInteractionValue(value: any) {
if (value === true) {
return (
waitingConfig.value?.confirm_text ||
$t('ai-platform.agent.chatPanel.confirm')
);
}
if (value === false) {
return (
waitingConfig.value?.cancel_text ||
$t('ai-platform.agent.chatPanel.cancelText')
);
}
if (Array.isArray(value)) {
const options = waitingConfig.value?.options || [];
const labels = value.map((v: string) => {
const opt = options.find((o: any) => o.value === v);
return opt?.label || v;
});
return labels.join(', ');
}
if (waitingConfig.value?.type === 'choice') {
const options = waitingConfig.value?.options || [];
const opt = options.find((o: any) => o.value === value);
return opt?.label || value;
}
return String(value);
}
// 滚动到底部
function scrollToBottom() {
chatBoxRef.value?.scrollToBottom();
}
// 清空对话
function clearChat() {
chatMessages.value = [];
conversationId.value = null;
streamingContent.value = '';
streamingSteps.value = [];
waitingForInput.value = false;
waitingConfig.value = null;
}
// 添加消息(语音消息用)
function handleAddMessage(message: any) {
const agentMsg: AgentMessage = {
id: message.id || generateId(),
role: message.role || 'user',
content: message.content || '',
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
voice: message.voice,
};
chatMessages.value.push(agentMsg);
scrollToBottom();
}
// 更新消息(语音识别完成后用)
function handleUpdateMessage(messageId: string, updates: any) {
const msgIndex = chatMessages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
const msg = chatMessages.value[msgIndex]!;
chatMessages.value[msgIndex] = { ...msg, ...updates };
// 如果语音识别成功,触发发送
if (updates.content && !updates.content.startsWith('[')) {
doSendMessage(updates.content, false);
}
}
}
// 消息反馈
async function handleChatFeedback(
messageId: string,
feedback: 'dislike' | 'like',
) {
if (messageId.startsWith('temp-') || messageId.startsWith('msg-')) return;
try {
await feedbackAgentMessageApi(messageId, { feedback });
const msg = chatMessages.value.find((m) => m.id === messageId);
if (msg) {
msg.feedback = feedback;
}
ElMessage.success($t('ai-platform.agent.chatPanel.feedbackThanks'));
} catch (error) {
console.error('反馈失败:', error);
}
}
// 设计面板确认
function handleDesignConfirm(data: Record<string, any>) {
showDesignPanel.value = false;
// 添加用户确认消息
const userMsg: AgentMessage = {
id: generateId(),
role: 'user',
content: $t('ai-platform.agent.chatPanel.confirmDesign'),
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(userMsg);
// 重置等待状态
waitingForInput.value = false;
waitingConfig.value = null;
currentDesign.value = null;
// 继续工作流,传递编辑后的数据
doSendMessage(JSON.stringify(data), false);
}
// 设计面板取消
function handleDesignCancel() {
showDesignPanel.value = false;
waitingForInput.value = false;
waitingConfig.value = null;
currentDesign.value = null;
}
// 应用设计面板确认
function handleAppDesignConfirm(data: Record<string, any>) {
showAppDesignPanel.value = false;
// 添加用户确认消息
const userMsg: AgentMessage = {
id: generateId(),
role: 'user',
content: $t('ai-platform.agent.chatPanel.confirmAppDesign'),
status: 'completed',
reasoning_steps: [],
tool_calls: [],
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
elapsed_time: 0,
error_message: '',
feedback: '',
created_at: new Date().toISOString(),
};
chatMessages.value.push(userMsg);
// 重置等待状态
waitingForInput.value = false;
waitingConfig.value = null;
currentAppDesign.value = null;
// 继续工作流,传递编辑后的数据
doSendMessage(JSON.stringify(data), false);
}
// 应用设计面板取消
function handleAppDesignCancel() {
showAppDesignPanel.value = false;
waitingForInput.value = false;
waitingConfig.value = null;
currentAppDesign.value = null;
}
// 暴露方法给父组件
defineExpose({
clearChat,
scrollToBottom,
});
onUnmounted(() => {
cancelStream?.();
});
</script>
<template>
<div class="flex h-full overflow-hidden">
<!-- 聊天区域 -->
<div
class="h-full overflow-hidden"
:class="showDesignPanel ? 'w-[400px] flex-shrink-0' : 'flex-1'"
>
<ChatBox
ref="chatBoxRef"
:messages="formattedMessages"
:loading="sending"
:streaming-content="streamingContent"
:assistant-name="agent?.name"
:welcome-config="welcomeConfig"
:disabled="disabled"
:placeholder="
placeholder || $t('ai-platform.agent.chatPanel.inputPlaceholder')
"
:enable-feedback="true"
:enable-regenerate="false"
:enable-attachment="enableAttachment ?? false"
:enable-image="enableImage ?? false"
:enable-voice="enableVoice ?? true"
:show-clear-button="showClearButton ?? true"
class="h-full"
@send="handleSendMessage"
@feedback="handleChatFeedback"
@clear="clearChat"
@interaction-submit="handleInteractionSubmit"
@interaction-cancel="handleInteractionCancel"
@add-message="handleAddMessage"
@update-message="handleUpdateMessage"
/>
</div>
<!-- 设计面板 -->
<div
v-if="showDesignPanel && currentDesign"
class="flex flex-1 flex-col overflow-hidden"
>
<!-- 加载状态 - AI 工作中动画 -->
<AiWorkingAnimation
v-if="currentDesign.type === 'loading'"
:title="currentDesign.title"
:subtitle="$t('ai-platform.agent.chatPanel.analyzingAndGenerating')"
class="ml-3 h-full min-h-[400px] flex-1 rounded-lg"
/>
<!-- 设计编辑器 -->
<DesignEditorPanel
v-else
v-model:visible="showDesignPanel"
:design="{
type: currentDesign.type as any,
title: currentDesign.title,
data: currentDesign.data,
nodeId: currentDesign.nodeId,
form_fields: currentDesign.form_fields,
table_configs: currentDesign.table_configs,
}"
@confirm="handleDesignConfirm"
@close="handleDesignCancel"
/>
</div>
<!-- 应用设计面板 -->
<div
v-if="showAppDesignPanel && currentAppDesign"
class="flex flex-1 flex-col overflow-hidden"
>
<AppDesignPanel
:visible="showAppDesignPanel"
:design="currentAppDesign as any"
@update:visible="showAppDesignPanel = $event"
@confirm="handleAppDesignConfirm"
@close="handleAppDesignCancel"
/>
</div>
</div>
</template>
@@ -0,0 +1,193 @@
<script lang="ts" setup>
import type { MenuItem } from '#/components/zq-form/zq-menu-selector/types';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElScrollbar,
} from 'element-plus';
import { publishAgentToMenuApi } from '#/api/ai-platform/ai-platform';
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
import { ZqMenuSelector } from '#/components/zq-form/zq-menu-selector';
import { useAppContextStore } from '#/store/app-context';
interface Props {
modelValue: boolean;
agentId: string;
agentName: string;
agentCode?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
published: [];
'update:modelValue': [value: boolean];
}>();
const appContextStore = useAppContextStore();
const dialogVisible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const loading = ref(false);
// 发布表单
const publishForm = ref({
menu_name: '',
menu_parent_id: undefined as string | undefined,
menu_icon: 'lucide:bot',
menu_order: 0,
});
// 监听弹窗打开
watch(
() => props.modelValue,
(visible) => {
if (visible) {
// 重置表单,菜单名称默认为智能体名称
publishForm.value = {
menu_name: props.agentName,
menu_parent_id: undefined,
menu_icon: 'lucide:bot',
menu_order: 0,
};
}
},
);
// 菜单选择回调
function handleMenuChange(menu: MenuItem | MenuItem[] | null) {
publishForm.value.menu_parent_id =
menu && !Array.isArray(menu) ? menu.id : undefined;
}
async function handlePublish() {
if (!publishForm.value.menu_name) {
ElMessage.warning($t('ui.placeholder.input'));
return;
}
loading.value = true;
try {
await publishAgentToMenuApi(props.agentId, {
menu_name: publishForm.value.menu_name,
menu_parent_id: publishForm.value.menu_parent_id,
menu_icon: publishForm.value.menu_icon,
menu_order: publishForm.value.menu_order,
});
ElMessage.success($t('common.operationSuccess'));
emit('published');
dialogVisible.value = false;
} catch (error: any) {
ElMessage.error(error?.message || $t('common.operationFailed'));
} finally {
loading.value = false;
}
}
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('ai-platform.agent.publishToMenu')"
width="500px"
destroy-on-close
align-center
append-to-body
>
<ElScrollbar max-height="60vh">
<div class="pr-4">
<ElForm :model="publishForm" label-width="100px">
<!-- 菜单配置 -->
<div class="mb-4 font-medium">
{{ $t('form-manager.publishDialog.menuConfig') }}
</div>
<ElFormItem
:label="$t('form-manager.publishDialog.menuName')"
required
>
<ElInput
v-model="publishForm.menu_name"
:placeholder="$t('form-manager.placeholder.name')"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.publishDialog.parentMenu')">
<ZqMenuSelector
:model-value="publishForm.menu_parent_id || null"
mode="dialog"
:placeholder="
$t('form-manager.publishDialog.parentMenuPlaceholder')
"
:application-id="appContextStore.currentApp?.id"
@change="handleMenuChange"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.publishDialog.menuIcon')">
<ZqIconPicker
v-model="publishForm.menu_icon"
prefix="lucide"
:auto-fetch-api="false"
class="w-full"
/>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInputNumber
v-model="publishForm.menu_order"
:min="0"
:max="999"
class="w-full"
/>
</ElFormItem>
<!-- 路由信息 -->
<template v-if="agentCode">
<div class="mb-4 mt-6 font-medium">
{{ $t('form-manager.publishDialog.routeInfo') }}
</div>
<ElFormItem :label="$t('form-manager.publishDialog.accessPath')">
<ElInput
:model-value="`/agent-chat/${agentCode}`"
disabled
class="text-muted-foreground"
/>
</ElFormItem>
</template>
</ElForm>
</div>
</ElScrollbar>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('common.cancel') }}
</ElButton>
<ElButton type="primary" :loading="loading" @click="handlePublish">
{{ $t('common.confirm') }}
</ElButton>
</template>
</ElDialog>
</template>
<style>
/* 确保 IconPicker 的弹出层在 Dialog 之上 */
.z-popup {
z-index: 2100 !important;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,642 @@
<script setup lang="ts">
import type {
AgentCreateInput,
AgentListItem,
} from '#/api/ai-platform/ai-platform';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import {
Bot,
CirclePlus,
Download,
Edit,
Menu,
MessageSquare,
MoreVertical,
Play,
Square,
Trash2,
Upload,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElSelect,
ElSwitch,
ElTag,
ElTooltip,
} from 'element-plus';
import {
createAgentApi,
deleteAgentApi,
disableAgentApi,
exportAgentConfigApi,
getAgentListApi,
publishAgentApi,
unpublishAgentMenuApi,
} from '#/api/ai-platform/ai-platform';
import { useAppContextStore } from '#/store/app-context';
import PublishToMenuDialog from './components/PublishToMenuDialog.vue';
import ImportDialog from './modules/import-dialog.vue';
defineOptions({ name: 'AgentList' });
const router = useRouter();
const appContextStore = useAppContextStore();
// 是否在主应用下(只有主应用才显示“子应用可见”开关)
const isMainApp = !appContextStore.currentApp?.id;
// 搜索关键词
const searchKeyword = ref('');
// 列表数据
const loading = ref(false);
const agentList = ref<AgentListItem[]>([]);
// 分页
const pagination = reactive({
current: 1,
pageSize: 12,
total: 0,
});
// 发布到菜单弹窗
const showPublishToMenuDialog = ref(false);
const publishingAgent = ref<AgentListItem | null>(null);
// 导入弹窗
const showImportDialog = ref(false);
// 创建对话框
const createDialogVisible = ref(false);
const createForm = reactive<AgentCreateInput>({
name: '',
code: '',
description: '',
mode: 'autonomous',
is_global: false,
});
const createLoading = ref(false);
// 获取列表
async function fetchList() {
loading.value = true;
try {
const res = await getAgentListApi({
page: pagination.current,
pageSize: pagination.pageSize,
name: searchKeyword.value || undefined,
applicationId: appContextStore.currentApp?.id,
});
agentList.value = res.items;
pagination.total = res.total;
} catch (error) {
console.error('获取智能体列表失败:', error);
} finally {
loading.value = false;
}
}
// 搜索
function handleSearch() {
pagination.current = 1;
fetchList();
}
// 分页变化
function handlePageChange(page: number) {
pagination.current = page;
fetchList();
}
// 每页条数变化
function handleSizeChange(size: number) {
pagination.pageSize = size;
pagination.current = 1;
fetchList();
}
// 打开创建对话框
function openCreateDialog() {
createForm.name = '';
createForm.code = '';
createForm.description = '';
createForm.mode = 'autonomous';
createForm.is_global = false;
createDialogVisible.value = true;
}
// 创建智能体
async function handleCreate() {
if (!createForm.name || !createForm.code) {
ElMessage.warning($t('ai-platform.agent.fillNameAndCode'));
return;
}
// 验证 code 格式
if (!/^[a-z]\w*$/i.test(createForm.code)) {
ElMessage.warning($t('ai-platform.agent.codeFormatError'));
return;
}
createLoading.value = true;
try {
const agent = await createAgentApi({
...createForm,
application_id: appContextStore.currentApp?.id,
});
ElMessage.success($t('ai-platform.agent.createSuccess'));
createDialogVisible.value = false;
// 跳转到编辑页(支持子应用模式)
router.push(
appContextStore.getContextPath(`/ai-platform/agent/editor/${agent.id}`),
);
} catch (error: any) {
ElMessage.error(error.message || $t('ai-platform.agent.createFailed'));
} finally {
createLoading.value = false;
}
}
// 编辑智能体
function handleEdit(agent: AgentListItem) {
router.push(
appContextStore.getContextPath(`/ai-platform/agent/editor/${agent.id}`),
);
}
// 对话
function handleChat(agent: AgentListItem) {
router.push(
appContextStore.getContextPath(`/ai-platform/agent/chat/${agent.id}`),
);
}
// 发布
async function handlePublish(agent: AgentListItem) {
try {
await publishAgentApi(agent.id);
ElMessage.success($t('ai-platform.agent.publishSuccess'));
fetchList();
} catch (error: any) {
ElMessage.error(error.message || $t('ai-platform.agent.publishFailed'));
}
}
// 停用
async function handleDisable(agent: AgentListItem) {
try {
await disableAgentApi(agent.id);
ElMessage.success($t('ai-platform.agent.disableSuccess'));
fetchList();
} catch (error: any) {
ElMessage.error(error.message || $t('ai-platform.agent.operationFailed'));
}
}
// 删除
async function handleDelete(agent: AgentListItem) {
try {
await ElMessageBox.confirm(
$t('ai-platform.agent.deleteConfirm', { name: agent.name }),
$t('ai-platform.agent.deleteTitle'),
{ type: 'warning' },
);
await deleteAgentApi(agent.id);
ElMessage.success($t('ai-platform.agent.deleteSuccess'));
fetchList();
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || $t('ai-platform.agent.deleteFailed'));
}
}
}
// 获取状态标签类型
function getStatusType(status: string) {
switch (status) {
case 'disabled': {
return 'danger';
}
case 'published': {
return 'success';
}
default: {
return 'info';
}
}
}
// 获取状态文本
function getStatusText(status: string) {
switch (status) {
case 'disabled': {
return $t('ai-platform.agent.status.disabled');
}
case 'published': {
return $t('ai-platform.agent.status.published');
}
default: {
return $t('ai-platform.agent.status.draft');
}
}
}
// 获取模式文本
function getModeText(mode: string) {
return mode === 'autonomous'
? $t('ai-platform.agent.mode.autonomous')
: $t('ai-platform.agent.mode.dialogFlow');
}
// 发布到菜单
function handlePublishToMenu(agent: AgentListItem) {
publishingAgent.value = agent;
showPublishToMenuDialog.value = true;
}
// 发布到菜单成功回调
function handlePublishedToMenu() {
fetchList();
}
// 取消发布菜单
async function handleUnpublishMenu(agent: AgentListItem) {
try {
await unpublishAgentMenuApi(agent.id);
ElMessage.success($t('ai-platform.agent.unpublishMenuSuccess'));
fetchList();
} catch (error: any) {
ElMessage.error(error.message || $t('ai-platform.agent.operationFailed'));
}
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function handleExport(agent: AgentListItem) {
try {
const blob = await exportAgentConfigApi(agent.id);
downloadBlob(blob, `${agent.code}.json`);
ElMessage.success($t('ai-platform.agent.importExport.exportSuccess'));
} catch {
ElMessage.error($t('ai-platform.agent.importExport.exportFailed'));
}
}
function handleImported() {
fetchList();
}
// 处理更多菜单命令
function handleCommand(command: string, agent: AgentListItem) {
switch (command) {
case 'export': {
handleExport(agent);
break;
}
case 'chat': {
handleChat(agent);
break;
}
case 'disable': {
handleDisable(agent);
break;
}
case 'publish': {
handlePublish(agent);
break;
}
case 'publishToMenu': {
handlePublishToMenu(agent);
break;
}
case 'unpublishMenu': {
handleUnpublishMenu(agent);
break;
}
}
}
onMounted(() => {
fetchList();
});
</script>
<template>
<div class="agent-list-page">
<Page auto-content-height v-loading="loading">
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<ElInput
v-model="searchKeyword"
:placeholder="$t('ai-platform.agent.searchPlaceholder')"
clearable
class="w-64"
@keyup.enter="handleSearch"
/>
<ElButton @click="handleSearch">
{{ $t('ai-platform.agent.search') }}
</ElButton>
</div>
<div class="flex items-center gap-2">
<ElButton @click="showImportDialog = true">
<Upload class="mr-1 h-4 w-4" />
{{ $t('ai-platform.agent.importExport.import') }}
</ElButton>
<ElButton type="primary" @click="openCreateDialog">
<CirclePlus class="mr-1 h-4 w-4" />
{{ $t('ai-platform.agent.create') }}
</ElButton>
</div>
</div>
</template>
<!-- 智能体卡片列表 -->
<div
v-if="agentList.length > 0"
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
<ElCard
v-for="agent in agentList"
:key="agent.id"
class="agent-card cursor-pointer transition-shadow"
shadow="hover"
:body-style="{ padding: '0' }"
style="border: none"
@click="handleEdit(agent)"
>
<div class="p-4">
<!-- 头部图标 + 右侧信息区 -->
<div class="mb-4 flex gap-3">
<div
class="bg-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg"
>
<Bot class="text-primary h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<!-- name + 操作 -->
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1 truncate text-sm font-medium">
{{ agent.name }}
</div>
<div
class="flex flex-shrink-0 items-center -space-x-1"
@click.stop
>
<ElTooltip
:content="$t('ai-platform.agent.edit')"
placement="top"
>
<ElButton text size="small" @click="handleEdit(agent)">
<Edit class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('ai-platform.agent.delete')"
placement="top"
>
<ElButton text size="small" @click="handleDelete(agent)">
<Trash2 class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElDropdown
trigger="click"
@command="(cmd: string) => handleCommand(cmd, agent)"
>
<ElButton text size="small">
<MoreVertical class="h-3.5 w-3.5" />
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-if="agent.status === 'published'"
command="chat"
>
<MessageSquare class="mr-2 h-4 w-4" />
{{ $t('ai-platform.agent.chat') }}
</ElDropdownItem>
<ElDropdownItem
v-if="agent.status === 'draft'"
command="publish"
>
<Play class="mr-2 h-4 w-4" />
{{ $t('ai-platform.agent.publish') }}
</ElDropdownItem>
<ElDropdownItem
v-if="
agent.status === 'published' && !agent.has_menu
"
command="publishToMenu"
>
<Menu class="mr-2 h-4 w-4" />
{{ $t('ai-platform.agent.publishToMenu') }}
</ElDropdownItem>
<ElDropdownItem
v-if="
agent.status === 'published' && agent.has_menu
"
command="unpublishMenu"
>
<Square class="mr-2 h-4 w-4" />
{{ $t('ai-platform.agent.unpublishMenu') }}
</ElDropdownItem>
<ElDropdownItem command="export">
<Download class="mr-2 h-4 w-4" />
{{ $t('ai-platform.agent.importExport.export') }}
</ElDropdownItem>
<ElDropdownItem
v-if="agent.status === 'published'"
command="disable"
>
<Square class="mr-2 h-4 w-4" />
{{ $t('ai-platform.agent.disable') }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
<!-- code -->
<div class="text-muted-foreground font-mono text-xs">
{{ agent.code }}
</div>
</div>
</div>
<!-- 描述 -->
<div
class="text-muted-foreground mb-4 line-clamp-1 min-h-[18px] text-xs"
>
{{ agent.description || $t('ai-platform.agent.noDescription') }}
</div>
<!-- 标签 -->
<div class="mb-4 flex items-center justify-between">
<div class="flex gap-1">
<ElTag size="small" :type="getStatusType(agent.status) as any">
{{ getStatusText(agent.status) }}
</ElTag>
<ElTag size="small" type="info">
{{ getModeText(agent.mode) }}
</ElTag>
</div>
</div>
<!-- 应用名称 + 创建时间 -->
<div class="flex items-center justify-between">
<div class="text-muted-foreground text-xs">
<span v-if="agent.application_name">{{
agent.application_name
}}</span>
<span v-else>{{ $t('ai-platform.agent.mainApp') }}</span>
</div>
<span class="text-muted-foreground text-xs">
{{ agent.sys_create_datetime }}
</span>
</div>
</div>
</ElCard>
</div>
<!-- 空状态 -->
<ElEmpty v-else :description="$t('ai-platform.agent.empty')" />
<!-- 分页 -->
<template #footer>
<div class="flex w-full items-center justify-end">
<ElPagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[12, 24, 36, 48]"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
size="small"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</template>
</Page>
<ImportDialog v-model="showImportDialog" @imported="handleImported" />
<!-- 发布到菜单弹窗 -->
<PublishToMenuDialog
v-if="publishingAgent"
v-model="showPublishToMenuDialog"
:agent-id="publishingAgent.id"
:agent-name="publishingAgent.name"
:agent-code="publishingAgent.code"
@published="handlePublishedToMenu"
/>
<!-- 创建对话框 -->
<ElDialog
v-model="createDialogVisible"
:title="$t('ai-platform.agent.create')"
width="500px"
>
<ElForm label-width="80px">
<ElFormItem :label="$t('ai-platform.agent.form.name')" required>
<ElInput
v-model="createForm.name"
:placeholder="$t('ai-platform.agent.form.namePlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.agent.form.code')" required>
<ElInput
v-model="createForm.code"
:placeholder="$t('ai-platform.agent.form.codePlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.agent.form.mode')">
<ElSelect v-model="createForm.mode" style="width: 100%">
<ElOption
:label="$t('ai-platform.agent.mode.autonomousLabel')"
value="autonomous"
>
<div class="flex items-center gap-2">
<div>
<div>{{ $t('ai-platform.agent.mode.autonomousLabel') }}</div>
<div class="text-muted-foreground text-xs">
{{ $t('ai-platform.agent.mode.autonomousDesc') }}
</div>
</div>
</div>
</ElOption>
<ElOption
:label="$t('ai-platform.agent.mode.dialogFlowLabel')"
value="dialog_flow"
>
<div class="flex items-center gap-2">
<div>
<div>{{ $t('ai-platform.agent.mode.dialogFlowLabel') }}</div>
<div class="text-muted-foreground text-xs">
{{ $t('ai-platform.agent.mode.dialogFlowDesc') }}
</div>
</div>
</div>
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.agent.form.description')">
<ElInput
v-model="createForm.description"
:rows="3"
:placeholder="$t('ai-platform.agent.form.descriptionPlaceholder')"
type="textarea"
/>
</ElFormItem>
<ElFormItem
v-if="isMainApp"
:label="$t('ai-platform.agent.form.globalVisible')"
>
<ElSwitch v-model="createForm.is_global" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="createDialogVisible = false">
{{ $t('ai-platform.agent.cancel') }}
</ElButton>
<ElButton :loading="createLoading" type="primary" @click="handleCreate">
{{ $t('ai-platform.agent.create') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.agent-card :deep(.el-card__body) {
padding: 0;
}
</style>
@@ -0,0 +1,278 @@
<script lang="ts" setup>
import type {
AgentImportCheckResult,
AgentImportInput,
} from '#/api/ai-platform/ai-platform';
import { computed, ref, watch } from 'vue';
import { Upload } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElAlert,
ElButton,
ElDescriptions,
ElDescriptionsItem,
ElInput,
ElMessage,
ElUpload,
} from 'element-plus';
import {
checkImportAgentApi,
importAgentConfigApi,
} from '#/api/ai-platform/ai-platform';
import { ZqDialog } from '#/components/zq-dialog';
import { useAppContextStore } from '#/store/app-context';
interface Props {
modelValue: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
imported: [];
'update:modelValue': [value: boolean];
}>();
const appContextStore = useAppContextStore();
const dialogVisible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
type Step = 'check' | 'result' | 'upload';
const step = ref<Step>('upload');
const loading = ref(false);
const importData = ref<AgentImportInput | null>(null);
const checkResult = ref<AgentImportCheckResult | null>(null);
const newCode = ref('');
function resetState() {
step.value = 'upload';
loading.value = false;
importData.value = null;
checkResult.value = null;
newCode.value = '';
}
watch(dialogVisible, (val) => {
if (!val) resetState();
});
const canConfirmImport = computed(() => {
if (!checkResult.value || !importData.value) return false;
if (checkResult.value.code_exists && !newCode.value.trim()) return false;
return true;
});
const showFooter = computed(() => step.value === 'result');
function readFileAsJson(file: File): Promise<AgentImportInput> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
try {
resolve(JSON.parse(reader.result as string));
} catch {
reject(new Error('parse'));
}
};
reader.onerror = () => reject(new Error('read'));
reader.readAsText(file);
});
}
async function handleFileChange(file: any) {
const rawFile = (file.raw || file) as File;
if (!rawFile) return;
loading.value = true;
try {
const data = await readFileAsJson(rawFile);
if (!data.name || !data.code) {
ElMessage.error($t('ai-platform.agent.importExport.fileParseError'));
return;
}
importData.value = data;
newCode.value = data.code;
step.value = 'check';
await runCheck();
} catch {
ElMessage.error($t('ai-platform.agent.importExport.fileParseError'));
} finally {
loading.value = false;
}
}
async function runCheck() {
if (!importData.value) return;
loading.value = true;
try {
checkResult.value = await checkImportAgentApi({
code: importData.value.code,
});
step.value = 'result';
} catch (error: any) {
ElMessage.error(
error?.message || $t('ai-platform.agent.importExport.importFailed'),
);
} finally {
loading.value = false;
}
}
async function handleConfirmImport() {
if (!importData.value || !canConfirmImport.value) return;
loading.value = true;
try {
const payload: AgentImportInput = {
...importData.value,
application_id: appContextStore.currentApp?.id,
code: checkResult.value?.code_exists
? newCode.value.trim()
: importData.value.code,
};
await importAgentConfigApi(payload);
ElMessage.success($t('ai-platform.agent.importExport.importSuccess'));
dialogVisible.value = false;
emit('imported');
} catch (error: any) {
ElMessage.error(
error?.message || $t('ai-platform.agent.importExport.importFailed'),
);
} finally {
loading.value = false;
}
}
</script>
<template>
<ZqDialog
v-model="dialogVisible"
:title="$t('ai-platform.agent.importExport.importTitle')"
:confirm-loading="loading"
width="520px"
:show-footer="showFooter"
@confirm="handleConfirmImport"
>
<template #footer>
<ElButton @click="resetState">
{{ $t('ai-platform.agent.importExport.reselect') }}
</ElButton>
<ElButton
type="primary"
:loading="loading"
:disabled="!canConfirmImport"
@click="handleConfirmImport"
>
{{ $t('ai-platform.agent.importExport.confirmImport') }}
</ElButton>
</template>
<div v-if="step === 'upload' || step === 'check'" class="py-4">
<ElUpload
drag
:auto-upload="false"
:show-file-list="false"
accept=".json"
:disabled="loading"
@change="handleFileChange"
>
<div class="flex flex-col items-center py-6">
<Upload class="text-muted-foreground mb-3 h-10 w-10" />
<div class="text-sm">
{{ $t('ai-platform.agent.importExport.dragOrClick') }}
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.agent.importExport.onlyJson') }}
</div>
</div>
</ElUpload>
<div
v-if="loading && step === 'check'"
class="text-muted-foreground mt-4 text-center text-sm"
>
{{ $t('ai-platform.agent.importExport.checking') }}
</div>
</div>
<div v-if="step === 'result' && importData && checkResult" class="space-y-4">
<ElAlert
:title="$t('ai-platform.agent.importExport.appTip')"
type="info"
show-icon
:closable="false"
/>
<ElAlert
:title="$t('ai-platform.agent.importExport.refTip')"
type="warning"
show-icon
:closable="false"
/>
<div>
<div class="mb-2 text-sm font-medium">
{{ $t('ai-platform.agent.importExport.agentInfo') }}
</div>
<ElDescriptions :column="1" border size="small">
<ElDescriptionsItem :label="$t('ai-platform.agent.form.name')">
{{ importData.name }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('ai-platform.agent.form.code')">
{{ importData.code }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="importData.mode"
:label="$t('ai-platform.agent.form.mode')"
>
{{ importData.mode }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="importData.description"
:label="$t('ai-platform.agent.form.description')"
>
{{ importData.description }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="importData.model_name"
:label="$t('ai-platform.agent.importExport.modelName')"
>
{{ importData.model_name }}
</ElDescriptionsItem>
<ElDescriptionsItem
v-if="importData.workflow_code"
:label="$t('ai-platform.agent.importExport.workflowCode')"
>
{{ importData.workflow_code }}
</ElDescriptionsItem>
</ElDescriptions>
</div>
<div v-if="checkResult.code_exists">
<ElAlert
:title="$t('ai-platform.agent.importExport.codeConflictTip')"
type="warning"
show-icon
:closable="false"
class="mb-3"
/>
<ElInput
v-model="newCode"
:placeholder="$t('ai-platform.agent.importExport.newCodePlaceholder')"
/>
</div>
<ElAlert
v-else
:title="$t('ai-platform.agent.importExport.codeAvailable')"
type="success"
show-icon
:closable="false"
/>
</div>
</ZqDialog>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,690 @@
<script setup lang="ts">
import type {
KnowledgeBaseCreateInput,
KnowledgeBaseListItem,
ModelListItem,
} from '#/api/ai-platform/ai-platform';
import { onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import {
CircleHelp,
Database,
Edit,
MoreVertical,
Plus,
Trash2,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElSelect,
ElSwitch,
ElTag,
ElTooltip,
} from 'element-plus';
import {
createKnowledgeBaseApi,
deleteKnowledgeBaseApi,
getActiveModelsApi,
getKnowledgeBaseListApi,
updateKnowledgeBaseApi,
} from '#/api/ai-platform/ai-platform';
import { ZqDialog } from '#/components/zq-dialog';
import { useAppContextStore } from '#/store/app-context';
defineOptions({ name: 'KnowledgeBase' });
const router = useRouter();
const appContextStore = useAppContextStore();
const isMainApp = !appContextStore.currentApp?.id;
// 表单
const createForm = reactive<KnowledgeBaseCreateInput & { id?: string }>({
name: '',
code: '',
description: '',
embedding_model_id: undefined,
embedding_dimensions: 1536,
chunk_strategy: 'auto',
chunk_size: 500,
chunk_overlap: 50,
retrieval_mode: 'hybrid',
top_k: 5,
score_threshold: 0.5,
indexing_technique: 'high_quality',
is_global: false,
});
const formRef = ref();
const isEditMode = ref(false);
const formLoading = ref(false);
const dialogVisible = ref(false);
const dialogTitle = ref('');
// Embedding 模型列表
const embeddingModels = ref<ModelListItem[]>([]);
async function loadEmbeddingModels() {
try {
const res = await getActiveModelsApi();
embeddingModels.value = res.filter((m) => m.model_type === 'embedding');
} catch {
// ignore
}
}
// 列表
const searchKeyword = ref('');
const loading = ref(false);
const kbList = ref<KnowledgeBaseListItem[]>([]);
const pagination = reactive({
current: 1,
pageSize: 12,
total: 0,
});
async function fetchList() {
loading.value = true;
try {
const res = await getKnowledgeBaseListApi({
page: pagination.current,
pageSize: pagination.pageSize,
name: searchKeyword.value || undefined,
applicationId: appContextStore.currentApp?.id,
});
kbList.value = res.items;
pagination.total = res.total;
} catch (error) {
console.error('Failed to load knowledge base list:', error);
} finally {
loading.value = false;
}
}
function handleSearch() {
pagination.current = 1;
fetchList();
}
function handlePageChange(page: number) {
pagination.current = page;
fetchList();
}
function handleSizeChange(size: number) {
pagination.pageSize = size;
pagination.current = 1;
fetchList();
}
function handleCreate() {
isEditMode.value = false;
createForm.id = undefined;
createForm.name = '';
createForm.code = '';
createForm.description = '';
createForm.embedding_model_id = undefined;
createForm.embedding_dimensions = 1536;
createForm.chunk_strategy = 'auto';
createForm.chunk_size = 500;
createForm.chunk_overlap = 50;
createForm.retrieval_mode = 'hybrid';
createForm.top_k = 5;
createForm.score_threshold = 0.5;
createForm.indexing_technique = 'high_quality';
createForm.is_global = false;
dialogTitle.value = $t('ai-platform.knowledge.create');
dialogVisible.value = true;
}
function handleEditInfo(item: KnowledgeBaseListItem) {
isEditMode.value = true;
createForm.id = item.id;
createForm.name = item.name;
createForm.code = item.code;
createForm.description = item.description || '';
createForm.is_global = item.is_global || false;
dialogTitle.value = $t('ai-platform.knowledge.edit');
dialogVisible.value = true;
}
async function handleConfirm() {
if (!formRef.value) return;
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
formLoading.value = true;
try {
if (isEditMode.value && createForm.id) {
await updateKnowledgeBaseApi(createForm.id, {
name: createForm.name,
description: createForm.description,
is_global: createForm.is_global,
});
ElMessage.success($t('ai-platform.knowledge.saveSuccess'));
} else {
const res = await createKnowledgeBaseApi({
application_id: appContextStore.currentApp?.id,
is_global: createForm.is_global,
name: createForm.name,
code: createForm.code,
description: createForm.description,
embedding_model_id: createForm.embedding_model_id,
embedding_dimensions: createForm.embedding_dimensions,
chunk_strategy: createForm.chunk_strategy,
chunk_size: createForm.chunk_size,
chunk_overlap: createForm.chunk_overlap,
retrieval_mode: createForm.retrieval_mode,
top_k: createForm.top_k,
score_threshold: createForm.score_threshold,
indexing_technique: createForm.indexing_technique,
});
ElMessage.success($t('ai-platform.knowledge.createSuccess'));
// 跳转到详情页
router.push(
appContextStore.getContextPath(
`/ai-platform/knowledge/detail/${res.id}`,
),
);
}
dialogVisible.value = false;
fetchList();
} catch {
// error handled by interceptor
} finally {
formLoading.value = false;
}
});
}
function handleDetail(item: KnowledgeBaseListItem) {
router.push(
appContextStore.getContextPath(`/ai-platform/knowledge/detail/${item.id}`),
);
}
async function handleDelete(item: KnowledgeBaseListItem) {
try {
await ElMessageBox.confirm(
$t('ai-platform.knowledge.deleteConfirm'),
$t('ai-platform.knowledge.delete'),
{ type: 'warning' },
);
await deleteKnowledgeBaseApi(item.id);
ElMessage.success($t('ai-platform.knowledge.deleteSuccess'));
fetchList();
} catch {
// cancelled
}
}
function handleCommand(cmd: string, item: KnowledgeBaseListItem) {
if (cmd === 'edit') {
handleEditInfo(item);
} else if (cmd === 'delete') {
handleDelete(item);
}
}
function getStatusType(status: string) {
const map: Record<string, string> = {
active: 'success',
disabled: 'info',
};
return map[status] || 'info';
}
function getStatusText(status: string) {
if (status === 'active') return $t('ai-platform.knowledge.active');
if (status === 'disabled') return $t('ai-platform.knowledge.disabled');
return status;
}
onMounted(() => {
fetchList();
loadEmbeddingModels();
});
</script>
<template>
<div class="ai-knowledge-list-page">
<Page auto-content-height v-loading="loading">
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<ElInput
v-model="searchKeyword"
:placeholder="$t('ai-platform.knowledge.searchPlaceholder')"
clearable
class="w-64"
@keyup.enter="handleSearch"
/>
<ElButton @click="handleSearch">
{{ $t('ai-platform.knowledge.search') }}
</ElButton>
</div>
<ElButton type="primary" @click="handleCreate">
<Plus class="mr-1 h-4 w-4" />
{{ $t('ai-platform.knowledge.create') }}
</ElButton>
</div>
</template>
<!-- 卡片列表 -->
<div
v-if="kbList.length > 0"
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
<ElCard
v-for="item in kbList"
:key="item.id"
class="cursor-pointer transition-shadow"
shadow="hover"
:body-style="{ padding: '0' }"
style="border: none"
@click="handleDetail(item)"
>
<div class="p-4">
<!-- 头部 -->
<div class="mb-4 flex gap-3">
<div
class="bg-primary/10 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg"
>
<Database class="text-primary h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1 truncate text-sm font-medium">
{{ item.name }}
</div>
<div
class="flex flex-shrink-0 items-center -space-x-1"
@click.stop
>
<ElTooltip
:content="$t('ai-platform.knowledge.edit')"
placement="top"
>
<ElButton text size="small" @click="handleEditInfo(item)">
<Edit class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('ai-platform.knowledge.delete')"
placement="top"
>
<ElButton text size="small" @click="handleDelete(item)">
<Trash2 class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElDropdown
trigger="click"
@command="(cmd: string) => handleCommand(cmd, item)"
>
<ElButton text size="small">
<MoreVertical class="h-3.5 w-3.5" />
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem command="edit">
<Edit class="mr-2 h-4 w-4" />
{{ $t('ai-platform.knowledge.edit') }}
</ElDropdownItem>
<ElDropdownItem command="delete">
<Trash2 class="mr-2 h-4 w-4" />
{{ $t('ai-platform.knowledge.delete') }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
<div class="text-muted-foreground font-mono text-xs">
{{ item.code }}
</div>
</div>
</div>
<!-- 描述 -->
<div
class="text-muted-foreground mb-4 line-clamp-1 min-h-[18px] text-xs"
>
{{
item.description || $t('ai-platform.knowledge.noDescription')
}}
</div>
<!-- 标签 -->
<div class="mb-4 flex items-center gap-1">
<ElTag size="small" :type="getStatusType(item.status) as any">
{{ getStatusText(item.status) }}
</ElTag>
<ElTag v-if="item.embedding_model_name" size="small">
{{ item.embedding_model_name }}
</ElTag>
</div>
<!-- 统计 + 时间 -->
<div class="flex items-center justify-between">
<div class="text-muted-foreground flex gap-3 text-xs">
<span>
{{ $t('ai-platform.knowledge.documents') }}:
{{ item.document_count }}
</span>
<span>
{{ $t('ai-platform.knowledge.segments') }}:
{{ item.segment_count }}
</span>
</div>
<span class="text-muted-foreground text-xs">
{{ item.sys_create_datetime }}
</span>
</div>
</div>
</ElCard>
</div>
<!-- 空状态 -->
<ElEmpty v-else :description="$t('common.noData')" />
<!-- 分页 -->
<template #footer>
<div class="flex w-full items-center justify-end">
<ElPagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[12, 24, 36, 48]"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
size="small"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</template>
</Page>
<!-- 创建/编辑弹窗 -->
<ZqDialog
v-model="dialogVisible"
:title="dialogTitle"
:confirm-loading="formLoading"
width="560px"
@confirm="handleConfirm"
>
<ElForm
ref="formRef"
:model="createForm"
label-position="top"
:rules="{
name: [
{
required: true,
message: $t('ai-platform.knowledge.namePlaceholder'),
trigger: 'blur',
},
],
code: [
{
required: true,
message: $t('ai-platform.knowledge.codePlaceholder'),
trigger: 'blur',
},
],
}"
>
<ElFormItem :label="$t('ai-platform.knowledge.name')" prop="name">
<ElInput
v-model="createForm.name"
:placeholder="$t('ai-platform.knowledge.namePlaceholder')"
/>
</ElFormItem>
<ElFormItem
v-if="!isEditMode"
:label="$t('ai-platform.knowledge.code')"
prop="code"
>
<ElInput
v-model="createForm.code"
:placeholder="$t('ai-platform.knowledge.codePlaceholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.knowledge.description')">
<ElInput
v-model="createForm.description"
type="textarea"
:placeholder="$t('ai-platform.knowledge.descriptionPlaceholder')"
:rows="3"
/>
</ElFormItem>
<!-- 创建模式下显示更多配置 -->
<template v-if="!isEditMode">
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('ai-platform.knowledge.embeddingModel') }}</span>
<ElTooltip
placement="top"
:content="$t('ai-platform.knowledge.embeddingModelTooltip')"
>
<CircleHelp
class="text-muted-foreground h-3.5 w-3.5 cursor-help"
/>
</ElTooltip>
</div>
</template>
<ElSelect
v-model="createForm.embedding_model_id"
:placeholder="
$t('ai-platform.knowledge.embeddingModelPlaceholder')
"
style="width: 100%"
clearable
>
<ElOption
v-for="model in embeddingModels"
:key="model.id"
:label="model.display_name"
:value="model.id"
/>
</ElSelect>
</ElFormItem>
<div class="grid grid-cols-2 gap-4">
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('ai-platform.knowledge.chunkStrategy') }}</span>
<ElTooltip
placement="top"
:content="$t('ai-platform.knowledge.chunkStrategyTooltip')"
>
<CircleHelp
class="text-muted-foreground h-3.5 w-3.5 cursor-help"
/>
</ElTooltip>
</div>
</template>
<ElSelect v-model="createForm.chunk_strategy" style="width: 100%">
<ElOption
:label="$t('ai-platform.knowledge.chunkStrategies.auto')"
value="auto"
/>
<ElOption
:label="$t('ai-platform.knowledge.chunkStrategies.recursive')"
value="recursive"
/>
<ElOption
:label="$t('ai-platform.knowledge.chunkStrategies.markdown')"
value="markdown"
/>
<ElOption
:label="$t('ai-platform.knowledge.chunkStrategies.fixed')"
value="fixed"
/>
<ElOption
:label="$t('ai-platform.knowledge.chunkStrategies.sentence')"
value="sentence"
/>
<ElOption
:label="$t('ai-platform.knowledge.chunkStrategies.qa')"
value="qa"
/>
</ElSelect>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{
$t('ai-platform.knowledge.indexingTechnique')
}}</span>
<ElTooltip
placement="top"
:content="
$t('ai-platform.knowledge.indexingTechniqueTooltip')
"
>
<CircleHelp
class="text-muted-foreground h-3.5 w-3.5 cursor-help"
/>
</ElTooltip>
</div>
</template>
<ElSelect
v-model="createForm.indexing_technique"
style="width: 100%"
>
<ElOption
:label="
$t('ai-platform.knowledge.indexingTechniques.high_quality')
"
value="high_quality"
/>
<ElOption
:label="
$t('ai-platform.knowledge.indexingTechniques.economy')
"
value="economy"
/>
</ElSelect>
</ElFormItem>
</div>
<div class="grid grid-cols-2 gap-4">
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('ai-platform.knowledge.retrievalMode') }}</span>
<ElTooltip
placement="top"
:content="$t('ai-platform.knowledge.retrievalModeTooltip')"
>
<CircleHelp
class="text-muted-foreground h-3.5 w-3.5 cursor-help"
/>
</ElTooltip>
</div>
</template>
<ElSelect v-model="createForm.retrieval_mode" style="width: 100%">
<ElOption
:label="$t('ai-platform.knowledge.retrievalModes.hybrid')"
value="hybrid"
/>
<ElOption
:label="$t('ai-platform.knowledge.retrievalModes.vector')"
value="vector"
/>
<ElOption
:label="$t('ai-platform.knowledge.retrievalModes.fulltext')"
value="fulltext"
/>
</ElSelect>
</ElFormItem>
</div>
<div class="grid grid-cols-2 gap-4">
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('ai-platform.knowledge.chunkSize') }}</span>
<ElTooltip
placement="top"
:content="$t('ai-platform.knowledge.chunkSizeTooltip')"
>
<CircleHelp
class="text-muted-foreground h-3.5 w-3.5 cursor-help"
/>
</ElTooltip>
</div>
</template>
<ElInputNumber
v-model="createForm.chunk_size"
:min="100"
:max="4000"
:step="100"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('ai-platform.knowledge.chunkOverlap') }}</span>
<ElTooltip
placement="top"
:content="$t('ai-platform.knowledge.chunkOverlapTooltip')"
>
<CircleHelp
class="text-muted-foreground h-3.5 w-3.5 cursor-help"
/>
</ElTooltip>
</div>
</template>
<ElInputNumber
v-model="createForm.chunk_overlap"
:min="0"
:max="500"
:step="10"
style="width: 100%"
/>
</ElFormItem>
</div>
</template>
<ElFormItem v-if="isMainApp">
<template #label>
<div class="flex items-center gap-1">
<span>{{ $t('ai-platform.knowledge.globalVisible') }}</span>
<ElTooltip
placement="top"
:content="$t('ai-platform.knowledge.globalVisibleTooltip')"
>
<CircleHelp
class="text-muted-foreground h-3.5 w-3.5 cursor-help"
/>
</ElTooltip>
</div>
</template>
<ElSwitch v-model="createForm.is_global" />
</ElFormItem>
</ElForm>
</ZqDialog>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { ExecutionLogEntry } from '#/api/ai-platform/ai-platform';
import { $t } from '@vben/locales';
import { ElEmpty, ElScrollbar, ElTimeline, ElTimelineItem } from 'element-plus';
import RunStatusTag from './RunStatusTag.vue';
defineProps<{
activeNodeId?: string;
logs: ExecutionLogEntry[];
}>();
const emit = defineEmits<{
select: [nodeId: string];
}>();
function formatDuration(ms?: number) {
if (!ms && ms !== 0) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
</script>
<template>
<ElScrollbar class="h-full">
<ElEmpty
v-if="logs.length === 0"
:description="$t('ai-platform.workflowRuns.detail.noLogs')"
/>
<ElTimeline v-else class="px-2 py-3">
<ElTimelineItem
v-for="(log, index) in logs"
:key="`${log.node_id}-${index}`"
:timestamp="formatDuration(log.elapsed_time)"
placement="top"
>
<button
type="button"
class="hover:bg-muted w-full rounded border px-3 py-2 text-left transition-colors"
:class="activeNodeId === log.node_id ? 'border-primary bg-primary/5' : 'border-border'"
@click="emit('select', log.node_id)"
>
<div class="mb-1 flex items-center justify-between gap-2">
<span class="text-foreground text-sm font-medium">
{{ log.node_label || log.node_id }}
</span>
<RunStatusTag :status="log.status" />
</div>
<div class="text-muted-foreground text-xs">
{{ log.node_type }}
</div>
<div
v-if="log.error"
class="text-destructive mt-1 line-clamp-2 text-xs"
>
{{ log.error }}
</div>
</button>
</ElTimelineItem>
</ElTimeline>
</ElScrollbar>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben/locales';
import { ElTag } from 'element-plus';
const props = defineProps<{ status: string }>();
const tagType = computed(() => {
switch (props.status) {
case 'completed':
return 'success';
case 'failed':
return 'danger';
case 'running':
return 'primary';
case 'waiting':
return 'warning';
case 'stopped':
return 'info';
default:
return 'info';
}
});
const label = computed(() => {
const key = `ai-platform.workflowRuns.status.${props.status}`;
const translated = $t(key);
return translated === key ? props.status : translated;
});
</script>
<template>
<ElTag :type="tagType" size="small">{{ label }}</ElTag>
</template>
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { Edge, Node } from '@vue-flow/core';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useVueFlow, VueFlow } from '@vue-flow/core';
import { MiniMap } from '@vue-flow/minimap';
import { workflowEdgeTypes, workflowNodeTypes } from '../../workflow/shared/workflowNodeTypes';
import '@vue-flow/minimap/dist/style.css';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
const props = defineProps<{
edges: Edge[];
fitNodeId?: string;
nodes: Node[];
}>();
const containerRef = ref<HTMLElement>();
const { fitView } = useVueFlow();
async function refreshView(nodeId?: string) {
await nextTick();
if (!containerRef.value?.clientHeight) return;
if (nodeId) {
fitView({ nodes: [nodeId], padding: 0.4, duration: 200 });
return;
}
fitView({ padding: 0.2, duration: 200 });
}
function onNodesInitialized() {
refreshView(props.fitNodeId);
}
watch(
() => props.fitNodeId,
(nodeId) => {
if (nodeId) refreshView(nodeId);
},
);
watch(
() => [props.nodes, props.edges],
() => refreshView(props.fitNodeId),
{ deep: true },
);
let resizeObserver: ResizeObserver | undefined;
onMounted(() => {
if (!containerRef.value) return;
resizeObserver = new ResizeObserver(() => {
if (containerRef.value && containerRef.value.clientHeight > 0) {
refreshView(props.fitNodeId);
}
});
resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
resizeObserver?.disconnect();
});
</script>
<template>
<div
ref="containerRef"
class="workflow-readonly-canvas bg-muted/30 h-full w-full min-h-[320px]"
>
<VueFlow
:nodes="props.nodes"
:edges="props.edges"
:node-types="workflowNodeTypes"
:edge-types="workflowEdgeTypes"
:nodes-draggable="false"
:nodes-connectable="false"
:elements-selectable="false"
:pan-on-drag="[1, 2]"
:zoom-on-scroll="true"
@nodes-initialized="onNodesInitialized"
>
<MiniMap pannable zoomable />
</VueFlow>
</div>
</template>
<style scoped>
.workflow-readonly-canvas :deep(.vue-flow) {
width: 100%;
height: 100%;
}
.workflow-readonly-canvas :deep(.vue-flow__container) {
width: 100%;
height: 100%;
}
.workflow-readonly-canvas :deep(.vue-flow__node) {
cursor: default;
overflow: visible;
}
.workflow-readonly-canvas :deep(.nopan) {
pointer-events: all;
}
.workflow-readonly-canvas :deep(.vue-flow__panel.top) {
display: none;
}
.workflow-readonly-canvas :deep(.vue-flow__edge button) {
display: none !important;
pointer-events: none;
}
</style>
@@ -0,0 +1,160 @@
import type { Column } from 'element-plus';
import type { VbenFormSchema } from '#/adapter/form';
import { $t } from '@vben/locales';
import { getWorkflowListApi } from '#/api/ai-platform/ai-platform';
type TagType = 'danger' | 'info' | 'primary' | 'success' | 'warning';
export function getStatusOptions() {
return [
{ label: $t('ai-platform.workflowRuns.status.pending'), value: 'pending', type: 'info' as TagType },
{ label: $t('ai-platform.workflowRuns.status.running'), value: 'running', type: 'primary' as TagType },
{ label: $t('ai-platform.workflowRuns.status.waiting'), value: 'waiting', type: 'warning' as TagType },
{ label: $t('ai-platform.workflowRuns.status.completed'), value: 'completed', type: 'success' as TagType },
{ label: $t('ai-platform.workflowRuns.status.failed'), value: 'failed', type: 'danger' as TagType },
{ label: $t('ai-platform.workflowRuns.status.stopped'), value: 'stopped', type: 'info' as TagType },
];
}
export function getTriggerOptions() {
return [
{ label: $t('ai-platform.workflowRuns.trigger.editor_draft'), value: 'editor_draft', type: 'warning' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.editor_published'), value: 'editor_published', type: 'primary' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.agent'), value: 'agent', type: 'success' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.api'), value: 'api', type: 'info' as TagType },
{ label: $t('ai-platform.workflowRuns.trigger.form_button'), value: 'form_button', type: 'info' as TagType },
];
}
export function getTagType(value: string, options: Array<{ type?: TagType; value: string }>): TagType {
const option = options.find((item) => item.value === value);
return option?.type || 'info';
}
export function getTagLabel(value: string, options: Array<{ label: string; value: string }>): string {
const option = options.find((item) => item.value === value);
return option?.label || value;
}
export function formatDuration(ms?: number) {
if (!ms && ms !== 0) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
export function formatTime(value?: string) {
if (!value) return '-';
return new Date(value).toLocaleString();
}
export function useSearchFormSchema(): VbenFormSchema[] {
return [
{
component: 'ApiSelect',
fieldName: 'workflowId',
label: $t('ai-platform.workflowRuns.filters.workflow'),
componentProps: {
api: async () => {
const res = await getWorkflowListApi({ page: 1, pageSize: 200 });
return (res.items || []).map((item) => ({
label: item.name,
value: item.id,
}));
},
placeholder: $t('ai-platform.workflowRuns.filters.allWorkflows'),
clearable: true,
filterable: true,
},
},
{
component: 'Select',
fieldName: 'status',
label: $t('ai-platform.workflowRuns.filters.status'),
componentProps: {
placeholder: $t('ai-platform.workflowRuns.filters.allStatus'),
options: getStatusOptions(),
clearable: true,
},
},
{
component: 'Select',
fieldName: 'triggerType',
label: $t('ai-platform.workflowRuns.filters.trigger'),
componentProps: {
placeholder: $t('ai-platform.workflowRuns.filters.allTriggers'),
options: getTriggerOptions(),
clearable: true,
},
},
];
}
export function useZqTableColumns(): Column[] {
return [
{
key: 'workflow_name',
dataKey: 'workflow_name',
title: $t('ai-platform.workflowRuns.columns.workflow'),
minWidth: 160,
},
{
key: 'status',
title: $t('ai-platform.workflowRuns.columns.status'),
width: 110,
align: 'center' as const,
slots: { default: 'cell-status' },
},
{
key: 'trigger_type',
title: $t('ai-platform.workflowRuns.columns.trigger'),
width: 140,
align: 'center' as const,
slots: { default: 'cell-trigger_type' },
},
{
key: 'total_steps',
dataKey: 'total_steps',
title: $t('ai-platform.workflowRuns.columns.steps'),
width: 80,
align: 'center' as const,
},
{
key: 'total_tokens',
dataKey: 'total_tokens',
title: $t('ai-platform.workflowRuns.columns.tokens'),
width: 90,
align: 'center' as const,
},
{
key: 'elapsed_time',
title: $t('ai-platform.workflowRuns.columns.duration'),
width: 100,
align: 'center' as const,
slots: { default: 'cell-elapsed_time' },
},
{
key: 'started_at',
title: $t('ai-platform.workflowRuns.columns.startedAt'),
width: 180,
slots: { default: 'cell-started_at' },
},
{
key: 'error_message',
dataKey: 'error_message',
title: $t('ai-platform.workflowRuns.columns.error'),
minWidth: 180,
showOverflowTooltip: true,
},
{
key: 'actions',
title: $t('ai-platform.workflowRuns.columns.actions'),
width: 120,
fixed: true,
align: 'center' as const,
slots: { default: 'cell-actions' },
},
];
}
@@ -0,0 +1,114 @@
<script lang="ts" setup>
import type { WorkflowRunListItem } from '#/api/ai-platform/ai-platform';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Eye } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElTag } from 'element-plus';
import { getAllWorkflowRunsApi } from '#/api/ai-platform/ai-platform';
import { useZqTable } from '#/components/zq-table';
import RunStatusTag from './components/RunStatusTag.vue';
import {
formatDuration,
formatTime,
getTagLabel,
getTagType,
getTriggerOptions,
useSearchFormSchema,
useZqTableColumns,
} from './data';
import DetailDialog from './modules/detail-dialog.vue';
defineOptions({ name: 'WorkflowRunHistory' });
const detailRef = ref<InstanceType<typeof DetailDialog>>();
const triggerOptions = getTriggerOptions();
const fetchRunList = async (params: any) => {
const res = await getAllWorkflowRunsApi({
page: params.page.currentPage,
pageSize: params.page.pageSize,
workflowId: params.form?.workflowId || undefined,
status: params.form?.status || undefined,
triggerType: params.form?.triggerType || undefined,
});
return {
items: res.items,
total: res.total,
};
};
const [Grid] = useZqTable({
gridOptions: {
columns: useZqTableColumns(),
border: true,
stripe: true,
showIndex: true,
proxyConfig: {
autoLoad: true,
ajax: {
query: fetchRunList,
},
},
pagerConfig: {
enabled: true,
pageSize: 20,
},
toolbarConfig: {
search: true,
refresh: true,
zoom: true,
custom: true,
},
},
formOptions: {
schema: useSearchFormSchema(),
showCollapseButton: true,
submitOnChange: true,
},
});
function openDetail(row: WorkflowRunListItem) {
detailRef.value?.open(row.id);
}
</script>
<template>
<Page auto-content-height>
<DetailDialog ref="detailRef" />
<Grid>
<template #cell-status="{ row }">
<RunStatusTag :status="row.status" />
</template>
<template #cell-trigger_type="{ row }">
<ElTag
:type="getTagType(row.trigger_type, triggerOptions)"
size="small"
>
{{ getTagLabel(row.trigger_type, triggerOptions) }}
</ElTag>
</template>
<template #cell-elapsed_time="{ row }">
{{ formatDuration(row.elapsed_time) }}
</template>
<template #cell-started_at="{ row }">
{{ formatTime(row.started_at) }}
</template>
<template #cell-actions="{ row }">
<ElButton link type="primary" :icon="Eye" @click.stop="openDetail(row)">
{{ $t('common.view') }}
</ElButton>
</template>
</Grid>
</Page>
</template>
@@ -0,0 +1,236 @@
<script lang="ts" setup>
import type { Edge, Node } from '@vue-flow/core';
import type { WorkflowRun } from '#/api/ai-platform/ai-platform';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ExternalLink } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElAlert, ElButton, ElTag } from 'element-plus';
import { getWorkflowDetailApi, getWorkflowRunDetailApi } from '#/api/ai-platform/ai-platform';
import { ZqDesc, ZqDescItem } from '#/components/zq-desc';
import { ZqDialog } from '#/components/zq-dialog';
import { useAppContextStore } from '#/store/app-context';
import { definitionToFlowElements } from '../../workflow/shared/loadDefinition';
import { applyRunReplay } from '../../workflow/shared/useRunReplay';
import RunStatusTag from '../components/RunStatusTag.vue';
import WorkflowReadonlyCanvas from '../components/WorkflowReadonlyCanvas.vue';
import {
formatDuration,
formatTime,
getTagLabel,
getTagType,
getTriggerOptions,
} from '../data';
const router = useRouter();
const appContextStore = useAppContextStore();
const triggerOptions = getTriggerOptions();
const visible = ref(false);
const loading = ref(false);
const runId = ref('');
const run = ref<WorkflowRun | null>(null);
const nodes = ref<Node[]>([]);
const edges = ref<Edge[]>([]);
const activeNodeId = ref('');
const definitionFallback = ref(false);
const dialogTitle = computed(() => run.value?.workflow_name || $t('ai-platform.workflowRuns.detail.runId'));
async function loadDetail() {
if (!runId.value) return;
loading.value = true;
definitionFallback.value = false;
activeNodeId.value = '';
run.value = null;
nodes.value = [];
edges.value = [];
try {
const detail = await getWorkflowRunDetailApi(runId.value);
run.value = detail;
let definition = detail.definition_snapshot;
if (!definition?.nodes?.length) {
definitionFallback.value = true;
try {
const workflow = await getWorkflowDetailApi(detail.workflow_id);
definition = detail.use_draft
? workflow.definition
: workflow.published_definition || workflow.definition;
} catch (error) {
console.error(error);
definition = { nodes: [], edges: [] };
}
}
const base = definitionToFlowElements(definition || { nodes: [], edges: [] });
const replayed = applyRunReplay(base.nodes, base.edges, detail);
nodes.value = replayed.nodes;
edges.value = replayed.edges;
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
}
function open(id: string) {
runId.value = id;
visible.value = true;
}
function handleOpen() {
loadDetail();
}
function openEditor() {
if (!run.value?.workflow_id) return;
visible.value = false;
router.push(
appContextStore.getContextPath(
`/ai-platform/workflow/editor/${run.value.workflow_id}`,
),
);
}
function triggerLabel(value?: string) {
if (!value) return '-';
return getTagLabel(value, triggerOptions);
}
defineExpose({ open });
</script>
<template>
<ZqDialog
v-model="visible"
class="workflow-run-detail-dialog"
:title="dialogTitle"
:loading="loading"
default-fullscreen
:draggable="false"
:show-footer="false"
@open="handleOpen"
>
<template #title>
<span class="flex items-center gap-2">
{{ dialogTitle }}
<RunStatusTag v-if="run" :status="run.status" />
</span>
</template>
<template #header-extra>
<ElButton
v-if="run?.workflow_id"
:icon="ExternalLink"
@click="openEditor"
>
{{ $t('ai-platform.workflowRuns.detail.openEditor') }}
</ElButton>
</template>
<div v-if="run" class="run-detail-layout flex h-full min-h-0 flex-col gap-4">
<ElAlert
v-if="definitionFallback"
type="warning"
:closable="false"
show-icon
:title="$t('ai-platform.workflowRuns.detail.snapshotMissing')"
/>
<ZqDesc :column="4">
<ZqDescItem :label="$t('ai-platform.workflowRuns.detail.runId')">
{{ runId }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.trigger')">
<ElTag
:type="getTagType(run.trigger_type, triggerOptions)"
size="small"
>
{{ triggerLabel(run.trigger_type) }}
</ElTag>
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.steps')">
{{ run.total_steps }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.tokens')">
{{ run.total_tokens }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.duration')">
{{ formatDuration(run.elapsed_time) }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.columns.startedAt')">
{{ formatTime(run.started_at) }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.detail.completedAt')">
{{ formatTime(run.completed_at) }}
</ZqDescItem>
<ZqDescItem :label="$t('ai-platform.workflowRuns.detail.version')">
{{
run.use_draft
? $t('ai-platform.workflowRuns.detail.draft')
: run.workflow_version || '-'
}}
</ZqDescItem>
<ZqDescItem
v-if="run.error_message"
:label="$t('ai-platform.workflowRuns.columns.error')"
:span="4"
>
<span class="text-destructive">{{ run.error_message }}</span>
</ZqDescItem>
</ZqDesc>
<div class="run-detail-canvas border-border overflow-hidden rounded-lg border">
<WorkflowReadonlyCanvas
:nodes="nodes"
:edges="edges"
:fit-node-id="activeNodeId"
/>
</div>
</div>
</ZqDialog>
</template>
<style>
.workflow-run-detail-dialog.is-fullscreen .el-dialog__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.workflow-run-detail-dialog .zq-dialog-body {
flex: 1;
min-height: 0;
}
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar,
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar__wrap,
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar__view,
.workflow-run-detail-dialog.is-fullscreen .zq-dialog-body .el-scrollbar__view > div {
height: 100%;
}
.workflow-run-detail-dialog .run-detail-layout {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 16px;
}
.workflow-run-detail-dialog .run-detail-canvas {
flex: 1;
min-height: 320px;
height: 0;
}
</style>
@@ -0,0 +1,900 @@
<script setup lang="ts">
import type { WorkflowStreamEvent } from '#/api/ai-platform/ai-platform';
import type { ChatMessage, ReasoningStep } from '#/components/ChatBox/index';
import { computed, onUnmounted, ref, watch } from 'vue';
import { Settings2, X } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import {
resumeWorkflowStreamApi,
runWorkflowStreamApi,
} from '#/api/ai-platform/ai-platform';
import { ChatBox } from '#/components/ChatBox/index';
const props = defineProps<{
nodes: any[];
workflowId: string;
}>();
const emit = defineEmits([
'close',
'node-start',
'node-complete',
'node-streaming',
'loop-iteration',
'clear-status',
]);
// 状态
const messages = ref<ChatMessage[]>([]);
const running = ref(false);
const streamingContent = ref('');
let cancelStream: (() => void) | null = null;
// 多变量输入对话框
const variablesDialogVisible = ref(false);
const variablesForm = ref<Record<string, any>>({});
const pendingMessage = ref('');
// 对话流交互状态
const waitingForInput = ref(false);
const waitingConfig = ref<any>(null);
const currentRunId = ref('');
// 设计预览编辑面板状态
// 应用设计面板状态
// 应用设置面板状态
// 仪表盘基础信息面板状态
// 仪表盘设计面板状态
// 仪表盘发布面板状态
// 系统总结面板状态
// 查找 Start 节点并提取变量
const startNode = computed(() => props.nodes.find((n) => n.type === 'start'));
const variables = computed(() => startNode.value?.data?.variables || []);
const hasMultipleVariables = computed(() => variables.value.length > 1);
// 初始化变量表单
watch(
variables,
(vars) => {
const newForm: Record<string, any> = {};
vars.forEach((v: any) => {
newForm[v.variable] =
variablesForm.value[v.variable] || v.default_value || '';
});
variablesForm.value = newForm;
},
{ immediate: true },
);
// 生成消息 ID
const generateId = () =>
`msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// 发送消息
const handleSend = (text: string) => {
if (!text || running.value) return;
// 如果工作流正在等待用户输入,调用恢复而不是重新运行
if (waitingForInput.value && currentRunId.value) {
// 移除当前消息的交互配置
const lastAssistantMsg = messages.value.findLast(
(m) => m.role === 'assistant',
);
if (lastAssistantMsg?.interaction) {
const msgIndex = messages.value.findIndex(
(m) => m.id === lastAssistantMsg.id,
);
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...lastAssistantMsg,
interaction: undefined,
};
}
}
// 添加用户消息
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: text,
timestamp: new Date(),
};
messages.value.push(userMsg);
// 重置等待状态
waitingForInput.value = false;
waitingConfig.value = null;
// 恢复工作流
resumeWorkflow(text);
return;
}
// 如果有多个变量,先弹出表单
if (hasMultipleVariables.value) {
pendingMessage.value = text;
// 将用户输入填入第一个变量(通常是 user_input
const firstVar = variables.value[0];
if (firstVar) {
variablesForm.value[firstVar.variable] = text;
}
variablesDialogVisible.value = true;
return;
}
// 单变量或无变量,直接运行
runWorkflow(text, { user_input: text });
};
// 确认变量表单后运行
const handleVariablesConfirm = () => {
variablesDialogVisible.value = false;
runWorkflow(pendingMessage.value, variablesForm.value);
};
// 当前正在处理的消息 ID
const currentAssistantMsgId = ref<null | string>(null);
// 当前执行步骤
const currentSteps = ref<ReasoningStep[]>([]);
// 获取节点显示名称
const getNodeLabel = (
nodeId?: string,
nodeType?: string,
fallbackLabel?: string,
) => {
if (!nodeId) return fallbackLabel || nodeType || '';
// 优先从前端 nodes 中获取配置的 label
const node = props.nodes.find((n) => n.id === nodeId);
const label = node?.data?.label;
if (label) {
return label;
}
// 如果前端没有找到,使用后端传来的 fallbackLabel
if (fallbackLabel) {
return fallbackLabel;
}
// 最后使用 nodeType
return nodeType || '';
};
// 运行工作流
const runWorkflow = (userMessage: string, inputs: Record<string, any>) => {
if (!props.workflowId) return;
// 添加用户消息
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: userMessage,
timestamp: new Date(),
};
messages.value.push(userMsg);
// 添加 AI 消息(pending 状态)
const assistantMsgId = generateId();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
timestamp: new Date(),
status: 'pending',
reasoning_steps: [],
};
messages.value.push(assistantMsg);
currentAssistantMsgId.value = assistantMsgId;
currentSteps.value = [];
streamingContent.value = '';
running.value = true;
// 流式运行(编辑器调试模式,使用草稿版本)
// 系统变量(application_id 等)由 API 层自动注入
cancelStream = runWorkflowStreamApi(
props.workflowId,
inputs,
(event: WorkflowStreamEvent) => {
handleStreamEvent(event, assistantMsgId);
},
(error) => {
console.error('Stream error:', error);
updateAssistantMessage(assistantMsgId, {
status: 'failed',
error_message:
error.message ||
$t('ai-platform.workflow.editor.chatPanel.execFailed'),
});
running.value = false;
ElMessage.error(
error.message ||
$t('ai-platform.workflow.editor.chatPanel.workflowExecFailed'),
);
},
() => {
cancelStream = null;
},
true, // useDraft: 编辑器调试使用草稿版本
);
};
// 更新助手消息
const updateAssistantMessage = (
msgId: string,
updates: Partial<ChatMessage>,
) => {
const index = messages.value.findIndex((m) => m.id === msgId);
if (index !== -1) {
const currentMsg = messages.value[index];
if (currentMsg) {
messages.value[index] = { ...currentMsg, ...updates } as ChatMessage;
}
}
};
// 处理流式事件
const handleStreamEvent = (event: WorkflowStreamEvent, msgId: string) => {
switch (event.type) {
case 'answer': {
// 消息内容 - 检查是否有空的初始消息可以更新
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
// 更新初始消息(typing indicator -> 实际内容)
updateAssistantMessage(msgId, {
status: 'completed',
content: event.content || '',
});
} else {
// 已有内容,创建新消息
const answerMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: event.content || '',
timestamp: new Date(),
status: 'completed',
};
messages.value.push(answerMsg);
}
break;
}
case 'complete': {
// 从 outputs 中提取结束节点的输出内容
// 只检查 output 字段(结束节点配置的输出内容)
let endOutput = '';
if (
event.outputs &&
typeof event.outputs.output === 'string' &&
event.outputs.output.trim()
) {
endOutput = event.outputs.output;
}
// 查找初始消息(用于显示执行状态的消息)
const initialMsg = messages.value.find((m) => m.id === msgId);
const initialMsgHasContent = initialMsg?.content?.trim();
// 只有当结束节点有明确的输出内容时才显示
if (endOutput) {
// 检查初始消息是否还是 typing indicator(没有内容)
if (initialMsg && !initialMsgHasContent) {
// 更新初始消息为结束节点输出
updateAssistantMessage(msgId, {
status: 'completed',
content: endOutput,
elapsed_time: event.elapsed_time,
tokens_used: event.total_tokens,
});
} else {
// 已有内容,追加新消息
const endMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: endOutput,
timestamp: new Date(),
status: 'completed',
elapsed_time: event.elapsed_time,
tokens_used: event.total_tokens,
};
messages.value.push(endMsg);
}
} else if (!initialMsgHasContent) {
// 没有结束节点输出,且初始消息还是 typing indicator
// 检查是否有其他助手消息(由 answer 或 node_event 创建)
const otherAssistantMessages = messages.value.filter(
(m) => m.role === 'assistant' && m.id !== msgId && m.content?.trim(),
);
if (otherAssistantMessages.length > 0) {
// 有其他消息,移除空的初始消息
const initialMsgIndex = messages.value.findIndex(
(m) => m.id === msgId,
);
if (initialMsgIndex !== -1) {
messages.value.splice(initialMsgIndex, 1);
}
} else {
// 没有任何消息,显示一个完成提示
updateAssistantMessage(msgId, {
status: 'completed',
elapsed_time: event.elapsed_time,
tokens_used: event.total_tokens,
content: $t(
'ai-platform.workflow.editor.chatPanel.workflowComplete',
),
});
}
}
// 如果初始消息已有内容(由 answer/node_event 更新),不需要额外处理
running.value = false;
streamingContent.value = '';
break;
}
case 'error': {
updateAssistantMessage(msgId, {
status: 'failed',
error_message:
event.message ||
$t('ai-platform.workflow.editor.chatPanel.execFailed'),
});
running.value = false;
break;
}
case 'llm_chunk': {
// LLM 流式输出
emit('node-streaming', {
node_id: event.node_id,
content: event.content,
accumulated_content: event.accumulated_content,
});
break;
}
case 'loop_complete': {
// 循环执行完成
const loopEvent = event as any;
const outputVar = loopEvent.output_variable || 'loop_results';
const outputs: Record<string, any> = {
total_iterations: loopEvent.total_iterations,
results_count: loopEvent.results_count,
};
// 将 loop_results 添加到输出中
if (loopEvent.loop_results) {
outputs[outputVar] = loopEvent.loop_results;
}
emit('node-complete', {
node_id: loopEvent.node_id,
node_type: 'loop',
status: 'success',
elapsed_time: 0,
tokens_used: 0,
outputs,
});
break;
}
case 'loop_iteration_complete': {
// 循环迭代完成
emit('loop-iteration', {
node_id: event.node_id,
type: 'complete',
iteration: event.iteration,
total: event.total,
output: event.output,
});
break;
}
case 'loop_iteration_error': {
// 循环迭代错误
emit('loop-iteration', {
node_id: event.node_id,
type: 'error',
iteration: event.iteration,
error: event.error,
});
break;
}
case 'loop_iteration_start': {
// 循环迭代开始
emit('loop-iteration', {
node_id: event.node_id,
type: 'start',
iteration: event.iteration,
total: event.total,
item: event.item,
});
break;
}
case 'node_complete': {
// 查找已存在的 node_start 步骤并更新
const existingIndex = currentSteps.value.findIndex(
(s) => s.node_id === event.node_id && s.type === 'node_start',
);
const nodeLabel = getNodeLabel(
event.node_id,
event.node_type,
event.node_label,
);
if (existingIndex === -1) {
// 如果没有找到对应的 start,添加新的 complete 步骤
currentSteps.value.push({
type: 'node_complete',
content: nodeLabel,
node_id: event.node_id,
node_type: event.node_type,
output: event.outputs,
status: 'completed',
timestamp: new Date().toISOString(),
});
} else {
// 更新已存在的步骤
currentSteps.value[existingIndex] = {
...currentSteps.value[existingIndex],
type: 'node_complete',
content: nodeLabel,
output: event.outputs,
status: 'completed',
};
}
updateAssistantMessage(msgId, {
reasoning_steps: [...currentSteps.value],
});
emit('node-complete', {
node_id: event.node_id,
node_type: event.node_type,
status: event.status,
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used,
error_message: event.error_message,
outputs: event.outputs,
inputs: event.inputs,
loop_iteration: event.loop_iteration,
warnings: event.warnings,
});
break;
}
case 'node_event': {
// 节点事件(如消息节点发送消息)
const nodeEvent = event.event;
if (nodeEvent?.type === 'message') {
// 检查是否有空的初始消息可以更新
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
// 更新初始消息(typing indicator -> 实际内容)
updateAssistantMessage(msgId, {
status: 'completed',
content: nodeEvent.content || '',
});
} else {
// 已有内容,创建新消息
const newMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: nodeEvent.content || '',
timestamp: new Date(),
status: 'completed',
};
messages.value.push(newMsg);
}
}
break;
}
case 'node_start': {
// 检查是否已存在相同 node_id 的步骤(避免重复)
const existingIndex = currentSteps.value.findIndex(
(s) => s.node_id === event.node_id,
);
if (existingIndex === -1) {
// 添加新的执行步骤
const nodeLabel = getNodeLabel(
event.node_id,
event.node_type,
event.node_label,
);
currentSteps.value.push({
type: 'node_start',
content: nodeLabel,
node_id: event.node_id,
node_type: event.node_type,
status: 'running',
timestamp: new Date().toISOString(),
});
}
updateAssistantMessage(msgId, {
reasoning_steps: [...currentSteps.value],
});
emit('node-start', {
node_id: event.node_id,
node_type: event.node_type,
node_label: event.node_label,
inputs: event.inputs,
loop_iteration: event.loop_iteration,
});
break;
}
case 'start': {
currentRunId.value = event.run_id || '';
// 保持 pending 状态以显示 typing indicator(三个点)
// 只有当有实际内容时才更新状态
break;
}
case 'waiting_input': {
waitingForInput.value = true;
waitingConfig.value = event.waiting_config || event.config;
const configData = event.waiting_config || (event as any).config;
if (configData?.type === 'design_preview') {
const waitingContent = `**${configData.title}**\n\n${configData.message || $t('ai-platform.workflow.editor.chatPanel.designPreviewHint')}\n\n????????/??/??????????`;
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
updateAssistantMessage(msgId, {
status: 'completed',
content: waitingContent,
});
} else {
const newMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: waitingContent,
timestamp: new Date(),
status: 'completed',
};
messages.value.push(newMsg);
}
} else {
const waitingContent = getWaitingPrompt(configData);
const initialMsg = messages.value.find((m) => m.id === msgId);
if (initialMsg && !initialMsg.content?.trim()) {
updateAssistantMessage(msgId, {
status: 'completed',
content: waitingContent,
interaction: configData as any,
});
} else {
const newMsg: ChatMessage = {
id: generateId(),
role: 'assistant',
content: waitingContent,
timestamp: new Date(),
status: 'completed',
interaction: configData as any,
};
messages.value.push(newMsg);
}
}
break;
}
}
};
// 获取等待提示文本
const getWaitingPrompt = (config: any) => {
if (!config) return $t('ai-platform.workflow.editor.chatPanel.waitingInput');
switch (config.type) {
case 'choice': {
return (
config.question ||
$t('ai-platform.workflow.editor.chatPanel.pleaseSelect')
);
}
case 'confirm': {
return `**${config.title || $t('ai-platform.workflow.editor.chatPanel.confirm')}**\n\n${config.content || $t('ai-platform.workflow.editor.chatPanel.pleaseConfirm')}`;
}
case 'question': {
return (
config.question ||
$t('ai-platform.workflow.editor.chatPanel.pleaseInput')
);
}
default: {
return $t('ai-platform.workflow.editor.chatPanel.waitingInput');
}
}
};
// 处理交互提交(来自 ChatBox)
const handleInteractionSubmit = (messageId: string, value: any) => {
// 找到消息并移除交互配置
const msgIndex = messages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
const msg = messages.value[msgIndex];
if (msg) {
messages.value[msgIndex] = { ...msg, interaction: undefined };
}
}
// 添加用户回复消息
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content: formatInteractionValue(value),
timestamp: new Date(),
};
messages.value.push(userMsg);
// 调用后端 API 继续工作流执行
if (currentRunId.value) {
resumeWorkflow(value);
}
waitingForInput.value = false;
waitingConfig.value = null;
};
// 处理交互取消(来自 ChatBox)
const handleInteractionCancel = (messageId: string) => {
// 找到消息并移除交互配置
const msgIndex = messages.value.findIndex((m) => m.id === messageId);
if (msgIndex !== -1) {
const msg = messages.value[msgIndex];
if (msg) {
messages.value[msgIndex] = { ...msg, interaction: undefined };
}
}
// 添加用户回复消息
const userMsg: ChatMessage = {
id: generateId(),
role: 'user',
content:
waitingConfig.value?.cancel_text ||
$t('ai-platform.workflow.editor.chatPanel.cancel'),
timestamp: new Date(),
};
messages.value.push(userMsg);
// 调用后端 API 继续工作流执行(传入 false)
if (currentRunId.value) {
resumeWorkflow(false);
}
waitingForInput.value = false;
waitingConfig.value = null;
};
// 恢复工作流执行
const resumeWorkflow = (userInput: any) => {
if (!currentRunId.value) return;
// 添加 AI 消息(pending 状态)
const assistantMsgId = generateId();
const assistantMsg: ChatMessage = {
id: assistantMsgId,
role: 'assistant',
content: '',
timestamp: new Date(),
status: 'pending',
};
messages.value.push(assistantMsg);
currentAssistantMsgId.value = assistantMsgId;
running.value = true;
cancelStream = resumeWorkflowStreamApi(
currentRunId.value,
userInput,
(event: WorkflowStreamEvent) => {
handleStreamEvent(event, assistantMsgId);
},
(error) => {
console.error('Resume stream error:', error);
updateAssistantMessage(assistantMsgId, {
status: 'failed',
error_message:
error.message ||
$t('ai-platform.workflow.editor.chatPanel.resumeFailed'),
});
running.value = false;
ElMessage.error(
error.message ||
$t('ai-platform.workflow.editor.chatPanel.resumeWorkflowFailed'),
);
},
() => {
cancelStream = null;
},
);
};
// 格式化交互值显示
const formatInteractionValue = (value: any) => {
if (value === true) {
return (
waitingConfig.value?.confirm_text ||
$t('ai-platform.workflow.editor.chatPanel.confirm')
);
}
if (Array.isArray(value)) {
const options = waitingConfig.value?.options || [];
const labels = value.map((v: string) => {
const opt = options.find((o: any) => o.value === v);
return opt?.label || v;
});
return labels.join(', ');
}
if (waitingConfig.value?.type === 'choice') {
const options = waitingConfig.value?.options || [];
const opt = options.find((o: any) => o.value === value);
return opt?.label || value;
}
return String(value);
};
// 停止运行
const handleStop = () => {
cancelStream?.();
cancelStream = null;
running.value = false;
// 更新当前正在处理的 AI 消息状态
if (currentAssistantMsgId.value) {
updateAssistantMessage(currentAssistantMsgId.value, {
status: 'failed',
error_message: $t('ai-platform.workflow.editor.chatPanel.userStopped'),
});
currentAssistantMsgId.value = null;
}
};
// 清空对话
const handleClear = () => {
messages.value = [];
streamingContent.value = '';
emit('clear-status');
};
// 处理添加消息(来自 ChatBox 内部,如语音消息)
const handleAddMessage = (message: ChatMessage) => {
messages.value.push(message);
};
// 处理更新消息(来自 ChatBox 内部,如语音识别完成)
const handleUpdateMessage = (
messageId: string,
updates: Partial<ChatMessage>,
) => {
const idx = messages.value.findIndex((m) => m.id === messageId);
if (idx !== -1) {
messages.value[idx] = { ...messages.value[idx]!, ...updates };
}
};
// 处理设计预览确认
onUnmounted(() => {
cancelStream?.();
});
</script>
<template>
<div class="flex h-full">
<!-- 聊天面板 -->
<div
class="border-border bg-card flex h-full w-96 flex-col border-l shadow-xl"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground flex items-center gap-2 font-medium">
{{ $t('ai-platform.workflow.editor.chatPanel.runPreview') }}
</div>
<div class="flex items-center gap-1">
<!-- 变量设置按钮多变量时显示 -->
<ElButton
v-if="hasMultipleVariables"
link
:icon="Settings2"
:title="$t('ai-platform.workflow.editor.chatPanel.configVariables')"
@click="variablesDialogVisible = true"
/>
<ElButton link :icon="X" @click="$emit('close')" />
</div>
</div>
<!-- ChatBox 组件 -->
<ChatBox
ref="chatBoxRef"
:messages="messages"
:loading="running"
:streaming-content="streamingContent"
:show-avatar="false"
:assistant-name="
$t('ai-platform.workflow.editor.chatPanel.assistantName')
"
:placeholder="$t('ai-platform.workflow.editor.chatPanel.placeholder')"
:enable-attachment="false"
:enable-image="false"
:enable-voice="true"
:show-clear-button="true"
:empty-text="$t('ai-platform.workflow.editor.chatPanel.emptyText')"
class="flex-1 rounded-none border-0"
@send="handleSend"
@stop="handleStop"
@clear="handleClear"
@interaction-submit="handleInteractionSubmit"
@interaction-cancel="handleInteractionCancel"
@add-message="handleAddMessage"
@update-message="handleUpdateMessage"
/>
<!-- 多变量输入对话框 -->
<ElDialog
v-model="variablesDialogVisible"
:title="$t('ai-platform.workflow.editor.chatPanel.configVariables')"
width="400px"
:close-on-click-modal="false"
>
<ElForm label-position="top">
<ElFormItem
v-for="v in variables"
:key="v.variable"
:label="v.variable"
:required="v.required"
>
<ElInput
v-model="variablesForm[v.variable]"
:placeholder="
$t('ai-platform.workflow.editor.chatPanel.inputVariable', {
variable: v.variable,
})
"
:type="v.type === 'text' ? 'textarea' : 'text'"
:rows="v.type === 'text' ? 3 : undefined"
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="variablesDialogVisible = false">
{{ $t('ai-platform.workflow.editor.chatPanel.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleVariablesConfirm">
{{ $t('ai-platform.workflow.editor.chatPanel.startRun') }}
</ElButton>
</template>
</ElDialog>
</div>
<!-- 设计编辑面板 -->
<!-- 应用设计面板 -->
<!-- 应用设置面板 -->
<!-- 仪表盘基础信息面板 -->
<!-- 仪表盘设计面板 -->
<!-- 仪表盘发布面板 -->
<!-- 系统总结面板 -->
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
/**
* 确认模式选择组件
* 支持三种模式:始终确认、从不确认、变量控制
*/
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElRadioButton, ElRadioGroup } from 'element-plus';
import SmartInput from './SmartInput.vue';
interface Props {
modelValue: boolean | string;
currentNodeId: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean | string];
}>();
// 确认模式
type ConfirmMode = 'always' | 'never' | 'variable';
// 当前模式
const mode = ref<ConfirmMode>('always');
// 变量值(当模式为 variable 时使用)
const variableValue = ref('');
// 解析 modelValue 到 mode 和 variableValue
const parseModelValue = (value: boolean | string) => {
if (typeof value === 'boolean') {
mode.value = value ? 'always' : 'never';
variableValue.value = '';
} else if (typeof value === 'string') {
if (value === 'always' || value === 'true') {
mode.value = 'always';
variableValue.value = '';
} else if (value === 'never' || value === 'false') {
mode.value = 'never';
variableValue.value = '';
} else if (value.includes('{{')) {
mode.value = 'variable';
variableValue.value = value;
} else {
// 默认当作变量引用
mode.value = 'variable';
variableValue.value = value;
}
}
};
// 初始化
parseModelValue(props.modelValue);
// 监听外部值变化
watch(
() => props.modelValue,
(newValue) => {
parseModelValue(newValue);
},
);
// 计算输出值
const outputValue = computed(() => {
switch (mode.value) {
case 'always': {
return true;
}
case 'never': {
return false;
}
case 'variable': {
return variableValue.value || true;
}
default: {
return true;
}
}
});
// 监听内部值变化,更新 modelValue
watch(
[mode, variableValue],
() => {
emit('update:modelValue', outputValue.value);
},
{ deep: true },
);
</script>
<template>
<div class="confirm-mode-select space-y-2">
<ElRadioGroup v-model="mode" size="small">
<ElRadioButton value="always">
{{ $t('ai-platform.workflow.editor.confirmMode.always') }}
</ElRadioButton>
<ElRadioButton value="never">
{{ $t('ai-platform.workflow.editor.confirmMode.never') }}
</ElRadioButton>
<ElRadioButton value="variable">
{{ $t('ai-platform.workflow.editor.confirmMode.variable') }}
</ElRadioButton>
</ElRadioGroup>
<!-- 变量输入仅在变量模式下显示 -->
<div v-if="mode === 'variable'" class="mt-2">
<SmartInput
v-model="variableValue"
:current-node-id="currentNodeId"
:placeholder="$t('ai-platform.workflow.editor.confirmMode.placeholder')"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.editor.confirmMode.hint') }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { Copy, Edit3, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
defineProps<{
x: number;
y: number;
}>();
const emit = defineEmits(['close', 'duplicate', 'delete', 'rename']);
// 点击外部关闭
const handleClickOutside = () => {
emit('close');
};
onMounted(() => document.addEventListener('click', handleClickOutside));
onUnmounted(() => document.removeEventListener('click', handleClickOutside));
</script>
<template>
<div
class="border-border bg-popover fixed z-50 w-36 rounded-lg border py-1 shadow-xl"
:style="{ left: `${x}px`, top: `${y}px` }"
@click.stop
>
<div
class="text-popover-foreground hover:bg-accent flex cursor-pointer items-center gap-2 px-3 py-2 text-xs"
@click="
$emit('rename');
$emit('close');
"
>
<Edit3 class="size-3.5" />
<span>{{ $t('ai-platform.workflow.editor.contextMenu.rename') }}</span>
</div>
<div
class="text-popover-foreground hover:bg-accent flex cursor-pointer items-center gap-2 px-3 py-2 text-xs"
@click="
$emit('duplicate');
$emit('close');
"
>
<Copy class="size-3.5" />
<span>{{ $t('ai-platform.workflow.editor.contextMenu.duplicate') }}</span>
</div>
<div class="bg-border my-1 h-px"></div>
<div
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
@click="
$emit('delete');
$emit('close');
"
>
<Trash2 class="size-3.5" />
<span>{{ $t('ai-platform.workflow.editor.contextMenu.delete') }}</span>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { ElAlert } from 'element-plus';
export interface DbConfigInfo {
database?: string;
dbName: string;
dbType: string;
schema?: string;
}
defineProps<{
dbConfig?: DbConfigInfo | null;
showDefaultWriteWarning?: boolean;
}>();
</script>
<template>
<div v-if="dbConfig" class="space-y-2">
<div class="bg-muted rounded px-3 py-2">
<div class="text-muted-foreground text-xs">
<span class="text-foreground font-medium">{{ dbConfig.dbName }}</span>
<span v-if="dbConfig.database"> · {{ dbConfig.database }}</span>
<span v-if="dbConfig.schema">.{{ dbConfig.schema }}</span>
<span class="ml-2">({{ dbConfig.dbType }})</span>
</div>
</div>
<ElAlert
v-if="showDefaultWriteWarning && dbConfig.dbName === 'default'"
type="warning"
:closable="false"
show-icon
>
<template #title>
<span class="text-xs">{{
$t('ai-platform.workflow.editor.dbConnection.defaultWriteWarning')
}}</span>
</template>
</ElAlert>
</div>
</template>
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElAlert, ElFormItem, ElOption, ElSelect } from 'element-plus';
import { getDatabaseConfigsApi } from '#/api/core/database-manager';
import type { DbConfigInfo } from './DbConfigSummary.vue';
const props = withDefaults(
defineProps<{
modelValue?: string;
writeMode?: boolean;
}>(),
{
modelValue: 'default',
writeMode: false,
},
);
const emit = defineEmits<{
'update:modelValue': [value: string];
change: [payload: Pick<DbConfigInfo, 'database' | 'dbName' | 'dbType'>];
}>();
const loading = ref(false);
const options = ref<
Array<{
db_name: string;
db_type: string;
display_name?: string;
name: string;
}>
>([]);
async function loadOptions() {
loading.value = true;
try {
options.value = await getDatabaseConfigsApi();
} catch (error) {
console.error('Failed to load database configs:', error);
} finally {
loading.value = false;
}
}
function handleChange(value: string) {
emit('update:modelValue', value);
const selected = options.value.find((item) => item.db_name === value);
emit('change', {
dbName: value,
dbType: selected?.db_type || 'postgresql',
database: selected?.database || '',
});
}
watch(
() => props.modelValue,
(value) => {
if (!value) {
emit('update:modelValue', 'default');
}
},
{ immediate: true },
);
onMounted(loadOptions);
</script>
<template>
<ElFormItem
:label="$t('ai-platform.workflow.editor.dbConnection.connectionLabel')"
>
<ElSelect
:model-value="modelValue"
:loading="loading"
class="w-full"
filterable
@change="handleChange"
>
<ElOption
v-for="item in options"
:key="item.db_name"
:label="item.display_name || item.name"
:value="item.db_name"
>
<span>{{ item.display_name || item.name }}</span>
<span class="text-muted-foreground ml-2 text-xs">({{ item.db_type }})</span>
</ElOption>
</ElSelect>
<ElAlert
v-if="writeMode && modelValue === 'default'"
class="mt-2"
type="warning"
:closable="false"
show-icon
>
<template #title>
<span class="text-xs">{{
$t('ai-platform.workflow.editor.dbConnection.defaultWriteWarning')
}}</span>
</template>
</ElAlert>
</ElFormItem>
</template>
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElFormItem, ElOption, ElSelect } from 'element-plus';
import { getDatabasesApi } from '#/api/core/database-manager';
import SmartInput from './SmartInput.vue';
const props = withDefaults(
defineProps<{
currentNodeId?: string;
dbName?: string;
dbType?: string;
modelValue?: string;
}>(),
{
currentNodeId: '',
dbName: 'default',
dbType: 'postgresql',
modelValue: '',
},
);
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
const loading = ref(false);
const options = ref<Array<{ name: string }>>([]);
const supportsDatabaseCatalog = computed(() =>
['postgresql', 'mysql', 'sqlserver'].includes(
(props.dbType || 'postgresql').toLowerCase(),
),
);
async function loadOptions(dbName: string) {
if (!dbName || !supportsDatabaseCatalog.value) {
options.value = [];
return;
}
loading.value = true;
try {
options.value = await getDatabasesApi(dbName);
} catch (error) {
console.error('Failed to load databases:', error);
options.value = [];
} finally {
loading.value = false;
}
}
function handleSelectChange(value: string) {
emit('update:modelValue', value || '');
}
watch(
() => [props.dbName, props.dbType] as const,
([dbName]) => {
loadOptions(dbName || 'default');
},
{ immediate: true },
);
</script>
<template>
<ElFormItem
:label="$t('ai-platform.workflow.editor.dbConnection.databaseLabel')"
>
<template v-if="supportsDatabaseCatalog">
<ElSelect
:model-value="modelValue"
:loading="loading"
class="mb-2 w-full"
clearable
filterable
:placeholder="
$t('ai-platform.workflow.editor.dbConnection.databasePlaceholder')
"
@change="handleSelectChange"
>
<ElOption
v-for="item in options"
:key="item.name"
:label="item.name"
:value="item.name"
/>
</ElSelect>
<SmartInput
:model-value="modelValue"
:current-node-id="currentNodeId"
:placeholder="
$t('ai-platform.workflow.editor.dbConnection.databaseInputPlaceholder')
"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>
<SmartInput
v-else
:model-value="modelValue"
:current-node-id="currentNodeId"
:placeholder="
$t('ai-platform.workflow.editor.dbConnection.databaseOraclePlaceholder')
"
@update:model-value="emit('update:modelValue', $event)"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{
supportsDatabaseCatalog
? $t('ai-platform.workflow.editor.dbConnection.databaseHint')
: $t('ai-platform.workflow.editor.dbConnection.databaseOracleHint')
}}
</div>
</ElFormItem>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { DbConfigInfo } from './DbConfigSummary.vue';
defineProps<{
dbConfig?: DbConfigInfo | null;
showDefaultBadge?: boolean;
}>();
function formatTablePath(dbConfig: DbConfigInfo, table?: string) {
const parts: string[] = [];
if (dbConfig.database) parts.push(dbConfig.database);
if (dbConfig.schema) parts.push(dbConfig.schema);
if (table) parts.push(table);
return parts.join('.') || table || '';
}
</script>
<template>
<div v-if="dbConfig" class="space-y-1">
<div class="text-muted-foreground flex items-center gap-1 text-[10px]">
<span class="font-medium">{{ dbConfig.dbName }}</span>
<span
v-if="showDefaultBadge && dbConfig.dbName === 'default'"
class="rounded bg-amber-100 px-1 text-amber-700 dark:bg-amber-950 dark:text-amber-300"
>
{{ $t('ai-platform.workflow.editor.dbConnection.defaultBadge') }}
</span>
</div>
<div
v-if="dbConfig.database || dbConfig.schema"
class="text-muted-foreground truncate font-mono text-[10px]"
>
{{ formatTablePath(dbConfig) }}
</div>
</div>
</template>
@@ -0,0 +1,479 @@
<script lang="ts">
// 迭代结果类型
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
CheckCircle2,
ChevronDown,
ChevronUp,
Clock,
Loader2,
Maximize,
XCircle,
Zap,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { CodeEditor } from '#/components/zq-form/code-editor';
import { ZqDialog } from '#/components/zq-dialog';
export interface IterationResult {
iteration?: number;
status: 'failed' | 'success';
elapsed_time?: number;
tokens_used?: number;
error_message?: string;
output?: any;
inputs?: any;
}
export interface NodeExecutionResult {
status: 'failed' | 'pending' | 'running' | 'success';
streaming_content?: string;
output?: any;
elapsed_time?: number;
tokens_used?: number;
error_message?: string;
inputs?: {
previous_output?: any;
variables?: Record<string, any>;
};
loop_iteration?: number;
iterations?: IterationResult[];
}
const props = defineProps<{
nodeType?: string;
result: NodeExecutionResult;
}>();
defineOptions({ inheritAttrs: false });
const expanded = ref(false);
const detailVisible = ref(false);
const hasIterations = computed(
() => props.result.iterations && props.result.iterations.length > 0,
);
const selectedIteration = ref(0);
watch(
() => props.result.iterations?.length,
(newLen) => {
if (newLen && newLen > 0) {
selectedIteration.value = newLen - 1;
}
},
);
const displayResult = computed(() => {
if (hasIterations.value && props.result.iterations) {
const iter = props.result.iterations[selectedIteration.value];
if (iter) {
return {
status: iter.status,
output: iter.output,
elapsed_time: iter.elapsed_time,
tokens_used: iter.tokens_used,
error_message: iter.error_message,
inputs: iter.inputs,
streaming_content: undefined,
};
}
}
return props.result;
});
const formattedOutput = computed(() => {
if (displayResult.value.output === undefined || displayResult.value.output === null) {
return '{}';
}
if (typeof displayResult.value.output === 'string') {
return displayResult.value.output;
}
return JSON.stringify(displayResult.value.output, null, 2);
});
const outputPreview = computed(() => {
const output = formattedOutput.value;
if (!output) return $t('ai-platform.workflow.editor.nodeResultCard.noOutput');
if (output.length <= 100) return output;
return `${output.slice(0, 100)}...`;
});
const hasDetails = computed(() => {
return formattedOutput.value.length > 100 || !!displayResult.value.inputs;
});
const canOpenDetail = computed(() => {
return (
(displayResult.value.status === 'success' ||
displayResult.value.status === 'failed') &&
(displayResult.value.output !== undefined ||
!!displayResult.value.inputs ||
!!displayResult.value.error_message)
);
});
const statusConfig = computed(() => {
const status = displayResult.value.status;
switch (status) {
case 'failed': {
return {
icon: XCircle,
text: $t('ai-platform.workflow.editor.nodeResultCard.failed'),
bgClass: 'bg-red-50 dark:bg-red-950',
borderClass: 'border-red-300 dark:border-red-700',
textClass: 'text-red-600 dark:text-red-400',
iconClass: 'text-red-500 dark:text-red-400',
};
}
case 'pending': {
return {
icon: Clock,
text: $t('ai-platform.workflow.editor.nodeResultCard.pending'),
bgClass: 'bg-muted',
borderClass: 'border-border',
textClass: 'text-muted-foreground',
iconClass: 'text-muted-foreground',
};
}
case 'running': {
return {
icon: Loader2,
text: $t('ai-platform.workflow.editor.nodeResultCard.running'),
bgClass: 'bg-blue-50 dark:bg-blue-950',
borderClass: 'border-blue-300 dark:border-blue-700',
textClass: 'text-blue-600 dark:text-blue-400',
iconClass: 'text-blue-500 dark:text-blue-400 animate-spin',
};
}
case 'success': {
return {
icon: CheckCircle2,
text: $t('ai-platform.workflow.editor.nodeResultCard.success'),
bgClass: 'bg-green-50 dark:bg-green-950',
borderClass: 'border-green-300 dark:border-green-700',
textClass: 'text-green-600 dark:text-green-400',
iconClass: 'text-green-500 dark:text-green-400',
};
}
default: {
return {
icon: Clock,
text: $t('ai-platform.workflow.editor.nodeResultCard.unknown'),
bgClass: 'bg-muted',
borderClass: 'border-border',
textClass: 'text-muted-foreground',
iconClass: 'text-muted-foreground',
};
}
}
});
function toggleExpand() {
if (hasDetails.value) {
expanded.value = !expanded.value;
}
}
function openDetailDialog() {
detailVisible.value = true;
}
const formattedInputs = computed(() => {
if (!displayResult.value.inputs) {
return JSON.stringify({ variables: {}, previous_output: null }, null, 2);
}
return JSON.stringify(displayResult.value.inputs, null, 2);
});
</script>
<template>
<div class="nodrag nopan w-[280px] max-w-[280px]" v-bind="$attrs">
<ZqDialog
v-model="detailVisible"
width="90%"
:show-footer="false"
:draggable="false"
:content-height="'calc(100vh - 200px)'"
>
<template #title>
<div class="flex flex-wrap items-center gap-2">
<component
:is="statusConfig.icon"
class="size-4"
:class="statusConfig.iconClass"
/>
<span class="text-sm font-medium" :class="statusConfig.textClass">
{{ statusConfig.text }}
</span>
<span
v-if="displayResult.elapsed_time !== undefined"
class="text-muted-foreground text-sm"
>
{{ $t('ai-platform.workflow.editor.nodeResultCard.elapsed') }}
{{ displayResult.elapsed_time }}ms
</span>
<span
v-if="displayResult.tokens_used"
class="text-muted-foreground text-sm"
>
消耗 Token{{ displayResult.tokens_used }}
</span>
<div v-if="hasIterations" class="flex items-center gap-1.5">
<span class="text-muted-foreground text-xs">
{{ $t('ai-platform.workflow.editor.nodeResultCard.iteration') }}
</span>
<div class="flex gap-1">
<button
v-for="(iter, idx) in result.iterations"
:key="idx"
type="button"
class="flex size-6 items-center justify-center rounded text-xs font-medium transition-colors"
:class="[
selectedIteration === idx
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-muted-foreground',
iter.status === 'failed' ? 'ring-1 ring-red-500' : '',
]"
@click.stop="selectedIteration = idx"
>
{{ idx + 1 }}
</button>
</div>
</div>
</div>
</template>
<div class="grid h-full min-h-[520px] grid-cols-2 gap-4">
<div class="flex min-h-0 flex-col overflow-hidden">
<div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.editor.nodeResultCard.input') }}
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded border">
<CodeEditor
:model-value="formattedInputs"
language="json"
height="100%"
:readonly="true"
:line-numbers="true"
:fold-gutter="true"
/>
</div>
</div>
<div class="flex min-h-0 flex-col overflow-hidden">
<div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.editor.nodeResultCard.output') }}
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded border">
<CodeEditor
:model-value="formattedOutput"
language="json"
height="100%"
:readonly="true"
:line-numbers="true"
:fold-gutter="true"
/>
</div>
</div>
</div>
<div
v-if="displayResult.status === 'failed' && displayResult.error_message"
class="mt-4"
>
<div class="mb-2 text-sm font-medium text-red-600 dark:text-red-400">
{{ $t('ai-platform.workflow.editor.nodeResultCard.errorInfo') }}
</div>
<div class="overflow-auto rounded border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950">
<pre class="whitespace-pre-wrap break-all font-mono text-sm text-red-600 dark:text-red-400">{{
displayResult.error_message
}}</pre>
</div>
</div>
</ZqDialog>
<div
class="nodrag nopan w-full overflow-hidden rounded-lg border transition-all duration-200"
:class="[statusConfig.bgClass, statusConfig.borderClass]"
@mousedown.stop
@pointerdown.stop
>
<div
v-if="hasIterations"
class="flex items-center gap-1 border-b px-3 py-1.5"
:class="statusConfig.borderClass"
>
<span class="text-muted-foreground mr-1 text-[10px]">
{{ $t('ai-platform.workflow.editor.nodeResultCard.iteration') }}
</span>
<div class="flex flex-wrap gap-1">
<button
v-for="(iter, idx) in result.iterations"
:key="idx"
type="button"
class="nodrag nopan flex size-5 items-center justify-center rounded text-[10px] font-medium transition-colors"
:class="[
selectedIteration === idx
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-accent text-muted-foreground',
iter.status === 'failed' ? 'ring-1 ring-red-500' : '',
]"
@click.stop="selectedIteration = idx"
@pointerdown.stop
>
{{ idx + 1 }}
</button>
</div>
</div>
<div class="flex items-center justify-between px-3 py-2">
<div
class="flex min-w-0 flex-1 items-center gap-2"
:class="{ 'cursor-pointer hover:opacity-80': hasDetails }"
@click.stop="toggleExpand"
>
<component
:is="statusConfig.icon"
class="size-4 shrink-0"
:class="statusConfig.iconClass"
/>
<span class="truncate text-xs font-medium" :class="statusConfig.textClass">
{{ statusConfig.text }}
</span>
</div>
<div class="nodrag nopan flex shrink-0 items-center gap-1.5">
<div
v-if="displayResult.elapsed_time !== undefined"
class="text-muted-foreground flex items-center gap-1 text-xs"
>
<Clock class="size-3" />
<span>{{ displayResult.elapsed_time }}ms</span>
</div>
<div
v-if="displayResult.tokens_used"
class="text-muted-foreground flex items-center gap-1 text-xs"
>
<Zap class="size-3" />
<span>{{ displayResult.tokens_used }}</span>
</div>
<button
v-if="canOpenDetail"
type="button"
class="nodrag nopan hover:bg-accent flex size-6 shrink-0 items-center justify-center rounded"
:title="$t('ai-platform.workflow.editor.nodeResultCard.fullscreen')"
@click.stop="openDetailDialog"
@pointerdown.stop
@mousedown.stop
>
<Maximize class="text-muted-foreground size-3.5" />
</button>
<button
v-if="hasDetails"
type="button"
class="nodrag nopan flex size-6 shrink-0 items-center justify-center rounded"
@click.stop="toggleExpand"
@pointerdown.stop
>
<component
:is="expanded ? ChevronUp : ChevronDown"
class="text-muted-foreground size-4"
/>
</button>
</div>
</div>
<div
v-if="
!expanded &&
displayResult.status !== 'pending' &&
displayResult.status !== 'running'
"
class="border-t px-3 py-2"
:class="statusConfig.borderClass"
>
<div
v-if="displayResult.status === 'failed'"
class="text-xs text-red-600 dark:text-red-400"
>
{{
displayResult.error_message ||
$t('ai-platform.workflow.editor.nodeResultCard.execFailed')
}}
</div>
<div v-else class="text-muted-foreground overflow-hidden text-xs">
<pre class="whitespace-pre-wrap break-all font-sans">{{
outputPreview
}}</pre>
</div>
</div>
<div
v-if="
displayResult.status === 'running' && displayResult.streaming_content
"
class="border-t px-3 py-2"
:class="statusConfig.borderClass"
>
<div class="text-muted-foreground overflow-hidden text-xs">
<pre class="whitespace-pre-wrap break-all font-sans">{{
displayResult.streaming_content
}}</pre>
<span
class="inline-block h-4 w-1 animate-pulse bg-blue-500 dark:bg-blue-400"
></span>
</div>
</div>
<div
v-if="expanded"
class="space-y-3 border-t px-3 py-2"
:class="statusConfig.borderClass"
>
<div v-if="displayResult.inputs">
<div class="text-muted-foreground mb-1 text-xs font-medium">
{{ $t('ai-platform.workflow.editor.nodeResultCard.input') }}
</div>
<div class="bg-card/50 max-h-32 overflow-auto rounded p-2 text-xs">
<pre class="text-muted-foreground whitespace-pre-wrap break-all font-mono">{{
JSON.stringify(displayResult.inputs, null, 2)
}}</pre>
</div>
</div>
<div v-if="displayResult.output !== undefined">
<div class="text-muted-foreground mb-1 text-xs font-medium">
{{ $t('ai-platform.workflow.editor.nodeResultCard.output') }}
</div>
<div class="bg-card/50 max-h-48 overflow-auto rounded p-2 text-xs">
<pre class="text-muted-foreground whitespace-pre-wrap break-all font-mono">{{
formattedOutput
}}</pre>
</div>
</div>
<div
v-if="displayResult.status === 'failed' && displayResult.error_message"
>
<div class="mb-1 text-xs font-medium text-red-500 dark:text-red-400">
{{ $t('ai-platform.workflow.editor.nodeResultCard.errorInfo') }}
</div>
<div
class="rounded bg-red-100/50 p-2 text-xs text-red-600 dark:bg-red-900/50 dark:text-red-400"
>
{{ displayResult.error_message }}
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue';
import { X } from '@vben/icons';
import { $t } from '@vben/locales';
import { useVueFlow } from '@vue-flow/core';
import { ElButton, ElScrollbar } from 'element-plus';
const props = defineProps<{
node: any;
}>();
const emit = defineEmits(['close', 'update:node']);
const { updateNodeData } = useVueFlow();
// 全屏状态
const isFullscreen = ref(false);
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
// 动态加载面板组件
const components: Record<string, any> = {
start: defineAsyncComponent(() => import('../panels/StartPanel.vue')),
llm: defineAsyncComponent(() => import('../panels/LLMPanel.vue')),
end: defineAsyncComponent(() => import('../panels/EndPanel.vue')),
http: defineAsyncComponent(() => import('../panels/HttpPanel.vue')),
code: defineAsyncComponent(() => import('../panels/CodePanel.vue')),
condition: defineAsyncComponent(() => import('../panels/ConditionPanel.vue')),
template: defineAsyncComponent(() => import('../panels/TemplatePanel.vue')),
parallel: defineAsyncComponent(() => import('../panels/ParallelPanel.vue')),
merge: defineAsyncComponent(() => import('../panels/MergePanel.vue')),
db_insert: defineAsyncComponent(() => import('../panels/DbInsertPanel.vue')),
db_update: defineAsyncComponent(() => import('../panels/DbUpdatePanel.vue')),
db_query: defineAsyncComponent(() => import('../panels/DbQueryPanel.vue')),
db_delete: defineAsyncComponent(() => import('../panels/DbDeletePanel.vue')),
db_sql: defineAsyncComponent(() => import('../panels/DbSqlPanel.vue')),
// 对话流节点
question: defineAsyncComponent(() => import('../panels/QuestionPanel.vue')),
choice: defineAsyncComponent(() => import('../panels/ChoicePanel.vue')),
message: defineAsyncComponent(() => import('../panels/MessagePanel.vue')),
confirm: defineAsyncComponent(() => import('../panels/ConfirmPanel.vue')),
// 意图识别节点
intent: defineAsyncComponent(() => import('../panels/IntentPanel.vue')),
// Snowflake Cortex 节点
snowflake_cortex_llm: defineAsyncComponent(
() => import('../panels/SnowflakeCortexLLMPanel.vue'),
),
snowflake_cortex_analyst: defineAsyncComponent(
() => import('../panels/SnowflakeCortexAnalystPanel.vue'),
),
// 循环节点
loop: defineAsyncComponent(() => import('../panels/LoopPanel.vue')),
// 子流程节点
subflow: defineAsyncComponent(() => import('../panels/SubflowPanel.vue')),
// Text-to-SQL 节点
text_to_sql: defineAsyncComponent(
() => import('../panels/TextToSqlPanel.vue'),
),
// 知识库节点
knowledge_retrieval: defineAsyncComponent(
() => import('../panels/KnowledgeRetrievalPanel.vue'),
),
};
const CurrentPanel = computed(() => {
if (!props.node) return null;
return components[props.node.type] || null;
});
const handleUpdate = (newData: any) => {
// 使用 VueFlow 的 updateNodeData 方法安全更新节点数据
// 这样不会触发节点重新渲染导致消失
if (props.node?.id) {
updateNodeData(props.node.id, newData);
}
};
</script>
<template>
<aside
class="bg-card z-20 flex h-full flex-col rounded-[8px] py-3 transition-all duration-300"
:class="isFullscreen ? 'w-full' : 'w-96'"
>
<div class="flex items-center justify-between px-4 pb-4 pt-1">
<div class="text-foreground font-medium">
{{ node.data.label || node.label }}
{{ $t('ai-platform.workflow.editor.panel.propertyConfig') }}
</div>
<div class="flex items-center gap-1">
<!-- <ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
@click="toggleFullscreen"
/> -->
<ElButton link :icon="X" @click="$emit('close')" />
</div>
</div>
<ElScrollbar class="flex-1">
<div class="p-4">
<component
:is="CurrentPanel"
v-if="CurrentPanel"
:key="node.id"
:data="node.data"
:node-id="node.id"
:node-type="node.type"
@update="handleUpdate"
/>
<div v-else class="text-muted-foreground py-10 text-center">
{{ $t('ai-platform.workflow.editor.panel.noConfig') }}
</div>
</div>
</ElScrollbar>
</aside>
</template>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Play } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
} from 'element-plus';
const props = withDefaults(
defineProps<{
visible: boolean;
nodes: any[];
workflowName?: string;
}>(),
{
workflowName: '',
},
);
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'submit', inputs: Record<string, any>): void;
}>();
const inputs = ref<Record<string, any>>({});
const startNode = computed(() => props.nodes.find((n) => n.type === 'start'));
const variables = computed(() => startNode.value?.data?.variables || []);
watch(
variables,
(vars) => {
const newInputs: Record<string, any> = {};
vars.forEach((v: any) => {
newInputs[v.variable] = inputs.value[v.variable] || v.default_value || '';
});
inputs.value = newInputs;
},
{ immediate: true },
);
watch(
() => props.visible,
(val) => {
if (!val) {
inputs.value = {};
}
},
);
const handleRun = () => {
emit('submit', { ...inputs.value });
emit('update:visible', false);
};
</script>
<template>
<ElDialog
:model-value="props.visible"
:title="workflowName || $t('ai-platform.workflow.editor.runPanel.runPreview')"
:width="'480px'"
:close-on-click-modal="false"
:destroy-on-close="true"
@update:model-value="emit('update:visible', $event)"
>
<div class="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider">
{{ $t('ai-platform.workflow.editor.runPanel.inputVariables') }}
</div>
<ElForm label-position="top" size="default">
<ElFormItem
v-for="v in variables"
:key="v.variable"
:label="v.label || v.variable"
:required="v.required"
>
<ElInput
v-model="inputs[v.variable]"
:placeholder="$t('ai-platform.workflow.editor.runPanel.inputPlaceholder', { type: v.type })"
:type="v.type === 'number' ? 'number' : 'text'"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="flex justify-center">
<ElButton type="primary" :icon="Play" @click="handleRun">
{{ $t('ai-platform.workflow.editor.runPanel.startRun') }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
@@ -0,0 +1,786 @@
<script setup lang="ts">
import type { WorkflowStreamEvent } from '#/api/ai-platform/ai-platform';
import { computed, onUnmounted, ref, watch } from 'vue';
import { CheckCircle2, Clock, Loader2, Play, X, XCircle } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElScrollbar,
ElTag,
} from 'element-plus';
import { runWorkflowStreamApi } from '#/api/ai-platform/ai-platform';
const props = defineProps<{
nodes: any[];
workflowId: string;
}>();
const emit = defineEmits([
'close',
'run-start',
'run-complete',
'clear-status',
'node-start',
'node-complete',
'node-message',
'waiting-for-input',
'loop-iteration',
]);
const running = ref(false);
const result = ref<any>(null);
const inputs = ref<Record<string, any>>({});
const runId = ref<string>('');
let cancelStream: (() => void) | null = null;
// 实时节点状态追踪
interface NodeExecutionState {
node_id: string;
node_type: string;
node_label: string;
status: 'failed' | 'pending' | 'running' | 'success';
start_time?: number;
elapsed_time?: number;
tokens_used?: number;
error_message?: string;
inputs?: {
previous_output?: any;
variables?: Record<string, any>;
};
outputs?: {
output?: any;
output_variables?: Record<string, any>;
};
// LLM 流式输出
streaming_content?: string;
// 并行执行
parallel_branches?: string[];
parallel_results?: Record<string, any>;
}
const nodeStates = ref<Map<string, NodeExecutionState>>(new Map());
const currentNodeId = ref<string>('');
let timerInterval: null | ReturnType<typeof setInterval> = null;
// 查找 Start 节点并提取变量
const startNode = computed(() => props.nodes.find((n) => n.type === 'start'));
const variables = computed(() => startNode.value?.data?.variables || []);
// 获取节点状态列表(按执行顺序)
const nodeStateList = computed(() => {
return [...nodeStates.value.values()];
});
// 计算总体统计
const totalStats = computed(() => {
const states = nodeStateList.value;
const completed = states.filter(
(s) => s.status === 'success' || s.status === 'failed',
);
const totalTime = completed.reduce(
(sum, s) => sum + (s.elapsed_time || 0),
0,
);
const totalTokenCount = completed.reduce(
(sum, s) => sum + (s.tokens_used || 0),
0,
);
return {
total: states.length,
completed: completed.length,
success: states.filter((s) => s.status === 'success').length,
failed: states.filter((s) => s.status === 'failed').length,
running: states.filter((s) => s.status === 'running').length,
totalTime,
totalTokenCount,
};
});
// 启动计时器更新当前运行节点的耗时
const startTimer = () => {
stopTimer();
timerInterval = setInterval(() => {
if (currentNodeId.value) {
const state = nodeStates.value.get(currentNodeId.value);
if (state && state.status === 'running' && state.start_time) {
state.elapsed_time = Date.now() - state.start_time;
}
}
}, 100);
};
const stopTimer = () => {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
};
// 初始化输入(使用默认值)
watch(
variables,
(vars) => {
const newInputs: Record<string, any> = {};
vars.forEach((v: any) => {
// 保留已有值,否则使用默认值
newInputs[v.variable] = inputs.value[v.variable] || v.default_value || '';
});
inputs.value = newInputs;
},
{ immediate: true },
);
// 组件卸载时取消流和计时器
onUnmounted(() => {
cancelStream?.();
stopTimer();
});
// 格式化耗时显示
const formatTime = (ms: number | undefined) => {
if (ms === undefined) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const handleRun = () => {
if (!props.workflowId) return;
running.value = true;
result.value = null;
nodeStates.value.clear();
currentNodeId.value = '';
emit('run-start');
startTimer();
// 编辑器调试模式,使用草稿版本
// 系统变量(application_id 等)由 API 层自动注入
cancelStream = runWorkflowStreamApi(
props.workflowId,
inputs.value,
(event: WorkflowStreamEvent) => {
// 处理不同类型的事件
switch (event.type) {
case 'complete': {
stopTimer();
result.value = {
status: 'completed',
outputs: event.outputs,
elapsed_time: event.elapsed_time,
total_tokens: event.total_tokens,
execution_log: nodeStateList.value,
};
running.value = false;
emit('run-complete', result.value);
break;
}
case 'error': {
stopTimer();
// 将当前运行中的节点标记为失败(优先使用事件中的 node_id)
const failedNodeId = event.node_id || currentNodeId.value;
if (failedNodeId) {
const state = nodeStates.value.get(failedNodeId);
if (state && state.status === 'running') {
state.status = 'failed';
state.error_message =
event.message ||
$t('ai-platform.workflow.editor.runPanel.execFailed');
state.elapsed_time = state.start_time
? Date.now() - state.start_time
: 0;
emit('node-complete', {
node_id: failedNodeId,
node_type: state.node_type,
status: 'failed',
error_message:
event.message ||
$t('ai-platform.workflow.editor.runPanel.execFailed'),
});
}
currentNodeId.value = '';
}
result.value = {
status: 'failed',
error_message: event.message,
execution_log: nodeStateList.value,
};
running.value = false;
emit('run-complete', result.value);
break;
}
case 'llm_chunk': {
// LLM 流式输出
const state = nodeStates.value.get(event.node_id || '');
if (state) {
state.streaming_content = event.accumulated_content || '';
}
break;
}
case 'loop_complete': {
// 循环执行完成
const loopEvent = event as any;
const outputVar = loopEvent.output_variable || 'loop_results';
const outputs: Record<string, any> = {
total_iterations: loopEvent.total_iterations,
results_count: loopEvent.results_count,
};
// 将 loop_results 添加到输出中
if (loopEvent.loop_results) {
outputs[outputVar] = loopEvent.loop_results;
}
emit('node-complete', {
node_id: loopEvent.node_id,
node_type: 'loop',
status: 'success',
elapsed_time: 0,
tokens_used: 0,
outputs,
});
break;
}
case 'loop_iteration_complete': {
// 循环迭代完成
emit('loop-iteration', {
type: 'complete',
node_id: event.node_id,
iteration: event.iteration,
total: event.total,
output: event.output,
});
break;
}
case 'loop_iteration_error': {
// 循环迭代错误
emit('loop-iteration', {
type: 'error',
node_id: event.node_id,
iteration: event.iteration,
error: event.error,
});
break;
}
case 'loop_iteration_start': {
// 循环迭代开始
emit('loop-iteration', {
type: 'start',
node_id: event.node_id,
iteration: event.iteration,
total: event.total,
item: event.item,
});
break;
}
case 'node_complete': {
// 更新节点完成状态(包含输出数据)
const state = nodeStates.value.get(event.node_id || '');
if (state) {
state.status = event.status === 'success' ? 'success' : 'failed';
state.elapsed_time = event.elapsed_time;
state.tokens_used = event.tokens_used;
state.error_message = event.error_message;
state.outputs = event.outputs;
}
currentNodeId.value = '';
emit('node-complete', {
node_id: event.node_id,
node_type: event.node_type,
status: event.status,
elapsed_time: event.elapsed_time,
tokens_used: event.tokens_used,
error_message: event.error_message,
warnings: event.warnings,
});
break;
}
case 'node_event': {
// 节点事件(如消息节点发送消息)
const nodeEvent = event.event;
if (nodeEvent?.type === 'message') {
// 显示消息
emit('node-message', {
node_id: event.node_id,
content: nodeEvent.content,
message_type: nodeEvent.message_type,
});
}
break;
}
case 'node_start': {
// 记录节点开始状态(包含输入数据)
currentNodeId.value = event.node_id || '';
nodeStates.value.set(event.node_id || '', {
node_id: event.node_id || '',
node_type: event.node_type || '',
node_label: event.node_label || event.node_id || '',
status: 'running',
start_time: Date.now(),
elapsed_time: 0,
inputs: event.inputs,
});
emit('node-start', {
node_id: event.node_id,
node_type: event.node_type,
});
break;
}
case 'parallel_complete': {
// 并行执行完成
const state = nodeStates.value.get(event.node_id || '');
if (state) {
state.status = 'success';
state.elapsed_time = Date.now() - (state.start_time || Date.now());
state.parallel_results = event.branch_results;
}
break;
}
case 'parallel_start': {
// 并行执行开始
const branches = event.branches || [];
nodeStates.value.set(event.node_id || '', {
node_id: event.node_id || '',
node_type: 'parallel',
node_label: $t('ai-platform.workflow.editor.runPanel.parallelExec'),
status: 'running',
start_time: Date.now(),
elapsed_time: 0,
parallel_branches: branches,
});
break;
}
case 'start': {
// 工作流开始执行
runId.value = event.run_id || '';
break;
}
case 'waiting_input': {
// 等待用户输入(对话流节点)
stopTimer();
const state = nodeStates.value.get(event.node_id || '');
if (state) {
state.status = 'waiting' as any;
state.elapsed_time = state.start_time
? Date.now() - state.start_time
: 0;
}
// 发送等待事件给父组件处理
emit('waiting-for-input', {
node_id: event.node_id,
node_type: event.node_type,
run_id: runId.value,
waiting_config: event.waiting_config,
loop_iteration: event.loop_iteration,
});
running.value = false;
break;
}
}
},
(error) => {
console.error('Stream error:', error);
stopTimer();
// 将当前运行中的节点标记为失败
if (currentNodeId.value) {
const state = nodeStates.value.get(currentNodeId.value);
if (state && state.status === 'running') {
state.status = 'failed';
state.error_message =
error.message ||
$t('ai-platform.workflow.editor.runPanel.execFailed');
emit('node-complete', {
node_id: currentNodeId.value,
node_type: state.node_type,
status: 'failed',
error_message:
error.message ||
$t('ai-platform.workflow.editor.runPanel.execFailed'),
});
}
currentNodeId.value = '';
}
ElMessage.error(
error.message ||
$t('ai-platform.workflow.editor.runPanel.workflowExecFailed'),
);
result.value = {
status: 'failed',
error_message: error.message,
execution_log: nodeStateList.value,
};
running.value = false;
},
() => {
// Stream completed
cancelStream = null;
},
true, // useDraft: 编辑器调试使用草稿版本
);
};
const handleStop = () => {
cancelStream?.();
cancelStream = null;
stopTimer();
running.value = false;
// 将当前运行中的节点标记为已停止
if (currentNodeId.value) {
const state = nodeStates.value.get(currentNodeId.value);
if (state && state.status === 'running') {
state.status = 'failed';
state.error_message = $t(
'ai-platform.workflow.editor.runPanel.userStopped',
);
}
}
};
</script>
<template>
<div
class="border-border bg-card z-30 flex h-full w-96 flex-col border-l shadow-xl"
>
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground flex items-center gap-2 font-medium">
<Play class="text-primary size-4" />
{{ $t('ai-platform.workflow.editor.runPanel.runPreview') }}
</div>
<ElButton link :icon="X" @click="$emit('close')" />
</div>
<ElScrollbar class="flex-1">
<div class="space-y-6 p-4">
<!-- 输入区域 -->
<div>
<div
class="text-muted-foreground mb-3 text-xs font-semibold uppercase tracking-wider"
>
{{ $t('ai-platform.workflow.editor.runPanel.inputVariables') }}
</div>
<div
v-if="variables.length === 0"
class="border-border bg-muted text-muted-foreground rounded border border-dashed p-3 text-center text-sm"
>
{{ $t('ai-platform.workflow.editor.runPanel.noVariablesHint') }}
</div>
<ElForm v-else label-position="top" size="default">
<ElFormItem
v-for="v in variables"
:key="v.variable"
:label="v.label || v.variable"
:required="v.required"
>
<ElInput
v-model="inputs[v.variable]"
:placeholder="
$t('ai-platform.workflow.editor.runPanel.inputPlaceholder', {
type: v.type,
})
"
:type="v.type === 'number' ? 'number' : 'text'"
/>
</ElFormItem>
</ElForm>
<div class="mt-2 flex gap-2">
<ElButton
v-if="!running"
type="primary"
class="flex-1"
@click="handleRun"
>
{{ $t('ai-platform.workflow.editor.runPanel.startRun') }}
</ElButton>
<ElButton v-else type="danger" class="flex-1" @click="handleStop">
{{ $t('ai-platform.workflow.editor.runPanel.stopRun') }}
</ElButton>
<ElButton v-if="result && !running" @click="emit('clear-status')">
{{ $t('ai-platform.workflow.editor.runPanel.clearStatus') }}
</ElButton>
</div>
</div>
<!-- 实时执行状态 -->
<div v-if="running || nodeStateList.length > 0" class="mt-4">
<div class="mb-2 flex items-center justify-between">
<div
class="text-muted-foreground text-xs font-semibold uppercase tracking-wider"
>
{{ $t('ai-platform.workflow.editor.runPanel.execStatus') }}
</div>
<div
v-if="running"
class="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400"
>
<Loader2 class="size-3 animate-spin" />
{{ $t('ai-platform.workflow.editor.runPanel.running') }}
</div>
</div>
<!-- 统计信息 -->
<div class="mb-3 flex gap-2 text-xs">
<ElTag size="small" type="info">
{{ totalStats.completed }}/{{ totalStats.total }}
{{ $t('ai-platform.workflow.editor.runPanel.nodes') }}
</ElTag>
<ElTag v-if="totalStats.totalTokenCount > 0" size="small">
消耗 Token{{ totalStats.totalTokenCount }}
</ElTag>
<ElTag v-if="totalStats.totalTime > 0" size="small">
{{ formatTime(totalStats.totalTime) }}
</ElTag>
</div>
<!-- 节点列表 -->
<div class="space-y-2">
<div
v-for="state in nodeStateList"
:key="state.node_id"
class="rounded-lg border p-2 text-xs transition-all"
:class="[
state.status === 'running'
? 'border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-950'
: '',
state.status === 'success'
? 'border-green-200 bg-green-50 dark:border-green-700 dark:bg-green-950'
: '',
state.status === 'failed'
? 'border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-950'
: '',
state.status === 'pending' ? 'border-border bg-muted' : '',
]"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- 状态图标 -->
<Loader2
v-if="state.status === 'running'"
class="size-3.5 animate-spin text-blue-600 dark:text-blue-400"
/>
<CheckCircle2
v-else-if="state.status === 'success'"
class="size-3.5 text-green-600 dark:text-green-400"
/>
<XCircle
v-else-if="state.status === 'failed'"
class="size-3.5 text-red-600 dark:text-red-400"
/>
<Clock v-else class="text-muted-foreground size-3.5" />
<!-- 节点名称 -->
<span
class="font-medium"
:class="[
state.status === 'running'
? 'text-blue-700 dark:text-blue-300'
: '',
state.status === 'success'
? 'text-green-700 dark:text-green-300'
: '',
state.status === 'failed'
? 'text-red-700 dark:text-red-300'
: '',
state.status === 'pending' ? 'text-muted-foreground' : '',
]"
>
{{ state.node_label }}
</span>
<span class="text-muted-foreground">({{ state.node_type }})</span>
</div>
<!-- 耗时 -->
<span
class="font-mono"
:class="[
state.status === 'running'
? 'text-blue-600 dark:text-blue-400'
: 'text-muted-foreground',
]"
>
{{ formatTime(state.elapsed_time) }}
</span>
</div>
<!-- Token 和错误信息 -->
<div
v-if="state.tokens_used || state.error_message"
class="mt-1 pl-5"
>
<span v-if="state.tokens_used" class="text-muted-foreground">
消耗 Token{{ state.tokens_used }}
</span>
<span
v-if="state.error_message"
class="text-red-500 dark:text-red-400"
>
{{ state.error_message }}
</span>
</div>
<!-- LLM 流式输出内容 -->
<div
v-if="state.node_type === 'llm' && state.streaming_content"
class="mt-2 rounded border border-blue-200 bg-blue-50/50 p-2 dark:border-blue-700 dark:bg-blue-900/50"
>
<div
class="mb-1 flex items-center gap-1 text-[10px] font-medium text-blue-600 dark:text-blue-400"
>
<Loader2
v-if="state.status === 'running'"
class="size-3 animate-spin"
/>
<span>{{
$t('ai-platform.workflow.editor.runPanel.llmOutput')
}}</span>
</div>
<div
class="text-foreground max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-xs"
>
{{ state.streaming_content }}
<span
v-if="state.status === 'running'"
class="ml-0.5 inline-block h-3 w-1 animate-pulse bg-blue-500 dark:bg-blue-400"
></span>
</div>
</div>
<!-- 并行执行状态 -->
<div
v-if="state.node_type === 'parallel'"
class="mt-2 rounded border border-purple-200 bg-purple-50/50 p-2 dark:border-purple-700 dark:bg-purple-900/50"
>
<div
class="mb-1 flex items-center gap-1 text-[10px] font-medium text-purple-600 dark:text-purple-400"
>
<Loader2
v-if="state.status === 'running'"
class="size-3 animate-spin"
/>
<span>{{
$t(
'ai-platform.workflow.editor.runPanel.parallelBranches',
{ count: state.parallel_branches?.length || 0 },
)
}}</span>
</div>
<div
v-if="state.parallel_branches"
class="mt-1 flex flex-wrap gap-1"
>
<span
v-for="branch in state.parallel_branches"
:key="branch"
class="rounded bg-purple-100 px-1.5 py-0.5 text-[10px] text-purple-700 dark:bg-purple-800 dark:text-purple-300"
>
{{ branch }}
</span>
</div>
<div v-if="state.parallel_results" class="mt-2">
<div class="text-muted-foreground mb-1 text-[10px]">
{{
$t('ai-platform.workflow.editor.runPanel.branchResults')
}}
</div>
<pre
class="text-muted-foreground bg-card border-border max-h-32 overflow-auto whitespace-pre-wrap break-all rounded border p-2 font-mono text-[10px]"
>{{ JSON.stringify(state.parallel_results, null, 2) }}</pre>
</div>
</div>
<!-- 输入输出详情可折叠 -->
<ElCollapse v-if="state.inputs || state.outputs" class="mt-2">
<ElCollapseItem
v-if="state.inputs"
:title="$t('ai-platform.workflow.editor.runPanel.input')"
name="inputs"
>
<pre
class="text-muted-foreground bg-muted max-h-32 overflow-auto whitespace-pre-wrap break-all rounded p-2 font-mono text-[10px]"
>{{ JSON.stringify(state.inputs, null, 2) }}</pre>
</ElCollapseItem>
<ElCollapseItem
v-if="state.outputs"
:title="$t('ai-platform.workflow.editor.runPanel.output')"
name="outputs"
>
<pre
class="text-muted-foreground bg-muted max-h-32 overflow-auto whitespace-pre-wrap break-all rounded p-2 font-mono text-[10px]"
>{{ JSON.stringify(state.outputs, null, 2) }}</pre>
</ElCollapseItem>
</ElCollapse>
</div>
</div>
</div>
<!-- 结果区域 -->
<div
v-if="result"
class="animate-in fade-in slide-in-from-bottom-4 mt-4 duration-300"
>
<div
class="text-muted-foreground mb-2 text-xs font-semibold uppercase tracking-wider"
>
{{ $t('ai-platform.workflow.editor.runPanel.runResult') }}
</div>
<div class="border-border bg-muted overflow-hidden rounded-lg border">
<div
class="border-border bg-muted/50 text-muted-foreground flex justify-between border-b px-3 py-2 text-xs"
>
<span
:class="
result.status === 'completed'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
"
>
{{
result.status === 'completed'
? $t('ai-platform.workflow.editor.runPanel.success')
: $t('ai-platform.workflow.editor.runPanel.failed')
}}
</span>
<span v-if="result.elapsed_time">{{ result.elapsed_time }}ms</span>
</div>
<div v-if="result.outputs" class="p-3">
<pre
class="text-foreground max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-xs"
>{{ JSON.stringify(result.outputs, null, 2) }}</pre
>
</div>
<div
v-if="result.error_message"
class="p-3 text-xs text-red-500 dark:text-red-400"
>
{{ result.error_message }}
</div>
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue';
import { $t } from '@vben/locales';
import { ElInput, ElPopover } from 'element-plus';
import VariableSelector from './VariableSelector.vue';
const props = defineProps<{
currentNodeId?: string;
modelValue: string;
placeholder?: string;
rows?: number;
size?: 'default' | 'large' | 'small';
type?: 'text' | 'textarea';
}>();
const emit = defineEmits(['update:modelValue', 'change']);
const showSelector = ref(false);
const inputRef = ref();
const handleInput = (val: string) => {
emit('update:modelValue', val);
};
const handleChange = (val: string) => {
emit('change', val);
};
// 监听键盘事件,输入 '{' 时触发
const handleKeyup = (e: KeyboardEvent) => {
if (e.key === '{' && props.currentNodeId) {
showSelector.value = true;
}
};
const insertVariable = (variable: string) => {
const val = props.modelValue || '';
const el = inputRef.value?.textarea || inputRef.value?.input;
if (el) {
const start = el.selectionStart || 0;
const end = el.selectionEnd || 0;
const newVal = val.slice(0, start) + variable + val.slice(end);
emit('update:modelValue', newVal);
emit('change', newVal);
nextTick(() => {
el.focus();
const newPos = start + variable.length;
el.setSelectionRange(newPos, newPos);
});
} else {
const newVal = val + variable;
emit('update:modelValue', newVal);
emit('change', newVal);
}
showSelector.value = false;
};
</script>
<template>
<div class="group relative w-full min-w-48">
<ElInput
ref="inputRef"
:model-value="modelValue"
:type="type"
:rows="rows"
:placeholder="placeholder"
:size="size"
class="smart-input-textarea"
@input="handleInput"
@change="handleChange"
@keyup="handleKeyup"
/>
<ElPopover
v-if="currentNodeId"
v-model:visible="showSelector"
trigger="click"
placement="bottom-end"
:width="260"
:show-arrow="false"
popper-class="!p-0"
>
<template #reference>
<div
class="text-muted-foreground/60 hover:bg-primary/10 hover:text-primary absolute z-10 flex h-5 cursor-pointer items-center rounded px-1 transition-all"
:class="
type === 'textarea'
? 'right-2 top-1'
: 'right-1 top-1/2 -translate-y-1/2'
"
:title="$t('ai-platform.workflow.editor.smartInput.insertVariable')"
@click.stop
>
<span class="font-mono text-[10px] font-bold leading-none">{ }</span>
</div>
</template>
<VariableSelector
:current-node-id="currentNodeId"
@select="insertVariable"
/>
</ElPopover>
</div>
</template>
<style scoped>
/* Element Plus 风格的滚动条样式 */
.smart-input-textarea :deep(.el-textarea__inner) {
scrollbar-width: thin;
scrollbar-color: var(--el-border-color-darker) transparent;
}
.smart-input-textarea :deep(.el-textarea__inner::-webkit-scrollbar) {
width: 6px;
height: 6px;
}
.smart-input-textarea :deep(.el-textarea__inner::-webkit-scrollbar-thumb) {
background-color: var(--el-border-color-darker);
border-radius: 3px;
}
.smart-input-textarea
:deep(.el-textarea__inner::-webkit-scrollbar-thumb:hover) {
background-color: var(--el-border-color-dark);
}
.smart-input-textarea :deep(.el-textarea__inner::-webkit-scrollbar-track) {
background-color: transparent;
}
</style>
@@ -0,0 +1,577 @@
<script lang="ts" setup>
/**
* 数据库表选择对话框
* 参考 data-source-config.vue 实现
*/
import type { ColumnInfo } from '#/api/core/database-manager';
import { onMounted, ref, watch } from 'vue';
import { Database, RefreshCw } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElIcon,
ElMessage,
ElScrollbar,
ElTag,
ElTooltip,
ElTree,
} from 'element-plus';
import {
getDatabaseConfigsApi,
getDatabasesApi,
getSchemasApi,
getTableColumnsApi,
getTablesApi,
} from '#/api/core/database-manager';
import { DatabaseTreeNodeIcon } from '#/components/database-tree-node-icon';
import { DatabaseTreeNodeLabel } from '#/components/database-tree-node-label';
// 树节点类型
interface TreeNode {
id: string;
label: string;
type: 'connection' | 'database' | 'schema' | 'table';
isLeaf: boolean;
meta?: {
database?: string;
dbName?: string;
dbType?: string;
schema?: string;
table?: string;
};
children?: TreeNode[];
}
// 表字段类型
interface TableField {
name: string;
type: string;
comment: string;
nullable: boolean;
isPrimaryKey: boolean;
}
// 选中的表信息
export interface SelectedTable {
tableName: string;
dbName: string;
dbType: string;
database: string;
schema?: string;
fields: TableField[];
}
const props = defineProps<{
currentTable?: string;
visible: boolean;
}>();
const emit = defineEmits<{
select: [table: SelectedTable];
'update:visible': [value: boolean];
}>();
// 对话框可见性
const dialogVisible = ref(false);
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
},
);
watch(dialogVisible, (val) => {
emit('update:visible', val);
});
// 树相关
const treeData = ref<TreeNode[]>([]);
const loading = ref(false);
// 树配置
const treeProps = {
label: 'label',
children: 'children',
isLeaf: 'isLeaf',
};
// 当前选中的表节点
const selectedTableNode = ref<null | TreeNode>(null);
const previewFields = ref<TableField[]>([]);
const previewLoading = ref(false);
// 加载数据库配置(根节点)
async function loadDatabaseConfigs() {
loading.value = true;
try {
const configs = await getDatabaseConfigsApi();
treeData.value = configs.map((config) => ({
id: `conn-${config.db_name}`,
label: config.display_name || config.name,
type: 'connection' as const,
isLeaf: false,
meta: {
dbName: config.db_name,
dbType: config.db_type,
isSystem: config.is_system,
},
}));
} catch (error) {
console.error('Failed to load database configs:', error);
ElMessage.error(
$t('ai-platform.workflow.editor.tableSelectDialog.loadConfigFailed'),
);
} finally {
loading.value = false;
}
}
// 加载数据库列表
async function loadDatabases(dbName: string, dbType?: string) {
try {
const databases = await getDatabasesApi(dbName);
return databases.map((db) => ({
id: `db-${dbName}-${db.name}`,
label: db.name,
type: 'database' as const,
isLeaf: false,
meta: {
dbName,
dbType,
database: db.name,
},
}));
} catch (error) {
console.error('Failed to load databases:', error);
return [];
}
}
// 加载Schema列表
async function loadSchemas(dbName: string, database: string, dbType?: string) {
try {
const schemas = await getSchemasApi(dbName, database);
return schemas.map((schema) => ({
id: `schema-${dbName}-${database}-${schema.name}`,
label: schema.name,
type: 'schema' as const,
isLeaf: false,
meta: {
dbName,
dbType,
database,
schema: schema.name,
},
}));
} catch (error) {
console.error('Failed to load schemas:', error);
return [];
}
}
// 加载表列表
async function loadTables(
dbName: string,
database: string,
schema?: string,
dbType?: string,
) {
try {
const tableList = await getTablesApi(dbName, database, schema);
return tableList.map((table) => ({
id: `table-${dbName}-${database}-${schema || ''}-${table.table_name}`,
label: table.table_name,
type: 'table' as const,
isLeaf: true,
meta: {
dbName,
dbType,
database,
schema: schema || table.schema_name,
table: table.table_name,
},
}));
} catch (error) {
console.error('Failed to load tables:', error);
return [];
}
}
// 懒加载子节点
async function loadNode(node: any, resolve: any) {
if (node.level === 0) {
resolve(treeData.value);
return;
}
const nodeData = node.data as TreeNode;
const { type, meta } = nodeData;
if (!meta) {
resolve([]);
return;
}
try {
if (type === 'connection' && meta.dbName) {
const nodes = await loadDatabases(meta.dbName, meta.dbType);
resolve(nodes);
return;
}
if (type === 'database' && meta.dbName && meta.database) {
const dbType = meta.dbType?.toLowerCase();
if (dbType === 'postgresql' || dbType === 'sqlserver') {
const nodes = await loadSchemas(
meta.dbName,
meta.database,
meta.dbType,
);
resolve(nodes);
} else {
const nodes = await loadTables(
meta.dbName,
meta.database,
undefined,
meta.dbType,
);
resolve(nodes);
}
return;
}
if (type === 'schema' && meta.dbName && meta.database) {
const nodes = await loadTables(
meta.dbName,
meta.database,
meta.schema,
meta.dbType,
);
resolve(nodes);
return;
}
resolve([]);
} catch (error) {
console.error('Failed to load node:', error);
resolve([]);
}
}
// 节点点击 - 预览表字段
async function handleNodeClick(data: TreeNode) {
if (data.type === 'table' && data.meta) {
selectedTableNode.value = data;
previewLoading.value = true;
try {
const columns = await getTableColumnsApi(
data.meta.dbName!,
data.meta.table!,
data.meta.database,
data.meta.schema,
);
previewFields.value = columns.map((col: ColumnInfo) => ({
name: col.column_name,
type: col.data_type,
comment: col.description || '',
nullable: col.is_nullable,
isPrimaryKey: col.is_primary_key,
}));
} catch (error) {
console.error('Failed to load columns:', error);
previewFields.value = [];
} finally {
previewLoading.value = false;
}
}
}
// 双击选中表
async function handleNodeDblClick(data: TreeNode) {
if (data.type === 'table' && data.meta) {
await selectTable(data);
}
}
// 选中表
async function selectTable(node: TreeNode) {
if (node.type !== 'table' || !node.meta) return;
// 如果还没加载字段,先加载
let fields = previewFields.value;
if (selectedTableNode.value?.id !== node.id || fields.length === 0) {
try {
const columns = await getTableColumnsApi(
node.meta.dbName!,
node.meta.table!,
node.meta.database,
node.meta.schema,
);
fields = columns.map((col: ColumnInfo) => ({
name: col.column_name,
type: col.data_type,
comment: col.description || '',
nullable: col.is_nullable,
isPrimaryKey: col.is_primary_key,
}));
} catch (error) {
console.error('Failed to load columns:', error);
fields = [];
}
}
emit('select', {
tableName: node.meta.table!,
dbName: node.meta.dbName!,
dbType: node.meta.dbType || '',
database: node.meta.database || '',
schema: node.meta.schema,
fields,
});
dialogVisible.value = false;
}
// 确认选择
function handleConfirm() {
if (selectedTableNode.value) {
selectTable(selectedTableNode.value);
} else {
ElMessage.warning(
$t('ai-platform.workflow.editor.tableSelectDialog.selectTableFirst'),
);
}
}
// 刷新树
async function refreshTree() {
await loadDatabaseConfigs();
ElMessage.success(
$t('ai-platform.workflow.editor.tableSelectDialog.refreshSuccess'),
);
}
// 初始化
onMounted(() => {
loadDatabaseConfigs();
});
</script>
<template>
<ElDialog
v-model="dialogVisible"
:title="$t('ai-platform.workflow.editor.tableSelectDialog.title')"
width="700px"
:close-on-click-modal="false"
destroy-on-close
class="table-select-dialog"
>
<div class="flex h-[500px] gap-3">
<!-- 左侧数据库树形结构 -->
<div
class="border-border bg-card flex h-full w-[280px] flex-col rounded-lg border"
>
<div
class="border-border flex items-center justify-between border-b px-3 py-2"
>
<div class="flex items-center gap-2">
<ElIcon><Database /></ElIcon>
<span class="text-sm font-medium">{{
$t('ai-platform.workflow.editor.tableSelectDialog.database')
}}</span>
</div>
<ElTooltip
:content="
$t('ai-platform.workflow.editor.tableSelectDialog.refresh')
"
>
<ElButton
:icon="RefreshCw"
link
size="small"
@click="refreshTree"
/>
</ElTooltip>
</div>
<!-- 树形结构 -->
<ElScrollbar class="flex-1">
<div v-if="loading" class="flex h-32 items-center justify-center">
<span class="text-muted-foreground text-sm">{{
$t('ai-platform.workflow.editor.tableSelectDialog.loading')
}}</span>
</div>
<ElTree
v-else
ref="treeRef"
:data="treeData"
:props="treeProps"
:load="loadNode"
node-key="id"
lazy
highlight-current
class="p-2"
@node-click="handleNodeClick"
@node-dblclick="handleNodeDblClick"
>
<template #default="{ data }">
<div class="flex items-center gap-2">
<DatabaseTreeNodeIcon
:type="data.type"
:db-type="data.meta?.dbType"
:size="16"
/>
<DatabaseTreeNodeLabel
:label="data.label"
:type="data.type"
:is-system="data.meta?.isSystem"
:db-name="data.meta?.dbName"
/>
<ElTag
v-if="
data.type === 'table' && data.meta?.table === currentTable
"
size="small"
type="success"
>
{{
$t('ai-platform.workflow.editor.tableSelectDialog.current')
}}
</ElTag>
</div>
</template>
</ElTree>
</ElScrollbar>
</div>
<!-- 右侧:字段预览 -->
<div class="border-border bg-card flex flex-1 flex-col rounded-lg border">
<div class="border-border flex items-center gap-2 border-b px-3 py-2">
<span class="text-sm font-medium">
{{
selectedTableNode
? $t(
'ai-platform.workflow.editor.tableSelectDialog.fieldList',
{ table: selectedTableNode.label },
)
: $t(
'ai-platform.workflow.editor.tableSelectDialog.fieldPreview',
)
}}
</span>
</div>
<ElScrollbar class="flex-1">
<div
v-if="!selectedTableNode"
class="flex h-full items-center justify-center"
>
<span class="text-muted-foreground text-sm">{{
$t('ai-platform.workflow.editor.tableSelectDialog.clickToPreview')
}}</span>
</div>
<div
v-else-if="previewLoading"
class="flex h-32 items-center justify-center"
>
<span class="text-muted-foreground text-sm">{{
$t('ai-platform.workflow.editor.tableSelectDialog.loading')
}}</span>
</div>
<div v-else class="p-2">
<div
v-for="field in previewFields"
:key="field.name"
class="border-border/50 flex items-center justify-between border-b px-2 py-2 text-sm last:border-b-0"
>
<div class="flex items-center gap-2">
<span
class="font-mono font-medium"
:class="{ 'text-primary': field.isPrimaryKey }"
>
{{ field.name }}
</span>
<ElTag v-if="field.isPrimaryKey" size="small" type="warning">
PK
</ElTag>
</div>
<span class="text-muted-foreground text-xs">{{
field.type
}}</span>
</div>
<div
v-if="previewFields.length === 0"
class="text-muted-foreground py-8 text-center text-sm"
>
{{ $t('ai-platform.workflow.editor.tableSelectDialog.noFields') }}
</div>
</div>
</ElScrollbar>
<!-- 选中的表信息 -->
<div
v-if="selectedTableNode?.meta"
class="border-border bg-muted border-t px-3 py-2"
>
<div class="text-muted-foreground text-xs">
<span class="font-medium">{{
selectedTableNode.meta.database
}}</span>
<span v-if="selectedTableNode.meta.schema">.{{ selectedTableNode.meta.schema }}</span>
<span class="text-primary font-medium">.{{ selectedTableNode.meta.table }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('ai-platform.workflow.editor.tableSelectDialog.cancel') }}
</ElButton>
<ElButton
type="primary"
:disabled="!selectedTableNode"
@click="handleConfirm"
>
{{ $t('ai-platform.workflow.editor.tableSelectDialog.confirmSelect') }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
:deep(.el-tree-node__content) {
display: flex;
align-items: center;
height: 28px;
line-height: 28px;
border-radius: 4px;
}
/* 懒加载时 loading 替换箭头位置 */
:deep(.el-tree-node__content:has(.el-tree-node__loading-icon) > .el-tree-node__expand-icon) {
display: none;
}
:deep(.el-tree-node__loading-icon) {
order: -1;
flex-shrink: 0;
width: 24px;
height: 24px;
margin-right: 0;
}
:deep(.el-tree-node__expand-icon) {
flex-shrink: 0;
width: 24px;
height: 24px;
padding: 0;
}
</style>
@@ -0,0 +1,761 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
BookOpen,
Bot,
ChevronDown,
ChevronRight,
CircleHelp,
Code,
Combine,
Database,
Globe,
HelpCircle,
LayoutTemplate,
ListChecks,
MessageSquareText,
Play,
Search,
Snowflake,
Square,
} from '@vben/icons';
import { $t } from '@vben/locales';
import { useVueFlow } from '@vue-flow/core';
import { ElInput, ElScrollbar, ElTooltip } from 'element-plus';
const props = defineProps<{
currentNodeId: string;
}>();
const emit = defineEmits(['select']);
const { nodes, edges } = useVueFlow();
const searchQuery = ref('');
const collapsedNodes = ref<Set<string>>(new Set());
// 定义不同节点的图标
const icons: any = {
start: Play,
llm: Bot,
http: Globe,
code: Code,
template: LayoutTemplate,
end: Square,
db_query: Database,
db_insert: Database,
db_update: Database,
db_delete: Database,
db_sql: Database,
// 对话流节点
question: HelpCircle,
choice: ListChecks,
message: MessageSquareText,
confirm: CircleHelp,
// Snowflake Cortex 节点
snowflake_cortex_llm: Snowflake,
snowflake_cortex_analyst: Snowflake,
// 循环节点
loop: Play,
// 合并节点
merge: Combine,
// 表单节点
// 应用节点
// 仪表盘节点
// 系统总结节点
// 子流程节点
subflow: Play,
// 知识库检索节点
knowledge_retrieval: BookOpen,
};
// 获取当前节点的父循环节点(如果存在)
const getParentLoopNode = (nodeId: string) => {
const currentNode = nodes.value.find((n) => n.id === nodeId);
if (currentNode?.parentNode) {
const parentNode = nodes.value.find((n) => n.id === currentNode.parentNode);
if (parentNode?.type === 'loop') {
return parentNode;
}
}
return null;
};
// 获取当前节点的所有上游节点(通过边的连接关系递归查找)
const getUpstreamNodeIds = (
nodeId: string,
visited = new Set<string>(),
): Set<string> => {
// 找到所有指向当前节点的边
const incomingEdges = edges.value.filter((e) => e.target === nodeId);
for (const edge of incomingEdges) {
const sourceId = edge.source;
if (!visited.has(sourceId)) {
visited.add(sourceId);
// 递归查找上游节点的上游
getUpstreamNodeIds(sourceId, visited);
}
}
// 如果当前节点是循环体内的子节点,也要包含父循环节点及其上游节点
const parentLoop = getParentLoopNode(nodeId);
if (parentLoop && !visited.has(parentLoop.id)) {
visited.add(parentLoop.id);
// 递归查找父循环节点的上游
getUpstreamNodeIds(parentLoop.id, visited);
}
return visited;
};
// 获取所有可用的前序节点(只显示上游节点)
const availableNodes = computed(() => {
// 获取当前节点的所有上游节点 ID
const upstreamIds = getUpstreamNodeIds(props.currentNodeId);
// 只保留上游节点
return nodes.value
.filter((n) => upstreamIds.has(n.id) && n.type !== 'end')
.map((node) => {
let variables: any[] = [];
// 根据节点类型获取输出变量 schema
switch (node.type) {
case 'choice': {
const choiceVar = node.data.variable_name || 'user_choice';
variables = [
{
key: choiceVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.userChoice',
),
},
{
key: `${choiceVar}_labels`,
label: $t(
'ai-platform.workflow.editor.variableSelector.choiceText',
),
},
];
break;
}
case 'code': {
// 默认提供 result 变量
variables = [
{
key: 'result',
label: $t(
'ai-platform.workflow.editor.variableSelector.execResult',
),
isSystem: true,
},
];
// 加上用户自定义的输出变量
const customOutputs = (node.data.outputs || [])
.filter((v: any) => v.variable && v.variable !== 'result')
.map((v: any) => ({
key: v.variable,
label: v.variable,
}));
variables.push(...customOutputs);
break;
}
case 'condition': {
// 条件节点不产生输出变量
break;
}
case 'confirm': {
const confirmVar = node.data.variable_name || 'confirmed';
variables = [
{
key: confirmVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.confirmed',
),
},
];
break;
}
case 'db_delete': {
const deleteOutputVar = node.data.output_variable || 'delete_result';
variables = [
{
key: deleteOutputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.deleteResult',
),
},
{
key: `${deleteOutputVar}_count`,
label: $t(
'ai-platform.workflow.editor.variableSelector.affectedRows',
),
},
];
break;
}
case 'db_insert': {
const insertOutputVar = node.data.output_variable || 'insert_result';
variables = [
{
key: insertOutputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.insertResult',
),
},
{
key: `${insertOutputVar}_id`,
label: $t(
'ai-platform.workflow.editor.variableSelector.insertId',
),
},
];
break;
}
case 'db_query': {
const queryOutputVar = node.data.output_variable || 'query_result';
variables = [
{
key: queryOutputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.queryResult',
),
},
{
key: `${queryOutputVar}_count`,
label: $t(
'ai-platform.workflow.editor.variableSelector.resultCount',
),
},
];
break;
}
case 'db_sql': {
const sqlOutputVar = node.data.output_variable || 'sql_result';
variables = [
{
key: sqlOutputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.sqlResult',
),
},
{
key: `${sqlOutputVar}_count`,
label: $t(
'ai-platform.workflow.editor.variableSelector.resultCount',
),
},
];
break;
}
case 'db_update': {
const updateOutputVar = node.data.output_variable || 'update_result';
variables = [
{
key: updateOutputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.updateResult',
),
},
{
key: `${updateOutputVar}_count`,
label: $t(
'ai-platform.workflow.editor.variableSelector.affectedRows',
),
},
];
break;
}
case 'http': {
variables = [
{
key: 'body',
label: $t(
'ai-platform.workflow.editor.variableSelector.responseBody',
),
},
{
key: 'status_code',
label: $t(
'ai-platform.workflow.editor.variableSelector.statusCode',
),
},
];
break;
}
case 'knowledge_retrieval': {
// 知识库检索节点固定输出变量
variables = [
{
key: 'knowledge_results',
label: $t(
'ai-platform.workflow.editor.variableSelector.knowledgeResults',
),
},
{
key: 'knowledge_context',
label: $t(
'ai-platform.workflow.editor.variableSelector.knowledgeContext',
),
},
{
key: 'knowledge_results_total',
label: $t(
'ai-platform.workflow.editor.variableSelector.knowledgeResultsTotal',
),
},
];
break;
}
case 'llm': {
// 使用配置的输出变量名,默认为 llm_response
const outputVar = node.data.output_variable || 'llm_response';
const outputMode = node.data.output_mode || 'text';
const outputSchema = node.data.output_schema || [];
if (outputMode === 'structured' && outputSchema.length > 0) {
// 结构化输出模式:展示每个定义的字段
variables = [
{
key: outputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.jsonString',
),
isStructured: true,
},
{
key: `${outputVar}_data`,
label: $t(
'ai-platform.workflow.editor.variableSelector.structuredData',
),
isStructured: true,
},
];
// 添加每个字段作为独立变量
for (const field of outputSchema) {
if (field.name) {
const fieldLabel = field.description || field.name;
const fieldType = field.type || 'string';
variables.push({
key: `${outputVar}_${field.name}`,
label: `${fieldLabel} (${fieldType})`,
isField: true,
});
}
}
variables.push({
key: `${outputVar}_tokens`,
label: $t(
'ai-platform.workflow.editor.variableSelector.tokenUsage',
),
});
} else {
// 普通文本输出模式
variables = [
{
key: outputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.llmResponse',
),
},
{
key: `${outputVar}_tokens`,
label: $t(
'ai-platform.workflow.editor.variableSelector.tokenUsage',
),
},
];
}
break;
}
// 循环节点
case 'loop': {
const itemVar = node.data.item_variable_name || 'item';
const indexVar = node.data.index_variable_name || 'index';
variables = [
{
key: itemVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.currentLoopItem',
),
isSystem: true,
},
{
key: indexVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.currentIndex',
),
isSystem: true,
},
];
// 如果是 for_each 模式,还可以引用原始数组
if (node.data.loop_mode === 'for_each') {
const outputVar = node.data.output_variable || 'loop_results';
variables.push({
key: outputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.loopResults',
),
});
}
break;
}
case 'merge': {
const mergeOutputVar = node.data.output_variable || 'merged_result';
variables = [
{
key: mergeOutputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.mergeResult',
),
},
{
key: 'branch_count',
label: $t(
'ai-platform.workflow.editor.variableSelector.branchCount',
),
},
];
break;
}
case 'message': {
// 消息节点不产生输出变量
break;
}
case 'parallel': {
// 并行节点不产生输出变量
break;
}
// 对话流节点
case 'question': {
const questionVar = node.data.variable_name || 'user_input';
variables = [
{
key: questionVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.userAnswer',
),
},
{
key: `${questionVar}_valid`,
label: $t('ai-platform.workflow.editor.variableSelector.isValid'),
},
];
break;
}
case 'snowflake_cortex_analyst': {
const analystResultVar =
node.data.output_variable || 'analyst_result';
const analystSqlVar = node.data.sql_variable || 'analyst_sql';
variables = [
{
key: analystResultVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.queryResultData',
),
},
{
key: analystSqlVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.generatedSql',
),
},
{
key: `${analystResultVar}_raw`,
label: $t(
'ai-platform.workflow.editor.variableSelector.rawResponse',
),
},
];
break;
}
// Snowflake Cortex 节点
case 'snowflake_cortex_llm': {
const cortexLlmVar = node.data.output_variable || 'cortex_response';
variables = [
{
key: cortexLlmVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.cortexLlmResponse',
),
},
];
break;
}
case 'start': {
// 默认的系统变量
variables = [
{
key: 'user_input',
label: $t(
'ai-platform.workflow.editor.variableSelector.userInput',
),
isSystem: true,
},
{
key: 'application_id',
label: $t('ai-platform.workflow.editor.variableSelector.appId'),
isSystem: true,
},
{
key: 'application_code',
label: $t('ai-platform.workflow.editor.variableSelector.appCode'),
isSystem: true,
},
{
key: 'form_code',
label: $t(
'ai-platform.workflow.editor.variableSelector.formCode',
),
isSystem: true,
},
];
// 加上自定义变量
variables.push(
...(node.data.variables || []).map((v: any) => ({
key: v.variable,
label: v.label || v.variable,
isSystem: false,
})),
);
break;
}
case 'subflow': {
const resultVar = node.data.result_variable || 'subflow_result';
variables = [
{
key: resultVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.subflowFullResult',
),
},
{
key: `${resultVar}.success`,
label: $t(
'ai-platform.workflow.editor.variableSelector.execSuccess',
),
},
{
key: `${resultVar}.outputs`,
label: $t(
'ai-platform.workflow.editor.variableSelector.subflowOutputVars',
),
},
{
key: `${resultVar}.total_tokens`,
label: $t(
'ai-platform.workflow.editor.variableSelector.tokenUsage',
),
},
];
// 如果配置了结果传递,添加传递的变量
const resultPassMode = node.data.result_pass_mode || 'all';
if (resultPassMode === 'all') {
// all 模式:提示所有子流程输出变量都可用
variables.push({
key: '*',
label: $t(
'ai-platform.workflow.editor.variableSelector.allSubflowVars',
),
isSystem: true,
});
} else if (resultPassMode === 'selected') {
// selected 模式:显示选择的变量
const resultVars = node.data.result_vars || [];
resultVars.forEach((varName: string) => {
variables.push({
key: varName,
label: `${varName} (${$t('ai-platform.workflow.editor.variableSelector.fromSubflow')})`,
});
});
}
break;
}
case 'template': {
const templateOutputVar =
node.data.output_variable || 'template_result';
variables = [
{
key: templateOutputVar,
label: $t(
'ai-platform.workflow.editor.variableSelector.renderResult',
),
},
];
break;
}
case 'text_to_sql': {
const textToSqlOutputVar =
node.data.output_variable || 'text_to_sql_result';
variables = [
{
key: `${textToSqlOutputVar}_sql`,
label: $t(
'ai-platform.workflow.editor.variableSelector.generatedSqlStatement',
),
},
{
key: `${textToSqlOutputVar}_thought`,
label: $t(
'ai-platform.workflow.editor.variableSelector.sqlThought',
),
},
];
break;
}
default: {
// 其他未知节点类型,尝试从 output_variable 获取
if (node.data.output_variable) {
variables = [
{
key: node.data.output_variable,
label: $t(
'ai-platform.workflow.editor.variableSelector.output',
),
},
];
}
break;
}
}
return {
id: node.id,
label: node.data.label || node.label,
type: node.type,
variables,
};
})
.filter((n) => n.variables.length > 0);
});
const filteredNodes = computed(() => {
if (!searchQuery.value) return availableNodes.value;
const query = searchQuery.value.toLowerCase();
return availableNodes.value.filter((node) => {
const matchNode = node.label.toLowerCase().includes(query);
const matchVar = node.variables.some(
(v: any) =>
v.key.toLowerCase().includes(query) ||
v.label.toLowerCase().includes(query),
);
return matchNode || matchVar;
});
});
const toggleNodeCollapse = (nodeId: string) => {
if (collapsedNodes.value.has(nodeId)) {
collapsedNodes.value.delete(nodeId);
} else {
collapsedNodes.value.add(nodeId);
}
};
const isNodeCollapsed = (nodeId: string) => {
return collapsedNodes.value.has(nodeId);
};
const handleSelect = (nodeId: string, variable: string) => {
emit('select', `{{${nodeId}.${variable}}}`);
};
</script>
<template>
<div class="flex h-[390px] w-full flex-col rounded-lg">
<div class="border-border bg-muted shrink-0 border-b p-2">
<ElInput
v-model="searchQuery"
size="small"
:placeholder="
$t('ai-platform.workflow.editor.variableSelector.searchPlaceholder')
"
:prefix-icon="Search"
/>
</div>
<ElScrollbar class="flex-1">
<div
v-if="filteredNodes.length === 0"
class="text-muted-foreground p-4 text-center text-xs"
>
{{ $t('ai-platform.workflow.editor.variableSelector.noVariables') }}
</div>
<div v-else class="p-2">
<div v-for="node in filteredNodes" :key="node.id" class="mb-1">
<div
class="bg-muted/50 text-foreground hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs font-medium transition-colors"
@click="toggleNodeCollapse(node.id)"
>
<component
:is="isNodeCollapsed(node.id) ? ChevronRight : ChevronDown"
class="text-muted-foreground size-3 shrink-0"
/>
<component
:is="icons[node.type]"
class="text-muted-foreground size-3 shrink-0"
/>
<span class="flex-1 truncate">{{ node.label }}</span>
<span class="text-muted-foreground text-[10px]">{{
node.variables.length
}}</span>
</div>
<div v-if="!isNodeCollapsed(node.id)" class="mt-1 space-y-0.5">
<ElTooltip
v-for="v in node.variables"
:key="v.key"
:content="v.label"
placement="left"
:show-after="500"
>
<div
class="hover:bg-accent hover:text-primary flex cursor-pointer flex-col rounded px-2 py-1.5 text-xs transition-colors"
:class="{
'bg-primary/5 text-primary': v.isSystem,
'bg-green-500/5 text-green-600 dark:text-green-400':
v.isField,
'bg-blue-500/5 text-blue-600 dark:text-blue-400':
v.isStructured,
'text-muted-foreground':
!v.isSystem && !v.isField && !v.isStructured,
}"
@click="handleSelect(node.id, v.key)"
>
<div class="flex items-center gap-1">
<span class="font-mono text-[10px]">{{ v.key }}</span>
<span
v-if="v.isSystem"
class="bg-primary/20 text-primary rounded px-1 text-[9px]"
>{{
$t('ai-platform.workflow.editor.variableSelector.system')
}}</span
>
<span
v-if="v.isField"
class="rounded bg-green-500/20 px-1 text-[9px] text-green-600 dark:text-green-400"
>{{
$t('ai-platform.workflow.editor.variableSelector.field')
}}</span
>
<span
v-if="v.isStructured"
class="rounded bg-blue-500/20 px-1 text-[9px] text-blue-600 dark:text-blue-400"
>{{
$t(
'ai-platform.workflow.editor.variableSelector.structured',
)
}}</span
>
</div>
<span
class="text-muted-foreground mt-0.5 truncate text-[10px]"
>{{ v.label }}</span>
</div>
</ElTooltip>
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
@@ -0,0 +1,201 @@
<script setup lang="ts">
import type { WorkflowVersion } from '#/api/ai-platform/ai-platform';
import { ref, watch } from 'vue';
import { Clock, RotateCcw } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElDrawer,
ElEmpty,
ElMessage,
ElMessageBox,
ElScrollbar,
ElTag,
ElTimeline,
ElTimelineItem,
} from 'element-plus';
import {
getWorkflowVersionsApi,
rollbackWorkflowApi,
} from '#/api/ai-platform/ai-platform';
const props = defineProps<{
visible: boolean;
workflowId: string;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'rollback', workflow: any): void;
}>();
const loading = ref(false);
const versions = ref<WorkflowVersion[]>([]);
const loadVersions = async () => {
if (!props.workflowId) return;
loading.value = true;
try {
const result = await getWorkflowVersionsApi(props.workflowId, {
pageSize: 50,
});
versions.value = result.items;
} catch (error) {
console.error('Failed to load version history:', error);
ElMessage.error(
$t('ai-platform.workflow.editor.versionHistory.loadFailed'),
);
} finally {
loading.value = false;
}
};
watch(
() => props.visible,
(val) => {
if (val) {
loadVersions();
}
},
);
const handleClose = () => {
emit('update:visible', false);
};
const handleRollback = async (version: WorkflowVersion) => {
try {
await ElMessageBox.confirm(
$t('ai-platform.workflow.editor.versionHistory.rollbackConfirm', {
version: version.version,
}),
$t('ai-platform.workflow.editor.versionHistory.rollbackTitle'),
{
confirmButtonText: $t(
'ai-platform.workflow.editor.versionHistory.rollbackBtn',
),
cancelButtonText: $t('ai-platform.workflow.editor.cancelBtn'),
type: 'warning',
},
);
const result = await rollbackWorkflowApi(props.workflowId, version.version);
ElMessage.success(
$t('ai-platform.workflow.editor.versionHistory.rollbackSuccess', {
version: version.version,
}),
);
emit('rollback', result);
handleClose();
} catch (error: any) {
if (error === 'cancel') return;
console.error('Rollback failed:', error);
ElMessage.error(
$t('ai-platform.workflow.editor.versionHistory.rollbackFailed'),
);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
</script>
<template>
<ElDrawer
:model-value="visible"
:title="$t('ai-platform.workflow.editor.versionHistory.title')"
direction="rtl"
size="400px"
@close="handleClose"
>
<div v-loading="loading" class="h-full">
<ElScrollbar v-if="versions.length > 0" class="h-full">
<ElTimeline class="px-4">
<ElTimelineItem
v-for="version in versions"
:key="version.id"
:timestamp="formatDate(version.published_at)"
placement="top"
>
<div class="border-border bg-card rounded-lg border p-4 shadow-sm">
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
<ElTag type="primary" size="small">
v{{ version.version }}
</ElTag>
<span
v-if="version.published_by"
class="text-muted-foreground text-xs"
>
{{ version.published_by }}
</span>
</div>
<ElButton
size="small"
:icon="RotateCcw"
@click="handleRollback(version)"
>
{{
$t('ai-platform.workflow.editor.versionHistory.rollback')
}}
</ElButton>
</div>
<p
v-if="version.description"
class="text-muted-foreground mb-2 text-sm"
>
{{ version.description }}
</p>
<p v-else class="text-muted-foreground/50 mb-2 text-sm italic">
{{
$t('ai-platform.workflow.editor.versionHistory.noDescription')
}}
</p>
<div
class="text-muted-foreground flex items-center gap-4 text-xs"
>
<span>{{
$t('ai-platform.workflow.editor.versionHistory.runCount', {
count: version.run_count,
})
}}</span>
<span>{{
$t(
'ai-platform.workflow.editor.versionHistory.successCount',
{ count: version.success_count },
)
}}</span>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</ElScrollbar>
<ElEmpty
v-else
:description="$t('ai-platform.workflow.editor.versionHistory.empty')"
class="mt-20"
>
<template #image>
<Clock class="text-muted-foreground/30 size-16" />
</template>
</ElEmpty>
</div>
</ElDrawer>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue';
import { Plus } from '@vben/icons';
import { EdgeLabelRenderer, getBezierPath } from '@vue-flow/core';
defineOptions({
inheritAttrs: false,
});
const props = defineProps<{
data?: {
status?: EdgeStatus;
};
id: string;
markerEnd?: string;
source: string;
sourcePosition: any;
sourceX: number;
sourceY: number;
style?: any;
target: string;
targetPosition: any;
targetX: number;
targetY: number;
}>();
// 边的执行状态类型
type EdgeStatus = 'completed' | 'failed' | 'idle' | 'running';
const isHovered = ref(false);
// 根据状态计算边的样式
const edgeStyle = computed(() => {
const status = props.data?.status || 'idle';
const baseStyle = { ...props.style };
switch (status) {
case 'completed': {
return {
...baseStyle,
stroke: '#22c55e',
strokeWidth: 2.5,
};
}
case 'failed': {
return {
...baseStyle,
stroke: '#ef4444',
strokeWidth: 2.5,
};
}
case 'running': {
return {
...baseStyle,
stroke: '#3b82f6',
strokeWidth: 2.5,
strokeDasharray: '5,5',
};
}
default: {
return baseStyle;
}
}
});
// 是否显示流动动画
const isRunning = computed(() => props.data?.status === 'running');
// 计算贝塞尔曲线路径和中点
const pathData = computed(() => {
const [path, labelX, labelY] = getBezierPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
});
return { path, labelX, labelY };
});
// 从父组件注入的添加节点函数
const onEdgeAddClick =
inject<
(
edgeId: string,
source: string,
target: string,
position: { x: number; y: number },
) => void
>('onEdgeAddClick');
const handleAddClick = (event: MouseEvent) => {
event.stopPropagation();
if (onEdgeAddClick) {
onEdgeAddClick(props.id, props.source, props.target, {
x: event.clientX,
y: event.clientY,
});
}
};
</script>
<template>
<g>
<!-- 主边路径 - 直接使用 path 以支持动画 -->
<path
:id="id"
:d="pathData.path"
fill="none"
:stroke="edgeStyle.stroke || '#94a3b8'"
:stroke-width="edgeStyle.strokeWidth || 2"
:stroke-dasharray="edgeStyle.strokeDasharray"
:marker-end="markerEnd"
:class="{ 'edge-path-running': isRunning }"
/>
<!-- 悬停检测区域 - 更宽的透明路径 -->
<path
:d="pathData.path"
fill="none"
stroke="transparent"
stroke-width="30"
class="cursor-pointer"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
/>
<!-- 中间的添加按钮 -->
<EdgeLabelRenderer>
<div
class="nodrag nopan"
:style="{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${pathData.labelX}px, ${pathData.labelY}px)`,
pointerEvents: 'all',
zIndex: 1000,
}"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<button
v-show="isHovered"
class="bg-primary text-primary-foreground border-card flex size-6 items-center justify-center rounded-full border-2 shadow-lg transition-all duration-200 hover:scale-110"
title="在此处插入节点"
@click="handleAddClick"
>
<Plus class="size-3.5" />
</button>
</div>
</EdgeLabelRenderer>
</g>
</template>
<style>
/* 执行中的边 - 流动动画 */
.edge-path-running {
animation: edge-dash-flow 0.5s linear infinite;
}
@keyframes edge-dash-flow {
to {
stroke-dashoffset: -10;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { LayoutTemplate } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.app_create.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="app_create"
>
<template #icon><LayoutTemplate class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.app_create.desc') }}
</template>
<div class="space-y-2">
<div class="text-muted-foreground space-y-1 text-xs">
<div v-if="data.name" class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.app_create.appName') }}</span>
<span class="max-w-[120px] truncate">{{ data.name }}</span>
</div>
<div v-if="data.code" class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.app_create.appCode') }}</span>
<span class="font-mono">{{ data.code }}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_create.updateIfExists')
}}</span>
<span>{{
data.update_if_exists
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">app_id, app_code</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { LayoutDashboard } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.app_design.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="app_design"
>
<template #icon><LayoutDashboard class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.app_design.desc') }}
</template>
<div class="space-y-2">
<div class="text-muted-foreground space-y-1 text-xs">
<div v-if="data.design_title" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_design.designTitle')
}}</span>
<span class="max-w-[120px] truncate">{{ data.design_title }}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_design.needConfirm')
}}</span>
<span>{{
data.require_confirmation !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">design_content</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Settings } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.app_settings.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="app_settings"
>
<template #icon><Settings class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.app_settings.desc') }}
</template>
<div class="space-y-2">
<div class="text-muted-foreground space-y-1 text-xs">
<div v-if="data.app_name" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_settings.appName')
}}</span>
<span class="max-w-[120px] truncate">{{ data.app_name }}</span>
</div>
<div v-if="data.app_layout" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_settings.layoutMode')
}}</span>
<span>{{ data.app_layout }}</span>
</div>
<div v-if="data.theme_builtin_type" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_settings.builtinTheme')
}}</span>
<span>{{ data.theme_builtin_type }}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_settings.needConfirm')
}}</span>
<span>{{
data.require_confirmation !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">settings</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { Save } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.app_update.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="app_update"
>
<template #icon><Save class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.app_update.desc') }}
</template>
<div class="space-y-2">
<div class="text-muted-foreground space-y-1 text-xs">
<div v-if="data.settings" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.app_update.settingsSource')
}}</span>
<span class="max-w-[120px] truncate font-mono">{{
data.settings
}}</span>
</div>
<div v-if="data.application_id" class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.app_update.app') }}</span>
<span class="max-w-[120px] truncate">{{ data.application_id }}</span>
</div>
<div v-else class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.app_update.target') }}</span>
<span>{{ $t('ai-platform.workflow.nodes.app_update.mainApp') }}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">update_success, config_id</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,400 @@
<script setup lang="ts">
import type { NodeExecutionResult } from '../components/NodeResultCard.vue';
import { computed, inject, ref } from 'vue';
import { CheckCircle2, Loader2, Plus, XCircle } from '@vben/icons';
import { Handle, Position } from '@vue-flow/core';
import { ElTooltip } from 'element-plus';
import NodeResultCard from '../components/NodeResultCard.vue';
const props = defineProps<{
// 新增:完整的执行结果(用于节点下方展示)
executionResult?: NodeExecutionResult;
executionStatus?: {
elapsed_time?: number;
error?: string;
status: 'failed' | 'running' | 'success';
tokens_used?: number;
};
icon?: any;
inputs?: Array<{ id: string; label?: string }>;
label?: string;
nodeId?: string;
nodeType?: string;
outputs?: Array<{ id: string; label?: string }>;
selected?: boolean;
}>();
const addButtonRef = ref<HTMLElement | null>(null);
// 从父组件注入的添加节点函数
const onNodeAddClick =
inject<(nodeId: string, position: { x: number; y: number }) => void>(
'onNodeAddClick',
);
const handleClick = () => {
const rect = addButtonRef.value?.getBoundingClientRect();
if (rect && onNodeAddClick && props.nodeId) {
onNodeAddClick(props.nodeId, { x: rect.right + 10, y: rect.top });
}
};
// 节点类型配色 - 支持 dark 模式,使用 Tailwind dark: 前缀
const nodeColorConfig = computed(() => {
const configs: Record<
string,
{ headerBg: string; iconBg: string; iconColor: string }
> = {
// 流程控制
start: {
headerBg:
'bg-gradient-to-r from-emerald-50 to-green-50 dark:from-emerald-950 dark:to-green-950',
iconBg: 'bg-emerald-200 dark:bg-emerald-800',
iconColor: 'text-emerald-600 dark:text-emerald-400',
},
end: {
headerBg:
'bg-gradient-to-r from-rose-50 to-red-50 dark:from-rose-950 dark:to-red-950',
iconBg: 'bg-rose-200 dark:bg-rose-800',
iconColor: 'text-rose-600 dark:text-rose-400',
},
condition: {
headerBg:
'bg-gradient-to-r from-amber-50 to-yellow-50 dark:from-amber-950 dark:to-yellow-950',
iconBg: 'bg-amber-200 dark:bg-amber-800',
iconColor: 'text-amber-600 dark:text-amber-400',
},
parallel: {
headerBg:
'bg-gradient-to-r from-cyan-50 to-sky-50 dark:from-cyan-950 dark:to-sky-950',
iconBg: 'bg-cyan-200 dark:bg-cyan-800',
iconColor: 'text-cyan-600 dark:text-cyan-400',
},
merge: {
headerBg:
'bg-gradient-to-r from-slate-50 to-gray-100 dark:from-slate-900 dark:to-gray-900',
iconBg: 'bg-slate-300 dark:bg-slate-700',
iconColor: 'text-slate-600 dark:text-slate-400',
},
// AI 相关
llm: {
headerBg:
'bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-950 dark:to-purple-950',
iconBg: 'bg-violet-200 dark:bg-violet-800',
iconColor: 'text-violet-600 dark:text-violet-400',
},
intent: {
headerBg:
'bg-gradient-to-r from-fuchsia-50 to-pink-50 dark:from-fuchsia-950 dark:to-pink-950',
iconBg: 'bg-fuchsia-200 dark:bg-fuchsia-800',
iconColor: 'text-fuchsia-600 dark:text-fuchsia-400',
},
// 代码和模板
code: {
headerBg:
'bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950',
iconBg: 'bg-orange-200 dark:bg-orange-800',
iconColor: 'text-orange-600 dark:text-orange-400',
},
template: {
headerBg:
'bg-gradient-to-r from-teal-50 to-emerald-50 dark:from-teal-950 dark:to-emerald-950',
iconBg: 'bg-teal-200 dark:bg-teal-800',
iconColor: 'text-teal-600 dark:text-teal-400',
},
// HTTP
http: {
headerBg:
'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950 dark:to-indigo-950',
iconBg: 'bg-blue-200 dark:bg-blue-800',
iconColor: 'text-blue-600 dark:text-blue-400',
},
// 数据库
db_query: {
headerBg:
'bg-gradient-to-r from-indigo-50 to-blue-50 dark:from-indigo-950 dark:to-blue-950',
iconBg: 'bg-indigo-200 dark:bg-indigo-800',
iconColor: 'text-indigo-600 dark:text-indigo-400',
},
db_insert: {
headerBg:
'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950 dark:to-emerald-950',
iconBg: 'bg-green-200 dark:bg-green-800',
iconColor: 'text-green-600 dark:text-green-400',
},
db_update: {
headerBg:
'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-950 dark:to-amber-950',
iconBg: 'bg-yellow-200 dark:bg-yellow-800',
iconColor: 'text-yellow-600 dark:text-yellow-400',
},
db_delete: {
headerBg:
'bg-gradient-to-r from-red-50 to-rose-50 dark:from-red-950 dark:to-rose-950',
iconBg: 'bg-red-200 dark:bg-red-800',
iconColor: 'text-red-600 dark:text-red-400',
},
db_sql: {
headerBg:
'bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-950 dark:to-violet-950',
iconBg: 'bg-purple-200 dark:bg-purple-800',
iconColor: 'text-purple-600 dark:text-purple-400',
},
// Text-to-SQL
text_to_sql: {
headerBg:
'bg-gradient-to-r from-cyan-50 to-teal-50 dark:from-cyan-950 dark:to-teal-950',
iconBg: 'bg-cyan-200 dark:bg-cyan-800',
iconColor: 'text-cyan-600 dark:text-cyan-400',
},
// 对话流
question: {
headerBg:
'bg-gradient-to-r from-sky-50 to-blue-50 dark:from-sky-950 dark:to-blue-950',
iconBg: 'bg-sky-200 dark:bg-sky-800',
iconColor: 'text-sky-600 dark:text-sky-400',
},
choice: {
headerBg:
'bg-gradient-to-r from-lime-50 to-green-50 dark:from-lime-950 dark:to-green-950',
iconBg: 'bg-lime-200 dark:bg-lime-800',
iconColor: 'text-lime-600 dark:text-lime-400',
},
message: {
headerBg:
'bg-gradient-to-r from-pink-50 to-rose-50 dark:from-pink-950 dark:to-rose-950',
iconBg: 'bg-pink-200 dark:bg-pink-800',
iconColor: 'text-pink-600 dark:text-pink-400',
},
confirm: {
headerBg:
'bg-gradient-to-r from-orange-50 to-red-50 dark:from-orange-950 dark:to-red-950',
iconBg: 'bg-orange-200 dark:bg-orange-800',
iconColor: 'text-orange-600 dark:text-orange-400',
},
// Snowflake
snowflake_cortex_llm: {
headerBg:
'bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950 dark:to-blue-950',
iconBg: 'bg-cyan-200 dark:bg-cyan-800',
iconColor: 'text-cyan-600 dark:text-cyan-400',
},
snowflake_cortex_analyst: {
headerBg:
'bg-gradient-to-r from-blue-50 to-cyan-50 dark:from-blue-950 dark:to-cyan-950',
iconBg: 'bg-blue-200 dark:bg-blue-800',
iconColor: 'text-blue-600 dark:text-blue-400',
},
// 子流程
subflow: {
headerBg:
'bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-950 dark:to-purple-950',
iconBg: 'bg-indigo-200 dark:bg-indigo-800',
iconColor: 'text-indigo-600 dark:text-indigo-400',
},
// 知识库
knowledge_retrieval: {
headerBg:
'bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-950 dark:to-cyan-950',
iconBg: 'bg-teal-200 dark:bg-teal-800',
iconColor: 'text-teal-600 dark:text-teal-400',
},
};
return (
configs[props.nodeType || ''] || {
headerBg: 'bg-gradient-to-r from-card to-muted',
iconBg: 'bg-card border border-border',
iconColor: 'text-primary',
}
);
});
</script>
<template>
<div class="flex flex-col">
<!-- 节点主体 -->
<div
class="bg-card group relative w-[280px] max-w-[280px] rounded-xl border shadow-sm transition-all duration-200 hover:shadow-md"
:class="[
selected ? 'border-primary ring-primary ring-1' : '',
executionStatus?.status === 'running'
? 'animate-pulse-border !border-blue-500 shadow-lg shadow-blue-500/20 ring-2 ring-blue-500/50'
: '',
executionStatus?.status === 'success'
? '!border-green-500 ring-1 ring-green-500'
: '',
executionStatus?.status === 'failed'
? '!border-red-500 ring-2 ring-red-500'
: '',
!selected && !executionStatus?.status ? 'border-border' : '',
]"
>
<!-- Status Badge (Absolute Top Right) -->
<div
v-if="executionStatus"
class="absolute -top-3 right-4 z-20 flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] shadow-sm"
:class="[
executionStatus.status === 'running'
? 'border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: '',
executionStatus.status === 'success'
? 'border-green-500/30 bg-green-500/10 text-green-600 dark:text-green-400'
: '',
executionStatus.status === 'failed'
? 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'
: '',
]"
>
<Loader2
v-if="executionStatus.status === 'running'"
class="size-3 animate-spin"
/>
<CheckCircle2
v-if="executionStatus.status === 'success'"
class="size-3"
/>
<ElTooltip
v-if="executionStatus.status === 'failed' && executionStatus.error"
:content="executionStatus.error"
placement="top"
>
<XCircle class="size-3 cursor-help" />
</ElTooltip>
<XCircle
v-else-if="executionStatus.status === 'failed'"
class="size-3"
/>
<span v-if="executionStatus.status === 'running'">运行中...</span>
<span v-else-if="executionStatus.elapsed_time !== undefined">{{ executionStatus.elapsed_time }}ms</span>
</div>
<!-- Header -->
<div
class="border-border/50 flex items-center gap-3 rounded-t-xl border-b px-4 py-3"
:class="nodeColorConfig.headerBg"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm"
:class="[nodeColorConfig.iconBg, nodeColorConfig.iconColor]"
>
<slot name="icon"></slot>
</div>
<div class="flex flex-1 flex-col overflow-hidden">
<div class="text-foreground truncate text-sm font-semibold">
{{ label }}
</div>
<div class="text-muted-foreground truncate text-[10px]">
<slot name="desc"></slot>
</div>
</div>
<!-- Token Usage -->
<div
v-if="executionStatus?.tokens_used"
class="text-muted-foreground bg-muted border-border flex items-center rounded border px-1.5 py-0.5 text-[10px]"
>
{{ executionStatus.tokens_used }} T
</div>
<div class="flex gap-1">
<slot name="actions"></slot>
</div>
</div>
<!-- Body -->
<div class="text-muted-foreground min-w-0 space-y-2 overflow-hidden p-4 text-xs">
<slot></slot>
</div>
<!-- Input Handles (Left) -->
<div v-if="inputs && inputs.length > 0">
<Handle
v-for="(input, index) in inputs"
:key="input.id"
type="target"
:position="Position.Left"
:id="input.id"
class="!bg-primary !z-20 !h-3 !w-3 !border-2 !border-white transition-transform hover:scale-125"
:style="{ top: `${((index + 1) * 100) / (inputs.length + 1)}%` }"
/>
</div>
<!-- Default single input if not specified but needed logic can be handled in parent -->
<!-- Output Handles (Right) -->
<div v-if="outputs && outputs.length > 0">
<Handle
v-for="(output, index) in outputs"
:key="output.id"
type="source"
:position="Position.Right"
:id="output.id"
class="!bg-primary !z-20 !h-3 !w-3 !border-2 !border-white transition-transform hover:scale-125"
:style="{ top: `${((index + 1) * 100) / (outputs.length + 1)}%` }"
/>
</div>
<!-- 悬停时显示的添加按钮 - 放在节点更右侧不覆盖 Handle -->
<!-- 分支类型节点condition/intent/parallel/choice不显示通用添加按钮需要从具体分支 Handle 拖线 -->
<div
v-if="
nodeType !== 'end' &&
!['condition', 'intent', 'parallel', 'choice'].includes(
nodeType || '',
)
"
ref="addButtonRef"
class="bg-primary hover:bg-primary/90 absolute -right-8 top-1/2 flex size-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full text-white opacity-0 shadow-md transition-opacity hover:scale-110 group-hover:opacity-100"
title="添加下一个节点"
@click.stop="handleClick"
>
<Plus class="pointer-events-none size-3.5" />
</div>
</div>
<!-- 节点下方的执行结果卡片(独立显示,不在节点内部) -->
<NodeResultCard
v-if="executionResult"
:result="executionResult"
:node-type="nodeType"
class="mt-3"
/>
</div>
</template>
<style scoped>
/* 自定义 Handle 样式 */
:deep(.vue-flow__handle) {
z-index: 20;
width: 10px;
height: 10px;
background: hsl(var(--primary));
}
/* 运行中边框呼吸动画 */
@keyframes pulse-border {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
}
.animate-pulse-border {
animation: pulse-border 1.5s ease-in-out infinite;
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { ListChecks } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.choice.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="choice"
>
<template #icon><ListChecks class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.choice.desc') }}
</template>
<div class="space-y-2">
<div class="rounded bg-purple-50 px-2 py-1.5 text-xs dark:bg-purple-950">
<div class="mb-1 font-medium text-purple-700 dark:text-purple-300">
{{ $t('ai-platform.workflow.nodes.common.question') }}
</div>
<div class="line-clamp-2 text-purple-600 dark:text-purple-400">
{{
data.question ||
$t('ai-platform.workflow.nodes.common.configureQuestion')
}}
</div>
</div>
<div class="space-y-1">
<div class="text-muted-foreground text-xs">
{{ $t('ai-platform.workflow.nodes.choice.options') }}
</div>
<div
v-if="data.options && data.options.length > 0"
class="flex flex-wrap gap-1"
>
<span
v-for="(opt, idx) in data.options.slice(0, 3)"
:key="idx"
class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs"
>
{{ opt.label || opt.value }}
</span>
<span
v-if="data.options.length > 3"
class="text-muted-foreground text-xs"
>
+{{ data.options.length - 3 }}
</span>
</div>
<div v-else class="text-muted-foreground text-xs">
{{ $t('ai-platform.workflow.nodes.choice.noOptions') }}
</div>
</div>
<div
class="text-muted-foreground flex items-center justify-between text-xs"
>
<span>{{
data.multiple
? $t('ai-platform.workflow.nodes.choice.multiple')
: $t('ai-platform.workflow.nodes.choice.single')
}}</span>
</div>
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{
data.variable_name || 'user_choice'
}}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { Code } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.code.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="code"
>
<template #icon><Code class="size-5" /></template>
<template #desc>{{ $t('ai-platform.workflow.nodes.code.desc') }}</template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{{
$t('ai-platform.workflow.nodes.code.language')
}}</span>
<span
class="text-primary bg-primary/10 rounded px-2 font-mono text-[10px]"
>
{{ data.language || 'python3' }}
</span>
</div>
<div
v-if="data.inputs && data.inputs.length > 0"
class="text-muted-foreground"
>
{{ $t('ai-platform.workflow.nodes.code.input') }}
{{ data.inputs.map((i: any) => i.variable).join(', ') }}
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue';
import { GitBranch } from '@vben/icons';
import { $t } from '@vben/locales';
import { Handle, Position } from '@vue-flow/core';
import BaseNode from './BaseNode.vue';
const props = defineProps(['selected', 'data', 'id']);
// 确保有默认分支
const branches = computed(() => {
return props.data.branches || [];
});
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.condition.label')"
:inputs="[{ id: 'target' }]"
:node-id="id"
node-type="condition"
>
<template #icon><GitBranch class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.condition.desc') }}
</template>
<div class="space-y-3 pt-1">
<!-- IF 分支 -->
<div
v-for="(branch, index) in branches"
:key="branch.id"
class="relative flex h-8 items-center justify-between rounded border border-blue-100 bg-blue-50 px-3 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300"
>
<span class="font-medium">IF {{ index + 1 }}</span>
<span class="text-muted-foreground max-w-[100px] truncate">{{
branch.name || $t('ai-platform.workflow.nodes.condition.unnamed')
}}</span>
<!-- Handle -->
<Handle
type="source"
:position="Position.Right"
:id="branch.id"
class="!border-card !h-3 !w-3 !border-2 !bg-blue-500 transition-transform hover:scale-125"
style="right: -17px; top: 50%; transform: translateY(-50%)"
/>
</div>
<!-- ELSE 分支 -->
<div
class="border-border bg-muted text-muted-foreground relative flex h-8 items-center justify-between rounded border px-3 text-xs"
>
<span class="font-medium">ELSE</span>
<!-- Handle -->
<Handle
type="source"
:position="Position.Right"
id="else"
class="!border-card !bg-muted-foreground !h-3 !w-3 !border-2 transition-transform hover:scale-125"
style="right: -17px; top: 50%; transform: translateY(-50%)"
/>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { CircleHelp } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.confirm.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="confirm"
>
<template #icon><CircleHelp class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.confirm.desc') }}
</template>
<div class="space-y-2">
<div class="rounded bg-amber-50 px-2 py-1.5 text-xs dark:bg-amber-950">
<div class="mb-1 font-medium text-amber-700 dark:text-amber-300">
{{
data.title || $t('ai-platform.workflow.nodes.confirm.confirmText')
}}
</div>
<div class="line-clamp-2 text-amber-600 dark:text-amber-400">
{{
data.content ||
$t('ai-platform.workflow.nodes.confirm.configureContent')
}}
</div>
</div>
<div class="flex items-center gap-2 text-xs">
<span
class="rounded bg-green-100 px-2 py-0.5 text-green-700 dark:bg-green-900 dark:text-green-300"
>
{{
data.confirm_text ||
$t('ai-platform.workflow.nodes.confirm.confirmText')
}}
</span>
<span class="bg-muted text-muted-foreground rounded px-2 py-0.5">
{{
data.cancel_text ||
$t('ai-platform.workflow.nodes.confirm.cancelText')
}}
</span>
</div>
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{ data.variable_name || 'confirmed' }}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { FileText } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps<{
data: any;
id: string;
selected: boolean;
}>();
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="
data.label || $t('ai-platform.workflow.nodes.dashboard_basic_info.label')
"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="dashboard_basic_info"
>
<template #icon><FileText class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.dashboard_basic_info.desc') }}
</template>
<div class="space-y-1 text-xs">
<div v-if="data.name" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_basic_info.name')
}}</span>
{{ data.name }}
</div>
<div v-if="data.code" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_basic_info.code')
}}</span>
{{ data.code }}
</div>
<div v-if="data.category" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_basic_info.category')
}}</span>
{{ data.category }}
</div>
</div>
<template #footer>
<div class="text-muted-foreground truncate text-[10px]">
{{ $t('ai-platform.workflow.nodes.common.output') }}
dashboard_basic_info
</div>
</template>
</BaseNode>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { FilePlus } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps<{
data: any;
id: string;
selected: boolean;
}>();
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="
data.label || $t('ai-platform.workflow.nodes.dashboard_create.label')
"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="dashboard_create"
>
<template #icon><FilePlus class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.dashboard_create.desc') }}
</template>
<div class="space-y-1 text-xs">
<div
v-if="data.dashboard_basic_info"
class="text-muted-foreground truncate"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_create.basicInfo')
}}</span>
{{ $t('ai-platform.workflow.nodes.common.configured') }}
</div>
<div v-if="data.page_config" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_create.pageConfig')
}}</span>
{{ $t('ai-platform.workflow.nodes.common.configured') }}
</div>
<div class="text-muted-foreground">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_create.updateIfExists')
}}</span>
{{
data.update_if_exists !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}
</div>
</div>
<template #footer>
<div class="text-muted-foreground truncate text-[10px]">
{{ $t('ai-platform.workflow.nodes.common.output') }} dashboard_id,
dashboard_code
</div>
</template>
</BaseNode>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { LayoutDashboard } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps<{
data: any;
id: string;
selected: boolean;
}>();
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="
data.label || $t('ai-platform.workflow.nodes.dashboard_design.label')
"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="dashboard_design"
>
<template #icon><LayoutDashboard class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.dashboard_design.desc') }}
</template>
<div class="space-y-1 text-xs">
<div v-if="data.design_title" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_design.title')
}}</span>
{{ data.design_title }}
</div>
<div class="text-muted-foreground">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_design.needConfirm')
}}</span>
{{
data.require_confirmation !== false
? $t('ai-platform.workflow.nodes.dashboard_design.need')
: $t('ai-platform.workflow.nodes.dashboard_design.notNeed')
}}
</div>
</div>
<template #footer>
<div class="text-muted-foreground truncate text-[10px]">
{{ $t('ai-platform.workflow.nodes.common.output') }} page_config
</div>
</template>
</BaseNode>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { Upload } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps<{
data: any;
id: string;
selected: boolean;
}>();
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="
data.label || $t('ai-platform.workflow.nodes.dashboard_publish.label')
"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="dashboard_publish"
>
<template #icon><Upload class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.dashboard_publish.desc') }}
</template>
<div class="space-y-1 text-xs">
<div v-if="data.dashboard_id" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_publish.dashboard')
}}</span>
{{ data.dashboard_id }}
</div>
<div v-if="data.menu_name" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_publish.menuName')
}}</span>
{{ data.menu_name }}
</div>
<div v-if="data.menu_icon" class="text-muted-foreground truncate">
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.dashboard_publish.icon')
}}</span>
{{ data.menu_icon }}
</div>
</div>
<template #footer>
<div class="text-muted-foreground truncate text-[10px]">
{{ $t('ai-platform.workflow.nodes.common.output') }} menu_id, route_path
</div>
</template>
</BaseNode>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import DbNodeConnectionBadge from '../components/DbNodeConnectionBadge.vue';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.db_delete.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="db_delete"
>
<template #icon><Trash2 class="size-5 text-red-600" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.db_delete.desc') }}
</template>
<div class="space-y-2">
<DbNodeConnectionBadge
:db-config="data.db_config"
show-default-badge
/>
<!-- 表名 -->
<div class="flex items-center gap-2">
<span
class="rounded border border-red-200 bg-red-50 px-1.5 py-0.5 text-[10px] font-bold text-red-600"
>
DELETE
</span>
<span
class="text-foreground flex-1 truncate font-mono text-xs"
:title="data.table"
>
{{ data.table || $t('ai-platform.workflow.nodes.db_delete.noTable') }}
</span>
</div>
<!-- 条件预览 -->
<div
v-if="data.where_conditions && data.where_conditions.length > 0"
class="rounded bg-red-50 px-2 py-1.5"
>
<div class="text-[10px] text-red-700">
{{
$t('ai-platform.workflow.nodes.common.conditions', {
count: data.where_conditions.length,
})
}}
</div>
</div>
<div v-else class="rounded bg-red-100 px-2 py-1.5">
<div class="text-[10px] font-medium text-red-600">
{{ $t('ai-platform.workflow.nodes.db_delete.mustSetCondition') }}
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { DatabaseZap } from '@vben/icons';
import { $t } from '@vben/locales';
import DbNodeConnectionBadge from '../components/DbNodeConnectionBadge.vue';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
const getFieldCount = (fieldMapping: Record<string, string> | undefined) => {
if (!fieldMapping) return 0;
return Object.keys(fieldMapping).length;
};
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.db_insert.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="db_insert"
>
<template #icon><DatabaseZap class="size-5 text-green-600" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.db_insert.desc') }}
</template>
<div class="space-y-2">
<DbNodeConnectionBadge
:db-config="data.db_config"
show-default-badge
/>
<!-- 表名 -->
<div class="flex items-center gap-2">
<span
class="rounded border border-green-200 bg-green-50 px-1.5 py-0.5 text-[10px] font-bold text-green-600 dark:border-green-700 dark:bg-green-950 dark:text-green-400"
>
INSERT
</span>
<span
class="text-foreground flex-1 truncate font-mono text-xs"
:title="data.table"
>
{{ data.table || $t('ai-platform.workflow.nodes.db_insert.noTable') }}
</span>
</div>
<!-- {{ $t('ai-platform.workflow.nodes.db_insert.fieldMapping') }}预览 -->
<div
v-if="data.field_mapping && getFieldCount(data.field_mapping) > 0"
class="bg-muted rounded px-2 py-1.5"
>
<div class="text-muted-foreground mb-1 text-[10px]">
{{ $t('ai-platform.workflow.nodes.db_insert.fieldMapping') }} ({{
getFieldCount(data.field_mapping)
}})
</div>
<div class="space-y-0.5">
<div
v-for="(value, field) in data.field_mapping"
:key="field"
class="flex items-center gap-1 text-[10px]"
>
<span class="text-muted-foreground font-medium">{{ field }}</span>
<span class="text-muted-foreground"></span>
<span
class="truncate font-mono text-green-600 dark:text-green-400"
:title="value"
>{{ value }}</span>
</div>
</div>
</div>
<!-- UPSERT 标记 -->
<div v-if="data.upsert" class="text-[10px] text-blue-600">
{{ $t('ai-platform.workflow.nodes.db_insert.upsert') }}
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Search } from '@vben/icons';
import { $t } from '@vben/locales';
import DbNodeConnectionBadge from '../components/DbNodeConnectionBadge.vue';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.db_query.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="db_query"
>
<template #icon><Search class="size-5 text-blue-600" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.db_query.desc') }}
</template>
<div class="space-y-2">
<DbNodeConnectionBadge :db-config="data.db_config" />
<!-- 表名 -->
<div class="flex items-center gap-2">
<span
class="rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-bold text-blue-600 dark:border-blue-700 dark:bg-blue-950 dark:text-blue-400"
>
SELECT
</span>
<span
class="text-foreground flex-1 truncate font-mono text-xs"
:title="data.table"
>
{{ data.table || $t('ai-platform.workflow.nodes.db_query.noTable') }}
</span>
</div>
<!-- 返回字段 -->
<div
v-if="data.return_fields && data.return_fields !== '*'"
class="bg-muted rounded px-2 py-1.5"
>
<div class="text-muted-foreground text-[10px]">
{{ $t('ai-platform.workflow.nodes.db_query.returnMode') }}
{{ data.return_fields }}
</div>
</div>
<!-- 条件预览 -->
<div
v-if="data.where_conditions && data.where_conditions.length > 0"
class="rounded bg-blue-50 px-2 py-1.5 dark:bg-blue-950"
>
<div class="text-[10px] text-blue-700 dark:text-blue-300">
{{
$t('ai-platform.workflow.nodes.common.conditions', {
count: data.where_conditions.length,
})
}}
</div>
</div>
<!-- 限制条数 -->
<div
v-if="data.limit && data.limit !== 100"
class="text-muted-foreground text-[10px]"
>
{{
$t('ai-platform.workflow.nodes.db_query.limit', { count: data.limit })
}}
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { Database } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.db_sql.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="db_sql"
>
<template #icon><Database class="size-5 text-purple-600" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.db_sql.desc') }}
</template>
<div class="flex min-w-0 items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span
class="shrink-0 rounded border border-purple-200 bg-purple-50 px-1.5 py-0.5 text-[10px] font-bold text-purple-600"
>
SQL
</span>
<span class="text-muted-foreground shrink-0 text-[10px]">
{{
data.sql_type === 'query'
? $t('ai-platform.workflow.nodes.db_sql.query')
: $t('ai-platform.workflow.nodes.db_sql.execute')
}}
</span>
</div>
<div
v-if="data.db_config?.dbName"
class="flex min-w-0 shrink items-center justify-end gap-1"
>
<span
v-if="data.sql_type === 'execute' && data.db_config.dbName === 'default'"
class="shrink-0 rounded bg-amber-100 px-1 text-[10px] text-amber-700 dark:bg-amber-950 dark:text-amber-300"
>
{{ $t('ai-platform.workflow.editor.dbConnection.defaultBadge') }}
</span>
<span
class="text-muted-foreground truncate text-[10px] font-medium"
:title="data.db_config.dbName"
>
{{ data.db_config.dbName }}
</span>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { DatabaseBackup } from '@vben/icons';
import { $t } from '@vben/locales';
import DbNodeConnectionBadge from '../components/DbNodeConnectionBadge.vue';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
const getFieldCount = (fieldMapping: Record<string, string> | undefined) => {
if (!fieldMapping) return 0;
return Object.keys(fieldMapping).length;
};
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.db_update.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="db_update"
>
<template #icon><DatabaseBackup class="size-5 text-orange-600" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.db_update.desc') }}
</template>
<div class="space-y-2">
<DbNodeConnectionBadge
:db-config="data.db_config"
show-default-badge
/>
<!-- 表名 -->
<div class="flex items-center gap-2">
<span
class="rounded border border-orange-200 bg-orange-50 px-1.5 py-0.5 text-[10px] font-bold text-orange-600"
>
UPDATE
</span>
<span
class="text-foreground flex-1 truncate font-mono text-xs"
:title="data.table"
>
{{ data.table || $t('ai-platform.workflow.nodes.db_update.noTable') }}
</span>
</div>
<!-- 字段映射预览 -->
<div
v-if="data.field_mapping && getFieldCount(data.field_mapping) > 0"
class="bg-muted rounded px-2 py-1.5"
>
<div class="text-muted-foreground mb-1 text-[10px]">
{{ $t('ai-platform.workflow.nodes.db_update.updateFields') }} ({{
getFieldCount(data.field_mapping)
}})
</div>
<div class="space-y-0.5">
<div
v-for="(value, field) in data.field_mapping"
:key="field"
class="flex items-center gap-1 text-[10px]"
>
<span class="text-muted-foreground font-medium">{{ field }}</span>
<span class="text-muted-foreground"></span>
<span class="truncate font-mono text-orange-600" :title="value">{{
value
}}</span>
</div>
</div>
</div>
<!-- 条件预览 -->
<div
v-if="data.where_conditions && data.where_conditions.length > 0"
class="rounded bg-yellow-50 px-2 py-1.5"
>
<div class="text-[10px] text-yellow-700">
{{
$t('ai-platform.workflow.nodes.common.conditions', {
count: data.where_conditions.length,
})
}}
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Square } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="$t('ai-platform.workflow.nodes.end.label')"
:inputs="[{ id: 'target' }]"
:node-id="id"
node-type="end"
>
<template #icon><Square class="size-5" /></template>
<template #desc>{{ $t('ai-platform.workflow.nodes.end.desc') }}</template>
<div class="bg-muted text-muted-foreground rounded p-2 text-xs">
<div v-if="data.outputs && data.outputs.length > 0">
{{
$t('ai-platform.workflow.nodes.common.outputVariablesCount', {
count: data.outputs.length,
})
}}
</div>
<div v-else>
{{ $t('ai-platform.workflow.nodes.common.noOutputVariables') }}
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { FileText } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="
data.label || $t('ai-platform.workflow.nodes.form_basic_info.label')
"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="form_basic_info"
>
<template #icon><FileText class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.form_basic_info.desc') }}
</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-purple-50 px-2 py-1 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.form_basic_info.formName')
}}</span>
<span class="max-w-[120px] truncate">{{
data.name || $t('ai-platform.workflow.nodes.common.notConfigured')
}}</span>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_basic_info.formCode')
}}</span>
<span class="font-mono">{{
data.code ||
(data.auto_generate_code
? $t('ai-platform.workflow.nodes.form_basic_info.autoGenerate')
: '-')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_basic_info.formType')
}}</span>
<span>{{
data.form_type === 'workflow'
? $t('ai-platform.workflow.nodes.form_basic_info.workflow')
: $t('ai-platform.workflow.nodes.form_basic_info.normal')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">form_basic_info</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { FilePlus } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.form_create.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="form_create"
>
<template #icon><FilePlus class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.form_create.desc') }}
</template>
<div class="space-y-2">
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_create.updateIfExists')
}}</span>
<span>{{
data.update_if_exists !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">form_id, form_code</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Code, FilePlus, FileText, Search, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
const props = defineProps<{
data: any;
id: string;
selected?: boolean;
type: string;
}>();
// 根据节点类型获取显示信息
const nodeInfo = computed(() => {
switch (props.type) {
case 'form_data_create': {
return {
label: $t('ai-platform.workflow.nodes.form_data.create'),
icon: FilePlus,
desc: $t('ai-platform.workflow.nodes.form_data.createDesc'),
};
}
case 'form_data_delete': {
return {
label: $t('ai-platform.workflow.nodes.form_data.delete'),
icon: Trash2,
desc: $t('ai-platform.workflow.nodes.form_data.deleteDesc'),
};
}
case 'form_data_list': {
return {
label: $t('ai-platform.workflow.nodes.form_data.list'),
icon: Search,
desc: $t('ai-platform.workflow.nodes.form_data.listDesc'),
};
}
case 'form_data_read': {
return {
label: $t('ai-platform.workflow.nodes.form_data.read'),
icon: FileText,
desc: $t('ai-platform.workflow.nodes.form_data.readDesc'),
};
}
case 'form_data_update': {
return {
label: $t('ai-platform.workflow.nodes.form_data.update'),
icon: FileText,
desc: $t('ai-platform.workflow.nodes.form_data.updateDesc'),
};
}
case 'form_schema_to_llm': {
return {
label: $t('ai-platform.workflow.nodes.form_data.schemaToLLM'),
icon: Code,
desc: $t('ai-platform.workflow.nodes.form_data.schemaToLLMDesc'),
};
}
default: {
return {
label: $t('ai-platform.workflow.nodes.form_data.label'),
icon: FileText,
desc: $t('ai-platform.workflow.nodes.form_data.desc'),
};
}
}
});
const label = computed(() => props.data?.label || nodeInfo.value.label);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="label"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
:node-type="type"
>
<template #icon>
<component :is="nodeInfo.icon" class="size-5" />
</template>
<template #desc>{{ nodeInfo.desc }}</template>
<div class="space-y-2">
<!-- 表单编码 -->
<div
v-if="type !== 'form_schema_to_llm'"
class="flex items-center justify-between rounded bg-blue-50 px-2 py-1 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.form_data.formCode')
}}</span>
<span class="font-mono text-xs">{{
data.form_code ||
$t('ai-platform.workflow.nodes.common.notConfigured')
}}</span>
</div>
<!-- 表单转LLM特殊显示 -->
<div
v-if="type === 'form_schema_to_llm'"
class="flex items-center justify-between rounded bg-purple-50 px-2 py-1 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.form_data.formCode')
}}</span>
<span class="font-mono text-xs">{{
data.form_code ||
$t('ai-platform.workflow.nodes.common.notConfigured')
}}</span>
</div>
<!-- 配置信息 -->
<div class="text-muted-foreground space-y-1 text-xs">
<div v-if="type === 'form_data_read'" class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.form_data.recordId') }}</span>
<span>{{
data.record_id
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
<div v-if="type === 'form_data_update'" class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.form_data.recordId') }}</span>
<span>{{
data.record_id
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
<div v-if="type === 'form_data_update'" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_data.updateData')
}}</span>
<span>{{
data.data ? $t('ai-platform.workflow.nodes.common.configured') : '-'
}}</span>
</div>
<div v-if="type === 'form_data_create'" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_data.createData')
}}</span>
<span>{{
data.data ? $t('ai-platform.workflow.nodes.common.configured') : '-'
}}</span>
</div>
<div v-if="type === 'form_data_delete'" class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.form_data.recordId') }}</span>
<span>{{
data.record_id
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
<div v-if="type === 'form_data_list'" class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_data.filterCondition')
}}</span>
<span>{{
data.filters
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{
data.output_variable ||
$t('ai-platform.workflow.nodes.common.notConfigured')
}}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { DatabaseZap } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.form_db_create.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="form_database_create"
>
<template #icon><DatabaseZap class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.form_db_create.desc') }}
</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-green-50 px-2 py-1 text-green-700 dark:bg-green-950 dark:text-green-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.form_db_create.operation')
}}</span>
<span>{{
$t('ai-platform.workflow.nodes.form_db_create.createTable')
}}</span>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_db_create.tableExists')
}}</span>
<span>{{
data.if_exists === 'replace'
? $t('ai-platform.workflow.nodes.form_db_create.dropRecreate')
: data.if_exists === 'error'
? $t('ai-platform.workflow.nodes.form_db_create.error')
: $t('ai-platform.workflow.nodes.form_db_create.skip')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_db_create.autoCreateSchema')
}}</span>
<span>{{
data.create_schema_if_not_exists !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">creation_result</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { Database } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.form_db_design.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="form_database_design"
>
<template #icon><Database class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.form_db_design.desc') }}
</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-blue-50 px-2 py-1 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.form_db_design.database')
}}</span>
<span>{{ data.db_config || 'default' }}</span>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_db_design.mainTable')
}}</span>
<span>{{
data.main_table
? $t('ai-platform.workflow.nodes.common.configured')
: $t('ai-platform.workflow.nodes.common.notConfigured')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_db_design.subTable')
}}</span>
<span>{{
data.sub_tables
? $t('ai-platform.workflow.nodes.common.configured')
: $t('ai-platform.workflow.nodes.form_db_design.noSubTable')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_db_design.systemFields')
}}</span>
<span>{{
data.auto_add_system_fields !== false
? $t('ai-platform.workflow.nodes.form_db_design.autoAdd')
: $t('ai-platform.workflow.nodes.form_db_design.noAdd')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">database_design</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { TableProperties } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="
data.label || $t('ai-platform.workflow.nodes.form_list_design.label')
"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="form_list_design"
>
<template #icon><TableProperties class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.form_list_design.desc') }}
</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-indigo-50 px-2 py-1 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.form_list_design.containerType')
}}</span>
<span>{{
data.container_type === 'drawer'
? $t('ai-platform.workflow.nodes.form_list_design.drawer')
: data.container_type === 'dialog'
? $t('ai-platform.workflow.nodes.form_list_design.dialog')
: data.container_type === 'page'
? $t('ai-platform.workflow.nodes.form_list_design.page')
: $t('ai-platform.workflow.nodes.form_list_design.drawer')
}}</span>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_list_design.pageSize')
}}</span>
<span>{{ data.page_size || 20 }}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_list_design.autoQueryFields')
}}</span>
<span>{{
data.auto_query_fields !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_list_design.autoColumns')
}}</span>
<span>{{
data.auto_columns !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">list_config</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { Send } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.form_publish.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="form_publish"
>
<template #icon><Send class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.form_publish.desc') }}
</template>
<div class="space-y-2">
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_publish.menuName')
}}</span>
<span class="max-w-[120px] truncate">{{
data.menu_name ||
$t('ai-platform.workflow.nodes.form_publish.notSet')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_publish.menuIcon')
}}</span>
<span>{{ data.menu_icon || 'lucide:file-text' }}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">menu_id, route_path</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { LayoutTemplate } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.form_ui_design.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="form_ui_design"
>
<template #icon><LayoutTemplate class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.form_ui_design.desc') }}
</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-purple-50 px-2 py-1 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.form_ui_design.layoutMode')
}}</span>
<span>{{
data.layout_mode === 'auto'
? $t('ai-platform.workflow.nodes.form_ui_design.auto')
: data.layout_mode === 'single'
? $t('ai-platform.workflow.nodes.form_ui_design.singleCol')
: data.layout_mode === 'double'
? $t('ai-platform.workflow.nodes.form_ui_design.doubleCol')
: data.layout_mode === 'triple'
? $t('ai-platform.workflow.nodes.form_ui_design.tripleCol')
: $t('ai-platform.workflow.nodes.form_ui_design.auto')
}}</span>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_ui_design.formName')
}}</span>
<span>{{
data.form_name ||
$t('ai-platform.workflow.nodes.form_ui_design.auto')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_ui_design.enableGrouping')
}}</span>
<span>{{
data.enable_grouping !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.form_ui_design.hideSystemFields')
}}</span>
<span>{{
data.hide_system_fields !== false
? $t('ai-platform.workflow.nodes.common.yes')
: $t('ai-platform.workflow.nodes.common.no')
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{ $t('ai-platform.workflow.nodes.common.output') }}</span>
<span class="font-mono">form_ui_design</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { Globe } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
const getMethodColor = (method: string) => {
const map: Record<string, string> = {
GET: 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-700',
POST: 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-700',
PUT: 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-700',
DELETE:
'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-700',
};
return (
map[method?.toUpperCase()] || 'text-muted-foreground bg-muted border-border'
);
};
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.http.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="http"
>
<template #icon><Globe class="size-5" /></template>
<template #desc>{{ $t('ai-platform.workflow.nodes.http.desc') }}</template>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span
class="rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase"
:class="getMethodColor(data.method || 'GET')"
>
{{ data.method || 'GET' }}
</span>
<span
class="text-muted-foreground flex-1 truncate font-mono"
:title="data.url"
>
{{ data.url || 'https://api.example.com' }}
</span>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Brain } from '@vben/icons';
import { $t } from '@vben/locales';
import { Handle, Position } from '@vue-flow/core';
import BaseNode from './BaseNode.vue';
const props = defineProps(['selected', 'data', 'id']);
// 获取意图列表
const intents = computed(() => {
return props.data.intents || [];
});
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.intent.label')"
:inputs="[{ id: 'target' }]"
:node-id="id"
node-type="intent"
>
<template #icon><Brain class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.intent.desc') }}
</template>
<div class="space-y-2 pt-1">
<!-- 意图分支 -->
<div
v-for="intent in intents"
:key="intent.name"
class="relative flex h-8 items-center justify-between rounded border border-purple-100 bg-purple-50 px-3 text-xs text-purple-700"
>
<span class="font-medium">{{ intent.name }}</span>
<span class="text-muted-foreground max-w-[100px] truncate">{{
intent.description || ''
}}</span>
<!-- Handle -->
<Handle
type="source"
:position="Position.Right"
:id="intent.branch_id || intent.name"
class="!h-3 !w-3 !border-2 !border-white !bg-purple-500 transition-transform hover:scale-125"
style="right: -17px; top: 50%; transform: translateY(-50%)"
/>
</div>
<!-- OTHER 分支 -->
<div
class="border-border bg-muted text-muted-foreground relative flex h-8 items-center justify-between rounded border px-3 text-xs"
>
<span class="font-medium">OTHER</span>
<span class="text-muted-foreground">{{
$t('ai-platform.workflow.nodes.intent.noMatch')
}}</span>
<!-- Handle -->
<Handle
type="source"
:position="Position.Right"
id="other"
class="!h-3 !w-3 !border-2 !border-white !bg-gray-400 transition-transform hover:scale-125"
style="right: -17px; top: 50%; transform: translateY(-50%)"
/>
</div>
<!-- 置信度阈值提示 -->
<div
v-if="data.confidence_threshold"
class="text-muted-foreground text-center text-[10px]"
>
{{ $t('ai-platform.workflow.nodes.intent.confidenceThreshold') }}
{{ data.confidence_threshold }}
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { BookOpen } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="
data.label ||
$t('ai-platform.workflow.nodes.knowledge_retrieval_node.label')
"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="knowledge_retrieval"
>
<template #icon><BookOpen class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.knowledge_retrieval_node.desc') }}
</template>
<div class="space-y-1.5">
<div
v-if="data.knowledge_base_ids?.length"
class="text-muted-foreground text-xs"
>
{{
$t('ai-platform.workflow.nodes.knowledge_retrieval_node.kbCount', {
count: data.knowledge_base_ids.length,
})
}}
</div>
<div v-if="data.top_k" class="text-muted-foreground text-xs">
{{
$t(
'ai-platform.workflow.nodes.knowledge_retrieval_node.topKThreshold',
{ topK: data.top_k, threshold: data.score_threshold || 0.5 },
)
}}
</div>
<div
v-if="data.retrieval_mode"
class="text-muted-foreground text-xs capitalize"
>
{{
data.retrieval_mode === 'vector'
? $t('ai-platform.workflow.nodes.knowledge_retrieval_node.vector')
: data.retrieval_mode === 'fulltext'
? $t(
'ai-platform.workflow.nodes.knowledge_retrieval_node.fulltext',
)
: $t('ai-platform.workflow.nodes.knowledge_retrieval_node.hybrid')
}}
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { Bot } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.llm.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="llm"
>
<template #icon><Bot class="size-5" /></template>
<template #desc>{{ $t('ai-platform.workflow.nodes.llm.desc') }}</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-blue-50 px-2 py-1 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
>
<span class="font-medium">Model</span>
<span>{{
data.model_name ||
data.model ||
$t('ai-platform.workflow.nodes.common.selectModel')
}}</span>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.llm.systemPrompt') }}</span>
<span>{{
data.system_prompt
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
<div class="flex justify-between">
<span>{{ $t('ai-platform.workflow.nodes.llm.userPrompt') }}</span>
<span>{{
data.user_prompt
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{
data.output_variable || 'llm_response'
}}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import type { NodeExecutionResult } from '../components/NodeResultCard.vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RefreshCw } from '@vben/icons';
import { $t } from '@vben/locales';
import { Handle, Position, useVueFlow } from '@vue-flow/core';
import NodeResultCard from '../components/NodeResultCard.vue';
const props = defineProps(['selected', 'data', 'id']);
const { getNodes, updateNodeInternals, updateNodeData, updateNode } =
useVueFlow();
// 容器元素引用
const containerRef = ref<HTMLElement | null>(null);
let resizeObserver: null | ResizeObserver = null;
// 使用 ResizeObserver 监听 DOM 尺寸变化
onMounted(() => {
if (containerRef.value) {
resizeObserver = new ResizeObserver(() => {
if (props.id) {
updateNodeInternals([props.id]);
}
});
resizeObserver.observe(containerRef.value);
}
});
onUnmounted(() => {
resizeObserver?.disconnect();
});
// 获取循环模式
const loopMode = computed(() => props.data?.loop_mode || 'for_each');
const loopModeText = computed(() => {
return loopMode.value === 'for_each'
? $t('ai-platform.workflow.nodes.loop.forEach')
: $t('ai-platform.workflow.nodes.loop.whileLoop');
});
// 获取配置信息
const itemsVariable = computed(() => props.data?.items_variable || '');
const maxIterations = computed(() => props.data?.max_iterations || 100);
// 执行状态
const executionStatus = computed(() => props.data?.executionStatus);
const isRunning = computed(() => executionStatus.value?.status === 'running');
const isSuccess = computed(() => executionStatus.value?.status === 'success');
const isFailed = computed(() => executionStatus.value?.status === 'failed');
const currentIteration = computed(() => executionStatus.value?.iteration ?? 0);
const totalIterations = computed(() => executionStatus.value?.total);
// 执行结果(用于显示日志)
const executionResult = computed<NodeExecutionResult | undefined>(() => {
return props.data?.executionResult;
});
// 默认最小尺寸
const minWidth = 400;
const minHeight = 350;
const padding = 20;
// 获取子节点列表
const childNodes = computed(() => {
const allNodes = getNodes.value;
return allNodes.filter((n) => n.parentNode === props.id);
});
// 子节点数量
const childCount = computed(() => childNodes.value.length);
// 实时计算容器尺寸(根据子节点位置动态调整)
const containerWidth = computed(() => {
if (childNodes.value.length === 0) {
return minWidth;
}
let maxRight = 0;
for (const child of childNodes.value) {
const childWidth = child.dimensions?.width || 280;
const right = child.position.x + childWidth + padding;
maxRight = Math.max(maxRight, right);
}
return Math.max(minWidth, maxRight);
});
const containerHeight = computed(() => {
if (childNodes.value.length === 0) {
return minHeight;
}
let maxBottom = 0;
for (const child of childNodes.value) {
const childHeight = child.dimensions?.height || 150;
const bottom = child.position.y + childHeight + padding;
maxBottom = Math.max(maxBottom, bottom);
}
return Math.max(minHeight, maxBottom);
});
// 监听尺寸变化,触发节点内部更新以重新计算连线位置,并同步尺寸到 data 和 style
watch(
[containerWidth, containerHeight],
([width, height]) => {
if (props.id) {
// 同步尺寸到 data,供 onDrop 判断使用
updateNodeData(props.id, { width, height });
// 设置节点的 style 属性,限制 VueFlow 节点包装元素的尺寸
updateNode(props.id, {
style: { width: `${width}px`, height: `${height}px` },
});
updateNodeInternals([props.id]);
}
},
{ flush: 'post', immediate: true },
);
</script>
<template>
<div
ref="containerRef"
class="loop-container bg-card rounded-xl border shadow-sm transition-all duration-200 hover:shadow-md"
:class="[
selected ? 'border-primary ring-primary ring-1' : '',
isRunning
? 'animate-pulse-border !border-blue-500 shadow-lg shadow-blue-500/20 ring-2 ring-blue-500/50'
: '',
isSuccess ? '!border-green-500 ring-1 ring-green-500' : '',
isFailed ? '!border-red-500 ring-2 ring-red-500' : '',
!selected && !executionStatus?.status ? 'border-border' : '',
]"
:style="{
width: `${containerWidth}px`,
height: `${containerHeight}px`,
}"
>
<!-- 循环节点头部 - BaseNode 保持一致的样式 -->
<div
class="border-border/50 flex items-center gap-3 rounded-t-xl border-b bg-gradient-to-r from-indigo-50 to-violet-50 px-4 py-3 dark:from-indigo-950 dark:to-violet-950"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-indigo-200 text-indigo-600 shadow-sm dark:bg-indigo-800 dark:text-indigo-400"
>
<RefreshCw class="size-5" :class="{ 'animate-spin': isRunning }" />
</div>
<div class="flex flex-1 flex-col overflow-hidden">
<div class="text-foreground truncate text-sm font-semibold">
{{ data?.label || $t('ai-platform.workflow.nodes.loop.label') }}
</div>
<div class="text-muted-foreground truncate text-[10px]">
{{ loopModeText }}
</div>
</div>
<!-- 子节点数量 + 运行状态显示 -->
<div class="flex items-center gap-2">
<!-- 子节点数量 -->
<div
v-if="childCount > 0"
class="flex items-center rounded border border-indigo-300 bg-indigo-100 px-1.5 py-0.5 text-[10px] text-indigo-600 dark:border-indigo-700 dark:bg-indigo-900 dark:text-indigo-400"
>
{{
$t('ai-platform.workflow.nodes.loop.nodeCount', {
count: childCount,
})
}}
</div>
<!-- 运行状态 -->
<div
v-if="isRunning"
class="flex items-center gap-1.5 rounded border border-blue-300 bg-blue-100 px-2 py-0.5 text-[10px] text-blue-600 dark:border-blue-700 dark:bg-blue-900 dark:text-blue-400"
>
<span
class="inline-block size-1.5 animate-pulse rounded-full bg-blue-500"
></span>
<span v-if="totalIterations">{{
$t('ai-platform.workflow.nodes.loop.iteration', {
current: currentIteration + 1,
total: totalIterations,
})
}}</span>
<span v-else>{{
$t('ai-platform.workflow.nodes.loop.iterationNoTotal', {
current: currentIteration + 1,
})
}}</span>
</div>
<div
v-else
class="text-muted-foreground bg-muted border-border flex items-center rounded border px-1.5 py-0.5 text-[10px]"
>
{{
$t('ai-platform.workflow.nodes.loop.maxIterations', {
max: maxIterations,
})
}}
</div>
</div>
</div>
<!-- 循环配置信息 -->
<div
class="border-border/50 text-muted-foreground space-y-1 border-b px-4 py-2 text-xs"
>
<div class="flex items-center justify-between">
<span>{{
loopMode === 'for_each'
? $t('ai-platform.workflow.nodes.loop.forEach')
: $t('ai-platform.workflow.nodes.loop.loopCondition')
}}</span>
<span
v-if="loopMode === 'for_each' && itemsVariable"
class="max-w-[150px] truncate font-mono text-indigo-600 dark:text-indigo-400"
>
{{ itemsVariable }}
</span>
<span
v-else-if="loopMode === 'while'"
class="text-indigo-600 dark:text-indigo-400"
>
{{ $t('ai-platform.workflow.nodes.loop.whileLoop') }}
</span>
<span v-else class="text-muted-foreground">{{
$t('ai-platform.workflow.nodes.common.notConfigured')
}}</span>
</div>
<div v-if="loopMode === 'for_each'" class="text-muted-foreground">
{{ $t('ai-platform.workflow.nodes.loop.availableVariables') }}
<code class="bg-muted rounded px-1 text-indigo-600 dark:text-indigo-400">item</code>
<code class="bg-muted rounded px-1 text-indigo-600 dark:text-indigo-400">index</code>
</div>
</div>
<!-- 循环体区域子节点容器 -->
<div
class="loop-body relative p-4"
:style="{ height: `${containerHeight - 120}px` }"
>
<div
v-if="childCount === 0"
class="border-border bg-muted/30 text-muted-foreground flex h-full min-h-[80px] items-center justify-center rounded-lg border-2 border-dashed text-sm"
>
{{ $t('ai-platform.workflow.nodes.loop.dragNodeHere') }}
</div>
</div>
<!-- 输入 Handle -->
<Handle
type="target"
:position="Position.Left"
id="target"
class="!bg-primary !h-3 !w-3 !border-2 !border-white transition-transform hover:scale-125"
:style="{ top: '28px' }"
/>
<!-- 输出 Handle -->
<Handle
type="source"
:position="Position.Right"
id="source"
class="!bg-primary !h-3 !w-3 !border-2 !border-white transition-transform hover:scale-125"
:style="{ top: '28px' }"
/>
<!-- 节点下方的执行结果卡片独立显示不在节点内部 -->
<NodeResultCard
v-if="executionResult"
:result="executionResult"
node-type="loop"
class="mt-3"
:style="{ width: `${containerWidth}px` }"
/>
</div>
</template>
<style scoped>
.loop-container {
position: relative;
z-index: 0;
/* 确保节点尺寸与 VueFlow 计算的尺寸一致 */
box-sizing: border-box;
overflow: visible;
}
.loop-body {
background: repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
hsl(var(--muted) / 0.3) 10px,
hsl(var(--muted) / 0.3) 20px
);
pointer-events: none;
overflow: hidden;
}
.loop-body > * {
pointer-events: auto;
}
</style>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { Combine } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.merge.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="merge"
>
<template #icon><Combine class="size-5" /></template>
<template #desc>{{ $t('ai-platform.workflow.nodes.merge.desc') }}</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-purple-50 px-2 py-1 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
>
<span class="text-xs font-medium">{{
$t('ai-platform.workflow.nodes.merge.mergeMode')
}}</span>
<span class="text-xs">{{
data.merge_mode === 'array'
? $t('ai-platform.workflow.nodes.merge.array')
: data.merge_mode === 'concat'
? $t('ai-platform.workflow.nodes.merge.concat')
: data.merge_mode === 'first'
? $t('ai-platform.workflow.nodes.merge.first')
: $t('ai-platform.workflow.nodes.merge.object')
}}</span>
</div>
<!-- 输出变量 -->
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{
data.output_variable || 'merged_result'
}}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { MessageSquareText } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.message.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="message"
>
<template #icon><MessageSquareText class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.message.desc') }}
</template>
<div class="space-y-2">
<div class="rounded bg-blue-50 px-2 py-1.5 text-xs dark:bg-blue-950">
<div class="mb-1 font-medium text-blue-700 dark:text-blue-300">
{{ $t('ai-platform.workflow.nodes.message.content') }}
</div>
<div class="line-clamp-3 text-blue-600 dark:text-blue-400">
{{
data.content ||
$t('ai-platform.workflow.nodes.message.configureContent')
}}
</div>
</div>
<div
class="text-muted-foreground flex items-center justify-between text-xs"
>
<span>{{ $t('ai-platform.workflow.nodes.message.messageType') }}</span>
<span class="font-medium">{{ data.message_type || 'text' }}</span>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue';
import { GitFork } from '@vben/icons';
import { $t } from '@vben/locales';
import { Handle, Position } from '@vue-flow/core';
import BaseNode from './BaseNode.vue';
const props = defineProps(['selected', 'data', 'id']);
// 获取分支配置
const branches = computed(() => {
return (
props.data.branches || [
{
id: 'branch_1',
name: $t('ai-platform.workflow.nodes.parallel.branch', { index: 1 }),
},
{
id: 'branch_2',
name: $t('ai-platform.workflow.nodes.parallel.branch', { index: 2 }),
},
]
);
});
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.parallel.label')"
:inputs="[{ id: 'target' }]"
:node-id="id"
node-type="parallel"
>
<template #icon><GitFork class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.parallel.desc') }}
</template>
<div class="space-y-2 pt-1">
<!-- 并行分支 -->
<div
v-for="(branch, index) in branches"
:key="branch.id"
class="relative flex h-8 items-center justify-between rounded border border-purple-100 bg-purple-50 px-3 text-xs text-purple-700"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.parallel.branch', { index: index + 1 })
}}</span>
<span class="text-muted-foreground max-w-[80px] truncate">{{
branch.name || `branch_${index + 1}`
}}</span>
<!-- Handle -->
<Handle
type="source"
:position="Position.Right"
:id="branch.id"
class="!h-3 !w-3 !border-2 !border-white !bg-purple-500 transition-transform hover:scale-125"
style="right: -17px; top: 50%; transform: translateY(-50%)"
/>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { HelpCircle } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.question.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="question"
>
<template #icon><HelpCircle class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.question.desc') }}
</template>
<div class="space-y-2">
<div class="rounded bg-purple-50 px-2 py-1.5 text-xs dark:bg-purple-950">
<div class="mb-1 font-medium text-purple-700 dark:text-purple-300">
{{ $t('ai-platform.workflow.nodes.common.question') }}
</div>
<div class="line-clamp-2 text-purple-600 dark:text-purple-400">
{{
data.question ||
$t('ai-platform.workflow.nodes.common.configureQuestion')
}}
</div>
</div>
<div
class="text-muted-foreground flex items-center justify-between text-xs"
>
<span>{{ $t('ai-platform.workflow.nodes.question.inputType') }}</span>
<span class="font-medium">{{ data.input_type || 'text' }}</span>
</div>
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{
data.variable_name || 'user_input'
}}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { MessageSquareMore } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || 'Cortex Analyst'"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="snowflake_cortex_analyst"
>
<template #icon><MessageSquareMore class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.snowflake_cortex_analyst.desc') }}
</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-indigo-50 px-2 py-1 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
>
<span class="font-medium">Analyst</span>
<span>{{
$t('ai-platform.workflow.nodes.snowflake_cortex_analyst.nlQuery')
}}</span>
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span>{{
$t(
'ai-platform.workflow.nodes.snowflake_cortex_analyst.questionLabel',
)
}}</span>
<span>{{
data.question
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
<div class="flex justify-between">
<span>{{
$t(
'ai-platform.workflow.nodes.snowflake_cortex_analyst.semanticModel',
)
}}</span>
<span>{{
data.semantic_model_file
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
</div>
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{
data.output_variable || 'analyst_result'
}}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { Snowflake } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
const getFunctionLabel = (func: string) => {
const map: Record<string, string> = {
complete: $t('ai-platform.workflow.nodes.snowflake_cortex_llm.complete'),
summarize: $t('ai-platform.workflow.nodes.snowflake_cortex_llm.summarize'),
translate: $t('ai-platform.workflow.nodes.snowflake_cortex_llm.translate'),
sentiment: $t('ai-platform.workflow.nodes.snowflake_cortex_llm.sentiment'),
extract_answer: $t(
'ai-platform.workflow.nodes.snowflake_cortex_llm.extractAnswer',
),
};
return map[func] || func;
};
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || 'Cortex LLM'"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="snowflake_cortex_llm"
>
<template #icon><Snowflake class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.snowflake_cortex_llm.desc') }}
</template>
<div class="space-y-2">
<div
class="flex items-center justify-between rounded bg-cyan-50 px-2 py-1 text-cyan-700 dark:bg-cyan-950 dark:text-cyan-300"
>
<span class="font-medium">{{
$t('ai-platform.workflow.nodes.snowflake_cortex_llm.function')
}}</span>
<span>{{
getFunctionLabel(data.function) ||
$t('ai-platform.workflow.nodes.snowflake_cortex_llm.selectFunction')
}}</span>
</div>
<div
v-if="data.function === 'complete'"
class="text-muted-foreground space-y-1 text-xs"
>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.snowflake_cortex_llm.model')
}}</span>
<span>{{ data.complete?.model || 'mistral-large' }}</span>
</div>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.snowflake_cortex_llm.prompt')
}}</span>
<span>{{
data.complete?.prompt
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
</div>
<div
v-else-if="data.function === 'translate'"
class="text-muted-foreground space-y-1 text-xs"
>
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.snowflake_cortex_llm.translateLabel')
}}</span>
<span>{{ data.translate?.source_language || 'en' }}
{{ data.translate?.target_language || 'zh' }}</span>
</div>
</div>
<div v-else class="text-muted-foreground text-xs">
<div class="flex justify-between">
<span>{{
$t('ai-platform.workflow.nodes.snowflake_cortex_llm.inputText')
}}</span>
<span>{{
data[data.function]?.text
? $t('ai-platform.workflow.nodes.common.configured')
: '-'
}}</span>
</div>
</div>
<div class="rounded bg-green-50 px-2 py-1 text-xs dark:bg-green-950">
<div
class="flex items-center justify-between text-green-700 dark:text-green-300"
>
<span>{{
$t('ai-platform.workflow.nodes.common.outputVariable')
}}</span>
<span class="font-mono">{{
data.output_variable || 'cortex_result'
}}</span>
</div>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { Play } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="$t('ai-platform.workflow.nodes.start.label')"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="start"
>
<template #icon><Play class="size-5" /></template>
<template #desc>{{ $t('ai-platform.workflow.nodes.start.desc') }}</template>
<div class="bg-muted text-muted-foreground rounded p-2 text-xs">
<div v-if="data.variables && data.variables.length > 0">
{{
$t('ai-platform.workflow.nodes.common.inputVariablesCount', {
count: data.variables.length,
})
}}
</div>
<div v-else>
{{ $t('ai-platform.workflow.nodes.common.noInputVariables') }}
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,404 @@
<script setup lang="ts">
import type { NodeExecutionResult } from '../components/NodeResultCard.vue';
import { computed, ref, watch } from 'vue';
import {
CheckCircle2,
ChevronDown,
ChevronRight,
Loader2,
Workflow,
XCircle,
} from '@vben/icons';
import { Handle, Position } from '@vue-flow/core';
import { ElButton, ElProgress, ElScrollbar, ElTooltip } from 'element-plus';
import { getWorkflowDetailApi } from '#/api/ai-platform/ai-platform';
import NodeResultCard from '../components/NodeResultCard.vue';
const props = defineProps<{
data: any;
id: string;
selected: boolean;
}>();
// 展开状态
const expanded = ref(false);
// 子流程定义(用于展开显示)
const subflowDefinition = ref<any>(null);
const loadingSubflow = ref(false);
// 执行状态
const executionStatus = computed(() => props.data?.executionStatus);
const isRunning = computed(() => executionStatus.value?.status === 'running');
const isSuccess = computed(() => executionStatus.value?.status === 'success');
const isFailed = computed(() => executionStatus.value?.status === 'failed');
// 执行结果
const executionResult = computed<NodeExecutionResult | undefined>(() => {
return props.data?.executionResult;
});
// 子流程执行日志
const subflowLogs = computed(() => {
return (
executionResult.value?.subflow_logs ||
props.data?.subflow_result?.logs ||
[]
);
});
// 子流程进度
const progress = computed(() => {
if (subflowLogs.value.length === 0) return 0;
const completed = subflowLogs.value.filter(
(l: any) => l.status === 'completed',
).length;
return Math.round((completed / subflowLogs.value.length) * 100);
});
// 当前执行的节点
const currentNodeLabel = computed(() => {
const running = subflowLogs.value.find((l: any) => l.status === 'running');
return running?.node_label || running?.node_type || '';
});
// 切换展开
const toggleExpand = async () => {
expanded.value = !expanded.value;
// 展开时加载子流程定义
if (expanded.value && !subflowDefinition.value && props.data?.subflow_id) {
await loadSubflowDefinition();
}
};
// 加载子流程定义
const loadSubflowDefinition = async () => {
if (!props.data?.subflow_id || loadingSubflow.value) return;
loadingSubflow.value = true;
try {
const workflow = await getWorkflowDetailApi(props.data.subflow_id);
subflowDefinition.value = workflow;
} catch (error) {
console.error('加载子流程定义失败:', error);
} finally {
loadingSubflow.value = false;
}
};
// 获取节点执行状态
const getNodeStatus = (nodeId: string) => {
const log = subflowLogs.value.find((l: any) => l.node_id === nodeId);
if (!log) return 'pending';
return log.status;
};
// 获取节点状态样式
const getNodeStatusClass = (nodeId: string) => {
const status = getNodeStatus(nodeId);
return {
'border-blue-500 bg-blue-50 dark:bg-blue-950': status === 'running',
'border-green-500 bg-green-50 dark:bg-green-950': status === 'completed',
'border-red-500 bg-red-50 dark:bg-red-950': status === 'failed',
'border-border bg-muted/30': status === 'pending',
};
};
// 子流程节点列表(排除开始和结束节点)
const subflowNodes = computed(() => {
if (!subflowDefinition.value?.definition?.nodes) return [];
return subflowDefinition.value.definition.nodes.filter(
(n: any) => n.type !== 'start' && n.type !== 'end',
);
});
// 监听子流程ID变化,重新加载
watch(
() => props.data?.subflow_id,
(newId, oldId) => {
if (newId !== oldId) {
subflowDefinition.value = null;
if (expanded.value && newId) {
loadSubflowDefinition();
}
}
},
);
</script>
<template>
<div class="subflow-node-wrapper flex flex-col">
<!-- 节点主体 -->
<div
class="bg-card group relative min-w-[320px] rounded-xl border shadow-sm transition-all duration-200 hover:shadow-md"
:class="[
selected ? 'border-primary ring-primary ring-1' : '',
isRunning
? 'animate-pulse-border !border-blue-500 shadow-lg shadow-blue-500/20 ring-2 ring-blue-500/50'
: '',
isSuccess ? '!border-green-500 ring-1 ring-green-500' : '',
isFailed ? '!border-red-500 ring-2 ring-red-500' : '',
!selected && !executionStatus?.status ? 'border-border' : '',
]"
>
<!-- Status Badge -->
<div
v-if="executionStatus"
class="absolute -top-3 right-4 z-10 flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] shadow-sm"
:class="[
isRunning
? 'border-blue-500/30 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: '',
isSuccess
? 'border-green-500/30 bg-green-500/10 text-green-600 dark:text-green-400'
: '',
isFailed
? 'border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400'
: '',
]"
>
<Loader2 v-if="isRunning" class="size-3 animate-spin" />
<CheckCircle2 v-if="isSuccess" class="size-3" />
<ElTooltip
v-if="isFailed && executionStatus.error"
:content="executionStatus.error"
placement="top"
>
<XCircle class="size-3 cursor-help" />
</ElTooltip>
<XCircle v-else-if="isFailed" class="size-3" />
<span v-if="isRunning">运行中...</span>
<span v-else-if="executionStatus.elapsed_time !== undefined">
{{ executionStatus.elapsed_time }}ms
</span>
</div>
<!-- Header -->
<div
class="border-border/50 flex items-center gap-3 rounded-t-xl border-b bg-gradient-to-r from-indigo-50 to-purple-50 px-4 py-3 dark:from-indigo-950 dark:to-purple-950"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-indigo-200 text-indigo-600 shadow-sm dark:bg-indigo-800 dark:text-indigo-400"
>
<Workflow class="size-5" />
</div>
<div class="flex flex-1 flex-col overflow-hidden">
<div class="text-foreground truncate text-sm font-semibold">
{{ data?.label || $t('ai-platform.workflow.nodes.subflow.label') }}
</div>
<div class="text-muted-foreground truncate text-[10px]">
{{
data?.subflow_name ||
$t('ai-platform.workflow.nodes.subflow.desc')
}}
</div>
</div>
<!-- 展开/折叠按钮 -->
<ElButton
v-if="data?.subflow_id"
link
size="small"
class="!p-1"
@click.stop="toggleExpand"
>
<ChevronDown v-if="expanded" class="size-4" />
<ChevronRight v-else class="size-4" />
</ElButton>
</div>
<!-- Body -->
<div class="text-muted-foreground space-y-2 p-4 text-xs">
<!-- 子流程信息 -->
<div
v-if="data?.subflow_name"
class="flex items-center justify-between"
>
<span>{{
$t('ai-platform.workflow.nodes.subflow.subflowLabel')
}}</span>
<span class="font-medium text-indigo-600 dark:text-indigo-400">
{{ data.subflow_name }}
</span>
</div>
<div v-else class="text-muted-foreground">
{{ $t('ai-platform.workflow.nodes.subflow.notSelected') }}
</div>
<!-- 变量传递模式 -->
<div
v-if="data?.var_pass_mode"
class="flex items-center justify-between"
>
<span>{{
$t('ai-platform.workflow.nodes.subflow.varPassMode')
}}</span>
<span class="text-muted-foreground">
{{
data.var_pass_mode === 'all'
? $t('ai-platform.workflow.nodes.subflow.all')
: data.var_pass_mode === 'selected'
? $t('ai-platform.workflow.nodes.subflow.selected')
: $t('ai-platform.workflow.nodes.subflow.none')
}}
</span>
</div>
<!-- 执行进度 -->
<div v-if="isRunning && subflowLogs.length > 0" class="space-y-1">
<div class="flex items-center justify-between">
<span>{{ $t('ai-platform.workflow.nodes.subflow.progress') }}</span>
<span>{{ progress }}%</span>
</div>
<ElProgress
:percentage="progress"
:show-text="false"
:stroke-width="4"
/>
<div v-if="currentNodeLabel" class="text-muted-foreground truncate">
{{
$t('ai-platform.workflow.nodes.subflow.current', {
label: currentNodeLabel,
})
}}
</div>
</div>
</div>
<!-- 展开的子流程内容 -->
<div v-if="expanded" class="border-border border-t">
<div class="bg-muted/30 px-4 py-2">
<div class="text-muted-foreground mb-2 text-xs font-medium">
{{ $t('ai-platform.workflow.nodes.subflow.subflowNodes') }}
</div>
<!-- 加载中 -->
<div
v-if="loadingSubflow"
class="flex items-center justify-center py-4"
>
<Loader2 class="size-4 animate-spin text-indigo-500" />
<span class="text-muted-foreground ml-2 text-xs">{{
$t('ai-platform.workflow.nodes.subflow.loading')
}}</span>
</div>
<!-- 子流程节点列表 -->
<ElScrollbar v-else-if="subflowNodes.length > 0" max-height="200px">
<div class="space-y-2 pr-2">
<div
v-for="node in subflowNodes"
:key="node.id"
class="flex items-center gap-2 rounded border px-3 py-2 text-xs transition-colors"
:class="getNodeStatusClass(node.id)"
>
<!-- 状态图标 -->
<div class="shrink-0">
<Loader2
v-if="getNodeStatus(node.id) === 'running'"
class="size-3 animate-spin text-blue-500"
/>
<CheckCircle2
v-else-if="getNodeStatus(node.id) === 'completed'"
class="size-3 text-green-500"
/>
<XCircle
v-else-if="getNodeStatus(node.id) === 'failed'"
class="size-3 text-red-500"
/>
<div
v-else
class="border-muted-foreground/30 size-3 rounded-full border-2"
></div>
</div>
<!-- 节点信息 -->
<div class="flex-1 truncate">
<span class="font-medium">{{
node.data?.label || node.type
}}</span>
</div>
<!-- 执行时间 -->
<div
v-if="
subflowLogs.find((l: any) => l.node_id === node.id)
?.elapsed_time
"
class="text-muted-foreground text-[10px]"
>
{{
subflowLogs.find((l: any) => l.node_id === node.id)
?.elapsed_time
}}ms
</div>
</div>
</div>
</ElScrollbar>
<!-- 无节点 -->
<div v-else class="text-muted-foreground py-4 text-center text-xs">
{{
data?.subflow_id
? $t('ai-platform.workflow.nodes.subflow.emptySubflow')
: $t('ai-platform.workflow.nodes.subflow.selectSubflow')
}}
</div>
</div>
</div>
<!-- Input Handle -->
<Handle
type="target"
:position="Position.Left"
id="target"
class="!bg-primary !h-3 !w-3 !border-2 !border-white transition-transform hover:scale-125"
:style="{ top: '28px' }"
/>
<!-- Output Handle -->
<Handle
type="source"
:position="Position.Right"
id="source"
class="!bg-primary !h-3 !w-3 !border-2 !border-white transition-transform hover:scale-125"
:style="{ top: '28px' }"
/>
</div>
<!-- 执行结果卡片 -->
<NodeResultCard
v-if="executionResult"
:result="executionResult"
node-type="subflow"
class="mt-3"
/>
</div>
</template>
<style scoped>
.subflow-node-wrapper {
position: relative;
}
/* 运行中边框呼吸动画 */
@keyframes pulse-border {
0%,
100% {
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(99, 102, 241, 0);
}
}
.animate-pulse-border {
animation: pulse-border 1.5s ease-in-out infinite;
}
</style>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { LayoutTemplate } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || $t('ai-platform.workflow.nodes.template.label')"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="template"
>
<template #icon><LayoutTemplate class="size-5" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.template.desc') }}
</template>
<div
class="bg-muted text-muted-foreground line-clamp-3 break-all rounded p-2 font-mono text-xs"
v-text="data.template || '{{ input }}'"
></div>
</BaseNode>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { DatabaseZap } from '@vben/icons';
import { $t } from '@vben/locales';
import BaseNode from './BaseNode.vue';
defineProps(['selected', 'data', 'id']);
</script>
<template>
<BaseNode
:selected="selected"
:execution-status="data.executionStatus"
:execution-result="data.executionResult"
:label="data.label || 'Text-to-SQL'"
:inputs="[{ id: 'target' }]"
:outputs="[{ id: 'source' }]"
:node-id="id"
node-type="text_to_sql"
>
<template #icon><DatabaseZap class="size-5 text-cyan-600" /></template>
<template #desc>
{{ $t('ai-platform.workflow.nodes.text_to_sql.desc') }}
</template>
<div class="space-y-2">
<!-- 数据库连接 -->
<div v-if="data.db_connection" class="flex items-center gap-2">
<span
class="rounded border border-cyan-200 bg-cyan-50 px-1.5 py-0.5 text-[10px] font-bold text-cyan-600 dark:border-cyan-800 dark:bg-cyan-950 dark:text-cyan-400"
>
DB
</span>
<span class="text-muted-foreground text-[10px]">
{{ data.db_connection }}
</span>
</div>
<!-- 问题预览 -->
<div v-if="data.user_question" class="bg-muted rounded px-2 py-1.5">
<div
class="text-muted-foreground line-clamp-2 text-[10px]"
:title="data.user_question"
>
{{ data.user_question }}
</div>
</div>
<div v-else class="text-muted-foreground text-[10px]">
{{ $t('ai-platform.workflow.nodes.text_to_sql.notConfigured') }}
</div>
<!-- {{ $t('ai-platform.workflow.nodes.text_to_sql.chartRecommend') }}开关 -->
<div v-if="data.enable_chart" class="flex items-center gap-1">
<span
class="rounded border border-green-200 bg-green-50 px-1.5 py-0.5 text-[10px] text-green-600 dark:border-green-800 dark:bg-green-950 dark:text-green-400"
>
{{ $t('ai-platform.workflow.nodes.text_to_sql.chartRecommend') }}
</span>
</div>
</div>
</BaseNode>
</template>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.appCreate.label'),
name: '',
code: '',
description: '',
icon: '',
app_type: 'mixed',
update_if_exists: false,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.appCreate.placeholder')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.appCreate.appNameOutput')
}}</span>
<span class="text-red-500">*</span>
</div>
</template>
<SmartInput
v-model="form.name"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.appCreate.appNamePlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appCreate.appNameHint')
}}<code v-pre>{{llm-1.app_name}}</code>
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.appCreate.appCodeOutput')
}}</span>
<span class="text-red-500">*</span>
</div>
</template>
<SmartInput
v-model="form.code"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.appCreate.appCodePlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appCreate.appCodeHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.appCreate.appDesc')">
<SmartInput
v-model="form.description"
:current-node-id="nodeId"
type="textarea"
:rows="3"
:placeholder="
$t('ai-platform.workflow.panels.appCreate.appDescPlaceholder')
"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.appCreate.appIcon')">
<SmartInput
v-model="form.icon"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.appCreate.appIconPlaceholder')
"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.appCreate.appType')">
<ElSelect
v-model="form.app_type"
:placeholder="
$t('ai-platform.workflow.panels.appCreate.appTypePlaceholder')
"
class="w-full"
>
<ElOption
:label="$t('ai-platform.workflow.panels.appCreate.mixedApp')"
value="mixed"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.appCreate.formApp')"
value="form"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.appCreate.workflowApp')"
value="workflow"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.appCreate.dashboardApp')"
value="dashboard"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.appCreate.screenApp')"
value="screen"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.appCreate.updateIfExists')"
>
<ElSwitch v-model="form.update_if_exists" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appCreate.updateIfExistsHint') }}
</div>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">app_id</span>
<span>{{
$t('ai-platform.workflow.panels.appCreate.createdAppId')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">app_code</span>
<span>{{
$t('ai-platform.workflow.panels.appCreate.appCodeOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">app_name</span>
<span>{{
$t('ai-platform.workflow.panels.appCreate.appNameOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">success</span>
<span>{{
$t('ai-platform.workflow.panels.appCreate.successOutput')
}}</span>
</div>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput } from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.appDesign.label'),
design_content: '',
design_title: $t(
'ai-platform.workflow.panels.appDesign.designTitlePlaceholder',
),
require_confirmation: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.appDesign.placeholder')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.appDesign.designContent')
}}</span>
<span class="text-red-500">*</span>
</div>
</template>
<SmartInput
v-model="form.design_content"
:current-node-id="nodeId"
type="textarea"
:rows="6"
:placeholder="
$t('ai-platform.workflow.panels.appDesign.designContentPlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appDesign.designContentHint')
}}<code v-pre>{{llm-1.output}}</code>
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.appDesign.designTitle')"
>
<SmartInput
v-model="form.design_title"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.appDesign.designTitlePlaceholder')
"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">design_content</span>
<span>{{
$t('ai-platform.workflow.panels.appDesign.designContentOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">design_title</span>
<span>{{
$t('ai-platform.workflow.panels.appDesign.designTitleOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">confirmed</span>
<span>{{
$t('ai-platform.workflow.panels.appDesign.confirmedOutput')
}}</span>
</div>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElOption, ElSelect } from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.appSettings.label'),
app_name: '',
home_path: '',
logo_source: '',
theme_builtin_type: 'default',
theme_color_primary: '',
app_layout: 'sidebar-nav',
require_confirmation: true,
...props.data,
});
// 布局选项
const layoutOptions = [
{
label: $t('ai-platform.appSettings.layoutOptions.sidebar-nav'),
value: 'sidebar-nav',
},
{
label: $t('ai-platform.appSettings.layoutOptions.sidebar-mixed-nav'),
value: 'sidebar-mixed-nav',
},
{
label: $t('ai-platform.appSettings.layoutOptions.header-nav'),
value: 'header-nav',
},
{
label: $t('ai-platform.appSettings.layoutOptions.mixed-nav'),
value: 'mixed-nav',
},
{
label: $t('ai-platform.appSettings.layoutOptions.full-content'),
value: 'full-content',
},
];
// 主题选项
const themeOptions = [
{
label: $t('ai-platform.appSettings.themeOptions.default'),
value: 'default',
},
{ label: $t('ai-platform.appSettings.themeOptions.violet'), value: 'violet' },
{ label: $t('ai-platform.appSettings.themeOptions.pink'), value: 'pink' },
{ label: $t('ai-platform.appSettings.themeOptions.rose'), value: 'rose' },
{ label: $t('ai-platform.appSettings.themeOptions.sky'), value: 'sky' },
{ label: $t('ai-platform.appSettings.themeOptions.cyan'), value: 'cyan' },
{ label: $t('ai-platform.appSettings.themeOptions.green'), value: 'green' },
{ label: $t('ai-platform.appSettings.themeOptions.orange'), value: 'orange' },
{ label: $t('ai-platform.appSettings.themeOptions.yellow'), value: 'yellow' },
{ label: $t('ai-platform.appSettings.themeOptions.zinc'), value: 'zinc' },
{
label: $t('ai-platform.appSettings.themeOptions.neutral'),
value: 'neutral',
},
{ label: $t('ai-platform.appSettings.themeOptions.slate'), value: 'slate' },
{ label: $t('ai-platform.appSettings.themeOptions.gray'), value: 'gray' },
{
label: $t('ai-platform.appSettings.themeOptions.deep-blue'),
value: 'deep-blue',
},
{
label: $t('ai-platform.appSettings.themeOptions.deep-green'),
value: 'deep-green',
},
];
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.appSettings.placeholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.appSettings.appName')">
<SmartInput
v-model="form.app_name"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.appSettings.appNamePlaceholder')"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appSettings.varRefHint') }}
<code v-pre>{{llm-1.output}}</code>
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.appSettings.homePath')">
<SmartInput
v-model="form.home_path"
:current-node-id="nodeId"
placeholder="e.g. /dashboard"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appSettings.homePathHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.appSettings.logoSource')">
<SmartInput
v-model="form.logo_source"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.appSettings.logoSourcePlaceholder')"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appSettings.logoHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.appSettings.builtinTheme')">
<ElSelect v-model="form.theme_builtin_type" class="w-full">
<ElOption
v-for="item in themeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.appSettings.primaryColor')">
<SmartInput
v-model="form.theme_color_primary"
:current-node-id="nodeId"
placeholder="e.g. hsl(212 100% 45%)"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appSettings.primaryColorHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.appSettings.layout')">
<ElSelect v-model="form.app_layout" class="w-full">
<ElOption
v-for="item in layoutOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">settings</span>
<span>{{
$t('ai-platform.workflow.panels.appSettings.settingsOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">confirmed</span>
<span>{{
$t('ai-platform.workflow.panels.appSettings.confirmedOutput')
}}</span>
</div>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput } from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.appUpdate.label'),
settings: '',
application_id: '',
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.appUpdate.placeholder')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.appUpdate.appSettingsLabel')
}}</span>
<span class="text-red-500">*</span>
</div>
</template>
<SmartInput
v-model="form.settings"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.appUpdate.appSettingsPlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appUpdate.appSettingsHint')
}}<code v-pre>{{app_settings-1.settings}}</code>
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.appUpdate.appId')">
<SmartInput
v-model="form.application_id"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.appUpdate.appIdPlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.appUpdate.appIdHint')
}}<code v-pre>{{ application_id }}</code>
</div>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">update_success</span>
<span>{{
$t('ai-platform.workflow.panels.appUpdate.updateSuccess')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">config_id</span>
<span>{{
$t('ai-platform.workflow.panels.appUpdate.configId')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">update_message</span>
<span>{{
$t('ai-platform.workflow.panels.appUpdate.updateMessage')
}}</span>
</div>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElSwitch,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.choice.label'),
question: '',
variable_name: 'user_choice',
options: [] as Array<{ description: string; label: string; value: string }>,
multiple: false,
min_select: 1,
max_select: 1,
...props.data,
});
// 初始化选项
if (!form.value.options || form.value.options.length === 0) {
form.value.options = [
{
value: 'option1',
label: `${$t('ai-platform.workflow.panels.choice.option', { index: 1 })}`,
description: '',
},
{
value: 'option2',
label: `${$t('ai-platform.workflow.panels.choice.option', { index: 2 })}`,
description: '',
},
];
}
const addOption = () => {
const idx = form.value.options.length + 1;
form.value.options.push({
value: `option${idx}`,
label: `${$t('ai-platform.workflow.panels.choice.option', { index: idx })}`,
description: '',
});
};
const removeOption = (index: number) => {
if (form.value.options.length > 1) {
form.value.options.splice(index, 1);
}
};
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.choice.placeholder')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<span>{{
$t('ai-platform.workflow.panels.choice.questionContent')
}}</span>
<span class="text-destructive ml-1">*</span>
</template>
<SmartInput
v-model="form.question"
:current-node-id="nodeId"
type="textarea"
:rows="2"
:placeholder="
$t('ai-platform.workflow.panels.choice.questionPlaceholder')
"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.variable_name" placeholder="user_choice" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.choice.outputHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex w-full items-center justify-between">
<span>{{ $t('ai-platform.workflow.panels.choice.optionList') }}</span>
<ElButton link type="primary" :icon="Plus" @click="addOption">
{{ $t('ai-platform.workflow.panels.choice.addOption') }}
</ElButton>
</div>
</template>
<div class="w-full space-y-2">
<div
v-for="(opt, index) in form.options"
:key="index"
class="border-border bg-muted flex items-start gap-2 rounded border p-2"
>
<div class="flex-1 space-y-2">
<div class="flex gap-2">
<ElInput
v-model="opt.value"
:placeholder="
$t('ai-platform.workflow.panels.choice.optionValue')
"
size="small"
class="flex-1"
/>
<ElInput
v-model="opt.label"
:placeholder="
$t('ai-platform.workflow.panels.choice.displayText')
"
size="small"
class="flex-1"
/>
</div>
<ElInput
v-model="opt.description"
:placeholder="$t('ai-platform.workflow.panels.choice.optionDesc')"
size="small"
/>
</div>
<ElButton
v-if="form.options.length > 1"
link
type="danger"
:icon="Trash2"
@click="removeOption(index)"
/>
</div>
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.choice.allowMultiple')">
<ElSwitch v-model="form.multiple" />
</ElFormItem>
<div v-if="form.multiple" class="flex gap-4">
<ElFormItem
:label="$t('ai-platform.workflow.panels.choice.minSelect')"
class="flex-1"
>
<ElInputNumber
v-model="form.min_select"
:min="1"
:max="form.options.length"
size="small"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.choice.maxSelect')"
class="flex-1"
>
<ElInputNumber
v-model="form.max_select"
:min="form.min_select"
:max="form.options.length"
size="small"
style="width: 100%"
/>
</ElFormItem>
</div>
</ElForm>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Maximize2, Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElTooltip,
} from 'element-plus';
import { CodeEditor } from '#/components/zq-form/code-editor';
import { ZqDialog } from '#/components/zq-dialog';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.code.label'),
language: 'python3',
code: '',
timeout: 30,
inputs: [] as any[],
outputs: [] as any[],
...props.data,
});
if (!form.value.inputs) form.value.inputs = [];
if (!form.value.outputs) form.value.outputs = [];
// 默认代码模板
const defaultCode = `def main(inputs):
# 在这里编写代码
# inputs 包含所有上下文变量
# 返回字典,result 字段将作为输出
return {
"result": "Hello " + inputs.get("name", "World")
}`;
// 初始化默认代码
if (!form.value.code) {
form.value.code = defaultCode;
}
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
const addInput = () => {
form.value.inputs.push({ variable: '', default_value: '' });
};
const removeInput = (index: number) => {
form.value.inputs.splice(index, 1);
};
const addOutput = () => {
form.value.outputs.push({ variable: '', type: 'string' });
};
const removeOutput = (index: number) => {
form.value.outputs.splice(index, 1);
};
const codeEditorVisible = ref(false);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.code.placeholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.code.timeout')">
<ElInputNumber
v-model="form.timeout"
:min="1"
:max="300"
class="w-full"
/>
</ElFormItem>
<div class="border-border rounded-lg border p-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-foreground text-xs">{{
$t('ai-platform.workflow.panels.common.inputVars')
}}</span>
<ElButton link type="primary" :icon="Plus" @click="addInput">
{{ $t('ai-platform.workflow.panels.common.add') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.inputs"
:key="index"
class="flex gap-2"
>
<ElInput
v-model="item.variable"
:placeholder="$t('ai-platform.workflow.panels.common.variableName')"
size="small"
class="!w-28"
/>
<SmartInput
v-model="item.default_value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.defaultValue')"
size="small"
/>
<ElButton
link
type="danger"
:icon="Trash2"
@click="removeInput(index)"
/>
</div>
<div
v-if="form.inputs.length === 0"
class="text-muted-foreground text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.code.noInputVars') }}
</div>
</div>
</div>
<div class="border-border rounded-lg border p-4">
<div class="mb-3 flex items-center justify-between">
<span class="text-foreground text-xs">{{
$t('ai-platform.workflow.panels.common.outputVars')
}}</span>
<ElButton link type="primary" :icon="Plus" @click="addOutput">
{{ $t('ai-platform.workflow.panels.common.add') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.outputs"
:key="index"
class="flex gap-2"
>
<ElInput
v-model="item.variable"
:placeholder="$t('ai-platform.workflow.panels.common.variableName')"
size="small"
class="!w-28"
/>
<ElButton
link
type="danger"
:icon="Trash2"
@click="removeOutput(index)"
/>
</div>
<div
v-if="form.outputs.length === 0"
class="text-muted-foreground text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.code.noOutputVars') }}
</div>
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-xs">{{
$t('ai-platform.workflow.panels.code.codePython')
}}</span>
<ElTooltip
:content="$t('ai-platform.workflow.panels.code.expandEditor')"
>
<ElButton link type="primary" @click="codeEditorVisible = true">
<Maximize2 class="size-4" />
</ElButton>
</ElTooltip>
</div>
<CodeEditor
v-model="form.code"
language="python"
height="280px"
:placeholder="$t('ai-platform.workflow.panels.code.codePlaceholder')"
:fold-gutter="true"
:bracket-matching="true"
:autocompletion="true"
:line-numbers="false"
/>
</div>
</ElForm>
<ZqDialog
v-model="codeEditorVisible"
class="code-editor-dialog"
:title="$t('ai-platform.workflow.panels.code.codePython')"
width="960px"
:show-footer="false"
>
<div class="code-editor-dialog-content">
<CodeEditor
v-model="form.code"
language="python"
height="100%"
:placeholder="$t('ai-platform.workflow.panels.code.codePlaceholder')"
:fold-gutter="true"
:bracket-matching="true"
:autocompletion="true"
:line-numbers="true"
/>
</div>
</ZqDialog>
</template>
<style>
/* 代码编辑器弹窗:内容区与编辑器占满 dialog(含全屏) */
.code-editor-dialog .zq-dialog-body .el-scrollbar,
.code-editor-dialog .zq-dialog-body .el-scrollbar__wrap,
.code-editor-dialog .zq-dialog-body .el-scrollbar__view,
.code-editor-dialog .zq-dialog-body .el-scrollbar__view > div,
.code-editor-dialog .code-editor-dialog-content {
height: 100%;
}
.code-editor-dialog:not(.is-fullscreen) .zq-dialog-body {
height: 70vh;
}
.code-editor-dialog:not(.is-fullscreen) .zq-dialog-body .el-scrollbar {
height: 100%;
}
.code-editor-dialog .code-editor-dialog-content {
display: flex;
flex-direction: column;
min-height: 0;
}
.code-editor-dialog .code-editor-dialog-content .code-editor {
flex: 1;
min-height: 0;
height: 100% !important;
}
.code-editor-dialog.is-fullscreen .el-dialog__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.code-editor-dialog.is-fullscreen .zq-dialog-body {
flex: 1;
min-height: 0;
}
.code-editor-dialog.is-fullscreen .zq-dialog-body .el-scrollbar {
height: 100% !important;
}
</style>
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Plus, Trash2, X } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const label = ref(
props.data.label || $t('ai-platform.workflow.panels.condition.label'),
);
const branches = ref<any[]>(props.data.branches || []);
const operators = [
{
label: $t('ai-platform.workflow.panels.condition.equals'),
value: 'equals',
group: 'compare',
},
{
label: $t('ai-platform.workflow.panels.condition.notEquals'),
value: 'not_equals',
group: 'compare',
},
{
label: $t('ai-platform.workflow.panels.condition.gt'),
value: 'gt',
group: 'number',
},
{
label: $t('ai-platform.workflow.panels.condition.gte'),
value: 'gte',
group: 'number',
},
{
label: $t('ai-platform.workflow.panels.condition.lt'),
value: 'lt',
group: 'number',
},
{
label: $t('ai-platform.workflow.panels.condition.lte'),
value: 'lte',
group: 'number',
},
{
label: $t('ai-platform.workflow.panels.condition.contains'),
value: 'contains',
group: 'string',
},
{
label: $t('ai-platform.workflow.panels.condition.notContains'),
value: 'not_contains',
group: 'string',
},
{
label: $t('ai-platform.workflow.panels.condition.startsWith'),
value: 'starts_with',
group: 'string',
},
{
label: $t('ai-platform.workflow.panels.condition.endsWith'),
value: 'ends_with',
group: 'string',
},
{
label: $t('ai-platform.workflow.panels.condition.isEmpty'),
value: 'is_empty',
group: 'empty',
},
{
label: $t('ai-platform.workflow.panels.condition.isNotEmpty'),
value: 'is_not_empty',
group: 'empty',
},
];
if (branches.value.length === 0) {
branches.value.push({
id: `branch-${Date.now()}`,
name: `${$t('ai-platform.workflow.panels.condition.branch', { index: 1 })}`,
conditions: [],
});
}
watch(
[label, branches],
([labelVal, branchesVal]) => {
emit('update', { ...props.data, label: labelVal, branches: branchesVal });
},
{ deep: true },
);
const addBranch = () => {
branches.value.push({
id: `branch-${Date.now()}`,
name: `${$t('ai-platform.workflow.panels.condition.branch', { index: branches.value.length + 1 })}`,
conditions: [],
});
};
const removeBranch = (index: number) => {
branches.value.splice(index, 1);
};
const addCondition = (branchIndex: number) => {
if (!branches.value[branchIndex].conditions) {
branches.value[branchIndex].conditions = [];
}
branches.value[branchIndex].conditions.push({
variable: '',
operator: 'contains',
value: '',
});
};
const removeCondition = (branchIndex: number, conditionIndex: number) => {
branches.value[branchIndex].conditions.splice(conditionIndex, 1);
};
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="label"
:placeholder="$t('ai-platform.workflow.panels.condition.placeholder')"
/>
</ElFormItem>
<!-- 使用提示 -->
<ElAlert type="info" :closable="false" show-icon>
<template #title>
<span class="text-xs font-medium">{{
$t('ai-platform.workflow.panels.common.usageTips')
}}</span>
</template>
<ul class="mt-1 list-inside list-disc text-xs">
<li>
<b>{{ $t('ai-platform.workflow.panels.condition.booleanHint') }}</b>: <code class="bg-muted rounded px-1">true</code> /
<code class="bg-muted rounded px-1">false</code>
</li>
<li>
<b>{{
$t('ai-platform.workflow.panels.condition.emptyStringHint')
}}</b>: <code class="bg-muted rounded px-1">is_empty</code> /
<code class="bg-muted rounded px-1">is_not_empty</code>
</li>
<li>
<b>{{ $t('ai-platform.workflow.panels.condition.mainAppHint') }}</b>:
<code class="bg-muted rounded px-1" v-pre>{{
start.application_id
}}</code>
== <code class="bg-muted rounded px-1">main</code>
</li>
<li>
<b>{{
$t('ai-platform.workflow.panels.condition.numberCompareHint')
}}</b>: {{ $t('ai-platform.workflow.panels.condition.numberCompare') }}
</li>
</ul>
</ElAlert>
<div class="flex items-center justify-between">
<span class="text-foreground text-sm font-medium">{{
$t('ai-platform.workflow.panels.condition.branchList')
}}</span>
<ElButton link type="primary" :icon="Plus" @click="addBranch">
{{ $t('ai-platform.workflow.panels.condition.addBranch') }}
</ElButton>
</div>
<div class="space-y-3">
<div
v-for="(branch, index) in branches"
:key="branch.id"
class="border-border bg-muted relative rounded border p-3"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-bold text-blue-600"
>IF {{ index + 1 }}</span
>
<ElButton
v-if="branches.length > 1"
link
type="danger"
:icon="Trash2"
@click="removeBranch(index)"
/>
</div>
<ElInput
v-model="branch.name"
:placeholder="$t('ai-platform.workflow.panels.parallel.branchName')"
size="small"
class="mb-2"
/>
<!-- Conditions -->
<div class="border-border mt-3 space-y-2 border-t pt-2">
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-[10px] font-medium">{{
$t('ai-platform.workflow.panels.condition.conditions')
}}</span>
<ElButton
size="small"
link
type="primary"
:icon="Plus"
class="!text-[10px]"
@click="addCondition(index)"
>
{{ $t('ai-platform.workflow.panels.common.addCondition') }}
</ElButton>
</div>
<div
v-if="!branch.conditions || branch.conditions.length === 0"
class="text-muted-foreground text-center text-[10px]"
>
{{ $t('ai-platform.workflow.panels.condition.noCondition') }}
</div>
<div
v-for="(cond, cIdx) in branch.conditions"
:key="cIdx"
class="flex flex-col gap-2 rounded border bg-white p-2 shadow-sm"
>
<div class="flex gap-2">
<SmartInput
v-model="cond.variable"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.variable')"
size="small"
class="flex-1"
/>
<ElButton
link
type="danger"
:icon="X"
@click="removeCondition(index, cIdx as number)"
/>
</div>
<div class="flex gap-2">
<ElSelect
v-model="cond.operator"
size="small"
style="width: 100px; flex-shrink: 0"
>
<ElOption
v-for="op in operators"
:key="op.value"
:label="op.label"
:value="op.value"
/>
</ElSelect>
<SmartInput
v-if="!['is_empty', 'is_not_empty'].includes(cond.operator)"
v-model="cond.value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.value')"
size="small"
class="flex-1"
/>
</div>
</div>
</div>
</div>
<div
class="border-border bg-muted text-muted-foreground rounded border p-3 text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.condition.elseBranch') }}
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import { getModelListApi } from '#/api/ai-platform/ai-platform';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.confirm.label'),
title: $t('ai-platform.workflow.panels.confirm.titlePlaceholder'),
content: '',
confirm_text: $t('ai-platform.workflow.panels.confirm.confirmPlaceholder'),
cancel_text: $t('ai-platform.workflow.panels.confirm.cancelPlaceholder'),
variable_name: 'confirmed',
use_llm_intent: false,
llm_model_id: '',
...props.data,
});
// 模型列表
const modelList = ref<any[]>([]);
const loadingModels = ref(false);
// 加载模型列表
const loadModels = async () => {
loadingModels.value = true;
try {
const res = await getModelListApi({
page: 1,
pageSize: 100,
is_active: true,
});
modelList.value = res.items || [];
} catch (error) {
console.error('加载模型列表失败', error);
} finally {
loadingModels.value = false;
}
};
onMounted(() => {
if (form.value.use_llm_intent) {
loadModels();
}
});
// 当启用 LLM 意图识别时加载模型列表
watch(
() => form.value.use_llm_intent,
(val) => {
if (val && modelList.value.length === 0) {
loadModels();
}
},
);
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.confirm.placeholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.confirm.dialogTitle')">
<SmartInput
v-model="form.title"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.confirm.titlePlaceholder')
"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<span>{{ $t('ai-platform.workflow.panels.confirm.content') }}</span>
<span class="text-destructive ml-1">*</span>
</template>
<SmartInput
v-model="form.content"
:current-node-id="nodeId"
type="textarea"
:rows="4"
:placeholder="
$t('ai-platform.workflow.panels.confirm.contentPlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.confirm.contentHint') }}
</div>
</ElFormItem>
<div class="flex gap-4">
<ElFormItem
:label="$t('ai-platform.workflow.panels.confirm.confirmBtnText')"
class="flex-1"
>
<ElInput
v-model="form.confirm_text"
:placeholder="
$t('ai-platform.workflow.panels.confirm.confirmPlaceholder')
"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.confirm.cancelBtnText')"
class="flex-1"
>
<ElInput
v-model="form.cancel_text"
:placeholder="
$t('ai-platform.workflow.panels.confirm.cancelPlaceholder')
"
/>
</ElFormItem>
</div>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.variable_name" placeholder="confirmed" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.confirm.outputHint') }}
</div>
</ElFormItem>
<!-- LLM 意图识别配置 -->
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.confirm.smartIntent')
}}</span>
<ElSwitch v-model="form.use_llm_intent" size="small" />
</div>
</template>
<div class="text-muted-foreground text-xs">
{{ $t('ai-platform.workflow.panels.confirm.smartIntentHint') }}
</div>
</ElFormItem>
<ElFormItem
v-if="form.use_llm_intent"
:label="$t('ai-platform.workflow.panels.confirm.intentModel')"
>
<ElSelect
v-model="form.llm_model_id"
:placeholder="$t('ai-platform.workflow.panels.common.selectModel')"
:loading="loadingModels"
filterable
class="w-full"
>
<ElOption
v-for="model in modelList"
:key="model.id"
:label="`${model.display_name} (${model.provider_name})`"
:value="model.id"
/>
</ElSelect>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.confirm.intentModelHint') }}
</div>
</ElFormItem>
</ElForm>
</template>
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{
data: any;
nodeId: string;
}>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dashboardBasicInfo.label'),
name: '',
code: '',
category: 'dashboard',
description: '',
sort: 0,
require_confirmation: true,
...props.data,
});
watch(
form,
(newVal) => {
emit('update', newVal);
},
{ deep: true },
);
const categoryOptions = [
{
label: $t('ai-platform.workflow.panels.dashboardBasicInfo.catDashboard'),
value: 'dashboard',
},
{
label: $t('ai-platform.workflow.panels.dashboardBasicInfo.catPortal'),
value: 'portal',
},
{
label: $t('ai-platform.workflow.panels.dashboardBasicInfo.catDataboard'),
value: 'databoard',
},
{
label: $t('ai-platform.workflow.panels.dashboardBasicInfo.catOther'),
value: 'other',
},
];
</script>
<template>
<div class="space-y-4">
<ElForm :model="form" label-position="top" size="small">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.dashboardBasicInfo.placeholder')
"
/>
</ElFormItem>
<ElFormItem
:label="
$t('ai-platform.workflow.panels.dashboardBasicInfo.dashboardName')
"
required
>
<SmartInput
v-model="form.name"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardBasicInfo.dashboardNamePlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="
$t('ai-platform.workflow.panels.dashboardBasicInfo.dashboardCode')
"
>
<SmartInput
v-model="form.code"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardBasicInfo.dashboardCodePlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardBasicInfo.category')"
>
<ElSelect
v-model="form.category"
class="w-full"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardBasicInfo.categoryPlaceholder',
)
"
>
<ElOption
v-for="opt in categoryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="
$t('ai-platform.workflow.panels.dashboardBasicInfo.description')
"
>
<SmartInput
v-model="form.description"
type="textarea"
:placeholder="
$t('ai-platform.workflow.panels.dashboardBasicInfo.descPlaceholder')
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardBasicInfo.sort')"
>
<ElInputNumber
v-model="form.sort"
:min="0"
:max="9999"
class="w-full"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
</ElForm>
</div>
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElSwitch } from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{
data: any;
nodeId: string;
}>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dashboardCreate.label'),
dashboard_basic_info: '',
page_config: '',
application_id: '',
update_if_exists: true,
...props.data,
});
watch(
form,
(newVal) => {
emit('update', newVal);
},
{ deep: true },
);
</script>
<template>
<div class="space-y-4">
<ElForm :model="form" label-position="top" size="small">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.dashboardCreate.placeholder')
"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardCreate.basicInfo')"
required
>
<SmartInput
v-model="form.dashboard_basic_info"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardCreate.basicInfoPlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardCreate.pageConfig')"
required
>
<SmartInput
v-model="form.page_config"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardCreate.pageConfigPlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardCreate.appId')"
>
<SmartInput
v-model="form.application_id"
:placeholder="
$t('ai-platform.workflow.panels.dashboardCreate.appIdPlaceholder')
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="
$t('ai-platform.workflow.panels.dashboardCreate.updateIfExists')
"
>
<ElSwitch v-model="form.update_if_exists" />
<span class="text-muted-foreground ml-2 text-xs">
{{
$t('ai-platform.workflow.panels.dashboardCreate.updateIfExistsHint')
}}
</span>
</ElFormItem>
</ElForm>
<div class="bg-muted/50 rounded-lg p-3">
<div class="text-muted-foreground text-xs">
<p class="mb-2 font-medium">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}:
</p>
<ul class="list-inside list-disc space-y-1">
<li>
<code>dashboard_id</code> -
{{
$t(
'ai-platform.workflow.panels.dashboardCreate.dashboardIdOutput',
)
}}
</li>
<li>
<code>dashboard_code</code> -
{{
$t(
'ai-platform.workflow.panels.dashboardCreate.dashboardCodeOutput',
)
}}
</li>
<li>
<code>page_meta</code> -
{{
$t('ai-platform.workflow.panels.dashboardCreate.pageMetaOutput')
}}
</li>
</ul>
</div>
</div>
</div>
</template>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput } from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{
data: any;
nodeId: string;
}>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dashboardDesign.label'),
dashboard_code: '',
design_title: $t('ai-platform.workflow.panels.dashboardDesign.designTitle'),
design_suggestion: '',
require_confirmation: true,
...props.data,
});
watch(
form,
(newVal) => {
emit('update', newVal);
},
{ deep: true },
);
</script>
<template>
<div class="space-y-4">
<ElForm :model="form" label-position="top" size="small">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.dashboardDesign.placeholder')
"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardDesign.dashboardCode')"
>
<SmartInput
v-model="form.dashboard_code"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardDesign.dashboardCodePlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="
$t('ai-platform.workflow.panels.dashboardDesign.designTitleLabel')
"
>
<ElInput
v-model="form.design_title"
:placeholder="
$t('ai-platform.workflow.panels.dashboardDesign.placeholder')
"
/>
</ElFormItem>
<ElFormItem
:label="
$t('ai-platform.workflow.panels.dashboardDesign.designSuggestion')
"
>
<SmartInput
v-model="form.design_suggestion"
type="textarea"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardDesign.designSuggestionPlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
</ElForm>
<div class="bg-muted/50 rounded-lg p-3">
<div class="text-muted-foreground text-xs">
<p class="mb-2 font-medium">
{{ $t('ai-platform.workflow.panels.common.usage') }}:
</p>
<ul class="list-inside list-disc space-y-1">
<li>
{{ $t('ai-platform.workflow.panels.dashboardDesign.usageTip1') }}
</li>
<li>
{{ $t('ai-platform.workflow.panels.dashboardDesign.usageTip2') }}
</li>
<li>
{{ $t('ai-platform.workflow.panels.dashboardDesign.usageTip3') }}
</li>
</ul>
</div>
</div>
</div>
</template>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{
data: any;
nodeId: string;
}>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dashboardPublish.label'),
dashboard_id: '',
menu_name: '',
menu_parent_id: '',
menu_icon: 'lucide:layout-dashboard',
menu_order: 0,
application_id: '',
require_confirmation: true,
...props.data,
});
watch(
form,
(newVal) => {
emit('update', newVal);
},
{ deep: true },
);
</script>
<template>
<div class="space-y-4">
<ElForm :model="form" label-position="top" size="small">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.dashboardPublish.placeholder')
"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardPublish.dashboardId')"
required
>
<SmartInput
v-model="form.dashboard_id"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardPublish.dashboardIdPlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardPublish.menuName')"
required
>
<SmartInput
v-model="form.menu_name"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardPublish.menuNamePlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardPublish.parentMenuId')"
>
<SmartInput
v-model="form.menu_parent_id"
:placeholder="
$t(
'ai-platform.workflow.panels.dashboardPublish.parentMenuIdPlaceholder',
)
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardPublish.menuIcon')"
>
<ElInput
v-model="form.menu_icon"
placeholder="lucide:layout-dashboard"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardPublish.menuSort')"
>
<ElInputNumber
v-model="form.menu_order"
:min="0"
:max="9999"
class="w-full"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.dashboardPublish.appId')"
>
<SmartInput
v-model="form.application_id"
:placeholder="
$t('ai-platform.workflow.panels.dashboardPublish.appIdPlaceholder')
"
:current-node-id="nodeId"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
</ElForm>
<div class="bg-muted/50 rounded-lg p-3">
<div class="text-muted-foreground text-xs">
<p class="mb-2 font-medium">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}:
</p>
<ul class="list-inside list-disc space-y-1">
<li>
<code>menu_id</code> -
{{
$t('ai-platform.workflow.panels.dashboardPublish.menuIdOutput')
}}
</li>
<li>
<code>route_path</code> -
{{
$t('ai-platform.workflow.panels.dashboardPublish.routePathOutput')
}}
</li>
<li>
<code>publish_result</code> -
{{
$t(
'ai-platform.workflow.panels.dashboardPublish.publishResultOutput',
)
}}
</li>
</ul>
</div>
</div>
</div>
</template>
@@ -0,0 +1,239 @@
<script setup lang="ts">
import type { SelectedTable } from '../components/TableSelectDialog.vue';
import { ref, watch } from 'vue';
import { Database, Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
import DbConfigSummary from '../components/DbConfigSummary.vue';
import TableSelectDialog from '../components/TableSelectDialog.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dbDelete.label'),
operation: 'delete',
table: '',
db_config: null as null | {
database: string;
dbName: string;
dbType: string;
schema?: string;
},
table_fields: [] as Array<{
isPrimaryKey: boolean;
name: string;
type: string;
}>,
where_conditions: [] as Array<{
field: string;
operator: string;
value: string;
}>,
output_variable: 'db_result',
...props.data,
});
// 表选择对话框
const tableSelectVisible = ref(false);
// 确保数组存在
if (!form.value.where_conditions) form.value.where_conditions = [];
if (!form.value.table_fields) form.value.table_fields = [];
// 条件操作符选项
const operatorOptions = [
{ value: '=', label: $t('ai-platform.workflow.panels.dbQuery.eqOp') },
{ value: '!=', label: $t('ai-platform.workflow.panels.dbQuery.neOp') },
{ value: '>', label: $t('ai-platform.workflow.panels.dbQuery.gtOp') },
{ value: '>=', label: $t('ai-platform.workflow.panels.dbQuery.gteOp') },
{ value: '<', label: $t('ai-platform.workflow.panels.dbQuery.ltOp') },
{ value: '<=', label: $t('ai-platform.workflow.panels.dbQuery.lteOp') },
{ value: 'in', label: $t('ai-platform.workflow.panels.dbQuery.inOp') },
];
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
const addCondition = () => {
form.value.where_conditions.push({ field: '', operator: '=', value: '' });
};
const removeCondition = (index: number) => {
form.value.where_conditions.splice(index, 1);
};
// 处理表选择
const handleTableSelect = (table: SelectedTable) => {
form.value.table = table.tableName;
form.value.db_config = {
dbName: table.dbName,
database: table.database,
schema: table.schema,
dbType: table.dbType,
};
form.value.table_fields = table.fields.map((f) => ({
name: f.name,
type: f.type,
isPrimaryKey: f.isPrimaryKey,
}));
};
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElAlert type="warning" :closable="false" show-icon>
<template #title>
<span class="text-xs font-medium">{{
$t('ai-platform.workflow.panels.dbDelete.dangerWarning')
}}</span>
</template>
</ElAlert>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.dbDelete.placeholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.targetTable')">
<div class="flex w-full gap-2">
<ElInput
v-model="form.table"
:placeholder="
$t('ai-platform.workflow.panels.common.tablePlaceholder')
"
readonly
class="flex-1"
>
<template #prefix>
<Database class="text-muted-foreground h-4 w-4" />
</template>
</ElInput>
<ElButton
type="primary"
:icon="Plus"
@click="tableSelectVisible = true"
>
{{ $t('ai-platform.workflow.panels.common.selectTable') }}
</ElButton>
</div>
<DbConfigSummary
:db-config="form.db_config"
show-default-write-warning
/>
</ElFormItem>
<!-- 表字段参考 -->
<div v-if="form.table_fields.length > 0">
<div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.panels.common.tableFieldsRef') }}
</div>
<div class="flex flex-wrap gap-1">
<ElTag
v-for="field in form.table_fields"
:key="field.name"
:type="field.isPrimaryKey ? 'warning' : 'info'"
size="small"
>
{{ field.name }}
</ElTag>
</div>
</div>
<!-- 删除条件 -->
<div>
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-sm font-medium">
{{ $t('ai-platform.workflow.panels.dbDelete.deleteConditions') }}
<span class="text-red-500">*</span>
</span>
<ElButton
link
type="primary"
:icon="Plus"
size="small"
@click="addCondition"
>
{{ $t('ai-platform.workflow.panels.common.addCondition') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.where_conditions"
:key="index"
class="flex items-center gap-2 rounded border border-red-100 bg-red-50 p-2"
>
<ElInput
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.field')"
size="small"
class="w-24"
/>
<ElSelect v-model="item.operator" size="small" class="w-24">
<ElOption
v-for="op in operatorOptions"
:key="op.value"
:value="op.value"
:label="op.label"
/>
</ElSelect>
<SmartInput
v-model="item.value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.value')"
size="small"
class="flex-1"
/>
<ElButton
link
type="danger"
:icon="Trash2"
size="small"
@click="removeCondition(index)"
/>
</div>
<div v-if="form.where_conditions.length === 0" class="py-4 text-center">
<ElAlert type="error" :closable="false" show-icon>
<template #title>
{{ $t('ai-platform.workflow.panels.dbDelete.mustAddCondition') }}
</template>
</ElAlert>
</div>
</div>
</div>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.output_variable" placeholder="db_result" />
</ElFormItem>
<!-- 表选择对话框 -->
<TableSelectDialog
v-model:visible="tableSelectVisible"
:current-table="form.table"
@select="handleTableSelect"
/>
</ElForm>
</template>
@@ -0,0 +1,365 @@
<script setup lang="ts">
import type { SelectedTable } from '../components/TableSelectDialog.vue';
import { ref, watch } from 'vue';
import { Database, Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCheckbox,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
import DbConfigSummary from '../components/DbConfigSummary.vue';
import TableSelectDialog from '../components/TableSelectDialog.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dbInsert.label'),
operation: 'insert',
table: '',
db_config: null as null | {
database: string;
dbName: string;
dbType: string;
schema?: string;
},
table_fields: [] as Array<{
isPrimaryKey: boolean;
name: string;
type: string;
}>,
field_mapping: [] as Array<{ field: string; value: string }>,
where_conditions: [] as Array<{
field: string;
operator: string;
value: string;
}>,
upsert: false,
output_variable: 'db_result',
...props.data,
});
// 表选择对话框
const tableSelectVisible = ref(false);
// 确保数组存在
if (!form.value.field_mapping) form.value.field_mapping = [];
if (!form.value.where_conditions) form.value.where_conditions = [];
if (!form.value.table_fields) form.value.table_fields = [];
// 将对象格式转换为数组格式(兼容旧数据)
if (props.data.field_mapping && !Array.isArray(props.data.field_mapping)) {
form.value.field_mapping = Object.entries(props.data.field_mapping).map(
([field, value]) => ({
field,
value: value as string,
}),
);
}
// 条件操作符选项
const operatorOptions = [
{ value: '=', label: $t('ai-platform.workflow.panels.dbQuery.eqOp') },
{ value: '!=', label: $t('ai-platform.workflow.panels.dbQuery.neOp') },
];
watch(
form,
(val) => {
// 将数组格式转换为对象格式存储
const fieldMappingObj: Record<string, string> = {};
val.field_mapping.forEach((item: any) => {
if (item.field) {
fieldMappingObj[item.field] = item.value;
}
});
emit('update', {
...val,
operation: val.upsert ? 'upsert' : 'insert',
field_mapping: fieldMappingObj,
});
},
{ deep: true },
);
// 添加字段映射
const addFieldMapping = () => {
form.value.field_mapping.push({ field: '', value: '' });
};
// 删除字段映射
const removeFieldMapping = (index: number) => {
form.value.field_mapping.splice(index, 1);
};
// 添加条件
const addCondition = () => {
form.value.where_conditions.push({ field: '', operator: '=', value: '' });
};
// 删除条件
const removeCondition = (index: number) => {
form.value.where_conditions.splice(index, 1);
};
// 处理表选择
const handleTableSelect = (table: SelectedTable) => {
form.value.table = table.tableName;
form.value.db_config = {
dbName: table.dbName,
database: table.database,
schema: table.schema,
dbType: table.dbType,
};
form.value.table_fields = table.fields.map((f) => ({
name: f.name,
type: f.type,
isPrimaryKey: f.isPrimaryKey,
}));
};
// 快速添加字段映射
const addFieldFromTable = (fieldName: string) => {
// 检查是否已存在
if (
form.value.field_mapping.some(
(f: { field: string }) => f.field === fieldName,
)
)
return;
form.value.field_mapping.push({ field: fieldName, value: '' });
};
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.dbInsert.placeholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.targetTable')">
<div class="flex w-full gap-2">
<ElInput
v-model="form.table"
:placeholder="
$t('ai-platform.workflow.panels.common.tablePlaceholder')
"
readonly
class="flex-1"
>
<template #prefix>
<Database class="text-muted-foreground h-4 w-4" />
</template>
</ElInput>
<ElButton
type="primary"
:icon="Plus"
@click="tableSelectVisible = true"
>
{{ $t('ai-platform.workflow.panels.common.selectTable') }}
</ElButton>
</div>
<DbConfigSummary
:db-config="form.db_config"
show-default-write-warning
/>
</ElFormItem>
<!-- 表字段快捷选择 -->
<div v-if="form.table_fields.length > 0" class="mb-4">
<div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.panels.common.tableFields') }}
</div>
<div class="flex flex-wrap gap-1">
<ElTag
v-for="field in form.table_fields"
:key="field.name"
:type="field.isPrimaryKey ? 'warning' : 'info'"
size="small"
class="cursor-pointer"
@click="addFieldFromTable(field.name)"
>
{{ field.name }}
<span class="ml-1 text-[10px] opacity-60">{{ field.type }}</span>
</ElTag>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.common.clickFieldToAdd') }}
</div>
</div>
<!-- 字段映射 -->
<div class="mb-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-sm font-medium">{{
$t('ai-platform.workflow.panels.common.fieldMapping')
}}</span>
<ElButton
link
type="primary"
:icon="Plus"
size="small"
@click="addFieldMapping"
>
{{ $t('ai-platform.workflow.panels.common.addField') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.field_mapping"
:key="index"
class="border-border bg-muted flex items-center gap-2 rounded border p-2"
>
<ElSelect
v-if="form.table_fields.length > 0"
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.selectField')"
size="small"
class="max-w-36"
filterable
allow-create
>
<ElOption
v-for="f in form.table_fields"
:key="f.name"
:value="f.name"
:label="f.name"
/>
</ElSelect>
<ElInput
v-else
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.dbField')"
size="small"
class="min-w-32"
/>
<span class="text-muted-foreground"></span>
<SmartInput
v-model="item.value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.varValue')"
size="small"
class="flex-1"
/>
<ElButton
link
type="danger"
:icon="Trash2"
size="small"
@click="removeFieldMapping(index)"
/>
</div>
<div
v-if="form.field_mapping.length === 0"
class="text-muted-foreground py-4 text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.common.clickAddField') }}
</div>
</div>
</div>
<!-- UPSERT 选项 -->
<ElFormItem>
<ElCheckbox v-model="form.upsert">
{{ $t('ai-platform.workflow.panels.common.upsert') }}
</ElCheckbox>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.common.upsertHint') }}
</div>
</ElFormItem>
<!-- UPSERT 条件 -->
<div v-if="form.upsert" class="mb-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-sm font-medium">
{{ $t('ai-platform.workflow.panels.common.judgeCondition') }}
<span class="text-red-500">*</span>
</span>
<ElButton
link
type="primary"
:icon="Plus"
size="small"
@click="addCondition"
>
{{ $t('ai-platform.workflow.panels.common.addCondition') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.where_conditions"
:key="index"
class="border-border bg-muted flex items-center gap-2 rounded border p-2"
>
<ElInput
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.field')"
size="small"
class="w-24"
/>
<ElSelect v-model="item.operator" size="small" class="w-20">
<ElOption
v-for="op in operatorOptions"
:key="op.value"
:value="op.value"
:label="op.label"
/>
</ElSelect>
<SmartInput
v-model="item.value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.value')"
size="small"
class="flex-1"
/>
<ElButton
link
type="danger"
:icon="Trash2"
size="small"
@click="removeCondition(index)"
/>
</div>
<div
v-if="form.where_conditions.length === 0"
class="py-4 text-center text-xs text-red-500 dark:text-red-400"
>
{{ $t('ai-platform.workflow.panels.common.addJudgeConditionHint') }}
</div>
</div>
</div>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.output_variable" placeholder="db_result" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.common.insertResultSavedTo') }}
</div>
</ElFormItem>
<!-- 表选择对话框 -->
<TableSelectDialog
v-model:visible="tableSelectVisible"
:current-table="form.table"
@select="handleTableSelect"
/>
</ElForm>
</template>
@@ -0,0 +1,275 @@
<script setup lang="ts">
import type { SelectedTable } from '../components/TableSelectDialog.vue';
import { ref, watch } from 'vue';
import { Database, Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
import DbConfigSummary from '../components/DbConfigSummary.vue';
import TableSelectDialog from '../components/TableSelectDialog.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dbQuery.label'),
operation: 'select',
table: '',
db_config: null as null | {
database: string;
dbName: string;
dbType: string;
schema?: string;
},
table_fields: [] as Array<{
isPrimaryKey: boolean;
name: string;
type: string;
}>,
where_conditions: [] as Array<{
field: string;
operator: string;
value: string;
}>,
return_fields: '*',
limit: 100,
order_by: '',
output_variable: 'db_result',
...props.data,
});
// 表选择对话框
const tableSelectVisible = ref(false);
// 确保数组存在
if (!form.value.where_conditions) form.value.where_conditions = [];
if (!form.value.table_fields) form.value.table_fields = [];
// 条件操作符选项
const operatorOptions = [
{ value: '=', label: $t('ai-platform.workflow.panels.dbQuery.eqOp') },
{ value: '!=', label: $t('ai-platform.workflow.panels.dbQuery.neOp') },
{ value: '>', label: $t('ai-platform.workflow.panels.dbQuery.gtOp') },
{ value: '>=', label: $t('ai-platform.workflow.panels.dbQuery.gteOp') },
{ value: '<', label: $t('ai-platform.workflow.panels.dbQuery.ltOp') },
{ value: '<=', label: $t('ai-platform.workflow.panels.dbQuery.lteOp') },
{ value: 'like', label: $t('ai-platform.workflow.panels.dbQuery.likeOp') },
{ value: 'in', label: $t('ai-platform.workflow.panels.dbQuery.inOp') },
{
value: 'is_null',
label: $t('ai-platform.workflow.panels.dbQuery.isNullOp'),
},
{
value: 'is_not_null',
label: $t('ai-platform.workflow.panels.dbQuery.isNotNullOp'),
},
];
watch(
form,
(val) => {
// 将 return_fields 字符串转换为数组
let returnFields = val.return_fields;
if (typeof returnFields === 'string') {
returnFields =
returnFields === '*' || returnFields.trim() === ''
? ['*']
: returnFields
.split(',')
.map((f: string) => f.trim())
.filter(Boolean);
}
emit('update', {
...val,
return_fields: returnFields,
});
},
{ deep: true },
);
const addCondition = () => {
form.value.where_conditions.push({ field: '', operator: '=', value: '' });
};
const removeCondition = (index: number) => {
form.value.where_conditions.splice(index, 1);
};
// 处理表选择
const handleTableSelect = (table: SelectedTable) => {
form.value.table = table.tableName;
form.value.db_config = {
dbName: table.dbName,
database: table.database,
schema: table.schema,
dbType: table.dbType,
};
form.value.table_fields = table.fields.map((f) => ({
name: f.name,
type: f.type,
isPrimaryKey: f.isPrimaryKey,
}));
};
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.dbQuery.placeholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.targetTable')">
<div class="flex w-full gap-2">
<ElInput
v-model="form.table"
:placeholder="
$t('ai-platform.workflow.panels.common.tablePlaceholder')
"
readonly
class="flex-1"
>
<template #prefix>
<Database class="text-muted-foreground h-4 w-4" />
</template>
</ElInput>
<ElButton
type="primary"
:icon="Plus"
@click="tableSelectVisible = true"
>
{{ $t('ai-platform.workflow.panels.common.selectTable') }}
</ElButton>
</div>
<DbConfigSummary :db-config="form.db_config" />
</ElFormItem>
<!-- 表字段快捷选择 -->
<div v-if="form.table_fields.length > 0" class="mb-4">
<div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.panels.common.availableFields') }}
</div>
<div class="flex flex-wrap gap-1">
<ElTag
v-for="field in form.table_fields"
:key="field.name"
:type="field.isPrimaryKey ? 'warning' : 'info'"
size="small"
>
{{ field.name }}
</ElTag>
</div>
</div>
<!-- 查询条件 -->
<div class="mb-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-sm font-medium">{{
$t('ai-platform.workflow.panels.common.queryConditions')
}}</span>
<ElButton
link
type="primary"
:icon="Plus"
size="small"
@click="addCondition"
>
{{ $t('ai-platform.workflow.panels.common.addCondition') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.where_conditions"
:key="index"
class="border-border bg-muted flex items-center gap-2 rounded border p-2"
>
<ElInput
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.field')"
size="small"
class="w-24"
/>
<ElSelect v-model="item.operator" size="small" class="w-28">
<ElOption
v-for="op in operatorOptions"
:key="op.value"
:value="op.value"
:label="op.label"
/>
</ElSelect>
<SmartInput
v-if="!['is_null', 'is_not_null'].includes(item.operator)"
v-model="item.value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.value')"
size="small"
class="flex-1"
/>
<ElButton
link
type="danger"
:icon="Trash2"
size="small"
@click="removeCondition(index)"
/>
</div>
<div
v-if="form.where_conditions.length === 0"
class="text-muted-foreground py-2 text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.common.noConditionQueryAll') }}
</div>
</div>
</div>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.returnFields')">
<ElInput
v-model="form.return_fields"
placeholder="* or id,name,created_at"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.common.returnFieldsHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.orderBy')">
<ElInput v-model="form.order_by" placeholder="e.g. created_at DESC" />
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.limitRows')">
<ElInputNumber v-model="form.limit" :min="1" :max="1000" />
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.output_variable" placeholder="db_result" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.common.resultSavedTo') }}
</div>
</ElFormItem>
<!-- 表选择对话框 -->
<TableSelectDialog
v-model:visible="tableSelectVisible"
:current-table="form.table"
@select="handleTableSelect"
/>
</ElForm>
</template>
@@ -0,0 +1,364 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Maximize2, Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElTooltip,
} from 'element-plus';
import { CodeEditor } from '#/components/zq-form/code-editor';
import { ZqDialog } from '#/components/zq-dialog';
import { getParamTypeOptions } from '#/views/_core/data-source/data';
import SmartInput from '../components/SmartInput.vue';
import DbConfigSummary from '../components/DbConfigSummary.vue';
import DbConnectionSelect from '../components/DbConnectionSelect.vue';
interface SqlParam {
name: string;
type: string;
value: string;
}
const props = defineProps<{ data: any; nodeId: string; nodeType?: string }>();
const emit = defineEmits(['update']);
const paramTypeOptions = getParamTypeOptions();
const initialDbConfig = {
dbName: 'default',
dbType: 'postgresql',
...(props.data?.db_config || {}),
};
function normalizeParam(raw: Partial<SqlParam> & { default?: string }): SqlParam {
return {
name: raw.name ?? '',
type: raw.type ?? 'string',
value: raw.value ?? raw.default ?? '',
};
}
const form = ref({
label: $t('ai-platform.workflow.panels.dbSql.label'),
sql_type: 'query',
sql: '',
params: [] as SqlParam[],
output_variable: 'sql_result',
...props.data,
db_config: initialDbConfig,
});
form.value.params = (form.value.params || []).map(normalizeParam);
if (!form.value.db_config) {
form.value.db_config = initialDbConfig;
}
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
const sqlEditorVisible = ref(false);
const addParam = () => {
form.value.params.push({
name: '',
type: 'string',
value: '',
});
};
const removeParam = (index: number) => {
form.value.params.splice(index, 1);
};
const sqlExamples = [
{
label: $t('ai-platform.workflow.panels.dbSql.queryExample'),
value: 'SELECT * FROM users WHERE status = :status LIMIT :limit',
},
{
label: $t('ai-platform.workflow.panels.dbSql.insertExample'),
value:
'INSERT INTO logs (user_id, action, created_at) VALUES (:user_id, :action, NOW())',
},
{
label: $t('ai-platform.workflow.panels.dbSql.updateExample'),
value: 'UPDATE orders SET status = :status WHERE id = :id',
},
{
label: $t('ai-platform.workflow.panels.dbSql.statsExample'),
value: 'SELECT COUNT(*) as total, status FROM orders GROUP BY status',
},
];
const insertExample = (sql: string) => {
form.value.sql = sql;
};
function handleConnectionChange(payload: { dbName: string; dbType: string }) {
form.value.db_config = {
...form.value.db_config,
dbName: payload.dbName,
dbType: payload.dbType,
};
}
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElAlert type="warning" :closable="false" show-icon>
<template #title>
<span class="text-xs font-medium">{{
$t('ai-platform.workflow.panels.dbSql.cautionWarning')
}}</span>
</template>
</ElAlert>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.dbSql.placeholder')"
/>
</ElFormItem>
<DbConnectionSelect
v-model="form.db_config.dbName"
:write-mode="form.sql_type === 'execute'"
@change="handleConnectionChange"
/>
<DbConfigSummary
:db-config="form.db_config"
:show-default-write-warning="form.sql_type === 'execute'"
/>
<ElFormItem :label="$t('ai-platform.workflow.panels.dbSql.execType')">
<ElRadioGroup v-model="form.sql_type">
<ElRadioButton value="query">
{{ $t('ai-platform.workflow.panels.dbSql.queryType') }}
</ElRadioButton>
<ElRadioButton value="execute">
{{ $t('ai-platform.workflow.panels.dbSql.executeType') }}
</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<div>
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-xs">{{
$t('ai-platform.workflow.panels.dbSql.sqlStatement')
}}</span>
<ElTooltip
:content="$t('ai-platform.workflow.panels.code.expandEditor')"
>
<ElButton link type="primary" @click="sqlEditorVisible = true">
<Maximize2 class="size-4" />
</ElButton>
</ElTooltip>
</div>
<CodeEditor
v-model="form.sql"
language="sql"
height="280px"
:placeholder="$t('ai-platform.workflow.panels.dbSql.sqlPlaceholder')"
:fold-gutter="true"
:bracket-matching="true"
:autocompletion="true"
:line-numbers="false"
class="font-mono"
/>
<div class="mt-2 flex flex-wrap items-center gap-1">
<span class="text-muted-foreground mr-2 text-xs">
{{ $t('ai-platform.workflow.panels.dbSql.examples') }}:
</span>
<button
v-for="example in sqlExamples"
:key="example.label"
type="button"
class="text-xs text-blue-500 hover:text-blue-700 hover:underline"
@click="insertExample(example.value)"
>
{{ example.label }}
</button>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.dbSql.sqlParamHint') }}
</div>
</div>
<div>
<div class="flex items-center justify-between">
<span class="text-foreground text-sm font-medium">{{
$t('ai-platform.workflow.panels.dbSql.paramList')
}}</span>
<ElButton link type="primary" :icon="Plus" @click="addParam">
{{ $t('ai-platform.workflow.panels.dbSql.addParam') }}
</ElButton>
</div>
<div
v-if="form.params.length === 0"
class="text-muted-foreground py-4 text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.dbSql.noParams') }}
</div>
<div v-else class="mt-3 space-y-3">
<div
v-for="(param, index) in form.params"
:key="index"
class="border-border bg-muted rounded border p-3"
>
<div class="flex items-center gap-2">
<ElInput
v-model="param.name"
:placeholder="$t('ai-platform.workflow.panels.dbSql.paramName')"
size="small"
style="width: 138px"
/>
<ElSelect
v-model="param.type"
:placeholder="$t('ai-platform.workflow.panels.dbSql.paramType')"
size="small"
style="width: 158px"
>
<ElOption
v-for="opt in paramTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<div class="db-sql-param-value shrink-0">
<SmartInput
v-model="param.value"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.dbSql.paramValuePlaceholder')
"
size="small"
/>
</div>
<ElButton
link
type="danger"
:icon="Trash2"
@click="removeParam(index)"
/>
</div>
</div>
</div>
<div class="text-muted-foreground mt-2 text-xs">
{{ $t('ai-platform.workflow.panels.dbSql.paramsHint') }}
</div>
</div>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.output_variable" placeholder="sql_result" />
<div class="text-muted-foreground mt-1 text-xs">
<span v-if="form.sql_type === 'query'">{{
$t('ai-platform.workflow.panels.dbSql.queryResultHint')
}}</span>
<span v-else>{{
$t('ai-platform.workflow.panels.dbSql.affectedRowsHint')
}}</span>
</div>
</ElFormItem>
</ElForm>
<ZqDialog
v-model="sqlEditorVisible"
class="code-editor-dialog"
:title="$t('ai-platform.workflow.panels.dbSql.sqlStatement')"
width="960px"
:show-footer="false"
>
<div class="code-editor-dialog-content">
<CodeEditor
v-model="form.sql"
language="sql"
height="100%"
:placeholder="$t('ai-platform.workflow.panels.dbSql.sqlPlaceholder')"
:fold-gutter="true"
:bracket-matching="true"
:autocompletion="true"
:line-numbers="true"
class="font-mono"
/>
</div>
</ZqDialog>
</template>
<style scoped>
.db-sql-param-value {
width: 142px;
}
.db-sql-param-value :deep(> div) {
width: 100%;
min-width: 0;
}
</style>
<style>
/* 与 CodePanel 共用:编辑器弹窗占满 dialog(含全屏) */
.code-editor-dialog .zq-dialog-body .el-scrollbar,
.code-editor-dialog .zq-dialog-body .el-scrollbar__wrap,
.code-editor-dialog .zq-dialog-body .el-scrollbar__view,
.code-editor-dialog .zq-dialog-body .el-scrollbar__view > div,
.code-editor-dialog .code-editor-dialog-content {
height: 100%;
}
.code-editor-dialog:not(.is-fullscreen) .zq-dialog-body {
height: 70vh;
}
.code-editor-dialog:not(.is-fullscreen) .zq-dialog-body .el-scrollbar {
height: 100%;
}
.code-editor-dialog .code-editor-dialog-content {
display: flex;
flex-direction: column;
min-height: 0;
}
.code-editor-dialog .code-editor-dialog-content .code-editor {
flex: 1;
min-height: 0;
height: 100% !important;
}
.code-editor-dialog.is-fullscreen .el-dialog__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.code-editor-dialog.is-fullscreen .zq-dialog-body {
flex: 1;
min-height: 0;
}
.code-editor-dialog.is-fullscreen .zq-dialog-body .el-scrollbar {
height: 100% !important;
}
</style>
@@ -0,0 +1,353 @@
<script setup lang="ts">
import type { SelectedTable } from '../components/TableSelectDialog.vue';
import { ref, watch } from 'vue';
import { Database, Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElAlert,
ElButton,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElTag,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
import DbConfigSummary from '../components/DbConfigSummary.vue';
import TableSelectDialog from '../components/TableSelectDialog.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.dbUpdate.label'),
operation: 'update',
table: '',
db_config: null as null | {
database: string;
dbName: string;
dbType: string;
schema?: string;
},
table_fields: [] as Array<{
isPrimaryKey: boolean;
name: string;
type: string;
}>,
field_mapping: [] as Array<{ field: string; value: string }>,
where_conditions: [] as Array<{
field: string;
operator: string;
value: string;
}>,
output_variable: 'db_result',
...props.data,
});
// 表选择对话框
const tableSelectVisible = ref(false);
// 确保数组存在
if (!form.value.field_mapping) form.value.field_mapping = [];
if (!form.value.where_conditions) form.value.where_conditions = [];
if (!form.value.table_fields) form.value.table_fields = [];
// 将对象格式转换为数组格式(兼容旧数据)
if (props.data.field_mapping && !Array.isArray(props.data.field_mapping)) {
form.value.field_mapping = Object.entries(props.data.field_mapping).map(
([field, value]) => ({
field,
value: value as string,
}),
);
}
// 条件操作符选项
const operatorOptions = [
{ value: '=', label: $t('ai-platform.workflow.panels.dbQuery.eqOp') },
{ value: '!=', label: $t('ai-platform.workflow.panels.dbQuery.neOp') },
{ value: '>', label: $t('ai-platform.workflow.panels.dbQuery.gtOp') },
{ value: '>=', label: $t('ai-platform.workflow.panels.dbQuery.gteOp') },
{ value: '<', label: $t('ai-platform.workflow.panels.dbQuery.ltOp') },
{ value: '<=', label: $t('ai-platform.workflow.panels.dbQuery.lteOp') },
{ value: 'like', label: $t('ai-platform.workflow.panels.dbQuery.likeOp') },
{ value: 'in', label: $t('ai-platform.workflow.panels.dbQuery.inOp') },
];
watch(
form,
(val) => {
const fieldMappingObj: Record<string, string> = {};
val.field_mapping.forEach((item: any) => {
if (item.field) {
fieldMappingObj[item.field] = item.value;
}
});
emit('update', {
...val,
field_mapping: fieldMappingObj,
});
},
{ deep: true },
);
const addFieldMapping = () => {
form.value.field_mapping.push({ field: '', value: '' });
};
const removeFieldMapping = (index: number) => {
form.value.field_mapping.splice(index, 1);
};
const addCondition = () => {
form.value.where_conditions.push({ field: '', operator: '=', value: '' });
};
const removeCondition = (index: number) => {
form.value.where_conditions.splice(index, 1);
};
// 处理表选择
const handleTableSelect = (table: SelectedTable) => {
form.value.table = table.tableName;
form.value.db_config = {
dbName: table.dbName,
database: table.database,
schema: table.schema,
dbType: table.dbType,
};
form.value.table_fields = table.fields.map((f) => ({
name: f.name,
type: f.type,
isPrimaryKey: f.isPrimaryKey,
}));
};
// 快速添加字段映射
const addFieldFromTable = (fieldName: string) => {
if (
form.value.field_mapping.some(
(f: { field: string }) => f.field === fieldName,
)
)
return;
form.value.field_mapping.push({ field: fieldName, value: '' });
};
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.dbUpdate.placeholder')"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.targetTable')">
<div class="flex w-full gap-2">
<ElInput
v-model="form.table"
:placeholder="
$t('ai-platform.workflow.panels.common.tablePlaceholder')
"
readonly
class="flex-1"
>
<template #prefix>
<Database class="text-muted-foreground h-4 w-4" />
</template>
</ElInput>
<ElButton
type="primary"
:icon="Plus"
@click="tableSelectVisible = true"
>
{{ $t('ai-platform.workflow.panels.common.selectTable') }}
</ElButton>
</div>
<DbConfigSummary
:db-config="form.db_config"
show-default-write-warning
/>
</ElFormItem>
<!-- 表字段快捷选择 -->
<div v-if="form.table_fields.length > 0" class="mb-4">
<div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.panels.common.tableFields') }}
</div>
<div class="flex flex-wrap gap-1">
<ElTag
v-for="field in form.table_fields"
:key="field.name"
:type="field.isPrimaryKey ? 'warning' : 'info'"
size="small"
class="cursor-pointer"
@click="addFieldFromTable(field.name)"
>
{{ field.name }}
</ElTag>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.common.clickFieldToAdd') }}
</div>
</div>
<!-- 更新字段 -->
<div class="mb-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-sm font-medium">{{
$t('ai-platform.workflow.panels.dbUpdate.updateFields')
}}</span>
<ElButton
link
type="primary"
:icon="Plus"
size="small"
@click="addFieldMapping"
>
{{ $t('ai-platform.workflow.panels.common.addField') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.field_mapping"
:key="index"
class="border-border bg-muted flex items-center gap-2 rounded border p-2"
>
<ElSelect
v-if="form.table_fields.length > 0"
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.selectField')"
size="small"
class="w-32"
filterable
allow-create
>
<ElOption
v-for="f in form.table_fields"
:key="f.name"
:value="f.name"
:label="f.name"
/>
</ElSelect>
<ElInput
v-else
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.dbField')"
size="small"
class="w-32"
/>
<span class="text-muted-foreground"></span>
<SmartInput
v-model="item.value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.varValue')"
size="small"
class="flex-1"
/>
<ElButton
link
type="danger"
:icon="Trash2"
size="small"
@click="removeFieldMapping(index)"
/>
</div>
<div
v-if="form.field_mapping.length === 0"
class="text-muted-foreground py-4 text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.common.clickAddField') }}
</div>
</div>
</div>
<!-- 更新条件 -->
<div class="mb-4">
<div class="mb-2 flex items-center justify-between">
<span class="text-foreground text-sm font-medium">
{{ $t('ai-platform.workflow.panels.dbUpdate.updateConditions') }}
<span class="text-red-500">*</span>
</span>
<ElButton
link
type="primary"
:icon="Plus"
size="small"
@click="addCondition"
>
{{ $t('ai-platform.workflow.panels.common.addCondition') }}
</ElButton>
</div>
<div class="space-y-2">
<div
v-for="(item, index) in form.where_conditions"
:key="index"
class="border-border bg-muted flex items-center gap-2 rounded border p-2"
>
<ElInput
v-model="item.field"
:placeholder="$t('ai-platform.workflow.panels.common.field')"
size="small"
class="w-24"
/>
<ElSelect v-model="item.operator" size="small" class="w-24">
<ElOption
v-for="op in operatorOptions"
:key="op.value"
:value="op.value"
:label="op.label"
/>
</ElSelect>
<SmartInput
v-model="item.value"
:current-node-id="nodeId"
:placeholder="$t('ai-platform.workflow.panels.common.value')"
size="small"
class="flex-1"
/>
<ElButton
link
type="danger"
:icon="Trash2"
size="small"
@click="removeCondition(index)"
/>
</div>
<div
v-if="form.where_conditions.length === 0"
class="text-muted-foreground py-4 text-center text-xs"
>
<ElAlert type="error" :closable="false" show-icon>
<template #title>
{{ $t('ai-platform.workflow.panels.dbUpdate.mustAddCondition') }}
</template>
</ElAlert>
</div>
</div>
</div>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.output_variable" placeholder="db_result" />
</ElFormItem>
<!-- 表选择对话框 -->
<TableSelectDialog
v-model:visible="tableSelectVisible"
:current-table="form.table"
@select="handleTableSelect"
/>
</ElForm>
</template>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Plus, Trash2 } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElOption, ElSelect } from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
outputs: props.data.outputs || [],
output: props.data.output || '',
});
watch(
form,
(val) => {
emit('update', { ...props.data, ...val });
},
{ deep: true },
);
const handleAdd = () => {
form.value.outputs.push({
variable: '',
type: 'string',
});
};
const handleDelete = (index: number) => {
form.value.outputs.splice(index, 1);
};
</script>
<template>
<div class="space-y-4">
<!-- 输出内容 -->
<div class="border-border border-b pb-4">
<div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.panels.end.outputContent') }}
</div>
<SmartInput
v-model="form.output"
:current-node-id="nodeId"
type="textarea"
:rows="3"
:placeholder="$t('ai-platform.workflow.panels.end.outputPlaceholder')"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.end.outputHint') }}
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-foreground text-sm font-medium">{{
$t('ai-platform.workflow.panels.common.outputVars')
}}</span>
<ElButton link type="primary" :icon="Plus" @click="handleAdd">
{{ $t('ai-platform.workflow.panels.common.add') }}
</ElButton>
</div>
<div
v-if="form.outputs.length === 0"
class="text-muted-foreground py-4 text-center text-xs"
>
{{ $t('ai-platform.workflow.panels.end.noOutputVars') }}
</div>
<div v-else class="space-y-3">
<div
v-for="(item, index) in form.outputs"
:key="index"
class="border-border bg-muted rounded border p-3"
>
<div class="flex items-center gap-2">
<ElInput
v-model="item.variable"
:placeholder="$t('ai-platform.workflow.panels.common.variableName')"
size="small"
/>
<ElSelect
v-model="item.type"
:placeholder="$t('ai-platform.workflow.panels.end.type')"
size="small"
style="width: 100px"
>
<ElOption label="String" value="string" />
<ElOption label="Number" value="number" />
<ElOption label="JSON" value="json" />
</ElSelect>
<ElButton
link
type="danger"
:icon="Trash2"
@click="handleDelete(index)"
/>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.formBasicInfo.label'),
name: '',
code: '',
form_type: 'normal',
description: '',
sort: 0,
auto_generate_code: false,
require_confirmation: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.formBasicInfo.placeholder')
"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formBasicInfo.formName')"
required
>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formBasicInfo.formName')
}}</span>
<span class="text-red-500">*</span>
</div>
</template>
<SmartInput
v-model="form.name"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formBasicInfo.formNamePlaceholder') +
' {{llm_basic_name}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formBasicInfo.formNameHint')
}}{{ llm_basic_name }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formBasicInfo.formCode')"
>
<SmartInput
v-model="form.code"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formBasicInfo.formCodePlaceholder') +
' {{llm_basic_code}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formBasicInfo.formCodeHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formBasicInfo.autoGenCode')"
>
<ElSwitch v-model="form.auto_generate_code" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formBasicInfo.autoGenCodeHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formBasicInfo.formType')"
>
<ElSelect
v-model="form.form_type"
:placeholder="
$t('ai-platform.workflow.panels.formBasicInfo.formTypePlaceholder')
"
>
<ElOption
:label="$t('ai-platform.workflow.panels.formBasicInfo.normalForm')"
value="normal"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formBasicInfo.workflowForm')"
value="workflow"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formBasicInfo.formDesc')"
>
<SmartInput
v-model="form.description"
:current-node-id="nodeId"
type="textarea"
:rows="3"
:placeholder="
$t('ai-platform.workflow.panels.formBasicInfo.formDescPlaceholder')
"
/>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.formBasicInfo.sort')">
<ElInputNumber
v-model="form.sort"
:min="0"
:placeholder="
$t('ai-platform.workflow.panels.formBasicInfo.sortPlaceholder')
"
class="w-full"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formBasicInfo.sortHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">form_basic_info</span>
<span>{{
$t('ai-platform.workflow.panels.formBasicInfo.fullConfigOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_name</span>
<span>{{
$t('ai-platform.workflow.panels.formBasicInfo.formNameOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_code</span>
<span>{{
$t('ai-platform.workflow.panels.formBasicInfo.formCodeOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_type</span>
<span>{{
$t('ai-platform.workflow.panels.formBasicInfo.formTypeOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_description</span>
<span>{{
$t('ai-platform.workflow.panels.formBasicInfo.formDescOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_sort</span>
<span>{{
$t('ai-platform.workflow.panels.formBasicInfo.sortOutput')
}}</span>
</div>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElSwitch } from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.formCreate.label'),
form_basic_info: '',
database_design: '',
form_ui_design: '',
list_config: '',
application_id: '',
update_if_exists: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.formCreate.placeholder')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formCreate.formBasicInfo')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(object)</span>
</div>
</template>
<SmartInput
v-model="form.form_basic_info"
:current-node-id="nodeId"
type="textarea"
:rows="2"
placeholder="{{form_basic_info.form_basic_info}}"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formCreate.basicInfoHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formCreate.dbDesignConfig')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(object)</span>
</div>
</template>
<SmartInput
v-model="form.database_design"
:current-node-id="nodeId"
type="textarea"
:rows="2"
placeholder="{{form_database_design.database_design}}"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formCreate.dbDesignHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formCreate.uiDesignConfig')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(object)</span>
</div>
</template>
<SmartInput
v-model="form.form_ui_design"
:current-node-id="nodeId"
type="textarea"
:rows="2"
placeholder="{{form_ui_design.form_ui_design}}"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formCreate.uiDesignHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formCreate.listDesignConfig')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(object)</span>
</div>
</template>
<SmartInput
v-model="form.list_config"
:current-node-id="nodeId"
type="textarea"
:rows="2"
placeholder="{{form_list_design.list_config}}"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formCreate.listDesignHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{ $t('ai-platform.workflow.panels.formCreate.appId') }}</span>
<span class="text-muted-foreground text-xs">{{
$t('ai-platform.workflow.panels.llm.optional')
}}</span>
</div>
</template>
<SmartInput
v-model="form.application_id"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formCreate.appIdPlaceholder')
"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formCreate.updateIfExists')"
>
<ElSwitch v-model="form.update_if_exists" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formCreate.updateIfExistsHint') }}
</div>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">form_id</span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.formIdOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_code</span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.formCodeOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_meta</span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.formMetaOutput')
}}</span>
</div>
</div>
</div>
<!-- 工作流说明 -->
<div
class="mt-4 rounded border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950"
>
<div class="mb-2 font-medium text-blue-700 dark:text-blue-300">
{{ $t('ai-platform.workflow.panels.formCreate.fullWorkflow') }}
</div>
<div class="text-muted-foreground text-xs">
<div class="flex items-center gap-1">
<span>{{
$t('ai-platform.workflow.panels.formCreate.formBasicInfo')
}}</span>
<span></span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.dbDesign')
}}</span>
<span></span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.dbCreate')
}}</span>
</div>
<div class="mt-1 flex items-center gap-1">
<span></span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.uiDesign')
}}</span>
<span></span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.listDesign')
}}</span>
<span></span>
<span class="font-medium text-blue-600 dark:text-blue-400">{{
$t('ai-platform.workflow.panels.formCreate.formCreateStep')
}}</span>
<span></span>
<span>{{
$t('ai-platform.workflow.panels.formCreate.formPublish')
}}</span>
</div>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,303 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSwitch,
} from 'element-plus';
import { getFormListApi } from '#/api/online-dev/form-manager';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{
data: any;
nodeId: string;
nodeType: string;
}>();
const emit = defineEmits(['update']);
// 根据节点类型设置默认值
const getDefaultLabel = () => {
switch (props.nodeType) {
case 'form_data_create': {
return $t('ai-platform.workflow.panels.formData.createLabel');
}
case 'form_data_delete': {
return $t('ai-platform.workflow.panels.formData.deleteLabel');
}
case 'form_data_list': {
return $t('ai-platform.workflow.panels.formData.listLabel');
}
case 'form_data_read': {
return $t('ai-platform.workflow.panels.formData.readLabel');
}
case 'form_data_update': {
return $t('ai-platform.workflow.panels.formData.updateLabel');
}
case 'form_schema_to_llm': {
return $t('ai-platform.workflow.panels.formData.schemaToLlmLabel');
}
default: {
return $t('ai-platform.workflow.panels.formData.label');
}
}
};
const getDefaultOutputVariable = () => {
switch (props.nodeType) {
case 'form_data_create': {
return 'form_data_create_result';
}
case 'form_data_delete': {
return 'form_data_delete_result';
}
case 'form_data_list': {
return 'form_data_list';
}
case 'form_data_read': {
return 'form_data';
}
case 'form_data_update': {
return 'form_data_update_result';
}
case 'form_schema_to_llm': {
return 'llm_output_schema';
}
default: {
return 'form_data_result';
}
}
};
const form = ref({
label: getDefaultLabel(),
form_code: '',
id: '',
data: '',
page: 1,
page_size: 20,
filters: '',
sort: [],
hard_delete: false,
is_array: false,
include_fields: [],
exclude_fields: [],
output_variable: getDefaultOutputVariable(),
...props.data,
});
// 表单输入模式:variable(变量输入)或 select(下拉选择)
const formInputMode = ref<'select' | 'variable'>('variable');
// 表单列表
const formList = ref<any[]>([]);
const formLoading = ref(false);
// 加载表单列表
const loadFormList = async () => {
try {
formLoading.value = true;
const res = await getFormListApi({ page: 1, pageSize: 100, status: 'published' });
formList.value = res.items || [];
} catch (error) {
console.error('加载表单列表失败:', error);
} finally {
formLoading.value = false;
}
};
onMounted(() => {
loadFormList();
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
// 判断是否显示某个字段
const showField = (field: string) => {
const nodeType = props.nodeType;
switch (field) {
case 'data': {
return ['form_data_create', 'form_data_update'].includes(nodeType);
}
case 'exclude_fields':
case 'include_fields':
case 'is_array': {
return nodeType === 'form_schema_to_llm';
}
case 'filters':
case 'page':
case 'page_size':
case 'sort': {
return nodeType === 'form_data_list';
}
case 'hard_delete': {
return nodeType === 'form_data_delete';
}
case 'id': {
return ['form_data_delete', 'form_data_read', 'form_data_update'].includes(nodeType);
}
default: {
return true;
}
}
};
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput v-model="form.label" :placeholder="getDefaultLabel()" />
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.formData.formCode')">
<div class="flex items-center gap-2 mb-2">
<ElRadioGroup v-model="formInputMode" size="small">
<ElRadioButton value="variable">{{ $t('ai-platform.workflow.panels.formData.variableInput') }}</ElRadioButton>
<ElRadioButton value="select">{{ $t('ai-platform.workflow.panels.formData.selectFromList') }}</ElRadioButton>
</ElRadioGroup>
</div>
<!-- 变量输入模式 -->
<template v-if="formInputMode === 'variable'">
<SmartInput
v-model="form.form_code"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formData.formCodeInputPlaceholder') +
' {{form_code}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formData.formCodeInputHint') }}
</div>
</template>
<!-- 下拉选择模式 -->
<template v-else>
<ElSelect
v-model="form.form_code"
:placeholder="$t('ai-platform.workflow.panels.formData.selectFormPlaceholder')"
filterable
:loading="formLoading"
class="w-full"
>
<ElOption
v-for="f in formList"
:key="f.code"
:label="`${f.name} (${f.code})`"
:value="f.code"
/>
</ElSelect>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formData.selectedFormHint', { code: form.form_code || '-' }) }}
</div>
</template>
</ElFormItem>
<!-- 记录ID读取/更新/删除 -->
<ElFormItem v-if="showField('id')" :label="$t('ai-platform.workflow.panels.formData.recordId')">
<SmartInput
v-model="form.id"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formData.recordIdPlaceholder') +
' {{record_id}}'
"
/>
</ElFormItem>
<!-- 数据创建/更新 -->
<ElFormItem v-if="showField('data')">
<template #label>
<div class="flex items-center gap-2">
<span>{{ $t('ai-platform.workflow.panels.formData.data') }}</span>
<span class="text-muted-foreground text-xs">(JSON)</span>
</div>
</template>
<SmartInput
v-model="form.data"
:current-node-id="nodeId"
type="textarea"
:rows="6"
:placeholder="$t('ai-platform.workflow.panels.formData.dataPlaceholder') + ' {{llm_response}}'"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formData.dataHint') }}
</div>
</ElFormItem>
<!-- 分页列表查询 -->
<template v-if="showField('page')">
<div class="flex gap-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.formData.pageNum')" class="flex-1">
<ElInputNumber v-model="form.page" :min="1" class="w-full" />
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.formData.pageSize')" class="flex-1">
<ElInputNumber v-model="form.page_size" :min="1" :max="1000" class="w-full" />
</ElFormItem>
</div>
</template>
<!-- 过滤条件列表查询 -->
<ElFormItem v-if="showField('filters')">
<template #label>
<div class="flex items-center gap-2">
<span>{{ $t('ai-platform.workflow.panels.formData.filterConditions') }}</span>
<span class="text-muted-foreground text-xs">(JSON)</span>
</div>
</template>
<SmartInput
v-model="form.filters"
:current-node-id="nodeId"
type="textarea"
:rows="4"
placeholder="{&quot;status&quot;: &quot;active&quot;, &quot;name&quot;: {&quot;type&quot;: &quot;like&quot;, &quot;value&quot;: &quot;Zhang&quot;}}"
/>
</ElFormItem>
<!-- 硬删除删除 -->
<ElFormItem v-if="showField('hard_delete')">
<div class="flex items-center gap-2">
<ElSwitch v-model="form.hard_delete" />
<span class="text-sm">{{ $t('ai-platform.workflow.panels.formData.hardDelete') }}</span>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formData.hardDeleteHint') }}
</div>
</ElFormItem>
<!-- 数组模式表单转LLM结构 -->
<ElFormItem v-if="showField('is_array')">
<div class="flex items-center gap-2">
<ElSwitch v-model="form.is_array" />
<span class="text-sm">{{ $t('ai-platform.workflow.panels.formData.generateArray') }}</span>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formData.generateArrayHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.outputVarName')">
<ElInput v-model="form.output_variable" :placeholder="getDefaultOutputVariable()" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formData.outputVarHint') }}
</div>
</ElFormItem>
</ElForm>
</template>
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElAlert,
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.formDbCreate.label'),
database_design: '',
db_database: '',
db_schema: '',
if_exists: 'skip',
create_schema_if_not_exists: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.formDbCreate.placeholder')
"
/>
</ElFormItem>
<ElAlert type="warning" :closable="false" show-icon class="mb-4">
<template #title>
<span class="text-sm">{{
$t('ai-platform.workflow.panels.formDbCreate.cautionWarning')
}}</span>
</template>
</ElAlert>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formDbCreate.dbDesignConfig')"
required
>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbCreate.dbDesignConfig')
}}</span>
<span class="text-red-500">*</span>
</div>
</template>
<SmartInput
v-model="form.database_design"
:current-node-id="nodeId"
type="textarea"
:rows="4"
:placeholder="
$t(
'ai-platform.workflow.panels.formDbCreate.dbDesignConfigPlaceholder',
) + ' {{database_design}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbCreate.dbDesignConfigHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formDbCreate.database')"
>
<SmartInput
v-model="form.db_database"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formDbCreate.databasePlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbCreate.databaseHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.formDbCreate.schema')">
<SmartInput
v-model="form.db_schema"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formDbCreate.schemaPlaceholder') +
' {{schema_name}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbCreate.schemaHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formDbCreate.ifExists')"
>
<ElSelect
v-model="form.if_exists"
:placeholder="
$t('ai-platform.workflow.panels.formDbCreate.ifExistsPlaceholder')
"
>
<ElOption
:label="
$t('ai-platform.workflow.panels.formDbCreate.skipRecommended')
"
value="skip"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formDbCreate.throwError')"
value="error"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formDbCreate.replaceDanger')"
value="replace"
/>
</ElSelect>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbCreate.ifExistsHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formDbCreate.autoCreateSchema')"
>
<ElSwitch v-model="form.create_schema_if_not_exists" />
<div class="text-muted-foreground mt-1 text-xs">
{{
$t('ai-platform.workflow.panels.formDbCreate.autoCreateSchemaHint')
}}
</div>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">creation_result</span>
<span>{{
$t('ai-platform.workflow.panels.formDbCreate.resultOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">all_tables_ready</span>
<span>{{
$t('ai-platform.workflow.panels.formDbCreate.allReadyOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">created_count</span>
<span>{{
$t('ai-platform.workflow.panels.formDbCreate.successCountOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">error_count</span>
<span>{{
$t('ai-platform.workflow.panels.formDbCreate.errorCountOutput')
}}</span>
</div>
</div>
</div>
<!-- 警告提示 -->
<div
class="mt-4 rounded border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-950"
>
<div class="mb-2 font-medium text-orange-700 dark:text-orange-300">
{{ $t('ai-platform.workflow.panels.formDbCreate.notes') }}
</div>
<ul class="text-muted-foreground list-disc space-y-1 pl-4 text-xs">
<li>{{ $t('ai-platform.workflow.panels.formDbCreate.note1') }}</li>
<li>{{ $t('ai-platform.workflow.panels.formDbCreate.note2') }}</li>
<li>{{ $t('ai-platform.workflow.panels.formDbCreate.note3') }}</li>
<li>{{ $t('ai-platform.workflow.panels.formDbCreate.note4') }}</li>
</ul>
</div>
</ElForm>
</template>
@@ -0,0 +1,360 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElRadioButton,
ElRadioGroup,
ElSwitch,
} from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import DbConnectionSelect from '../components/DbConnectionSelect.vue';
import DbDatabaseSelect from '../components/DbDatabaseSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.formDbDesign.label'),
design_mode: 'main', // 'main' or 'sub'
// 主表配置
table_name: '',
fields: '',
// 从表配置
sub_table_name: '',
sub_fields: '',
parent_table: '',
foreign_key: '',
// 通用配置
db_config: 'default',
db_database: '',
db_schema: '',
db_type: 'postgresql',
auto_add_system_fields: true,
require_confirmation: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
const resolvedDbTypeLabel = computed(() => form.value.db_type || 'postgresql');
function handleConnectionChange(payload: {
database?: string;
dbName: string;
dbType: string;
}) {
const connectionChanged = form.value.db_config !== payload.dbName;
form.value.db_config = payload.dbName;
form.value.db_type = payload.dbType;
if (connectionChanged) {
form.value.db_database = payload.database || '';
}
}
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.formDbDesign.placeholder')
"
/>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formDbDesign.designMode')"
>
<ElRadioGroup v-model="form.design_mode">
<ElRadioButton value="main">
{{ $t('ai-platform.workflow.panels.formDbDesign.mainTable') }}
</ElRadioButton>
<ElRadioButton value="sub">
{{ $t('ai-platform.workflow.panels.formDbDesign.subTable') }}
</ElRadioButton>
</ElRadioGroup>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.designModeHint') }}
</div>
</ElFormItem>
<!-- 主表配置 -->
<template v-if="form.design_mode === 'main'">
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.tableName')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.table_name"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formDbDesign.tableNamePlaceholder') +
' {{llm_table_name}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.tableNameHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.fieldList')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(array)</span>
</div>
</template>
<SmartInput
v-model="form.fields"
:current-node-id="nodeId"
type="textarea"
:rows="8"
:placeholder="
$t('ai-platform.workflow.panels.formDbDesign.fieldListPlaceholder') +
' {{llm_fields}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.fieldListHint') }}
</div>
</ElFormItem>
</template>
<!-- 从表配置 -->
<template v-else>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.subTableName')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.sub_table_name"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formDbDesign.subTableNamePlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.subTableNameHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.relatedMainTable')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.parent_table"
:current-node-id="nodeId"
:placeholder="
$t(
'ai-platform.workflow.panels.formDbDesign.relatedMainTablePlaceholder',
)
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{
$t('ai-platform.workflow.panels.formDbDesign.relatedMainTableHint')
}}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.foreignKey')
}}</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.foreign_key"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formDbDesign.foreignKeyPlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.foreignKeyHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.fieldList')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(array)</span>
</div>
</template>
<SmartInput
v-model="form.sub_fields"
:current-node-id="nodeId"
type="textarea"
:rows="8"
:placeholder="
$t(
'ai-platform.workflow.panels.formDbDesign.subFieldListPlaceholder',
)
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.subFieldListHint') }}
</div>
</ElFormItem>
</template>
<DbConnectionSelect
v-model="form.db_config"
write-mode
@change="handleConnectionChange"
/>
<DbDatabaseSelect
v-model="form.db_database"
:db-name="form.db_config"
:db-type="form.db_type"
:current-node-id="nodeId"
/>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.dbSchema')
}}</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.db_schema"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formDbDesign.dbSchemaPlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.dbSchemaHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.formDbDesign.dbType')">
<ElInput :model-value="resolvedDbTypeLabel" readonly />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formDbDesign.dbTypeHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="
$t('ai-platform.workflow.panels.formDbDesign.autoAddSystemFields')
"
>
<ElSwitch v-model="form.auto_add_system_fields" />
<div class="text-muted-foreground mt-1 text-xs">
{{
$t('ai-platform.workflow.panels.formDbDesign.autoAddSystemFieldsHint')
}}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">database_design</span>
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.fullDesignOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">table_name</span>
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.tableNameOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">field_count</span>
<span>{{
$t('ai-platform.workflow.panels.formDbDesign.fieldCountOutput')
}}</span>
</div>
</div>
</div>
<!-- 字段配置示例 -->
<div
class="mt-4 rounded border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950"
>
<div class="mb-2 font-medium text-blue-700 dark:text-blue-300">
{{ $t('ai-platform.workflow.panels.formDbDesign.fieldConfigExample') }}
</div>
<pre class="text-muted-foreground overflow-x-auto text-[10px]">
[
{
"name": "name",
"type": "varchar",
"maxLength": 100,
"comment": "Name",
"nullable": false
},
{
"name": "age",
"type": "int",
"comment": "Age",
"nullable": true
}
]</pre
>
</div>
</ElForm>
</template>
@@ -0,0 +1,286 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.formListDesign.label'),
form_ui_design: '',
database_design: '',
auto_query_fields: true,
auto_columns: true,
container_type: 'drawer',
page_size: 20,
show_index: true,
show_selection: true,
enable_export: true,
enable_import: false,
require_confirmation: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.formListDesign.placeholder')
"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formListDesign.uiDesignConfig')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(object)</span>
</div>
</template>
<SmartInput
v-model="form.form_ui_design"
:current-node-id="nodeId"
type="textarea"
:rows="3"
:placeholder="
$t(
'ai-platform.workflow.panels.formListDesign.uiDesignConfigPlaceholder',
) + ' {{form_ui_design.form_ui_design}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{
$t('ai-platform.workflow.panels.formListDesign.uiDesignConfigHint')
}}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formListDesign.dbDesignConfig')
}}</span>
<span class="text-muted-foreground text-xs"
>(object,
{{ $t('ai-platform.workflow.panels.llm.optional') }})</span
>
</div>
</template>
<SmartInput
v-model="form.database_design"
:current-node-id="nodeId"
type="textarea"
:rows="2"
:placeholder="
$t(
'ai-platform.workflow.panels.formListDesign.dbDesignConfigPlaceholder',
) + ' {{form_database_design.database_design}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{
$t('ai-platform.workflow.panels.formListDesign.dbDesignConfigHint')
}}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.autoGenQuery')"
>
<ElSwitch v-model="form.auto_query_fields" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formListDesign.autoGenQueryHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.autoGenColumns')"
>
<ElSwitch v-model="form.auto_columns" />
<div class="text-muted-foreground mt-1 text-xs">
{{
$t('ai-platform.workflow.panels.formListDesign.autoGenColumnsHint')
}}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.containerType')"
>
<ElSelect
v-model="form.container_type"
:placeholder="
$t(
'ai-platform.workflow.panels.formListDesign.containerTypePlaceholder',
)
"
>
<ElOption
:label="$t('ai-platform.workflow.panels.formListDesign.drawer')"
value="drawer"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formListDesign.dialog')"
value="dialog"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formListDesign.page')"
value="page"
/>
</ElSelect>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formListDesign.containerTypeHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.pageSize')"
>
<ElSelect
v-model="form.page_size"
:placeholder="
$t('ai-platform.workflow.panels.formListDesign.pageSizePlaceholder')
"
>
<ElOption
:label="
$t('ai-platform.workflow.panels.formListDesign.perPage', {
count: 10,
})
"
:value="10"
/>
<ElOption
:label="
$t('ai-platform.workflow.panels.formListDesign.perPage', {
count: 20,
})
"
:value="20"
/>
<ElOption
:label="
$t('ai-platform.workflow.panels.formListDesign.perPage', {
count: 50,
})
"
:value="50"
/>
<ElOption
:label="
$t('ai-platform.workflow.panels.formListDesign.perPage', {
count: 100,
})
"
:value="100"
/>
</ElSelect>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.showIndex')"
>
<ElSwitch v-model="form.show_index" />
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.showSelection')"
>
<ElSwitch v-model="form.show_selection" />
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.enableExport')"
>
<ElSwitch v-model="form.enable_export" />
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formListDesign.enableImport')"
>
<ElSwitch v-model="form.enable_import" />
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">list_config</span>
<span>{{
$t('ai-platform.workflow.panels.formListDesign.fullConfigOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">query_field_count</span>
<span>{{
$t(
'ai-platform.workflow.panels.formListDesign.queryFieldCountOutput',
)
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">column_count</span>
<span>{{
$t('ai-platform.workflow.panels.formListDesign.columnCountOutput')
}}</span>
</div>
</div>
</div>
<!-- 智能推断说明 -->
<div
class="mt-4 rounded border border-indigo-200 bg-indigo-50 p-3 dark:border-indigo-800 dark:bg-indigo-950"
>
<div class="mb-2 font-medium text-indigo-700 dark:text-indigo-300">
{{ $t('ai-platform.workflow.panels.formListDesign.inferRules') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div> {{ $t('ai-platform.workflow.panels.formListDesign.inferRule1') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formListDesign.inferRule2') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formListDesign.inferRule3') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formListDesign.inferRule4') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formListDesign.inferRule5') }}</div>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput } from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.formPublish.label'),
form_id: '',
form_code: '',
require_confirmation: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="$t('ai-platform.workflow.panels.formPublish.placeholder')"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formPublish.formId')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.form_id"
:current-node-id="nodeId"
placeholder="{{form_create.form_id}}"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formPublish.formIdHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formPublish.formCode')
}}</span>
<span class="text-muted-foreground text-xs">{{
$t('ai-platform.workflow.panels.llm.optional')
}}</span>
</div>
</template>
<SmartInput
v-model="form.form_code"
:current-node-id="nodeId"
placeholder="{{form_create.form_code}}"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formPublish.formCodeHint') }}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">menu_id</span>
<span>{{
$t('ai-platform.workflow.panels.formPublish.menuIdOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">route_path</span>
<span>{{
$t('ai-platform.workflow.panels.formPublish.routePathOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">publish_result</span>
<span>{{
$t('ai-platform.workflow.panels.formPublish.publishResultOutput')
}}</span>
</div>
</div>
</div>
<!-- 工作流说明 -->
<div
class="mt-4 rounded border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950"
>
<div class="mb-2 font-medium text-blue-700 dark:text-blue-300">
{{ $t('ai-platform.workflow.panels.common.usage') }}
</div>
<div class="text-muted-foreground text-xs">
<p>{{ $t('ai-platform.workflow.panels.formPublish.usageTip1') }}</p>
<p class="mt-1">
{{ $t('ai-platform.workflow.panels.formPublish.usageTip2') }}
</p>
</div>
</div>
</ElForm>
</template>
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import ConfirmModeSelect from '../components/ConfirmModeSelect.vue';
import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>();
const emit = defineEmits(['update']);
const form = ref({
label: $t('ai-platform.workflow.panels.formUIDesign.label'),
database_design: '',
form_name: '',
form_code: '',
layout_mode: 'auto',
label_width: 120,
enable_grouping: true,
hide_system_fields: true,
require_confirmation: true,
...props.data,
});
watch(
form,
(val) => {
emit('update', val);
},
{ deep: true },
);
</script>
<template>
<ElForm label-position="top" size="small" class="space-y-4">
<ElFormItem :label="$t('ai-platform.workflow.panels.common.nodeName')">
<ElInput
v-model="form.label"
:placeholder="
$t('ai-platform.workflow.panels.formUIDesign.placeholder')
"
/>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formUIDesign.dbDesignConfig')
}}</span>
<span class="text-red-500">*</span>
<span class="text-muted-foreground text-xs">(object)</span>
</div>
</template>
<SmartInput
v-model="form.database_design"
:current-node-id="nodeId"
type="textarea"
:rows="3"
:placeholder="
$t(
'ai-platform.workflow.panels.formUIDesign.dbDesignConfigPlaceholder',
) + ' {{form_database_design.database_design}}'
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formUIDesign.dbDesignConfigHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formUIDesign.formName')
}}</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.form_name"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formUIDesign.formNamePlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formUIDesign.formNameHint') }}
</div>
</ElFormItem>
<ElFormItem>
<template #label>
<div class="flex items-center gap-2">
<span>{{
$t('ai-platform.workflow.panels.formUIDesign.formCode')
}}</span>
<span class="text-muted-foreground text-xs">(string)</span>
</div>
</template>
<SmartInput
v-model="form.form_code"
:current-node-id="nodeId"
:placeholder="
$t('ai-platform.workflow.panels.formUIDesign.formCodePlaceholder')
"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formUIDesign.formCodeHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formUIDesign.layoutMode')"
>
<ElSelect
v-model="form.layout_mode"
:placeholder="
$t('ai-platform.workflow.panels.formUIDesign.layoutModePlaceholder')
"
>
<ElOption
:label="$t('ai-platform.workflow.panels.formUIDesign.layoutAuto')"
value="auto"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formUIDesign.layoutSingle')"
value="single"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formUIDesign.layoutDouble')"
value="double"
/>
<ElOption
:label="$t('ai-platform.workflow.panels.formUIDesign.layoutTriple')"
value="triple"
/>
</ElSelect>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formUIDesign.layoutAutoHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formUIDesign.labelWidth')"
>
<ElInputNumber
v-model="form.label_width"
:min="60"
:max="200"
:step="10"
controls-position="right"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formUIDesign.labelWidthHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formUIDesign.enableGrouping')"
>
<ElSwitch v-model="form.enable_grouping" />
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.formUIDesign.enableGroupingHint') }}
</div>
</ElFormItem>
<ElFormItem
:label="$t('ai-platform.workflow.panels.formUIDesign.hideSystemFields')"
>
<ElSwitch v-model="form.hide_system_fields" />
<div class="text-muted-foreground mt-1 text-xs">
{{
$t('ai-platform.workflow.panels.formUIDesign.hideSystemFieldsHint')
}}
</div>
</ElFormItem>
<ElFormItem :label="$t('ai-platform.workflow.panels.common.confirmMode')">
<ConfirmModeSelect
v-model="form.require_confirmation"
:current-node-id="nodeId"
/>
</ElFormItem>
<!-- 输出变量说明 -->
<div
class="mt-4 rounded border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950"
>
<div class="mb-2 font-medium text-green-700 dark:text-green-300">
{{ $t('ai-platform.workflow.panels.common.outputVars') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div class="flex justify-between">
<span class="font-mono">form_ui_design</span>
<span>{{
$t('ai-platform.workflow.panels.formUIDesign.fullConfigOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">form_code</span>
<span>{{
$t('ai-platform.workflow.panels.formUIDesign.formCodeOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">field_count</span>
<span>{{
$t('ai-platform.workflow.panels.formUIDesign.fieldCountOutput')
}}</span>
</div>
<div class="flex justify-between">
<span class="font-mono">group_count</span>
<span>{{
$t('ai-platform.workflow.panels.formUIDesign.groupCountOutput')
}}</span>
</div>
</div>
</div>
<!-- 智能映射说明 -->
<div
class="mt-4 rounded border border-purple-200 bg-purple-50 p-3 dark:border-purple-800 dark:bg-purple-950"
>
<div class="mb-2 font-medium text-purple-700 dark:text-purple-300">
{{ $t('ai-platform.workflow.panels.formUIDesign.mappingRules') }}
</div>
<div class="text-muted-foreground space-y-1 text-xs">
<div> {{ $t('ai-platform.workflow.panels.formUIDesign.mappingRule1') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formUIDesign.mappingRule2') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formUIDesign.mappingRule3') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formUIDesign.mappingRule4') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formUIDesign.mappingRule5') }}</div>
<div> {{ $t('ai-platform.workflow.panels.formUIDesign.mappingRule6') }}</div>
</div>
</div>
</ElForm>
</template>

Some files were not shown because too many files have changed in this diff Show More