feat: 添加工作流执行配置和并行数设置,优化工作流管理功能

This commit is contained in:
jxxghp
2026-06-04 15:56:57 +08:00
parent 8b43e0a754
commit addc0838c0
6 changed files with 869 additions and 5 deletions

View File

@@ -1489,6 +1489,10 @@ export interface Workflow {
actions?: any[]
// 动作流
flows?: any[]
// 工作流执行配置
execution_config?: { [key: string]: any }
// 工作流结构化执行状态
execution_state?: { [key: string]: any }
// 创建时间
add_time?: string
// 最后执行时间

View File

@@ -25,9 +25,67 @@ onConnect((connection: Connection) => {
$toast.warning(t('dialog.workflowActions.invalidConnection'))
return
}
addEdges(connection)
addEdges(
normalizeWorkflowEdge({
...connection,
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
type: 'animation',
animated: true,
}),
)
})
// 当前选中的流程边ID
const selectedEdgeId = ref<string | null>(null)
// 当前选中的动作节点ID
const selectedNodeId = ref<string | null>(null)
// 流程边配置表单
const edgeForm = ref({
condition: '',
join_policy: '',
branch_policy: '',
})
// 动作节点执行配置表单
const nodeForm = ref({
inputs_text: '',
outputs_text: '',
join_policy: '',
fail_policy: '',
branch_policy: '',
concurrency_key: '',
timeout: null as number | null,
retry_max_attempts: null as number | null,
retry_interval: null as number | null,
retry_backoff: null as number | null,
})
// 汇合策略选项
const joinPolicyOptions = computed(() => [
{ title: t('dialog.workflowActions.joinPolicyDefault'), value: '' },
{ title: t('dialog.workflowActions.joinPolicyAllSuccess'), value: 'all_success' },
{ title: t('dialog.workflowActions.joinPolicyAnySuccess'), value: 'any_success' },
{ title: t('dialog.workflowActions.joinPolicyAllDone'), value: 'all_done' },
{ title: t('dialog.workflowActions.joinPolicyFailFast'), value: 'fail_fast' },
])
// 分支策略选项
const branchPolicyOptions = computed(() => [
{ title: t('dialog.workflowActions.branchPolicyDefault'), value: '' },
{ title: t('dialog.workflowActions.branchPolicyParallel'), value: 'parallel' },
{ title: t('dialog.workflowActions.branchPolicyExclusive'), value: 'exclusive' },
])
// 失败策略选项
const failPolicyOptions = computed(() => [
{ title: t('dialog.workflowActions.failPolicyDefault'), value: '' },
{ title: t('dialog.workflowActions.failPolicyStop'), value: 'stop' },
{ title: t('dialog.workflowActions.failPolicyContinue'), value: 'continue' },
{ title: t('dialog.workflowActions.failPolicyIgnore'), value: 'ignore' },
])
// 获取指定节点端口的类型(输入/输出)
const getPortType = (node: GraphNode, handleId: string) => {
// 检查是否是输入端口(对应 handleBounds.target
@@ -59,6 +117,341 @@ const isValidConnection = (connection: Connection) => {
return sourcePortType === 'output' && targetPortType === 'input' && connection.source !== connection.target
}
// 读取流程边扩展配置,兼容后端支持的顶层字段与 data 字段
const getEdgeConfigValue = (edge: any, key: string) => {
return edge?.[key] ?? edge?.data?.[key] ?? ''
}
// 统一流程边数据结构,确保条件和汇合策略能被后端读取
const normalizeWorkflowEdge = (edge: any) => {
const condition = String(getEdgeConfigValue(edge, 'condition') || '').trim()
const joinPolicy = String(getEdgeConfigValue(edge, 'join_policy') || '').trim()
const branchPolicy = String(getEdgeConfigValue(edge, 'branch_policy') || '').trim()
const edgeClass = String(edge?.class || '')
.replace(/\bworkflow-conditional-edge\b/g, '')
.trim()
const data = {
...(edge?.data || {}),
condition: condition || undefined,
join_policy: joinPolicy || undefined,
branch_policy: branchPolicy || undefined,
}
return {
...edge,
animated: edge?.animated ?? true,
type: edge?.type || 'animation',
label: condition ? t('dialog.workflowActions.edgeConditionalLabel') : undefined,
class: [edgeClass, condition ? 'workflow-conditional-edge' : ''].filter(Boolean).join(' ') || undefined,
condition: condition || undefined,
join_policy: joinPolicy || undefined,
branch_policy: branchPolicy || undefined,
data,
}
}
// 标准化所有流程边,导入和保存前都会调用
const normalizeWorkflowEdges = () => {
edges.value = (edges.value || []).map(edge => normalizeWorkflowEdge(edge))
}
// 判断扩展配置是否为空,避免旧 data 中的空值覆盖顶层字段
const isEmptyConfigValue = (value: any) => {
if (value === undefined || value === null || value === '') return true
if (Array.isArray(value)) return value.length === 0
if (typeof value === 'object') return Object.keys(value).length === 0
return false
}
// 读取动作节点扩展配置,兼容顶层字段与 data 字段
const getNodeConfigValue = (node: any, key: string) => {
const nodeValue = node?.[key]
if (!isEmptyConfigValue(nodeValue)) return nodeValue
const dataValue = node?.data?.[key]
return isEmptyConfigValue(dataValue) ? undefined : dataValue
}
// 将输入声明统一为路径数组
const normalizeInputPaths = (value: any) => {
if (Array.isArray(value)) {
return value.map(item => String(item).trim()).filter(Boolean)
}
if (typeof value === 'string') {
return value
.split(/[\n,]+/)
.map(item => item.trim())
.filter(Boolean)
}
return []
}
// 解析 JSON 形式的结构化配置
const parseStructuredConfig = (value: string, label: string) => {
const text = String(value || '').trim()
if (!text) return undefined
try {
const parsed = JSON.parse(text)
if (parsed && (Array.isArray(parsed) || typeof parsed === 'object')) {
return parsed
}
} catch (error) {
console.error(error)
}
throw new Error(t('dialog.workflowActions.invalidJsonConfig', { label }))
}
// 尝试把存量结构化配置标准化为对象或数组
const normalizeStructuredConfig = (value: any) => {
if (isEmptyConfigValue(value)) return undefined
if (Array.isArray(value) || typeof value === 'object') return value
if (typeof value !== 'string') return undefined
try {
const parsed = JSON.parse(value)
return parsed && (Array.isArray(parsed) || typeof parsed === 'object') ? parsed : undefined
} catch {
return undefined
}
}
// 将结构化配置格式化为面板中的 JSON 文本
const stringifyStructuredConfig = (value: any) => {
const normalizedValue = normalizeStructuredConfig(value)
return normalizedValue ? JSON.stringify(normalizedValue, null, 2) : ''
}
// 数值字段统一清洗,空值会在保存时被移除
const normalizeNumber = (value: any, minValue = 0, integer = false) => {
if (value === undefined || value === null || value === '') return undefined
const numberValue = Number(value)
if (!Number.isFinite(numberValue) || numberValue < minValue) return undefined
return integer ? Math.floor(numberValue) : numberValue
}
// 读取节点重试策略
const normalizeRetryConfig = (value: any) => {
const retryConfig = normalizeStructuredConfig(value)
return retryConfig && !Array.isArray(retryConfig) ? retryConfig : {}
}
// 根据面板表单构造重试策略
const buildRetryConfigFromForm = () => {
const retryConfig: Record<string, number> = {}
const maxAttempts = normalizeNumber(nodeForm.value.retry_max_attempts, 1, true)
const interval = normalizeNumber(nodeForm.value.retry_interval, 0)
const backoff = normalizeNumber(nodeForm.value.retry_backoff, 1)
if (maxAttempts !== undefined) retryConfig.max_attempts = maxAttempts
if (interval !== undefined) retryConfig.interval = interval
if (backoff !== undefined) retryConfig.backoff = backoff
return Object.keys(retryConfig).length ? retryConfig : undefined
}
// 统一动作节点数据结构,确保执行策略能被后端读取
const normalizeWorkflowNode = (node: any) => {
const inputPaths = normalizeInputPaths(getNodeConfigValue(node, 'inputs'))
const outputs = normalizeStructuredConfig(getNodeConfigValue(node, 'outputs'))
const joinPolicy = String(getNodeConfigValue(node, 'join_policy') || '').trim()
const failPolicy = String(getNodeConfigValue(node, 'fail_policy') || '').trim()
const branchPolicy = String(getNodeConfigValue(node, 'branch_policy') || '').trim()
const concurrencyKey = String(getNodeConfigValue(node, 'concurrency_key') || '').trim()
const timeout = normalizeNumber(getNodeConfigValue(node, 'timeout'), 1, true)
const retryConfig = normalizeRetryConfig(getNodeConfigValue(node, 'retry'))
const retry = Object.keys(retryConfig).length ? retryConfig : undefined
const data = {
...(node?.data || {}),
inputs: inputPaths.length ? inputPaths : undefined,
outputs: outputs || undefined,
join_policy: joinPolicy || undefined,
fail_policy: failPolicy || undefined,
branch_policy: branchPolicy || undefined,
concurrency_key: concurrencyKey || undefined,
timeout: timeout || undefined,
retry,
}
return {
...node,
inputs: inputPaths.length ? inputPaths : undefined,
outputs: outputs || undefined,
join_policy: joinPolicy || undefined,
fail_policy: failPolicy || undefined,
branch_policy: branchPolicy || undefined,
concurrency_key: concurrencyKey || undefined,
timeout: timeout || undefined,
retry,
data,
}
}
// 标准化所有动作节点,导入和保存前都会调用
const normalizeWorkflowNodes = () => {
nodes.value = (nodes.value || []).map(node => normalizeWorkflowNode(node))
}
// 获取节点名称,便于在边设置面板展示流转关系
const getNodeName = (nodeId?: string) => {
const node = nodes.value.find(item => item.id === nodeId)
return (node as any)?.name || node?.data?.label || nodeId || ''
}
// 选中流程边时打开设置面板
function handleEdgeClick(params: any) {
const edge = params?.edge
if (!edge) return
selectedNodeId.value = null
selectedEdgeId.value = edge.id
edgeForm.value = {
condition: String(getEdgeConfigValue(edge, 'condition') || ''),
join_policy: String(getEdgeConfigValue(edge, 'join_policy') || ''),
branch_policy: String(getEdgeConfigValue(edge, 'branch_policy') || ''),
}
}
// 关闭流程边设置面板
function closeEdgeSettings() {
selectedEdgeId.value = null
edgeForm.value = {
condition: '',
join_policy: '',
branch_policy: '',
}
}
// 保存流程边设置
function saveEdgeSettings() {
if (!selectedEdgeId.value) return
edges.value = edges.value.map(edge => {
if (edge.id !== selectedEdgeId.value) return edge
return normalizeWorkflowEdge({
...edge,
condition: edgeForm.value.condition,
join_policy: edgeForm.value.join_policy,
data: {
...(edge.data || {}),
condition: edgeForm.value.condition,
join_policy: edgeForm.value.join_policy,
branch_policy: edgeForm.value.branch_policy,
},
branch_policy: edgeForm.value.branch_policy,
})
})
$toast.success(t('dialog.workflowActions.edgeSaveSuccess'))
}
// 删除当前选中的流程边
function deleteSelectedEdge() {
if (!selectedEdgeId.value) return
edges.value = edges.value.filter(edge => edge.id !== selectedEdgeId.value)
closeEdgeSettings()
}
// 当前选中的流程边
const selectedEdge = computed(() => {
if (!selectedEdgeId.value) return null
return edges.value.find(edge => edge.id === selectedEdgeId.value) || null
})
// 当前选中的动作节点
const selectedNode = computed(() => {
if (!selectedNodeId.value) return null
return nodes.value.find(node => node.id === selectedNodeId.value) || null
})
// 将节点数据填入右侧执行配置面板
function fillNodeForm(node: any) {
const retryConfig = normalizeRetryConfig(getNodeConfigValue(node, 'retry'))
nodeForm.value = {
inputs_text: normalizeInputPaths(getNodeConfigValue(node, 'inputs')).join('\n'),
outputs_text: stringifyStructuredConfig(getNodeConfigValue(node, 'outputs')),
join_policy: String(getNodeConfigValue(node, 'join_policy') || ''),
fail_policy: String(getNodeConfigValue(node, 'fail_policy') || ''),
branch_policy: String(getNodeConfigValue(node, 'branch_policy') || ''),
concurrency_key: String(getNodeConfigValue(node, 'concurrency_key') || ''),
timeout: normalizeNumber(getNodeConfigValue(node, 'timeout'), 1, true) ?? null,
retry_max_attempts: normalizeNumber(retryConfig.max_attempts, 1, true) ?? null,
retry_interval: normalizeNumber(retryConfig.interval, 0) ?? null,
retry_backoff: normalizeNumber(retryConfig.backoff, 1) ?? null,
}
}
// 选中动作节点时打开执行配置面板
function handleNodeClick(params: any) {
const node = params?.node
if (!node) return
if (node.name == '备注') return
selectedEdgeId.value = null
selectedNodeId.value = node.id
fillNodeForm(node)
}
// 关闭动作节点执行配置面板
function closeNodeSettings() {
selectedNodeId.value = null
nodeForm.value = {
inputs_text: '',
outputs_text: '',
join_policy: '',
fail_policy: '',
branch_policy: '',
concurrency_key: '',
timeout: null,
retry_max_attempts: null,
retry_interval: null,
retry_backoff: null,
}
}
// 根据面板表单构造动作节点执行配置
function buildNodeConfigFromForm() {
return {
inputs: normalizeInputPaths(nodeForm.value.inputs_text),
outputs: parseStructuredConfig(nodeForm.value.outputs_text, t('dialog.workflowActions.nodeOutputsLabel')),
join_policy: nodeForm.value.join_policy,
fail_policy: nodeForm.value.fail_policy,
branch_policy: nodeForm.value.branch_policy,
concurrency_key: nodeForm.value.concurrency_key,
timeout: normalizeNumber(nodeForm.value.timeout, 1, true),
retry: buildRetryConfigFromForm(),
}
}
// 保存动作节点执行配置
function saveNodeSettings() {
if (!selectedNodeId.value) return
let nodeConfig: any
try {
nodeConfig = buildNodeConfigFromForm()
} catch (error: any) {
$toast.error(error.message)
return
}
nodes.value = nodes.value.map(node => {
if (node.id !== selectedNodeId.value) return node
return normalizeWorkflowNode({
...node,
inputs: nodeConfig.inputs,
outputs: nodeConfig.outputs,
join_policy: nodeConfig.join_policy,
fail_policy: nodeConfig.fail_policy,
branch_policy: nodeConfig.branch_policy,
concurrency_key: nodeConfig.concurrency_key,
timeout: nodeConfig.timeout,
retry: nodeConfig.retry,
data: {
...(node.data || {}),
inputs: nodeConfig.inputs,
outputs: nodeConfig.outputs,
join_policy: nodeConfig.join_policy,
fail_policy: nodeConfig.fail_policy,
branch_policy: nodeConfig.branch_policy,
concurrency_key: nodeConfig.concurrency_key,
timeout: nodeConfig.timeout,
retry: nodeConfig.retry,
},
})
})
$toast.success(t('dialog.workflowActions.nodeSaveSuccess'))
}
// 自定义节点类型
const nodeTypes: Record<string, any> = ref({})
@@ -142,8 +535,10 @@ function handleComponentClick(action: any) {
// 调用API 编辑任务
async function updateWorkflow() {
// 更新节点和流程
workflowForm.value.actions = nodes
workflowForm.value.flows = edges
normalizeWorkflowNodes()
normalizeWorkflowEdges()
workflowForm.value.actions = nodes.value
workflowForm.value.flows = edges.value
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
@@ -166,6 +561,11 @@ function saveCodeString(type: string, code: any) {
if (type === 'workflow') {
nodes.value = codeObject.actions || []
edges.value = codeObject.flows || []
if (codeObject.execution_config) {
workflowForm.value.execution_config = codeObject.execution_config
}
normalizeWorkflowNodes()
normalizeWorkflowEdges()
}
importCodeDialog.value = false
$toast.success(t('dialog.workflowActions.importSuccess'))
@@ -178,7 +578,13 @@ function saveCodeString(type: string, code: any) {
// 分享工作流程
function shareWorkflow() {
const codeString = JSON.stringify({ actions: nodes.value, flows: edges.value })
normalizeWorkflowNodes()
normalizeWorkflowEdges()
const codeString = JSON.stringify({
actions: nodes.value,
flows: edges.value,
execution_config: workflowForm.value.execution_config,
})
navigator.clipboard.writeText(codeString)
$toast.success(t('dialog.workflowActions.codeCopied'))
}
@@ -187,9 +593,31 @@ onMounted(() => {
if (props.workflow) {
nodes.value = props.workflow.actions ?? []
edges.value = props.workflow.flows ?? []
normalizeWorkflowNodes()
normalizeWorkflowEdges()
}
})
watch(
edges,
() => {
if (selectedEdgeId.value && !selectedEdge.value) {
closeEdgeSettings()
}
},
{ deep: true },
)
watch(
nodes,
() => {
if (selectedNodeId.value && !selectedNode.value) {
closeNodeSettings()
}
},
{ deep: true },
)
// 判断是不是MACOS
const isMacOS = computed(() => {
return /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent)
@@ -231,6 +659,8 @@ const isMacOS = computed(() => {
:edge-updater-radius="10"
@dragover="onDragOver"
@dragleave="onDragLeave"
@node-click="handleNodeClick"
@edge-click="handleEdgeClick"
:delete-key-code="isMacOS ? 'Backspace' : 'Delete'"
auto-connect
>
@@ -243,6 +673,187 @@ const isMacOS = computed(() => {
>
</DropzoneBackground>
</VueFlow>
<div v-if="selectedEdge" class="workflow-edge-panel">
<div class="edge-panel-header">
<div class="edge-panel-title">
<VIcon icon="mdi-source-branch" size="20" />
<span>{{ t('dialog.workflowActions.edgeSettingsTitle') }}</span>
</div>
<VBtn icon variant="text" size="small" @click="closeEdgeSettings">
<VIcon icon="mdi-close" />
</VBtn>
</div>
<div class="edge-route">
<span>{{ getNodeName(selectedEdge.source) }}</span>
<VIcon icon="mdi-arrow-right" size="18" />
<span>{{ getNodeName(selectedEdge.target) }}</span>
</div>
<VTextarea
v-model="edgeForm.condition"
:label="t('dialog.workflowActions.edgeConditionLabel')"
:placeholder="t('dialog.workflowActions.edgeConditionPlaceholder')"
rows="3"
auto-grow
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VSelect
v-model="edgeForm.join_policy"
:items="joinPolicyOptions"
:label="t('dialog.workflowActions.edgeJoinPolicyLabel')"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VSelect
v-model="edgeForm.branch_policy"
:items="branchPolicyOptions"
:label="t('dialog.workflowActions.edgeBranchPolicyLabel')"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<div class="edge-panel-actions">
<VBtn icon variant="text" color="error" @click="deleteSelectedEdge">
<VIcon icon="mdi-delete" />
</VBtn>
<VSpacer />
<VBtn variant="text" @click="closeEdgeSettings">
{{ t('dialog.workflowActions.edgeCancel') }}
</VBtn>
<VBtn color="primary" @click="saveEdgeSettings">
{{ t('dialog.workflowActions.edgeSave') }}
</VBtn>
</div>
</div>
<div v-if="selectedNode" class="workflow-edge-panel workflow-node-panel">
<div class="edge-panel-header">
<div class="edge-panel-title">
<VIcon icon="mdi-tune-variant" size="20" />
<span>{{ t('dialog.workflowActions.nodeSettingsTitle') }}</span>
</div>
<VBtn icon variant="text" size="small" @click="closeNodeSettings">
<VIcon icon="mdi-close" />
</VBtn>
</div>
<div class="edge-route">
<VIcon icon="mdi-checkbox-blank-circle-outline" size="16" />
<span>{{ getNodeName(selectedNode.id) }}</span>
</div>
<VSelect
v-model="nodeForm.join_policy"
:items="joinPolicyOptions"
:label="t('dialog.workflowActions.nodeJoinPolicyLabel')"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VSelect
v-model="nodeForm.fail_policy"
:items="failPolicyOptions"
:label="t('dialog.workflowActions.nodeFailPolicyLabel')"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VSelect
v-model="nodeForm.branch_policy"
:items="branchPolicyOptions"
:label="t('dialog.workflowActions.nodeBranchPolicyLabel')"
item-title="title"
item-value="value"
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VTextField
v-model="nodeForm.concurrency_key"
:label="t('dialog.workflowActions.nodeConcurrencyKeyLabel')"
:placeholder="t('dialog.workflowActions.nodeConcurrencyKeyPlaceholder')"
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<div class="workflow-number-grid">
<VTextField
v-model.number="nodeForm.timeout"
type="number"
min="1"
:label="t('dialog.workflowActions.nodeTimeoutLabel')"
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VTextField
v-model.number="nodeForm.retry_max_attempts"
type="number"
min="1"
:label="t('dialog.workflowActions.nodeRetryAttemptsLabel')"
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VTextField
v-model.number="nodeForm.retry_interval"
type="number"
min="0"
step="0.1"
:label="t('dialog.workflowActions.nodeRetryIntervalLabel')"
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
/>
<VTextField
v-model.number="nodeForm.retry_backoff"
type="number"
min="1"
step="0.1"
:label="t('dialog.workflowActions.nodeRetryBackoffLabel')"
clearable
variant="outlined"
density="comfortable"
hide-details="auto"
/>
</div>
<div class="edge-panel-actions">
<VSpacer />
<VBtn variant="text" @click="closeNodeSettings">
{{ t('dialog.workflowActions.edgeCancel') }}
</VBtn>
<VBtn color="primary" @click="saveNodeSettings">
{{ t('dialog.workflowActions.edgeSave') }}
</VBtn>
</div>
</div>
<WorkflowSidebar @component-click="handleComponentClick" />
</div>
</VCardText>
@@ -285,6 +896,74 @@ const isMacOS = computed(() => {
inline-size: 100%;
}
.workflow-edge-panel {
position: absolute;
z-index: 120;
display: flex;
flex-direction: column;
padding: 16px;
border: 1px solid rgb(var(--v-theme-primary));
border-radius: 8px;
background-color: rgb(var(--v-theme-surface));
box-shadow: 0 8px 24px rgba(var(--v-shadow-key-umbra-color), 0.32);
gap: 14px;
inline-size: min(360px, calc(100vw - 32px));
inset-block-start: 20px;
inset-inline-end: 20px;
max-block-size: calc(100% - 40px);
overflow-y: auto;
}
.workflow-node-panel {
inline-size: min(420px, calc(100vw - 32px));
}
.edge-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.edge-panel-title {
display: flex;
align-items: center;
color: rgb(var(--v-theme-on-surface));
font-size: 16px;
font-weight: 600;
gap: 8px;
}
.edge-route {
display: flex;
align-items: center;
border-radius: 6px;
background-color: rgba(var(--v-theme-primary), 0.08);
color: rgb(var(--v-theme-on-surface));
font-size: 13px;
gap: 8px;
padding-block: 8px;
padding-inline: 10px;
span {
overflow: hidden;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.edge-panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.workflow-number-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vue-flow__minimap {
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
@@ -345,9 +1024,26 @@ const isMacOS = computed(() => {
}
}
.vue-flow__edge.workflow-conditional-edge {
.vue-flow__edge-path {
stroke: rgb(var(--v-theme-warning));
}
}
@media screen and (width <= 600px) {
.vue-flow__minimap {
display: none;
}
.workflow-edge-panel {
inline-size: auto;
inset-block: auto 88px;
inset-inline: 16px;
max-block-size: min(72vh, calc(100% - 112px));
}
.workflow-number-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -37,9 +37,35 @@ const workflowForm = ref<Workflow>(
event_type: undefined,
state: 'P',
run_count: 0,
execution_config: {},
},
)
// 将并发数清洗为正整数,空值表示使用后端默认值
const normalizePositiveInteger = (value: any) => {
if (value === undefined || value === null || value === '') return undefined
const numberValue = Number(value)
if (!Number.isFinite(numberValue) || numberValue < 1) return undefined
return Math.floor(numberValue)
}
// 工作流级执行配置中的最大并行数
const workflowMaxWorkers = computed<number | null>({
get() {
return normalizePositiveInteger(workflowForm.value.execution_config?.max_workers) ?? null
},
set(value) {
const executionConfig = { ...(workflowForm.value.execution_config || {}) }
const maxWorkers = normalizePositiveInteger(value)
if (maxWorkers) {
executionConfig.max_workers = maxWorkers
} else {
delete executionConfig.max_workers
}
workflowForm.value.execution_config = Object.keys(executionConfig).length ? executionConfig : undefined
},
})
// 监听props变化处理存量数据
watch(
() => props.workflow,
@@ -49,7 +75,10 @@ watch(
if (!newWorkflow.trigger_type) {
newWorkflow.trigger_type = 'timer'
}
workflowForm.value = { ...newWorkflow }
workflowForm.value = {
...newWorkflow,
execution_config: { ...(newWorkflow.execution_config || {}) },
}
}
},
{ immediate: true },
@@ -99,6 +128,18 @@ watch(
// 提示框
const $toast = useToast()
// 保存前统一清洗工作流执行配置
function normalizeWorkflowExecutionConfig() {
const executionConfig = { ...(workflowForm.value.execution_config || {}) }
const maxWorkers = normalizePositiveInteger(executionConfig.max_workers)
if (maxWorkers) {
executionConfig.max_workers = maxWorkers
} else {
delete executionConfig.max_workers
}
workflowForm.value.execution_config = Object.keys(executionConfig).length ? executionConfig : undefined
}
// 调用API 新增任务
async function addWorkflow() {
if (!workflowForm.value.name) {
@@ -122,6 +163,7 @@ async function addWorkflow() {
return
}
normalizeWorkflowExecutionConfig()
startNProgress()
try {
const result: { [key: string]: string } = await api.post('workflow/', workflowForm.value)
@@ -160,6 +202,7 @@ async function editWorkflow() {
return
}
normalizeWorkflowExecutionConfig()
startNProgress()
try {
const result: { [key: string]: string } = await api.put(`workflow/${workflowForm.value.id}`, workflowForm.value)
@@ -256,6 +299,16 @@ onMounted(() => {
prepend-inner-icon="mdi-text-box-outline"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model.number="workflowMaxWorkers"
type="number"
min="1"
clearable
:label="t('dialog.workflowAddEdit.maxWorkers')"
prepend-inner-icon="mdi-call-split"
/>
</VCol>
</VRow>
</VForm>
</VCardText>

View File

@@ -2415,6 +2415,7 @@ export default {
namePlaceholder: 'Workflow name',
desc: 'Description',
descPlaceholder: 'Workflow description',
maxWorkers: 'Max Parallel Actions',
enabled: 'Enabled',
triggerType: 'Trigger Type',
triggerTypeTimer: 'Timer Trigger',
@@ -2465,6 +2466,42 @@ export default {
importSuccess: 'Import successful!',
importFailed: 'Import failed!',
codeCopied: 'Task workflow code copied to clipboard!',
edgeSettingsTitle: 'Flow Condition',
edgeConditionLabel: 'Condition',
edgeConditionPlaceholder: 'outputs.A.items.count > 0',
edgeJoinPolicyLabel: 'Target Join Policy',
edgeBranchPolicyLabel: 'Source Branch Policy',
joinPolicyDefault: 'Default',
joinPolicyAllSuccess: 'All Success',
joinPolicyAnySuccess: 'Any Success',
joinPolicyAllDone: 'All Done',
joinPolicyFailFast: 'Fail Fast',
branchPolicyDefault: 'Default',
branchPolicyParallel: 'Parallel',
branchPolicyExclusive: 'Exclusive First',
failPolicyDefault: 'Default',
failPolicyStop: 'Stop on Failure',
failPolicyContinue: 'Continue on Failure',
failPolicyIgnore: 'Ignore Failure',
edgeConditionalLabel: 'Condition',
edgeSave: 'Save',
edgeCancel: 'Cancel',
edgeSaveSuccess: 'Flow condition saved',
nodeSettingsTitle: 'Action Execution Policy',
nodeInputsLabel: 'Input Declarations',
nodeInputsPlaceholder: 'outputs.FetchTorrentsAction.torrents',
nodeOutputsLabel: 'Output Declarations',
nodeJoinPolicyLabel: 'Node Join Policy',
nodeFailPolicyLabel: 'Failure Policy',
nodeBranchPolicyLabel: 'Branch Policy',
nodeConcurrencyKeyLabel: 'Concurrency Key',
nodeConcurrencyKeyPlaceholder: 'downloader',
nodeTimeoutLabel: 'Timeout Seconds',
nodeRetryAttemptsLabel: 'Retry Attempts',
nodeRetryIntervalLabel: 'Retry Interval',
nodeRetryBackoffLabel: 'Backoff Factor',
nodeSaveSuccess: 'Action execution policy saved',
invalidJsonConfig: '{label} is not a valid JSON object or array',
},
siteCookieUpdate: {
title: 'Update Site Cookie & UA',

View File

@@ -2367,6 +2367,7 @@ export default {
namePlaceholder: '工作流名称',
desc: '描述',
descPlaceholder: '工作流描述',
maxWorkers: '最大并行数',
enabled: '启用',
triggerType: '触发类型',
triggerTypeTimer: '定时触发',
@@ -2417,6 +2418,42 @@ export default {
importSuccess: '导入成功!',
importFailed: '导入失败!',
codeCopied: '任务流程代码已复制到剪贴板!',
edgeSettingsTitle: '流程条件',
edgeConditionLabel: '流转条件',
edgeConditionPlaceholder: 'outputs.A.items.count > 0',
edgeJoinPolicyLabel: '目标汇合策略',
edgeBranchPolicyLabel: '源分支策略',
joinPolicyDefault: '默认',
joinPolicyAllSuccess: '全部成功',
joinPolicyAnySuccess: '任一成功',
joinPolicyAllDone: '全部完成',
joinPolicyFailFast: '失败即停',
branchPolicyDefault: '默认',
branchPolicyParallel: '并行',
branchPolicyExclusive: '互斥首选',
failPolicyDefault: '默认',
failPolicyStop: '失败停止',
failPolicyContinue: '失败继续',
failPolicyIgnore: '忽略失败',
edgeConditionalLabel: '条件',
edgeSave: '保存',
edgeCancel: '取消',
edgeSaveSuccess: '流程条件已保存',
nodeSettingsTitle: '动作执行策略',
nodeInputsLabel: '输入声明',
nodeInputsPlaceholder: 'outputs.FetchTorrentsAction.torrents',
nodeOutputsLabel: '输出声明',
nodeJoinPolicyLabel: '节点汇合策略',
nodeFailPolicyLabel: '失败策略',
nodeBranchPolicyLabel: '分支策略',
nodeConcurrencyKeyLabel: '并发互斥键',
nodeConcurrencyKeyPlaceholder: 'downloader',
nodeTimeoutLabel: '超时秒数',
nodeRetryAttemptsLabel: '重试次数',
nodeRetryIntervalLabel: '重试间隔',
nodeRetryBackoffLabel: '退避倍数',
nodeSaveSuccess: '动作执行策略已保存',
invalidJsonConfig: '{label} 不是有效的 JSON 对象或数组',
},
siteCookieUpdate: {
title: '更新站点Cookie & UA',

View File

@@ -2368,6 +2368,7 @@ export default {
namePlaceholder: '工作流名稱',
desc: '描述',
descPlaceholder: '工作流描述',
maxWorkers: '最大並行數',
enabled: '啟用',
triggerType: '觸發類型',
triggerTypeTimer: '定時觸發',
@@ -2418,6 +2419,42 @@ export default {
importSuccess: '匯入成功!',
importFailed: '匯入失敗!',
codeCopied: '任務流程代碼已複製到剪貼簿!',
edgeSettingsTitle: '流程條件',
edgeConditionLabel: '流轉條件',
edgeConditionPlaceholder: 'outputs.A.items.count > 0',
edgeJoinPolicyLabel: '目標匯合策略',
edgeBranchPolicyLabel: '來源分支策略',
joinPolicyDefault: '預設',
joinPolicyAllSuccess: '全部成功',
joinPolicyAnySuccess: '任一成功',
joinPolicyAllDone: '全部完成',
joinPolicyFailFast: '失敗即停',
branchPolicyDefault: '預設',
branchPolicyParallel: '並行',
branchPolicyExclusive: '互斥首選',
failPolicyDefault: '預設',
failPolicyStop: '失敗停止',
failPolicyContinue: '失敗繼續',
failPolicyIgnore: '忽略失敗',
edgeConditionalLabel: '條件',
edgeSave: '儲存',
edgeCancel: '取消',
edgeSaveSuccess: '流程條件已儲存',
nodeSettingsTitle: '動作執行策略',
nodeInputsLabel: '輸入宣告',
nodeInputsPlaceholder: 'outputs.FetchTorrentsAction.torrents',
nodeOutputsLabel: '輸出宣告',
nodeJoinPolicyLabel: '節點匯合策略',
nodeFailPolicyLabel: '失敗策略',
nodeBranchPolicyLabel: '分支策略',
nodeConcurrencyKeyLabel: '並發互斥鍵',
nodeConcurrencyKeyPlaceholder: 'downloader',
nodeTimeoutLabel: '超時秒數',
nodeRetryAttemptsLabel: '重試次數',
nodeRetryIntervalLabel: '重試間隔',
nodeRetryBackoffLabel: '退避倍數',
nodeSaveSuccess: '動作執行策略已儲存',
invalidJsonConfig: '{label} 不是有效的 JSON 物件或陣列',
},
siteCookieUpdate: {
title: '更新站點Cookie & UA',