feat: restore workflow editing experience

This commit is contained in:
2026-06-12 10:48:46 +08:00
parent 16490392b2
commit 7e3a2587a7
11 changed files with 338 additions and 66 deletions
@@ -0,0 +1,195 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
const props = withDefaults(
defineProps<{
autocompletion?: boolean;
bracketMatching?: boolean;
foldGutter?: boolean;
height?: number | string;
language?: string;
lineNumbers?: boolean;
modelValue?: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
autocompletion: false,
bracketMatching: false,
foldGutter: false,
height: '240px',
language: 'text',
lineNumbers: false,
modelValue: '',
placeholder: '',
readonly: false,
},
);
const emit = defineEmits<{
change: [value: string];
'update:modelValue': [value: string];
}>();
const lineNumbersRef = ref<HTMLPreElement | null>(null);
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const editorStyle = computed(() => ({
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
}));
const lineNumbersText = computed(() => {
const count = Math.max(1, props.modelValue.split(/\r\n|\r|\n/).length);
return Array.from({ length: count }, (_, index) => index + 1).join('\n');
});
const languageLabel = computed(() => {
const language = props.language === 'python3' ? 'python' : props.language;
return language.toUpperCase();
});
function handleInput(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
emit('update:modelValue', value);
}
function handleChange(event: Event) {
const value = (event.target as HTMLTextAreaElement).value;
emit('change', value);
}
function handleKeydown(event: KeyboardEvent) {
if (props.readonly || event.key !== 'Tab') {
return;
}
event.preventDefault();
const target = event.target as HTMLTextAreaElement;
const start = target.selectionStart;
const end = target.selectionEnd;
const value = props.modelValue || '';
const nextValue = `${value.slice(0, start)} ${value.slice(end)}`;
emit('update:modelValue', nextValue);
requestAnimationFrame(() => {
target.selectionStart = start + 2;
target.selectionEnd = start + 2;
});
}
function handleScroll(event: Event) {
if (!lineNumbersRef.value) {
return;
}
lineNumbersRef.value.scrollTop = (event.target as HTMLTextAreaElement).scrollTop;
}
defineExpose({
focus: () => textareaRef.value?.focus(),
});
</script>
<template>
<div
class="code-editor"
:class="{ 'code-editor--readonly': readonly }"
:style="editorStyle"
>
<div class="code-editor__body">
<pre
v-if="lineNumbers"
ref="lineNumbersRef"
class="code-editor__lines"
aria-hidden="true"
>{{ lineNumbersText }}</pre
>
<textarea
ref="textareaRef"
class="code-editor__textarea"
:placeholder="placeholder"
:readonly="readonly"
:spellcheck="false"
:value="modelValue"
@change="handleChange"
@input="handleInput"
@keydown="handleKeydown"
@scroll="handleScroll"
></textarea>
<span v-if="language" class="code-editor__language">
{{ languageLabel }}
</span>
</div>
</div>
</template>
<style scoped>
.code-editor {
overflow: hidden;
border: 1px solid hsl(var(--border));
border-radius: 6px;
background: hsl(var(--muted) / 45%);
}
.code-editor__body {
position: relative;
display: flex;
height: 100%;
min-height: 0;
}
.code-editor__lines {
min-width: 42px;
height: 100%;
margin: 0;
padding: 10px 8px;
overflow: hidden;
border-right: 1px solid hsl(var(--border));
color: hsl(var(--muted-foreground) / 70%);
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
text-align: right;
user-select: none;
}
.code-editor__textarea {
width: 100%;
height: 100%;
min-width: 0;
padding: 10px 12px;
overflow: auto;
border: 0;
outline: none;
resize: none;
background: transparent;
color: hsl(var(--foreground));
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
tab-size: 2;
}
.code-editor__textarea::placeholder {
color: hsl(var(--muted-foreground) / 55%);
}
.code-editor__language {
position: absolute;
right: 8px;
bottom: 6px;
pointer-events: none;
color: hsl(var(--muted-foreground) / 55%);
font-size: 10px;
font-weight: 600;
letter-spacing: 0;
}
.code-editor--readonly .code-editor__textarea {
cursor: default;
}
</style>
@@ -0,0 +1 @@
export { default as CodeEditor } from './code-editor.vue';
@@ -946,6 +946,9 @@
"userInput": "用户输入", "userInput": "用户输入",
"sessionId": "会话 ID", "sessionId": "会话 ID",
"agentId": "智能体 ID", "agentId": "智能体 ID",
"appId": "应用 ID",
"appCode": "应用编码",
"formCode": "表单编码",
"renderResult": "渲染结果", "renderResult": "渲染结果",
"subflowFullResult": "子流程完整结果", "subflowFullResult": "子流程完整结果",
"execSuccess": "执行是否成功", "execSuccess": "执行是否成功",
@@ -1066,7 +1069,10 @@
"start": { "start": {
"userInputDesc": "用户在对话框中输入的内容,自动传入此变量", "userInputDesc": "用户在对话框中输入的内容,自动传入此变量",
"sessionIdDesc": "当前对话会话 ID,用于多轮记忆和执行追踪", "sessionIdDesc": "当前对话会话 ID,用于多轮记忆和执行追踪",
"agentIdDesc": "当前调用智能体 ID,用于智能体协作和审计" "agentIdDesc": "当前调用智能体 ID,用于智能体协作和审计",
"appIdDesc": "当前应用 ID,用于应用内流程隔离和上下文注入",
"appCodeDesc": "当前应用编码,用于跨流程引用应用上下文",
"formCodeDesc": "当前表单编码,用于表单触发或表单相关流程"
}, },
"choice": { "choice": {
"label": "选项节点", "label": "选项节点",
@@ -17,6 +17,7 @@ import {
} from '@vben/icons'; } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
import { CodeEditor } from '#/components/zq-form/code-editor';
import { ZqDialog } from '#/components/zq-dialog'; import { ZqDialog } from '#/components/zq-dialog';
export interface IterationResult { export interface IterationResult {
@@ -253,8 +254,14 @@ const formattedInputs = computed(() => {
<div class="text-foreground mb-2 text-sm font-medium"> <div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.editor.nodeResultCard.input') }} {{ $t('ai-platform.workflow.editor.nodeResultCard.input') }}
</div> </div>
<div class="result-json-view min-h-0 flex-1 overflow-auto rounded border"> <div class="min-h-0 flex-1 overflow-hidden">
<pre>{{ formattedInputs }}</pre> <CodeEditor
:model-value="formattedInputs"
language="json"
height="100%"
:line-numbers="true"
:readonly="true"
/>
</div> </div>
</div> </div>
@@ -262,8 +269,14 @@ const formattedInputs = computed(() => {
<div class="text-foreground mb-2 text-sm font-medium"> <div class="text-foreground mb-2 text-sm font-medium">
{{ $t('ai-platform.workflow.editor.nodeResultCard.output') }} {{ $t('ai-platform.workflow.editor.nodeResultCard.output') }}
</div> </div>
<div class="result-json-view min-h-0 flex-1 overflow-auto rounded border"> <div class="min-h-0 flex-1 overflow-hidden">
<pre>{{ formattedOutput }}</pre> <CodeEditor
:model-value="formattedOutput"
language="json"
height="100%"
:line-numbers="true"
:readonly="true"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -462,23 +475,3 @@ const formattedInputs = computed(() => {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.result-json-view {
background: hsl(var(--muted) / 35%);
padding: 12px;
}
.result-json-view pre {
min-height: 100%;
margin: 0;
color: hsl(var(--muted-foreground));
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
</style>
@@ -395,6 +395,21 @@ const availableNodes = computed(() => {
), ),
isSystem: true, 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( variables.push(
@@ -58,6 +58,7 @@ import {
runWorkflowStreamApi, runWorkflowStreamApi,
updateWorkflowApi, updateWorkflowApi,
} from '#/api/ai-platform/ai-platform'; } from '#/api/ai-platform/ai-platform';
import { useAppContextStore } from '#/store/app-context';
import { useWorkflowEditorStore } from '#/store/workflow-editor'; import { useWorkflowEditorStore } from '#/store/workflow-editor';
import RunDialog from './components/RunDialog.vue'; import RunDialog from './components/RunDialog.vue';
@@ -80,6 +81,7 @@ const AiChatPanel = defineAsyncComponent(
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const appContextStore = useAppContextStore();
const workflowId = route.params.id as string; const workflowId = route.params.id as string;
// 使用 Pinia store 管理工作流编辑器状态 // 使用 Pinia store 管理工作流编辑器状态
@@ -1822,7 +1824,7 @@ const handleNodeComplete = (event: {
}; };
const handleBack = () => { const handleBack = () => {
router.push('/ai-platform/workflow'); router.push(appContextStore.getContextPath('/ai-platform/workflow'));
}; };
// 键盘快捷键 // 键盘快捷键
@@ -13,6 +13,7 @@ import {
ElTooltip, ElTooltip,
} from 'element-plus'; } from 'element-plus';
import { CodeEditor } from '#/components/zq-form/code-editor';
import { ZqDialog } from '#/components/zq-dialog'; import { ZqDialog } from '#/components/zq-dialog';
import SmartInput from '../components/SmartInput.vue'; import SmartInput from '../components/SmartInput.vue';
@@ -185,13 +186,14 @@ const codeEditorVisible = ref(false);
</ElButton> </ElButton>
</ElTooltip> </ElTooltip>
</div> </div>
<ElInput <CodeEditor
v-model="form.code" v-model="form.code"
type="textarea" language="python"
:rows="14" height="280px"
resize="vertical"
class="workflow-code-textarea"
:placeholder="$t('ai-platform.workflow.panels.code.codePlaceholder')" :placeholder="$t('ai-platform.workflow.panels.code.codePlaceholder')"
:autocompletion="true"
:bracket-matching="true"
:fold-gutter="true"
/> />
</div> </div>
</ElForm> </ElForm>
@@ -204,12 +206,15 @@ const codeEditorVisible = ref(false);
:show-footer="false" :show-footer="false"
> >
<div class="code-editor-dialog-content"> <div class="code-editor-dialog-content">
<ElInput <CodeEditor
v-model="form.code" v-model="form.code"
type="textarea" language="python"
resize="none" height="100%"
class="workflow-code-textarea workflow-code-textarea--dialog"
:placeholder="$t('ai-platform.workflow.panels.code.codePlaceholder')" :placeholder="$t('ai-platform.workflow.panels.code.codePlaceholder')"
:autocompletion="true"
:bracket-matching="true"
:fold-gutter="true"
:line-numbers="true"
/> />
</div> </div>
</ZqDialog> </ZqDialog>
@@ -239,6 +244,12 @@ const codeEditorVisible = ref(false);
min-height: 0; 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 { .code-editor-dialog.is-fullscreen .el-dialog__body {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -255,16 +266,4 @@ const codeEditorVisible = ref(false);
height: 100% !important; height: 100% !important;
} }
.workflow-code-textarea textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
}
.workflow-code-textarea--dialog,
.workflow-code-textarea--dialog textarea {
height: 100%;
}
</style> </style>
@@ -16,6 +16,8 @@ import {
ElTabs, ElTabs,
} from 'element-plus'; } from 'element-plus';
import { CodeEditor } from '#/components/zq-form/code-editor';
import SmartInput from '../components/SmartInput.vue'; import SmartInput from '../components/SmartInput.vue';
const props = defineProps<{ data: any; nodeId: string }>(); const props = defineProps<{ data: any; nodeId: string }>();
@@ -173,13 +175,13 @@ const removeParam = (index: number) => {
</ElTabPane> </ElTabPane>
<ElTabPane label="Body"> <ElTabPane label="Body">
<ElInput <CodeEditor
v-model="form.body" v-model="form.body"
type="textarea" language="json"
:rows="10" height="200px"
resize="vertical"
class="workflow-code-textarea"
placeholder='{"key": "value"}' placeholder='{"key": "value"}'
:bracket-matching="true"
:fold-gutter="true"
/> />
<div class="text-muted-foreground mt-1 text-xs"> <div class="text-muted-foreground mt-1 text-xs">
{{ $t('ai-platform.workflow.panels.http.bodyHint') }} {{ $t('ai-platform.workflow.panels.http.bodyHint') }}
@@ -205,11 +207,4 @@ const removeParam = (index: number) => {
padding: 12px; padding: 12px;
} }
:deep(.workflow-code-textarea textarea) {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
}
</style> </style>
@@ -76,6 +76,42 @@ const handleDelete = (index: number) => {
</div> </div>
</div> </div>
<div class="border-primary/30 bg-primary/5 rounded border p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-primary text-sm font-medium">application_id</span>
<span class="text-muted-foreground text-xs">{{
$t('ai-platform.workflow.panels.common.sysVar')
}}</span>
</div>
<div class="text-muted-foreground text-xs">
{{ $t('ai-platform.workflow.panels.start.appIdDesc') }}
</div>
</div>
<div class="border-primary/30 bg-primary/5 rounded border p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-primary text-sm font-medium">application_code</span>
<span class="text-muted-foreground text-xs">{{
$t('ai-platform.workflow.panels.common.sysVar')
}}</span>
</div>
<div class="text-muted-foreground text-xs">
{{ $t('ai-platform.workflow.panels.start.appCodeDesc') }}
</div>
</div>
<div class="border-primary/30 bg-primary/5 rounded border p-3">
<div class="mb-2 flex items-center justify-between">
<span class="text-primary text-sm font-medium">form_code</span>
<span class="text-muted-foreground text-xs">{{
$t('ai-platform.workflow.panels.common.sysVar')
}}</span>
</div>
<div class="text-muted-foreground text-xs">
{{ $t('ai-platform.workflow.panels.start.formCodeDesc') }}
</div>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-foreground text-sm font-medium">{{ <span class="text-foreground text-sm font-medium">{{
$t('ai-platform.workflow.panels.common.customVars') $t('ai-platform.workflow.panels.common.customVars')
@@ -36,6 +36,7 @@ import {
ElOption, ElOption,
ElPagination, ElPagination,
ElSelect, ElSelect,
ElSwitch,
ElTag, ElTag,
ElTooltip, ElTooltip,
} from 'element-plus'; } from 'element-plus';
@@ -51,11 +52,14 @@ import {
updateWorkflowApi, updateWorkflowApi,
} from '#/api/ai-platform/ai-platform'; } from '#/api/ai-platform/ai-platform';
import { ZqDialog } from '#/components/zq-dialog'; import { ZqDialog } from '#/components/zq-dialog';
import { useAppContextStore } from '#/store/app-context';
import RunDialog from '../workflow/editor/components/RunDialog.vue'; import RunDialog from '../workflow/editor/components/RunDialog.vue';
import ImportDialog from './modules/import-dialog.vue'; import ImportDialog from './modules/import-dialog.vue';
const router = useRouter(); const router = useRouter();
const appContextStore = useAppContextStore();
const isMainApp = computed(() => appContextStore.isMainApp);
// 工作流类型选项 // 工作流类型选项
const workflowTypeOptions = computed(() => [ const workflowTypeOptions = computed(() => [
@@ -114,6 +118,7 @@ async function handleConfirm() {
} else { } else {
// 创建模式 // 创建模式
const res = await createWorkflowApi({ const res = await createWorkflowApi({
application_id: appContextStore.currentApp?.id,
is_global: createForm.is_global, is_global: createForm.is_global,
name: createForm.name, name: createForm.name,
code: createForm.code, code: createForm.code,
@@ -123,7 +128,11 @@ async function handleConfirm() {
ElMessage.success($t('ai-platform.workflow.createSuccess')); ElMessage.success($t('ai-platform.workflow.createSuccess'));
dialogVisible.value = false; dialogVisible.value = false;
fetchList(); fetchList();
router.push(`/ai-platform/workflow/editor/${res.id}`); router.push(
appContextStore.getContextPath(
`/ai-platform/workflow/editor/${res.id}`,
),
);
} }
} catch { } catch {
// error handled by request interceptor // error handled by request interceptor
@@ -156,6 +165,7 @@ async function fetchList() {
page: pagination.current, page: pagination.current,
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
name: searchKeyword.value || undefined, name: searchKeyword.value || undefined,
applicationId: appContextStore.currentApp?.id,
}); });
workflowList.value = res.items; workflowList.value = res.items;
pagination.total = res.total; pagination.total = res.total;
@@ -223,7 +233,9 @@ const handleQuickRun = async (row: WorkflowListItem) => {
}; };
const handleEdit = (row: WorkflowListItem) => { const handleEdit = (row: WorkflowListItem) => {
router.push(`/ai-platform/workflow/editor/${row.id}`); router.push(
appContextStore.getContextPath(`/ai-platform/workflow/editor/${row.id}`),
);
}; };
const handleDelete = async (row: WorkflowListItem) => { const handleDelete = async (row: WorkflowListItem) => {
@@ -296,7 +308,9 @@ const handleCopy = async (row: WorkflowListItem) => {
const res = await copyWorkflowApi(row.id); const res = await copyWorkflowApi(row.id);
ElMessage.success($t('ai-platform.workflow.copySuccess')); ElMessage.success($t('ai-platform.workflow.copySuccess'));
fetchList(); fetchList();
router.push(`/ai-platform/workflow/editor/${res.id}`); router.push(
appContextStore.getContextPath(`/ai-platform/workflow/editor/${res.id}`),
);
} catch (error) { } catch (error) {
if (error !== 'cancel') { if (error !== 'cancel') {
console.error(error); console.error(error);
@@ -333,7 +347,7 @@ const getWorkflowTypeText = (type: string) => {
const item = workflowTypeOptions.value.find( const item = workflowTypeOptions.value.find(
(o: { label: string; value: string }) => o.value === type, (o: { label: string; value: string }) => o.value === type,
); );
return item?.label || workflowTypeLabelMap[type] || '智能体协作'; return item?.label || workflowTypeLabelMap[type] || type;
}; };
const getStatusType = (status: string) => { const getStatusType = (status: string) => {
@@ -458,7 +472,11 @@ onMounted(() => {
{{ $t('ai-platform.workflow.publish') }} {{ $t('ai-platform.workflow.publish') }}
</ElDropdownItem> </ElDropdownItem>
<ElDropdownItem <ElDropdownItem
v-if="item.status === 'published'" v-if="
item.status === 'published' &&
(item.workflow_type === 'data_process' ||
item.workflow_type === 'automation')
"
command="run" command="run"
> >
<Play class="mr-2 h-4 w-4" /> <Play class="mr-2 h-4 w-4" />
@@ -503,8 +521,10 @@ onMounted(() => {
<!-- 应用名称 + 创建时间 --> <!-- 应用名称 + 创建时间 -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-muted-foreground text-xs"> <div class="text-muted-foreground text-xs">
v{{ item.published_version || item.version || '-' }} · <span v-if="item.application_name">
{{ item.run_count || 0 }} 次运行 {{ item.application_name }}
</span>
<span v-else>{{ $t('ai-platform.workflow.mainApp') }}</span>
</div> </div>
<span class="text-muted-foreground text-xs"> <span class="text-muted-foreground text-xs">
{{ item.sys_create_datetime }} {{ item.sys_create_datetime }}
@@ -608,6 +628,12 @@ onMounted(() => {
:rows="3" :rows="3"
/> />
</ElFormItem> </ElFormItem>
<ElFormItem
v-if="isMainApp"
:label="$t('ai-platform.workflow.form.globalVisible')"
>
<ElSwitch v-model="createForm.is_global" />
</ElFormItem>
</ElForm> </ElForm>
</ZqDialog> </ZqDialog>
@@ -24,6 +24,7 @@ import {
importWorkflowConfigApi, importWorkflowConfigApi,
} from '#/api/ai-platform/ai-platform'; } from '#/api/ai-platform/ai-platform';
import { ZqDialog } from '#/components/zq-dialog'; import { ZqDialog } from '#/components/zq-dialog';
import { useAppContextStore } from '#/store/app-context';
interface Props { interface Props {
modelValue: boolean; modelValue: boolean;
@@ -36,6 +37,8 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean]; 'update:modelValue': [value: boolean];
}>(); }>();
const appContextStore = useAppContextStore();
const dialogVisible = computed({ const dialogVisible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => emit('update:modelValue', val), set: (val) => emit('update:modelValue', val),
@@ -129,6 +132,7 @@ async function handleConfirmImport() {
try { try {
const payload: WorkflowImportInput = { const payload: WorkflowImportInput = {
...importData.value, ...importData.value,
application_id: appContextStore.currentApp?.id,
code: checkResult.value?.code_exists code: checkResult.value?.code_exists
? newCode.value.trim() ? newCode.value.trim()
: importData.value.code, : importData.value.code,