mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-16 13:11:22 +08:00
feat: 添加工作流执行配置和并行数设置,优化工作流管理功能
This commit is contained in:
@@ -1489,6 +1489,10 @@ export interface Workflow {
|
||||
actions?: any[]
|
||||
// 动作流
|
||||
flows?: any[]
|
||||
// 工作流执行配置
|
||||
execution_config?: { [key: string]: any }
|
||||
// 工作流结构化执行状态
|
||||
execution_state?: { [key: string]: any }
|
||||
// 创建时间
|
||||
add_time?: string
|
||||
// 最后执行时间
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user