mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-27 14:52:56 +00:00
feat: 重构前端项目结构并优化代码
refactor: 迁移前端资源文件至web目录 feat: 新增多种图标资源 style: 统一代码风格和格式化配置 docs: 更新README和文档说明 chore: 更新依赖和配置文件 fix: 修复部分类型定义和枚举 perf: 优化路由和组件加载逻辑
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
<template>
|
||||
<div class="app-container job-page">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="status-content">
|
||||
<span>调度器监控</span>
|
||||
<div class="status-item">
|
||||
<span class="label">状态:</span>
|
||||
<el-tag :type="getSchedulerStatusType(schedulerStatus.status)" size="large">
|
||||
{{ schedulerStatus.status }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">运行中:</span>
|
||||
<el-tag :type="schedulerStatus.is_running ? 'success' : 'danger'" size="large">
|
||||
{{ schedulerStatus.is_running ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="label">任务数量:</span>
|
||||
<el-tag type="warning" size="large">{{ schedulerStatus.job_count }}</el-tag>
|
||||
</div>
|
||||
<div class="status-actions">
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:scheduler']"
|
||||
type="success"
|
||||
icon="VideoPlay"
|
||||
:disabled="schedulerStatus.status !== '停止'"
|
||||
@click="handleStartScheduler"
|
||||
>
|
||||
启动
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:scheduler']"
|
||||
type="warning"
|
||||
icon="VideoPause"
|
||||
:disabled="schedulerStatus.status !== '运行中'"
|
||||
@click="handlePauseScheduler"
|
||||
>
|
||||
暂停
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:scheduler']"
|
||||
type="primary"
|
||||
icon="RefreshRight"
|
||||
:disabled="schedulerStatus.status !== '暂停'"
|
||||
@click="handleResumeScheduler"
|
||||
>
|
||||
恢复
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:scheduler']"
|
||||
type="danger"
|
||||
icon="SwitchButton"
|
||||
:disabled="schedulerStatus.status === '停止'"
|
||||
@click="handleShutdownScheduler"
|
||||
>
|
||||
关闭
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:task']"
|
||||
type="danger"
|
||||
icon="Delete"
|
||||
:disabled="schedulerStatus.job_count === 0"
|
||||
@click="handleClearAllJobs"
|
||||
>
|
||||
清空任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:query']"
|
||||
type="info"
|
||||
icon="Monitor"
|
||||
@click="handleOpenConsole"
|
||||
>
|
||||
控制台
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:scheduler']"
|
||||
type="primary"
|
||||
icon="Refresh"
|
||||
@click="handleSyncJobs"
|
||||
>
|
||||
同步
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:update']"
|
||||
type="warning"
|
||||
icon="Refresh"
|
||||
@click="refreshMain"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table="{ data, loading }">
|
||||
<div v-loading="loading" class="job-cards-container">
|
||||
<el-empty v-if="!data || data.length === 0" :image-size="80" description="暂无数据" />
|
||||
<el-row v-else :gutter="16">
|
||||
<el-col
|
||||
v-for="(job, index) in data"
|
||||
:key="job.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="8"
|
||||
:lg="6"
|
||||
class="job-card-col"
|
||||
>
|
||||
<el-card class="job-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="job-card-header">
|
||||
<div class="job-card-title">
|
||||
<span class="job-index">{{ index + 1 }}</span>
|
||||
<span class="job-name" :title="job.name">{{ job.name }}</span>
|
||||
</div>
|
||||
<el-tag :type="getJobStatusType(job.status)" size="small">
|
||||
{{ getJobStatusLabel(job.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="job-card-body">
|
||||
<div class="job-info-item">
|
||||
<span class="job-info-label">任务ID:</span>
|
||||
<span class="job-info-value">{{ job.id }}</span>
|
||||
</div>
|
||||
<div class="job-info-item">
|
||||
<span class="job-info-label">触发器:</span>
|
||||
<el-tag
|
||||
v-if="String(job.trigger ?? '').includes('cron')"
|
||||
type="primary"
|
||||
size="small"
|
||||
class="job-trigger-tag"
|
||||
>
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ formatTrigger(job.trigger) }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="String(job.trigger ?? '').includes('interval')"
|
||||
type="success"
|
||||
size="small"
|
||||
class="job-trigger-tag"
|
||||
>
|
||||
<el-icon><Timer /></el-icon>
|
||||
{{ formatTrigger(job.trigger) }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="String(job.trigger ?? '').includes('date')"
|
||||
type="warning"
|
||||
size="small"
|
||||
class="job-trigger-tag"
|
||||
>
|
||||
<el-icon><Calendar /></el-icon>
|
||||
{{ formatTrigger(job.trigger) }}
|
||||
</el-tag>
|
||||
<el-tag v-else type="info" size="small" class="job-trigger-tag">
|
||||
{{ formatTrigger(job.trigger) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="job-info-item">
|
||||
<span class="job-info-label">下次执行:</span>
|
||||
<span class="job-info-value">{{ job.next_run_time || '无' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="job-card-footer">
|
||||
<el-button
|
||||
v-if="job.status === '暂停中'"
|
||||
v-hasPerm="['module_task:cronjob:job:task']"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon="VideoPlay"
|
||||
@click="handleResumeJob(job.id)"
|
||||
>
|
||||
恢复
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="job.status === '运行中'"
|
||||
v-hasPerm="['module_task:cronjob:job:task']"
|
||||
type="warning"
|
||||
size="small"
|
||||
icon="VideoPause"
|
||||
@click="handlePauseJob(job.id)"
|
||||
>
|
||||
暂停
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="job.status !== '已停止' && job.status !== '未知'"
|
||||
v-hasPerm="['module_task:cronjob:job:task']"
|
||||
type="success"
|
||||
size="small"
|
||||
icon="CaretRight"
|
||||
@click="handleRunJobNow(job.id)"
|
||||
>
|
||||
调试
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="job.status !== '未知'"
|
||||
v-hasPerm="['module_task:cronjob:job:task']"
|
||||
type="danger"
|
||||
size="small"
|
||||
icon="Close"
|
||||
@click="handleRemoveJob(job.id)"
|
||||
>
|
||||
移除
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:query']"
|
||||
type="info"
|
||||
size="small"
|
||||
icon="List"
|
||||
@click="handleOpenExecutionLogDrawer(job)"
|
||||
>
|
||||
记录
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
|
||||
<EnhancedDialog v-model="consoleVisible" title="调度器控制台" width="900px">
|
||||
<div class="terminal-wrapper">
|
||||
<Terminal name="scheduler-console" :show-header="false" theme="dark" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleRefreshConsole">刷新</el-button>
|
||||
<el-button @click="handleClearConsole">清空</el-button>
|
||||
<el-button type="primary" @click="consoleVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<EnhancedDrawer v-model="executionLogDrawerVisible" title="执行记录" direction="rtl" size="80%">
|
||||
<div class="execution-log-drawer">
|
||||
<PageSearch
|
||||
ref="logSearchRef"
|
||||
:search-config="logSearchConfig"
|
||||
@query-click="handleLogQueryClick"
|
||||
@reset-click="handleLogResetClick"
|
||||
/>
|
||||
<PageContent ref="logContentRef" :content-config="logContentConfig">
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
:data="data"
|
||||
border
|
||||
stripe
|
||||
height="100%"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" align="center" min-width="55" />
|
||||
<el-table-column type="index" fixed label="序号" min-width="60">
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="任务ID"
|
||||
prop="job_id"
|
||||
min-width="80"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="任务名称" prop="job_name" min-width="140" />
|
||||
<el-table-column label="触发方式" prop="trigger_type" min-width="120">
|
||||
<template #default="scope">
|
||||
<el-tag size="small">{{ getTriggerTypeLabel(scope.row.trigger_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getLogStatusType(scope.row.status)" size="small">
|
||||
{{ getLogStatusLabel(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="下次执行时间"
|
||||
prop="next_run_time"
|
||||
min-width="200"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="执行结果"
|
||||
prop="result"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="错误信息"
|
||||
prop="error"
|
||||
min-width="100"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="执行元数据" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.job_state"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="View"
|
||||
@click="handleViewJobState(scope.row)"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="160" />
|
||||
<el-table-column label="更新时间" prop="updated_time" min-width="160" />
|
||||
<el-table-column label="操作" min-width="80" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:job:delete']"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleLogRowDelete(scope.row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
</div>
|
||||
</EnhancedDrawer>
|
||||
|
||||
<EnhancedDialog v-model="jobStateVisible" title="执行元数据" width="800px">
|
||||
<JsonPretty :value="jobStateData" height="400px" />
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="jobStateVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: 'Job',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import JobAPI, {
|
||||
SchedulerStatus,
|
||||
SchedulerJob,
|
||||
JobLogPageQuery,
|
||||
JobLogTable,
|
||||
} from '@/api/module_task/cronjob/job';
|
||||
import PageSearch from '@/components/CURD/PageSearch.vue';
|
||||
import PageContent from '@/components/CURD/PageContent.vue';
|
||||
import EnhancedDialog from '@/components/CURD/EnhancedDialog.vue';
|
||||
import EnhancedDrawer from '@/components/CURD/EnhancedDrawer.vue';
|
||||
import type { ISearchConfig, IContentConfig, IObject } from '@/components/CURD/types';
|
||||
import { ref, reactive, nextTick } from 'vue';
|
||||
import { Terminal, TerminalApi } from 'vue-web-terminal';
|
||||
import JsonPretty from '@/components/JsonPretty/index.vue';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const refreshMain = refreshList;
|
||||
|
||||
const schedulerStatus = ref<SchedulerStatus>({
|
||||
status: '未知',
|
||||
is_running: false,
|
||||
job_count: 0,
|
||||
});
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_task:cronjob:job',
|
||||
colon: true,
|
||||
isExpandable: false,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '任务名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入任务名称', clearable: true, style: { width: '150px' } },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '任务状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '运行中', value: '运行中' },
|
||||
{ label: '暂停', value: '暂停' },
|
||||
{ label: '停止', value: '停止' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '150px' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentConfig = reactive<IContentConfig>({
|
||||
permPrefix: 'module_task:cronjob:job',
|
||||
cols: [],
|
||||
hideColumnFilter: true,
|
||||
showToolbar: false,
|
||||
pagination: false,
|
||||
indexAction: async (params) => {
|
||||
try {
|
||||
const statusRes = await JobAPI.getSchedulerStatus();
|
||||
schedulerStatus.value = statusRes.data.data;
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
}
|
||||
const response = await JobAPI.getSchedulerJobs();
|
||||
const raw = response.data?.data;
|
||||
let jobs = Array.isArray(raw) ? raw : [];
|
||||
const p = params as { name?: string; status?: string };
|
||||
const name = typeof p.name === 'string' ? p.name.trim() : '';
|
||||
if (name) jobs = jobs.filter((job) => job.name.includes(name));
|
||||
if (p.status) jobs = jobs.filter((job) => job.status === p.status);
|
||||
// pagination 为 false 时 PageContent 将整段返回值赋给 pageData,必须为列表数组
|
||||
return jobs;
|
||||
},
|
||||
});
|
||||
|
||||
const consoleVisible = ref(false);
|
||||
|
||||
const executionLogDrawerVisible = ref(false);
|
||||
const currentLogJobId = ref<string | undefined>(undefined);
|
||||
const logSearchRef = ref<InstanceType<typeof PageSearch>>();
|
||||
const logContentRef = ref<InstanceType<typeof PageContent>>();
|
||||
const jobStateVisible = ref(false);
|
||||
const jobStateData = ref<any>(null);
|
||||
|
||||
const logSearchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_task:cronjob:job',
|
||||
colon: true,
|
||||
isExpandable: false,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'status',
|
||||
label: '执行状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '待执行', value: 'pending' },
|
||||
{ label: '执行中', value: 'running' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
{ label: '超时', value: 'timeout' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '120px' } },
|
||||
},
|
||||
{
|
||||
prop: 'trigger_type',
|
||||
label: '触发方式',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Cron表达式', value: 'cron' },
|
||||
{ label: '时间间隔', value: 'interval' },
|
||||
{ label: '固定日期', value: 'date' },
|
||||
{ label: '一次性任务', value: 'manual' },
|
||||
],
|
||||
attrs: { placeholder: '请选择', clearable: true, style: { width: '120px' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logContentConfig = reactive<IContentConfig>({
|
||||
permPrefix: 'module_task:cronjob:job',
|
||||
cols: [],
|
||||
hideColumnFilter: true,
|
||||
toolbar: ['delete'],
|
||||
defaultToolbar: ['refresh'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const jobId = currentLogJobId.value;
|
||||
if (!jobId) {
|
||||
return { total: 0, list: [] };
|
||||
}
|
||||
const p = params as JobLogPageQuery;
|
||||
const res = await JobAPI.getJobLogList({
|
||||
page_no: p.page_no ?? 1,
|
||||
page_size: p.page_size ?? 10,
|
||||
job_id: jobId,
|
||||
job_name: undefined,
|
||||
status: p.status,
|
||||
trigger_type: p.trigger_type,
|
||||
});
|
||||
const page = res.data.data;
|
||||
return {
|
||||
total: page?.total ?? 0,
|
||||
list: page?.items ?? [],
|
||||
};
|
||||
},
|
||||
deleteAction: (ids) =>
|
||||
JobAPI.deleteJobLog(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
),
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除选中的执行记录?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function getSchedulerStatusType(status: string) {
|
||||
switch (status) {
|
||||
case '运行中':
|
||||
return 'success';
|
||||
case '暂停':
|
||||
return 'warning';
|
||||
case '停止':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function getJobStatusType(status: string) {
|
||||
switch (status) {
|
||||
case '运行中':
|
||||
return 'success';
|
||||
case '暂停中':
|
||||
return 'warning';
|
||||
case '已停止':
|
||||
return 'danger';
|
||||
case '未知':
|
||||
return 'info';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
function getJobStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case '运行中':
|
||||
return '运行中';
|
||||
case '暂停中':
|
||||
return '暂停中';
|
||||
case '已停止':
|
||||
return '已停止';
|
||||
case '未知':
|
||||
return '未知';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTrigger(trigger: string) {
|
||||
if (!trigger) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
if (trigger.includes('cron')) {
|
||||
const match = trigger.match(/cron\[([^\]]+)\]/);
|
||||
if (match) {
|
||||
const params = match[1];
|
||||
// 提取关键参数
|
||||
const month = params.match(/month='([^']+)'/);
|
||||
const day = params.match(/day='([^']+)'/);
|
||||
const hour = params.match(/hour='([^']+)'/);
|
||||
const minute = params.match(/minute='([^']+)'/);
|
||||
const second = params.match(/second='([^']+)'/);
|
||||
const dayOfWeek = params.match(/day_of_week='([^']+)'/);
|
||||
|
||||
// 构建简化的 cron 表达式
|
||||
const parts = [];
|
||||
if (second && second[1] !== "'*'") parts.push(`秒:${second[1]}`);
|
||||
if (minute && minute[1] !== "'*'") parts.push(`分:${minute[1]}`);
|
||||
if (hour && hour[1] !== "'*'") parts.push(`时:${hour[1]}`);
|
||||
if (day && day[1] !== "'*'") parts.push(`日:${day[1]}`);
|
||||
if (month && month[1] !== "'*'") parts.push(`月:${month[1]}`);
|
||||
if (dayOfWeek && dayOfWeek[1] !== "'*'") parts.push(`周:${dayOfWeek[1]}`);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'Cron: 每分钟';
|
||||
}
|
||||
return `Cron: ${parts.join(' ')}`;
|
||||
}
|
||||
return trigger;
|
||||
}
|
||||
|
||||
if (trigger.includes('interval')) {
|
||||
const match = trigger.match(/interval\[([^\]]+)\]/);
|
||||
return match ? `间隔时长: ${match[1]}` : trigger;
|
||||
}
|
||||
|
||||
if (trigger.includes('date')) {
|
||||
const match = trigger.match(/date\[([^\]]+)\]/);
|
||||
return match ? `执行日期: ${match[1]}` : trigger;
|
||||
}
|
||||
|
||||
return trigger;
|
||||
}
|
||||
|
||||
async function handleSyncJobs() {
|
||||
try {
|
||||
await JobAPI.syncJobsToDb();
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartScheduler() {
|
||||
try {
|
||||
await JobAPI.startScheduler();
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePauseScheduler() {
|
||||
try {
|
||||
await JobAPI.pauseScheduler();
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeScheduler() {
|
||||
try {
|
||||
await JobAPI.resumeScheduler();
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShutdownScheduler() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要关闭调度器吗?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await JobAPI.shutdownScheduler();
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearAllJobs() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要清空所有任务吗?\n' +
|
||||
'此操作会将所有待执行任务的日志标记为已取消,不会删除历史执行记录。\n' +
|
||||
'如需删除所有执行记录,请使用执行记录的批量删除功能。',
|
||||
'警告',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
dangerouslyUseHTMLString: false,
|
||||
}
|
||||
);
|
||||
await JobAPI.clearAllJobs();
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenConsole() {
|
||||
consoleVisible.value = true;
|
||||
await handleRefreshConsole();
|
||||
}
|
||||
|
||||
async function handleRefreshConsole() {
|
||||
try {
|
||||
const response = await JobAPI.getSchedulerConsole();
|
||||
const data = response.data.data || '暂无任务信息';
|
||||
TerminalApi.pushMessage('scheduler-console', {
|
||||
type: 'normal',
|
||||
content: data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
TerminalApi.pushMessage('scheduler-console', {
|
||||
type: 'normal',
|
||||
class: 'error',
|
||||
content: '获取控制台信息失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearConsole() {
|
||||
TerminalApi.clear('scheduler-console');
|
||||
}
|
||||
|
||||
async function handlePauseJob(jobId: string) {
|
||||
try {
|
||||
await JobAPI.pauseJob(jobId);
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeJob(jobId: string) {
|
||||
try {
|
||||
await JobAPI.resumeJob(jobId);
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunJobNow(jobId: string) {
|
||||
try {
|
||||
await JobAPI.runJobNow(jobId);
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveJob(jobId: string) {
|
||||
ElMessageBox.confirm('确认移除该任务?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
await JobAPI.removeJob(jobId);
|
||||
await refreshMain();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessageBox.close();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOpenExecutionLogDrawer(job: IObject) {
|
||||
currentLogJobId.value = (job as SchedulerJob).id;
|
||||
executionLogDrawerVisible.value = true;
|
||||
await nextTick();
|
||||
logContentRef.value?.fetchPageData(logSearchRef.value?.getQueryParams() ?? {}, true);
|
||||
}
|
||||
|
||||
function handleLogQueryClick() {
|
||||
logContentRef.value?.fetchPageData(logSearchRef.value!.getQueryParams(), true);
|
||||
}
|
||||
|
||||
function handleLogResetClick() {
|
||||
logContentRef.value?.fetchPageData(logSearchRef.value!.getQueryParams(), true);
|
||||
}
|
||||
|
||||
function handleLogRowDelete(id: number) {
|
||||
logContentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
function getTriggerTypeLabel(type: string | undefined) {
|
||||
const map: Record<string, string> = {
|
||||
cron: 'Cron表达式',
|
||||
interval: '时间间隔',
|
||||
date: '固定日期',
|
||||
manual: '一次性任务',
|
||||
};
|
||||
return map[type || ''] || type || '-';
|
||||
}
|
||||
|
||||
function getLogStatusType(status: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' {
|
||||
const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
pending: 'info',
|
||||
running: 'primary',
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
timeout: 'warning',
|
||||
cancelled: 'info',
|
||||
};
|
||||
return map[status] || 'info';
|
||||
}
|
||||
|
||||
function getLogStatusLabel(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待执行',
|
||||
running: '执行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
timeout: '超时',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
function handleViewJobState(row: JobLogTable | IObject) {
|
||||
const jobState = (row as JobLogTable).job_state;
|
||||
if (jobState) {
|
||||
try {
|
||||
jobStateData.value = JSON.parse(jobState);
|
||||
} catch {
|
||||
jobStateData.value = jobState;
|
||||
}
|
||||
jobStateVisible.value = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.job-page :deep(.data-table) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-item .label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.job-card-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.job-card-title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-index {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--el-color-primary) 0%,
|
||||
var(--el-color-primary-dark-2) 100%
|
||||
);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.job-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.job-info-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.job-info-label {
|
||||
flex-shrink: 0;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.job-info-value {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.job-card-footer .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-log-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.execution-log-drawer :deep(.el-card.data-table) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,799 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent
|
||||
ref="contentRef"
|
||||
:content-config="contentConfig"
|
||||
@add-click="handleOpenDialog('create')"
|
||||
>
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" align="center" min-width="55" />
|
||||
<el-table-column type="index" fixed label="序号" min-width="60">
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节点名称" prop="name" min-width="140" />
|
||||
<el-table-column label="节点编码" prop="code" min-width="120" />
|
||||
<el-table-column label="存储器" prop="jobstore" min-width="80" />
|
||||
<el-table-column label="执行器" prop="executor" min-width="80" />
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="180" sortable />
|
||||
|
||||
<OperationColumn :list-data-length="data.length">
|
||||
<template #default="scope">
|
||||
<el-space class="flex">
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:node:execute']"
|
||||
type="warning"
|
||||
size="small"
|
||||
link
|
||||
icon="VideoPlay"
|
||||
@click="handleOpenExecuteDialog(scope.row)"
|
||||
>
|
||||
调试
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:node:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleOpenDialog('update', scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:cronjob:node:delete']"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleRowDelete(scope.row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</el-space>
|
||||
</template>
|
||||
</OperationColumn>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
|
||||
<EnhancedDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="1000px"
|
||||
@close="handleCloseDialog"
|
||||
@opened="handleDialogOpened"
|
||||
>
|
||||
<el-splitter direction="horizontal" style="height: 500px">
|
||||
<el-splitter-panel size="300px" :min="200" :max="400">
|
||||
<el-scrollbar style="height: 100%">
|
||||
<el-form
|
||||
ref="dataFormRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
label-width="auto"
|
||||
style="padding: 0 10px"
|
||||
>
|
||||
<el-form-item label="节点名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入节点名称" :maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="节点编码" prop="code">
|
||||
<el-input v-model="formData.code" placeholder="请输入节点编码" :maxlength="32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储器" prop="jobstore">
|
||||
<el-select v-model="formData.jobstore" placeholder="请选择存储器">
|
||||
<el-option
|
||||
v-for="item in dictStore.getDictArray('sys_job_store')"
|
||||
:key="item.dict_value"
|
||||
:label="item.dict_label"
|
||||
:value="item.dict_value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行器" prop="executor">
|
||||
<el-select v-model="formData.executor" placeholder="请选择执行器">
|
||||
<el-option
|
||||
v-for="item in dictStore.getDictArray('sys_job_executor')"
|
||||
:key="item.dict_value"
|
||||
:label="item.dict_label"
|
||||
:value="item.dict_value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="位置参数" prop="args">
|
||||
<div class="dynamic-params">
|
||||
<div v-for="(_item, index) in argsList" :key="index" class="param-item">
|
||||
<el-input v-model="argsList[index]" placeholder="参数值" />
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="Delete"
|
||||
circle
|
||||
@click="argsList.splice(index, 1)"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" icon="Plus" @click="argsList.push('')">
|
||||
添加位置参数
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键字参数" prop="kwargs">
|
||||
<div class="dynamic-params">
|
||||
<div v-for="(item, index) in kwargsList" :key="index" class="param-item">
|
||||
<el-input v-model="item.key" placeholder="键" />
|
||||
<el-input v-model="item.value" placeholder="值" />
|
||||
<el-button
|
||||
type="danger"
|
||||
icon="Delete"
|
||||
circle
|
||||
@click="kwargsList.splice(index, 1)"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
icon="Plus"
|
||||
@click="kwargsList.push({ key: '', value: '' })"
|
||||
>
|
||||
添加关键词参数
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="合并运行" prop="coalesce">
|
||||
<el-radio-group v-model="formData.coalesce">
|
||||
<el-radio :value="true">是</el-radio>
|
||||
<el-radio :value="false">否</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大实例数" prop="max_instances">
|
||||
<el-input-number
|
||||
v-model="formData.max_instances"
|
||||
controls-position="right"
|
||||
:min="1"
|
||||
:max="10"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-scrollbar>
|
||||
</el-splitter-panel>
|
||||
|
||||
<el-splitter-panel>
|
||||
<div class="code-editor-container">
|
||||
<div class="code-editor-header">
|
||||
<span class="code-editor-title">处理器</span>
|
||||
<span class="code-editor-tip">定义 handler(*args, **kwargs) 函数</span>
|
||||
</div>
|
||||
<Codemirror
|
||||
ref="codeEditorRef"
|
||||
v-model:value="formData.func"
|
||||
:options="codeEditorOptions"
|
||||
border
|
||||
height="calc(100% - 40px)"
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
</el-splitter>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="handleCloseDialog">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
|
||||
<EnhancedDialog
|
||||
v-model="executeDialogVisible"
|
||||
title="调试节点"
|
||||
width="700px"
|
||||
@close="handleCloseExecuteDialog"
|
||||
>
|
||||
<el-form
|
||||
ref="executeFormRef"
|
||||
:model="executeFormData"
|
||||
:rules="executeRules"
|
||||
label-suffix=":"
|
||||
label-width="auto"
|
||||
>
|
||||
<el-form-item label="节点名称">
|
||||
<el-input :value="currentExecuteNode?.name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="执行方式" prop="trigger">
|
||||
<el-radio-group v-model="executeFormData.trigger">
|
||||
<el-radio value="now">立即执行</el-radio>
|
||||
<el-radio value="cron">Cron表达式</el-radio>
|
||||
<el-radio value="interval">时间间隔</el-radio>
|
||||
<el-radio value="date">固定日期</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
v-if="executeFormData.trigger === 'cron'"
|
||||
label="Cron表达式"
|
||||
prop="trigger_args"
|
||||
>
|
||||
<el-popover
|
||||
:visible="openCron"
|
||||
width="700px"
|
||||
trigger="click"
|
||||
:persistent="false"
|
||||
placement="auto-end"
|
||||
popper-class="node-cron-popover-fix"
|
||||
>
|
||||
<template #reference>
|
||||
<el-input
|
||||
v-model="executeFormData.trigger_args"
|
||||
placeholder="请输入 * * * * * ? *"
|
||||
@click="openCron = true"
|
||||
/>
|
||||
</template>
|
||||
<vue3CronPlus i18n="cn" @change="handlechangeCron" @close="openCron = false" />
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
v-else-if="executeFormData.trigger === 'interval'"
|
||||
label="间隔时间"
|
||||
prop="trigger_args"
|
||||
>
|
||||
<el-popover
|
||||
:visible="openInterval"
|
||||
width="600px"
|
||||
trigger="click"
|
||||
:persistent="false"
|
||||
placement="auto-end"
|
||||
>
|
||||
<template #reference>
|
||||
<el-input
|
||||
v-model="executeFormData.trigger_args"
|
||||
placeholder="请点击设置间隔时间"
|
||||
@click="openInterval = true"
|
||||
/>
|
||||
</template>
|
||||
<IntervalTab
|
||||
:cron-value="executeFormData.trigger_args"
|
||||
@confirm="handleIntervalConfirm"
|
||||
@cancel="openInterval = false"
|
||||
/>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
v-else-if="executeFormData.trigger === 'date'"
|
||||
label="执行时间"
|
||||
prop="trigger_args"
|
||||
>
|
||||
<el-date-picker
|
||||
v-model="executeFormData.trigger_args"
|
||||
type="datetime"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择执行时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
executeFormData.trigger &&
|
||||
executeFormData.trigger !== 'now' &&
|
||||
executeFormData.trigger !== 'date'
|
||||
"
|
||||
>
|
||||
<el-form-item label="开始时间" prop="start_date">
|
||||
<el-date-picker
|
||||
v-model="executeFormData.start_date"
|
||||
type="datetime"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择开始时间(可选)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间" prop="end_date">
|
||||
<el-date-picker
|
||||
v-model="executeFormData.end_date"
|
||||
type="datetime"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择结束时间(可选)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleCloseExecuteDialog">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleExecuteNode">
|
||||
确认
|
||||
</el-button>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: 'Node',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import NodeAPI, {
|
||||
NodeTable,
|
||||
NodeForm,
|
||||
NodePageQuery,
|
||||
TriggerType,
|
||||
} from '@/api/module_task/cronjob/node';
|
||||
import { useDictStore } from '@/store/index';
|
||||
import PageSearch from '@/components/CURD/PageSearch.vue';
|
||||
import PageContent from '@/components/CURD/PageContent.vue';
|
||||
import EnhancedDialog from '@/components/CURD/EnhancedDialog.vue';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { vue3CronPlus } from 'vue3-cron-plus';
|
||||
import 'vue3-cron-plus/dist/index.css';
|
||||
import OperationColumn from '@/components/OperationColumn/index.vue';
|
||||
import IntervalTab from '@/components/IntervalTab/index.vue';
|
||||
import Codemirror, { CmComponentRef } from 'codemirror-editor-vue3';
|
||||
import type { EditorConfiguration } from 'codemirror';
|
||||
import 'codemirror/mode/python/python.js';
|
||||
import 'codemirror/theme/dracula.css';
|
||||
|
||||
const dictStore = useDictStore();
|
||||
|
||||
const codeEditorOptions: EditorConfiguration = {
|
||||
mode: 'python',
|
||||
lineNumbers: true,
|
||||
smartIndent: true,
|
||||
indentUnit: 4,
|
||||
tabSize: 4,
|
||||
theme: 'dracula',
|
||||
lineWrapping: true,
|
||||
autofocus: false,
|
||||
};
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
const dataFormRef = ref();
|
||||
const executeFormRef = ref();
|
||||
const submitLoading = ref(false);
|
||||
const openCron = ref(false);
|
||||
const openInterval = ref(false);
|
||||
const codeEditorRef = ref<CmComponentRef>();
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_task:cronjob:node',
|
||||
colon: true,
|
||||
isExpandable: false,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '节点名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入节点名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '节点编码',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入节点编码', clearable: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const contentConfig = reactive<IContentConfig<NodePageQuery>>({
|
||||
permPrefix: 'module_task:cronjob:node',
|
||||
cols: [],
|
||||
hideColumnFilter: true,
|
||||
toolbar: ['add', 'delete'],
|
||||
defaultToolbar: ['refresh', 'filter'],
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await NodeAPI.listNode(params as NodePageQuery);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: (ids) =>
|
||||
NodeAPI.deleteNode(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||
),
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message:
|
||||
'确认删除选中的节点吗?\n' +
|
||||
'此操作将同时删除节点定义并移除调度器中的相关任务。\n' +
|
||||
'正在运行的任务会被立即移除,待执行任务的日志将被标记为已取消。',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
const defaultCodeBlock = `def handler(*args, **kwargs):
|
||||
"""
|
||||
Demo: 调用工程中的方法处理数据
|
||||
|
||||
演示如何:
|
||||
1. 从工程中导入方法
|
||||
2. 调用处理器处理数据
|
||||
3. 返回处理结果
|
||||
"""
|
||||
|
||||
# 从工程中导入方法
|
||||
from app.plugin.module_task.cronjob.node.handlers.demo_handler import (
|
||||
demo_handler,
|
||||
process_data
|
||||
)
|
||||
|
||||
print("=" * 50)
|
||||
print("Demo 任务开始执行")
|
||||
print("=" * 50)
|
||||
|
||||
# 1. 调用 demo_handler
|
||||
print("1. 调用 demo_handler:")
|
||||
result1 = demo_handler("参数1", "参数2", key="value")
|
||||
print(f" 返回: {result1}")
|
||||
|
||||
# 2. 调用 process_data 计算平均值
|
||||
print("2. 数据处理 - 计算平均值:")
|
||||
numbers = [10, 20, 30, 40, 50]
|
||||
result2 = process_data(numbers, operation="avg")
|
||||
print(f" 输入: {numbers}")
|
||||
print(f" 结果: {result2}")
|
||||
|
||||
# 3. 调用 process_data 计算总和
|
||||
print("3. 数据处理 - 计算总和:")
|
||||
result3 = process_data(numbers, operation="sum")
|
||||
print(f" 输入: {numbers}")
|
||||
print(f" 结果: {result3}")
|
||||
|
||||
print("=" * 50)
|
||||
print("Demo 任务执行完成")
|
||||
print("=" * 50)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"demo_result": result1,
|
||||
"avg_result": result2,
|
||||
"sum_result": result3
|
||||
}
|
||||
`;
|
||||
|
||||
const formData = reactive<NodeForm>({
|
||||
id: undefined,
|
||||
name: '',
|
||||
code: undefined,
|
||||
jobstore: 'default',
|
||||
executor: 'default',
|
||||
func: defaultCodeBlock,
|
||||
args: undefined,
|
||||
kwargs: undefined,
|
||||
coalesce: false,
|
||||
max_instances: 1,
|
||||
start_date: undefined,
|
||||
end_date: undefined,
|
||||
});
|
||||
|
||||
const argsList = ref<string[]>([]);
|
||||
const kwargsList = ref<{ key: string; value: string }[]>([]);
|
||||
|
||||
const executeDialogVisible = ref(false);
|
||||
const currentExecuteNode = ref<NodeTable | null>(null);
|
||||
const executeFormData = reactive<{
|
||||
trigger: TriggerType;
|
||||
trigger_args?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}>({
|
||||
trigger: 'now',
|
||||
trigger_args: undefined,
|
||||
start_date: undefined,
|
||||
end_date: undefined,
|
||||
});
|
||||
|
||||
const dialogVisible = reactive({
|
||||
title: '',
|
||||
visible: false,
|
||||
type: 'create' as 'create' | 'update' | 'detail',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入节点名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入节点编码', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
const executeRules = reactive({
|
||||
trigger: [{ required: true, message: '请选择执行方式', trigger: 'change' }],
|
||||
trigger_args: [{ required: true, message: '请设置执行参数', trigger: 'blur' }],
|
||||
});
|
||||
|
||||
function handleRowDelete(id: number) {
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
const initialFormData: Partial<NodeForm> = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
code: undefined,
|
||||
jobstore: 'sqlalchemy',
|
||||
executor: 'default',
|
||||
func: defaultCodeBlock,
|
||||
args: undefined,
|
||||
kwargs: undefined,
|
||||
coalesce: false,
|
||||
max_instances: 5,
|
||||
start_date: undefined,
|
||||
end_date: undefined,
|
||||
};
|
||||
|
||||
async function resetForm() {
|
||||
if (dataFormRef.value) {
|
||||
dataFormRef.value.resetFields();
|
||||
dataFormRef.value.clearValidate();
|
||||
}
|
||||
Object.assign(formData, initialFormData);
|
||||
argsList.value = [];
|
||||
kwargsList.value = [];
|
||||
}
|
||||
|
||||
async function handleCloseDialog() {
|
||||
dialogVisible.visible = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleOpenDialog(type: 'create' | 'update', id?: number) {
|
||||
dialogVisible.type = type;
|
||||
if (id) {
|
||||
const response = await NodeAPI.detailNode(id);
|
||||
dialogVisible.title = '修改节点';
|
||||
Object.assign(formData, response.data.data);
|
||||
const data = response.data.data;
|
||||
argsList.value = data.args ? data.args.split(',').map((v: string) => v.trim()) : [];
|
||||
kwargsList.value = data.kwargs
|
||||
? Object.entries(JSON.parse(data.kwargs)).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}))
|
||||
: [];
|
||||
} else {
|
||||
dialogVisible.title = '新增节点';
|
||||
formData.id = undefined;
|
||||
argsList.value = [];
|
||||
kwargsList.value = [];
|
||||
}
|
||||
dialogVisible.visible = true;
|
||||
}
|
||||
|
||||
function handleDialogOpened() {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
codeEditorRef.value?.refresh?.();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
dataFormRef.value.validate(async (valid: any) => {
|
||||
if (valid) {
|
||||
submitLoading.value = true;
|
||||
const id = formData.id;
|
||||
try {
|
||||
const submitData = {
|
||||
...formData,
|
||||
args: argsList.value.filter((v) => v.trim()).join(',') || undefined,
|
||||
kwargs:
|
||||
kwargsList.value.filter((v) => v.key.trim()).length > 0
|
||||
? JSON.stringify(
|
||||
Object.fromEntries(
|
||||
kwargsList.value.filter((v) => v.key.trim()).map((v) => [v.key, v.value])
|
||||
)
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
if (id) {
|
||||
await NodeAPI.updateNode(id, submitData);
|
||||
} else {
|
||||
await NodeAPI.createNode(submitData);
|
||||
}
|
||||
dialogVisible.visible = false;
|
||||
resetForm();
|
||||
refreshList();
|
||||
} catch (error: any) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handlechangeCron = (cronStr: string) => {
|
||||
if (typeof cronStr == 'string') {
|
||||
executeFormData.trigger_args = cronStr;
|
||||
}
|
||||
};
|
||||
|
||||
const handleIntervalConfirm = (value: string) => {
|
||||
executeFormData.trigger_args = value;
|
||||
openInterval.value = false;
|
||||
};
|
||||
|
||||
function handleOpenExecuteDialog(row: NodeTable) {
|
||||
currentExecuteNode.value = row;
|
||||
executeFormData.trigger = 'now';
|
||||
executeFormData.trigger_args = undefined;
|
||||
executeFormData.start_date = undefined;
|
||||
executeFormData.end_date = undefined;
|
||||
executeDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function handleCloseExecuteDialog() {
|
||||
executeDialogVisible.value = false;
|
||||
currentExecuteNode.value = null;
|
||||
if (executeFormRef.value) {
|
||||
executeFormRef.value.resetFields();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecuteNode() {
|
||||
if (executeFormData.trigger !== 'now') {
|
||||
const valid = await executeFormRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
}
|
||||
|
||||
try {
|
||||
submitLoading.value = true;
|
||||
const params: any = {
|
||||
trigger: executeFormData.trigger,
|
||||
};
|
||||
|
||||
if (executeFormData.trigger !== 'now') {
|
||||
params.trigger_args = executeFormData.trigger_args;
|
||||
params.start_date = executeFormData.start_date;
|
||||
params.end_date = executeFormData.end_date;
|
||||
}
|
||||
|
||||
await NodeAPI.executeNode(currentExecuteNode.value?.id as number, params);
|
||||
|
||||
handleCloseExecuteDialog();
|
||||
|
||||
refreshList();
|
||||
} catch (error: any) {
|
||||
ElMessage.error({
|
||||
message: error.response?.data?.msg || '调试失败',
|
||||
type: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
console.error(error);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await dictStore.getDict(['sys_job_store', 'sys_job_executor']);
|
||||
refreshList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.code-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.code-editor-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-editor-tip {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dynamic-params {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
max-height: 200px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.execution-log-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- popover 挂载到 body,需单独写;修复 vue3-cron-plus 全局 .el-tag--info { margin-left: -60px } 误伤多选下拉里 tag -->
|
||||
<style lang="scss">
|
||||
.node-cron-popover-fix {
|
||||
.vue3-cron-plus-container .el-select .el-tag {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
/* 具体秒数等多选行:避免文案与选择器挤在同一行错位 */
|
||||
.vue3-cron-plus-container .tabBody .el-radio.long {
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
|
||||
.el-radio__label {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
align-items: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
flex: 1 1 200px;
|
||||
min-width: 180px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div
|
||||
class="dynamic-node"
|
||||
:class="nodeClass"
|
||||
@mouseenter="showHandles = true"
|
||||
@mouseleave="showHandles = false"
|
||||
>
|
||||
<div class="node-content">
|
||||
<span class="node-label">{{ data.label }}</span>
|
||||
<span v-if="data.config && Object.keys(data.config).length > 0" class="node-badge">
|
||||
{{ Object.keys(data.config).length }}
|
||||
</span>
|
||||
</div>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'input'"
|
||||
:id="'top-' + id"
|
||||
type="target"
|
||||
position="top"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'input'"
|
||||
:id="'left-' + id"
|
||||
type="target"
|
||||
position="left"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'output'"
|
||||
:id="'right-' + id"
|
||||
type="source"
|
||||
position="right"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'output'"
|
||||
:id="'bottom-' + id"
|
||||
type="source"
|
||||
position="bottom"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: nodeType.color || '#3b82f6' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { Handle } from '@vue-flow/core';
|
||||
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
data: Object,
|
||||
nodeStatus: String,
|
||||
});
|
||||
|
||||
const showHandles = ref(false);
|
||||
|
||||
const nodeType = computed(() => {
|
||||
return {
|
||||
code: props.data?.type || 'custom',
|
||||
name: props.data?.label || '自定义节点',
|
||||
color: getCategoryColor(props.data?.category),
|
||||
};
|
||||
});
|
||||
|
||||
function getCategoryColor(category) {
|
||||
const colorMap = {
|
||||
trigger: '#e6a23c',
|
||||
action: '#409eff',
|
||||
condition: '#67c23a',
|
||||
control: '#909399',
|
||||
};
|
||||
return colorMap[category] || '#409eff';
|
||||
}
|
||||
|
||||
const nodeClass = computed(() => {
|
||||
if (props.data?.type === 'input') {
|
||||
return 'start-node';
|
||||
}
|
||||
if (props.data?.type === 'output') {
|
||||
return 'end-node';
|
||||
}
|
||||
return 'custom-node';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-flow__node-input {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
padding: 0 !important;
|
||||
color: #ffffff !important;
|
||||
cursor: pointer !important;
|
||||
background: linear-gradient(135deg, #67c23a 0%, #5daf34 100%) !important;
|
||||
border: 3px solid #5daf34 !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow:
|
||||
0 6px 12px rgba(103, 194, 58, 0.4),
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.vue-flow__node-output {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
padding: 0 !important;
|
||||
color: #ffffff !important;
|
||||
cursor: pointer !important;
|
||||
background: linear-gradient(135deg, #f56c6c 0%, #e04e4e 100%) !important;
|
||||
border: 3px solid #e04e4e !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow:
|
||||
0 6px 12px rgba(245, 108, 108, 0.4),
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.dynamic-node {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
border: 2px solid #409eff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.vue-flow__node-input .dynamic-node,
|
||||
.vue-flow__node-output .dynamic-node {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.node-content {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
padding: 0 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: #409eff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.vue-flow__handle.handle-visible,
|
||||
.vue-flow__handle.vue-flow__handle-connecting,
|
||||
.vue-flow__handle.vue-flow__handle-valid {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="edge-config-panel">
|
||||
<div class="panel-header">
|
||||
<span>连线配置</span>
|
||||
<ElButton type="text" class="close-btn" @click="handleClose">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<ElForm :model="formData" label-width="80px" size="small">
|
||||
<ElFormItem label="连线名称">
|
||||
<ElInput v-model="formData.label" placeholder="请输入连线名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="连线类型">
|
||||
<ElSelect v-model="formData.type" placeholder="请选择连线类型">
|
||||
<ElOption label="折线" value="smoothstep" />
|
||||
<ElOption label="曲线" value="default" />
|
||||
<ElOption label="直线" value="straight" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="连线颜色">
|
||||
<ElColorPicker v-model="formData.color" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="线条宽度">
|
||||
<ElInputNumber v-model="formData.strokeWidth" :min="1" :max="10" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="启用动画">
|
||||
<ElSwitch v-model="formData.animated" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="条件表达式">
|
||||
<ElInput
|
||||
v-model="formData.condition"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入条件表达式"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="描述">
|
||||
<ElInput
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入描述信息"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="panel-actions">
|
||||
<ElButton type="primary" size="small" @click="handleSave">保存</ElButton>
|
||||
<ElButton type="danger" size="small" @click="handleDelete">删除连线</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElInputNumber,
|
||||
ElSwitch,
|
||||
ElColorPicker,
|
||||
ElMessage,
|
||||
ElIcon,
|
||||
} from 'element-plus';
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
edge: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'save', 'delete']);
|
||||
|
||||
const formData = ref({
|
||||
label: props.edge?.label || '',
|
||||
type: props.edge?.type || 'smoothstep',
|
||||
color: props.edge?.style?.stroke || '#000000',
|
||||
strokeWidth: props.edge?.style?.strokeWidth || 2,
|
||||
animated: props.edge?.animated || false,
|
||||
condition: props.edge?.data?.condition || '',
|
||||
description: props.edge?.data?.description || '',
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.edge,
|
||||
(newEdge) => {
|
||||
if (newEdge) {
|
||||
formData.value = {
|
||||
label: newEdge.label || '',
|
||||
type: newEdge.type || 'smoothstep',
|
||||
color: newEdge.style?.stroke || '#000000',
|
||||
strokeWidth: newEdge.style?.strokeWidth || 2,
|
||||
animated: newEdge.animated || false,
|
||||
condition: newEdge.data?.condition || '',
|
||||
description: newEdge.data?.description || '',
|
||||
};
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit('save', formData.value);
|
||||
ElMessage.success('保存成功');
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit('delete');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.edge-config-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="node-config-panel">
|
||||
<div class="panel-header">
|
||||
<span>节点配置</span>
|
||||
<ElButton type="text" class="close-btn" @click="handleClose">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="panel-content">
|
||||
<ElForm :model="formData" label-width="80px" size="small">
|
||||
<ElFormItem label="节点类型">
|
||||
<ElSelect v-model="formData.type" placeholder="请选择节点类型" @change="handleTypeChange">
|
||||
<ElOption
|
||||
v-for="type in nodeTypes"
|
||||
:key="type.id"
|
||||
:label="type.name"
|
||||
:value="type.code"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="节点名称">
|
||||
<ElInput v-model="formData.label" placeholder="请输入节点名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="位置参数">
|
||||
<ElInput v-model="formData.args" placeholder="多个参数用逗号分隔,如: arg1, arg2, arg3" />
|
||||
<div class="field-hint">多个参数用逗号分隔</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="关键字参数">
|
||||
<ElInput
|
||||
v-model="formData.kwargsStr"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder='JSON格式,如: {"key": "value", "count": 10}'
|
||||
/>
|
||||
<div class="field-hint">JSON 格式的关键字参数</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="描述">
|
||||
<ElInput
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入描述信息"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<div class="panel-actions">
|
||||
<ElButton type="primary" size="small" @click="handleSave">保存</ElButton>
|
||||
<ElButton type="danger" size="small" @click="handleDelete">删除节点</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElMessage,
|
||||
ElIcon,
|
||||
} from 'element-plus';
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
import WorkflowNodeTypeAPI, {
|
||||
type WorkflowNodeTypeOption,
|
||||
} from '@/api/module_task/workflow/node-type';
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'save', 'delete']);
|
||||
|
||||
const nodeTypes = ref<WorkflowNodeTypeOption[]>([]);
|
||||
|
||||
const formData = ref({
|
||||
type: props.node?.type || '',
|
||||
label: props.node?.data?.label || '',
|
||||
args: props.node?.data?.args || '',
|
||||
kwargsStr: props.node?.data?.kwargsStr || '{}',
|
||||
description: props.node?.data?.description || '',
|
||||
});
|
||||
|
||||
const loadNodeTypes = async () => {
|
||||
try {
|
||||
const res = await WorkflowNodeTypeAPI.getWorkflowNodeTypeOptions();
|
||||
if (res.data) {
|
||||
nodeTypes.value = res.data.data || [];
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载节点类型失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeChange = async (typeCode: string) => {
|
||||
const nodeType = nodeTypes.value.find((t) => t.code === typeCode);
|
||||
if (nodeType) {
|
||||
formData.value.args = nodeType.args || '';
|
||||
formData.value.kwargsStr = nodeType.kwargs || '{}';
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
(newNode) => {
|
||||
if (newNode) {
|
||||
const kwargsData = newNode.data?.kwargs;
|
||||
let kwargsStr = '{}';
|
||||
if (kwargsData) {
|
||||
if (typeof kwargsData === 'string') {
|
||||
kwargsStr = kwargsData;
|
||||
} else if (typeof kwargsData === 'object') {
|
||||
kwargsStr = JSON.stringify(kwargsData, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
formData.value = {
|
||||
type: newNode.type || '',
|
||||
label: newNode.data?.label || '',
|
||||
args: newNode.data?.args || '',
|
||||
kwargsStr,
|
||||
description: newNode.data?.description || '',
|
||||
};
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
try {
|
||||
if (formData.value.kwargsStr && formData.value.kwargsStr.trim()) {
|
||||
JSON.parse(formData.value.kwargsStr);
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('关键字参数 JSON 格式错误');
|
||||
return;
|
||||
}
|
||||
|
||||
emit('save', {
|
||||
type: formData.value.type,
|
||||
label: formData.value.label,
|
||||
args: formData.value.args,
|
||||
kwargs: formData.value.kwargsStr,
|
||||
description: formData.value.description,
|
||||
});
|
||||
ElMessage.success('保存成功');
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit('delete');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNodeTypes();
|
||||
if (props.node?.type) {
|
||||
handleTypeChange(props.node.type);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.node-config-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.panel-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,980 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="dialogVisible"
|
||||
:title="drawerTitle"
|
||||
:close-on-click-modal="true"
|
||||
size="80%"
|
||||
class="workflow-drawer"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-container class="workflow-create-content">
|
||||
<el-splitter direction="horizontal" style="height: 100%">
|
||||
<el-splitter-panel size="250px" :min="200" :max="400">
|
||||
<el-scrollbar style="height: 100%">
|
||||
<div class="panel-section">
|
||||
<div class="section-title">基础信息</div>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
label-width="50px"
|
||||
:rules="formRules"
|
||||
size="small"
|
||||
>
|
||||
<el-form-item label="编码" prop="code">
|
||||
<el-input v-model="formData.code" placeholder="请输入流程编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入流程名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入流程描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-divider style="margin: 4px 0" />
|
||||
|
||||
<div class="panel-section">
|
||||
<div class="section-title">节点</div>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索节点名称"
|
||||
clearable
|
||||
size="small"
|
||||
class="search-box"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-space direction="vertical" :size="8" fill style="width: 100%; margin-top: 8px">
|
||||
<el-tag
|
||||
v-for="item in filteredNodes"
|
||||
:key="item.id"
|
||||
:type="getCategoryType(item.category) as any"
|
||||
effect="plain"
|
||||
draggable="true"
|
||||
style="justify-content: center; cursor: move; user-select: none"
|
||||
@dragstart="onDragStart($event, item)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
{{ item.name }}
|
||||
<span style="margin-left: 4px; font-size: 10px; opacity: 0.7">
|
||||
[{{ getCategoryText(item.category) }}]
|
||||
</span>
|
||||
</el-tag>
|
||||
</el-space>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-splitter-panel>
|
||||
|
||||
<el-splitter-panel>
|
||||
<div class="canvas-main">
|
||||
<div class="canvas-container" @click="handleCanvasClick">
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
class="basic-flow"
|
||||
:default-viewport="{ zoom: 1.5 }"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="4"
|
||||
:node-types="nodeTypesRegistry"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
@node-click="onNodeClick"
|
||||
@edge-click="onEdgeClick"
|
||||
@drop="onDrop"
|
||||
@dragover="onDragOver"
|
||||
>
|
||||
<Controls />
|
||||
<Background pattern-color="#aaa" :gap="16" />
|
||||
<Panel position="top-right" class="workflow-toolbar">
|
||||
<el-button
|
||||
class="vue-flow__controls-button"
|
||||
title="格式化画布"
|
||||
:icon="Grid"
|
||||
@click="handleFormatCanvas"
|
||||
/>
|
||||
<el-dropdown trigger="click" @command="handleEdgeStyleChange">
|
||||
<el-button class="vue-flow__controls-button" title="连线样式" :icon="Share" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
command="bezier"
|
||||
:class="{ active: edgeStyle === 'bezier' }"
|
||||
>
|
||||
平滑曲线
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
command="smoothstep"
|
||||
:class="{ active: edgeStyle === 'smoothstep' }"
|
||||
>
|
||||
阶梯折线
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
command="straight"
|
||||
:class="{ active: edgeStyle === 'straight' }"
|
||||
>
|
||||
直线
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button
|
||||
class="vue-flow__controls-button"
|
||||
:title="edgeAnimated ? '关闭动画' : '开启动画'"
|
||||
:icon="VideoPlay"
|
||||
@click="handleEdgeAnimatedChange(!edgeAnimated)"
|
||||
/>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button class="vue-flow__controls-button" title="布局方向">
|
||||
<el-icon><Rank /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
@click="
|
||||
layoutDirection = 'LR';
|
||||
handleLayout();
|
||||
"
|
||||
>
|
||||
横向布局
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
@click="
|
||||
layoutDirection = 'TB';
|
||||
handleLayout();
|
||||
"
|
||||
>
|
||||
纵向布局
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</Panel>
|
||||
<MiniMap pannable zoomable />
|
||||
</VueFlow>
|
||||
</div>
|
||||
</div>
|
||||
</el-splitter-panel>
|
||||
|
||||
<el-splitter-panel v-if="updateState" size="320px" :min="280" :max="400">
|
||||
<NodeConfigPanel
|
||||
v-if="updateState === 'node'"
|
||||
:node="selectedNode"
|
||||
@close="handleClosePanel"
|
||||
@save="handleSaveNode"
|
||||
@delete="handleDeleteNode"
|
||||
/>
|
||||
<EdgeConfigPanel
|
||||
v-if="updateState === 'edge'"
|
||||
:edge="selectedEdge"
|
||||
@close="handleClosePanel"
|
||||
@save="handleSaveEdge"
|
||||
@delete="handleDeleteEdge"
|
||||
/>
|
||||
</el-splitter-panel>
|
||||
</el-splitter>
|
||||
</el-container>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleFinish">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed, onMounted, markRaw, type Component } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Panel, VueFlow, useVueFlow } from '@vue-flow/core';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { MiniMap } from '@vue-flow/minimap';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import type { Node, Edge, DefaultEdgeOptions, MarkerType } from '@vue-flow/core';
|
||||
import { Search, Share, VideoPlay, Rank, Grid } from '@element-plus/icons-vue';
|
||||
import dagre from 'dagre';
|
||||
import '@vue-flow/core/dist/style.css';
|
||||
import '@vue-flow/core/dist/theme-default.css';
|
||||
import '@vue-flow/controls/dist/style.css';
|
||||
import '@vue-flow/minimap/dist/style.css';
|
||||
import 'element-plus/dist/index.css';
|
||||
|
||||
import DynamicNode from './DynamicNode.vue';
|
||||
import NodeConfigPanel from './NodeConfigPanel.vue';
|
||||
import EdgeConfigPanel from './EdgeConfigPanel.vue';
|
||||
import WorkflowDefinitionAPI, {
|
||||
type WorkflowTable,
|
||||
type WorkflowForm,
|
||||
} from '@/api/module_task/workflow/definition';
|
||||
import WorkflowNodeTypeAPI from '@/api/module_task/workflow/node-type';
|
||||
|
||||
defineOptions({
|
||||
name: 'WorkflowCreateDrawer',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workflow: {
|
||||
type: Object as () => WorkflowTable | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'refresh']);
|
||||
|
||||
const formRef = ref();
|
||||
const workflowId = ref<number>();
|
||||
|
||||
const formData = reactive<Partial<WorkflowForm>>({
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
code: [{ required: true, message: '请输入流程编码', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val),
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() => {
|
||||
return props.workflow ? '编辑工作流' : '创建工作流';
|
||||
});
|
||||
|
||||
const {
|
||||
onInit,
|
||||
onConnect,
|
||||
addEdges,
|
||||
getNodes: getNodesRef,
|
||||
getEdges: getEdgesRef,
|
||||
setEdges,
|
||||
setNodes,
|
||||
screenToFlowCoordinate,
|
||||
onNodesInitialized,
|
||||
updateNode,
|
||||
addNodes,
|
||||
} = useVueFlow();
|
||||
|
||||
const defaultEdgeOptions: DefaultEdgeOptions = {
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
markerEnd: 'arrowclosed' as MarkerType,
|
||||
};
|
||||
|
||||
const edgeStyle = ref<string>('smoothstep');
|
||||
const edgeAnimated = ref<boolean>(true);
|
||||
|
||||
const handleEdgeStyleChange = (value: string) => {
|
||||
edgeStyle.value = value;
|
||||
defaultEdgeOptions.type = value;
|
||||
setEdges(
|
||||
getEdgesRef.value.map((edge) => ({
|
||||
...edge,
|
||||
type: value,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdgeAnimatedChange = (value: boolean) => {
|
||||
edgeAnimated.value = value;
|
||||
defaultEdgeOptions.animated = value;
|
||||
setEdges(
|
||||
getEdgesRef.value.map((edge) => ({
|
||||
...edge,
|
||||
animated: value,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const layoutDirection = ref<'LR' | 'TB'>('LR');
|
||||
|
||||
const handleLayout = () => {
|
||||
const currentNodes = getNodesRef.value;
|
||||
const currentEdges = getEdgesRef.value;
|
||||
|
||||
if (currentNodes.length === 0) {
|
||||
ElMessage.warning('画布中没有节点,无法布局');
|
||||
return;
|
||||
}
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 60;
|
||||
|
||||
dagreGraph.setGraph({
|
||||
rankdir: layoutDirection.value,
|
||||
nodesep: 80,
|
||||
ranksep: 120,
|
||||
marginx: 50,
|
||||
marginy: 50,
|
||||
});
|
||||
|
||||
currentNodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
currentEdges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = currentNodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(
|
||||
currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
type: edgeStyle.value,
|
||||
animated: edgeAnimated.value,
|
||||
}))
|
||||
);
|
||||
ElMessage.success('画布布局完成');
|
||||
};
|
||||
|
||||
const handleFormatCanvas = () => {
|
||||
const currentNodes = getNodesRef.value;
|
||||
const currentEdges = getEdgesRef.value;
|
||||
|
||||
if (currentNodes.length === 0) {
|
||||
ElMessage.warning('画布中没有节点,无法格式化');
|
||||
return;
|
||||
}
|
||||
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const nodeWidth = 180;
|
||||
const nodeHeight = 60;
|
||||
|
||||
dagreGraph.setGraph({
|
||||
rankdir: layoutDirection.value,
|
||||
nodesep: 100,
|
||||
ranksep: 150,
|
||||
marginx: 80,
|
||||
marginy: 80,
|
||||
});
|
||||
|
||||
currentNodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
currentEdges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
const layoutedNodes = currentNodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(
|
||||
currentEdges.map((edge) => ({
|
||||
...edge,
|
||||
type: edgeStyle.value,
|
||||
animated: edgeAnimated.value,
|
||||
}))
|
||||
);
|
||||
ElMessage.success('画布格式化完成');
|
||||
};
|
||||
|
||||
const nodes = ref<Node[]>([]);
|
||||
const edges = ref<Edge[]>([]);
|
||||
|
||||
const searchKeyword = ref('');
|
||||
|
||||
type LoadedNodeType = {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
category: string;
|
||||
args?: string;
|
||||
kwargs?: string;
|
||||
};
|
||||
|
||||
const allNodes = ref<LoadedNodeType[]>([]);
|
||||
|
||||
const filteredNodes = computed(() => {
|
||||
if (!searchKeyword.value) {
|
||||
return allNodes.value;
|
||||
}
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
return allNodes.value.filter((node) => node.name.toLowerCase().includes(keyword));
|
||||
});
|
||||
|
||||
const getCategoryType = (category: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
trigger: 'warning',
|
||||
action: 'primary',
|
||||
condition: 'success',
|
||||
control: 'info',
|
||||
};
|
||||
return typeMap[category] || 'info';
|
||||
};
|
||||
|
||||
const getCategoryText = (category: string) => {
|
||||
const textMap: Record<string, string> = {
|
||||
trigger: '触发器',
|
||||
action: '动作',
|
||||
condition: '条件',
|
||||
control: '控制',
|
||||
};
|
||||
return textMap[category] || category;
|
||||
};
|
||||
|
||||
const nodeTypesRegistry = ref<Record<string, Component>>({});
|
||||
|
||||
const updateState = ref('');
|
||||
const selectedEdge = ref<Edge>();
|
||||
const selectedNode = ref<Node>();
|
||||
const loading = ref(false);
|
||||
|
||||
const getNodes = () => getNodesRef.value;
|
||||
const getEdges = () =>
|
||||
getEdgesRef.value.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: typeof edge.label === 'string' ? edge.label : undefined,
|
||||
type: edge.type,
|
||||
animated: edge.animated,
|
||||
style: edge.style,
|
||||
data: edge.data,
|
||||
}));
|
||||
|
||||
const loadNodeTypes = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await WorkflowNodeTypeAPI.getWorkflowNodeTypeOptions();
|
||||
if (res.data && res.data.data) {
|
||||
allNodes.value = res.data.data.map((nodeType: any) => ({
|
||||
id: nodeType.id,
|
||||
type: nodeType.code,
|
||||
name: nodeType.name,
|
||||
category: nodeType.category || 'action',
|
||||
args: nodeType.args || '',
|
||||
kwargs: nodeType.kwargs || '{}',
|
||||
}));
|
||||
|
||||
const newTypes: Record<string, Component> = {};
|
||||
res.data.data.forEach((nodeType: any) => {
|
||||
newTypes[nodeType.code] = markRaw(DynamicNode);
|
||||
});
|
||||
|
||||
nodeTypesRegistry.value = newTypes;
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载节点类型失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadNodeTypes();
|
||||
});
|
||||
|
||||
onInit((vueFlowInstance) => {
|
||||
vueFlowInstance.fitView();
|
||||
if (workflowId.value) {
|
||||
WorkflowDefinitionAPI.getWorkflowDetail(workflowId.value)
|
||||
.then((res) => {
|
||||
if (res.data && res.data.data) {
|
||||
nodes.value = res.data.data.nodes || [];
|
||||
edges.value = res.data.data.edges || [];
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('流程加载失败');
|
||||
});
|
||||
} else {
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
});
|
||||
|
||||
onConnect((connection) => {
|
||||
addEdges({
|
||||
...connection,
|
||||
type: edgeStyle.value,
|
||||
animated: edgeAnimated.value,
|
||||
});
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
});
|
||||
|
||||
function handleValidate() {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const allNodesList = getNodes();
|
||||
const allEdgesList = getEdges();
|
||||
|
||||
if (allNodesList.length === 0) {
|
||||
errors.push('流程中没有节点');
|
||||
}
|
||||
|
||||
const nodeIds = new Set(allNodesList.map((n: Node) => n.id));
|
||||
allEdgesList.forEach((edge: Edge) => {
|
||||
if (!nodeIds.has(edge.source)) {
|
||||
errors.push(`连线 ${edge.label || edge.id} 的源节点不存在`);
|
||||
}
|
||||
if (!nodeIds.has(edge.target)) {
|
||||
errors.push(`连线 ${edge.label || edge.id} 的目标节点不存在`);
|
||||
}
|
||||
});
|
||||
|
||||
const orphanNodes = allNodesList.filter(
|
||||
(node: Node) => !allEdgesList.some((e: Edge) => e.source === node.id || e.target === node.id)
|
||||
);
|
||||
|
||||
if (orphanNodes.length > 0) {
|
||||
warnings.push(
|
||||
`有 ${orphanNodes.length} 个孤立节点: ${orphanNodes.map((n: Node) => n.data.label).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
ElMessageBox.alert(
|
||||
`<div style="max-height: 300px; overflow-y: auto;">
|
||||
<strong>错误 (${errors.length}):</strong>
|
||||
<ul>${errors.map((e) => `<li style="color: #f56c6c;">${e}</li>`).join('')}</ul>
|
||||
${
|
||||
warnings.length > 0
|
||||
? `<strong>警告 (${warnings.length}):</strong>
|
||||
<ul>${warnings.map((w) => `<li style="color: #e6a23c;">${w}</li>`).join('')}</ul>`
|
||||
: ''
|
||||
}
|
||||
</div>`,
|
||||
'流程验证结果',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
dangerouslyUseHTMLString: true,
|
||||
}
|
||||
);
|
||||
throw new Error('验证失败');
|
||||
} else if (warnings.length > 0) {
|
||||
ElMessageBox.alert(
|
||||
`<div style="max-height: 300px; overflow-y: auto;">
|
||||
<strong>流程验证通过,但有警告 (${warnings.length}):</strong>
|
||||
<ul>${warnings.map((w) => `<li style="color: #e6a23c;">${w}</li>`).join('')}</ul>
|
||||
</div>`,
|
||||
'流程验证结果',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
dangerouslyUseHTMLString: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const onEdgeClick = (event: any) => {
|
||||
event.event.stopPropagation();
|
||||
selectedEdge.value = event.edge;
|
||||
updateState.value = 'edge';
|
||||
};
|
||||
|
||||
const handleCanvasClick = (event: MouseEvent) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
(event.target.classList.contains('vue-flow__node') || event.target.closest('.vue-flow__node'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateState.value = '';
|
||||
selectedNode.value = undefined;
|
||||
selectedEdge.value = undefined;
|
||||
};
|
||||
|
||||
const onNodeClick = (event: any) => {
|
||||
event.event.stopPropagation();
|
||||
selectedNode.value = event.node;
|
||||
updateState.value = 'node';
|
||||
};
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
handleNodeDrop(event, screenToFlowCoordinate, onNodesInitialized, updateNode, addNodes);
|
||||
}
|
||||
|
||||
function handleClosePanel() {
|
||||
updateState.value = '';
|
||||
selectedNode.value = undefined;
|
||||
selectedEdge.value = undefined;
|
||||
}
|
||||
|
||||
function handleSaveNode(data: any) {
|
||||
if (!selectedNode.value) return;
|
||||
const nodeId = selectedNode.value!.id;
|
||||
if (nodeId && updateNodeData(nodeId, data, getNodes, setNodes)) {
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteNode() {
|
||||
if (!selectedNode.value) return;
|
||||
const nodeId = selectedNode.value!.id;
|
||||
if (!nodeId) return;
|
||||
|
||||
ElMessageBox.confirm('确定要删除该节点吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
deleteNode(nodeId, getNodes, setNodes, getEdges, setEdges);
|
||||
ElMessage.success('节点删除成功');
|
||||
handleClosePanel();
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaveEdge(data: any) {
|
||||
if (!selectedEdge.value) return;
|
||||
const edgeId = selectedEdge.value!.id;
|
||||
if (edgeId && updateEdgeData(edgeId, data, getEdges, setEdges)) {
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteEdge() {
|
||||
if (!selectedEdge.value) return;
|
||||
const edgeId = selectedEdge.value!.id;
|
||||
if (!edgeId) return;
|
||||
|
||||
ElMessageBox.confirm('确定要删除该连线吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
deleteEdge(edgeId, getEdges, setEdges);
|
||||
ElMessage.success('连线删除成功');
|
||||
handleClosePanel();
|
||||
saveToHistory(nodes.value as any, edges.value as any);
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const workflowData = {
|
||||
nodes: nodes.value,
|
||||
edges: edges.value,
|
||||
};
|
||||
|
||||
const saveData = {
|
||||
...formData,
|
||||
nodes: workflowData.nodes,
|
||||
edges: workflowData.edges,
|
||||
};
|
||||
|
||||
if (workflowId.value) {
|
||||
return WorkflowDefinitionAPI.updateWorkflow(workflowId.value, saveData as WorkflowForm);
|
||||
} else {
|
||||
return WorkflowDefinitionAPI.createWorkflow(saveData as WorkflowForm).then((res) => {
|
||||
if (res.data && res.data.data) {
|
||||
workflowId.value = res.data.data.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.workflow,
|
||||
(newWorkflow) => {
|
||||
if (newWorkflow) {
|
||||
Object.assign(formData, {
|
||||
code: newWorkflow.code,
|
||||
name: newWorkflow.name,
|
||||
description: newWorkflow.description,
|
||||
});
|
||||
workflowId.value = newWorkflow.id;
|
||||
nodes.value = newWorkflow.nodes || [];
|
||||
edges.value = newWorkflow.edges || [];
|
||||
} else {
|
||||
Object.assign(formData, {
|
||||
code: '',
|
||||
name: '',
|
||||
description: '',
|
||||
});
|
||||
workflowId.value = undefined;
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
await handleValidate();
|
||||
await handleSave();
|
||||
emit('refresh');
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('保存流程失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// 历史记录管理
|
||||
type HistoryItem = {
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
};
|
||||
const history = ref<HistoryItem[]>([]);
|
||||
const historyIndex = ref(-1);
|
||||
|
||||
function saveToHistory(nodesData: any, edgesData: any) {
|
||||
history.value = history.value.slice(0, historyIndex.value + 1);
|
||||
history.value.push({ nodes: nodesData, edges: edgesData });
|
||||
historyIndex.value = history.value.length - 1;
|
||||
}
|
||||
|
||||
// 拖拽相关函数
|
||||
function onDragStart(event: DragEvent, node: LoadedNodeType) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/vueflow', JSON.stringify(node));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
// 拖拽结束
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeDrop(
|
||||
event: DragEvent,
|
||||
screenToFlowCoordinate: (position: { x: number; y: number }) => { x: number; y: number },
|
||||
onNodesInitialized: (callback: () => void) => void,
|
||||
updateNode: (id: string, node: Partial<Node>) => void,
|
||||
addNodes: (nodes: Node[]) => void
|
||||
) {
|
||||
const data = event.dataTransfer?.getData('application/vueflow');
|
||||
if (!data) return;
|
||||
|
||||
const nodeType = JSON.parse(data);
|
||||
const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY });
|
||||
|
||||
const newNode: Node = {
|
||||
id: `node-${Date.now()}`,
|
||||
type: nodeType.type,
|
||||
position,
|
||||
data: {
|
||||
label: nodeType.name,
|
||||
type: nodeType.type,
|
||||
category: nodeType.category,
|
||||
args: nodeType.args,
|
||||
kwargs: nodeType.kwargs,
|
||||
},
|
||||
};
|
||||
|
||||
addNodes([newNode]);
|
||||
}
|
||||
|
||||
// 节点操作函数
|
||||
function updateNodeData(
|
||||
nodeId: string,
|
||||
data: any,
|
||||
getNodes: () => Node[],
|
||||
setNodes: (nodes: Node[]) => void
|
||||
) {
|
||||
const currentNodes = getNodes();
|
||||
const nodeIndex = currentNodes.findIndex((n) => n.id === nodeId);
|
||||
if (nodeIndex === -1) return false;
|
||||
|
||||
const updatedNodes = [...currentNodes];
|
||||
updatedNodes[nodeIndex] = {
|
||||
...updatedNodes[nodeIndex],
|
||||
data: {
|
||||
...updatedNodes[nodeIndex].data,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
setNodes(updatedNodes);
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteNode(
|
||||
nodeId: string,
|
||||
getNodes: () => Node[],
|
||||
setNodes: (nodes: Node[]) => void,
|
||||
getEdges: () => Edge[],
|
||||
setEdges: (edges: Edge[]) => void
|
||||
) {
|
||||
const currentNodes = getNodes();
|
||||
const currentEdges = getEdges();
|
||||
const filteredNodes = currentNodes.filter((n) => n.id !== nodeId);
|
||||
const filteredEdges = currentEdges.filter((e) => e.source !== nodeId && e.target !== nodeId);
|
||||
setNodes(filteredNodes);
|
||||
setEdges(filteredEdges);
|
||||
}
|
||||
|
||||
// 边操作函数
|
||||
function updateEdgeData(
|
||||
edgeId: string,
|
||||
data: any,
|
||||
getEdges: () => Edge[],
|
||||
setEdges: (edges: Edge[]) => void
|
||||
) {
|
||||
const currentEdges = getEdges();
|
||||
const edgeIndex = currentEdges.findIndex((e) => e.id === edgeId);
|
||||
if (edgeIndex === -1) return false;
|
||||
|
||||
const updatedEdges = [...currentEdges];
|
||||
updatedEdges[edgeIndex] = {
|
||||
...updatedEdges[edgeIndex],
|
||||
...data,
|
||||
data: {
|
||||
...updatedEdges[edgeIndex].data,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
setEdges(updatedEdges);
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteEdge(edgeId: string, getEdges: () => Edge[], setEdges: (edges: Edge[]) => void) {
|
||||
const currentEdges = getEdges();
|
||||
const filteredEdges = currentEdges.filter((e) => e.id !== edgeId);
|
||||
setEdges(filteredEdges);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.workflow-drawer {
|
||||
:deep(.el-drawer__body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-create-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-splitter) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.el-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.basic-info-section {
|
||||
padding: 12px;
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
padding: 12px;
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.canvas-main {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__controls) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__controls-button) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.workflow-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig" @add-click="handleCreate">
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" align="center" min-width="55" />
|
||||
<el-table-column type="index" fixed label="序号" min-width="60">
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ID" prop="id" min-width="80" />
|
||||
<el-table-column label="名称" prop="name" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="编码" prop="code" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="状态" prop="status" min-width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status) as any">
|
||||
{{ getStatusText(scope.row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="描述"
|
||||
prop="description"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="180" />
|
||||
|
||||
<OperationColumn :list-data-length="data.length">
|
||||
<template #default="scope">
|
||||
<el-space class="flex">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'draft'"
|
||||
v-hasPerm="['module_task:workflow:definition:update']"
|
||||
type="success"
|
||||
size="small"
|
||||
link
|
||||
icon="upload"
|
||||
@click="handlePublish(scope.row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-dropdown
|
||||
v-if="scope.row.status === 'published'"
|
||||
v-hasPerm="['module_task:workflow:definition:execute']"
|
||||
@command="(e: string) => handleExecute(e, scope.row)"
|
||||
>
|
||||
<el-button type="warning" size="small" link icon="video-play">
|
||||
执行
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="execute">立即执行</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:definition:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="handleEdit(scope.row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:definition:delete']"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleRowDelete(scope.row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</el-space>
|
||||
</template>
|
||||
</OperationColumn>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
|
||||
<WorkflowDesignDrawer
|
||||
v-model:visible="createVisible"
|
||||
:workflow="selectedWorkflow"
|
||||
@refresh="refreshList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: 'WorkflowDefinition',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ArrowDown } from '@element-plus/icons-vue';
|
||||
import PageSearch from '@/components/CURD/PageSearch.vue';
|
||||
import PageContent from '@/components/CURD/PageContent.vue';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import OperationColumn from '@/components/OperationColumn/index.vue';
|
||||
import WorkflowDefinitionAPI, {
|
||||
type WorkflowTable,
|
||||
type WorkflowPageQuery,
|
||||
} from '@/api/module_task/workflow/definition';
|
||||
import WorkflowDesignDrawer from '../components/WorkflowDesignDrawer.vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
|
||||
const selectedWorkflow = ref<WorkflowTable>();
|
||||
const createVisible = ref(false);
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_task:workflow:definition',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '流程名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入流程名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '流程编码',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '请输入流程编码', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '已发布', value: 'published' },
|
||||
{ label: '已归档', value: 'archived' },
|
||||
],
|
||||
attrs: { placeholder: '请选择状态', clearable: true, style: { width: '170px' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function normalizeWorkflowQuery(params: Record<string, unknown>): WorkflowPageQuery {
|
||||
const p = { ...params } as Record<string, unknown>;
|
||||
if (p.status === '' || p.status === null) p.status = undefined;
|
||||
return p as unknown as WorkflowPageQuery;
|
||||
}
|
||||
|
||||
const contentConfig = reactive<IContentConfig<WorkflowPageQuery>>({
|
||||
permPrefix: 'module_task:workflow:definition',
|
||||
title: '工作流管理',
|
||||
tooltip: '流程编排列表,支持发布与执行',
|
||||
cols: [],
|
||||
hideColumnFilter: true,
|
||||
toolbar: [
|
||||
{ name: 'add', text: '新增', attrs: { icon: 'plus', type: 'success' }, perm: 'create' },
|
||||
'delete',
|
||||
],
|
||||
defaultToolbar: ['refresh'],
|
||||
initialFetch: false,
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await WorkflowDefinitionAPI.getWorkflowList(
|
||||
normalizeWorkflowQuery(params as unknown as Record<string, unknown>)
|
||||
);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: (ids) =>
|
||||
WorkflowDefinitionAPI.deleteWorkflow(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||
),
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除选中的工作流吗?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function handleRowDelete(id?: number) {
|
||||
if (id == null) return;
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
selectedWorkflow.value = undefined;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(record: WorkflowTable) {
|
||||
selectedWorkflow.value = record;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function getStatusType(status: string) {
|
||||
const typeMap: Record<string, string> = {
|
||||
draft: 'info',
|
||||
published: 'success',
|
||||
archived: 'warning',
|
||||
};
|
||||
return typeMap[status] || '';
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
const textMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '已归档',
|
||||
};
|
||||
return textMap[status] || status;
|
||||
}
|
||||
|
||||
function handlePublish(record: WorkflowTable) {
|
||||
ElMessageBox.confirm('确定要发布此工作流吗?发布后可执行。', '确认发布', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (!record.id) {
|
||||
ElMessage.error('工作流ID不存在');
|
||||
return;
|
||||
}
|
||||
await WorkflowDefinitionAPI.publishWorkflow(record.id, {});
|
||||
ElMessage.success('发布成功');
|
||||
refreshList();
|
||||
} catch {
|
||||
ElMessage.error('发布失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function handleExecute(action: string, record: WorkflowTable) {
|
||||
if (action !== 'execute') return;
|
||||
ElMessageBox.confirm('确定要立即执行此工作流吗?', '确认执行', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
.then(async () => {
|
||||
try {
|
||||
if (!record.id) {
|
||||
ElMessage.error('工作流ID不存在');
|
||||
return;
|
||||
}
|
||||
const res = await WorkflowDefinitionAPI.executeWorkflow({
|
||||
workflow_id: record.id,
|
||||
variables: {},
|
||||
});
|
||||
if (res.data?.data) {
|
||||
const result = res.data.data;
|
||||
ElMessage.success(`工作流执行${result.status === 'completed' ? '成功' : '失败'}`);
|
||||
}
|
||||
refreshList();
|
||||
} catch {
|
||||
ElMessage.error('执行失败');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<PageSearch
|
||||
ref="searchRef"
|
||||
:search-config="searchConfig"
|
||||
@query-click="handleQueryClick"
|
||||
@reset-click="handleResetClick"
|
||||
/>
|
||||
|
||||
<PageContent ref="contentRef" :content-config="contentConfig" @add-click="openDialog()">
|
||||
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
|
||||
<div class="data-table__content">
|
||||
<el-table
|
||||
:ref="tableRef as any"
|
||||
v-loading="loading"
|
||||
:data="data"
|
||||
height="100%"
|
||||
border
|
||||
stripe
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #empty>
|
||||
<el-empty :image-size="80" description="暂无数据" />
|
||||
</template>
|
||||
<el-table-column type="selection" align="center" min-width="55" />
|
||||
<el-table-column type="index" fixed label="序号" min-width="60">
|
||||
<template #default="scope">
|
||||
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ID" prop="id" min-width="70" />
|
||||
<el-table-column label="名称" prop="name" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="编码" prop="code" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="分类" prop="category" min-width="100">
|
||||
<template #default="scope">
|
||||
{{ categoryLabel(scope.row.category) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排序" prop="sort_order" min-width="80" />
|
||||
<el-table-column label="启用" prop="is_active" min-width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.is_active ? 'success' : 'info'">
|
||||
{{ scope.row.is_active ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" prop="created_time" min-width="170" />
|
||||
|
||||
<OperationColumn :list-data-length="data.length">
|
||||
<template #default="scope">
|
||||
<el-space class="flex">
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:node-type:update']"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
icon="edit"
|
||||
@click="openDialog(scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPerm="['module_task:workflow:node-type:delete']"
|
||||
type="danger"
|
||||
size="small"
|
||||
link
|
||||
icon="delete"
|
||||
@click="handleRowDelete(scope.row.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</el-space>
|
||||
</template>
|
||||
</OperationColumn>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</PageContent>
|
||||
|
||||
<EnhancedDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
@close="handleCloseDialog"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" maxlength="128" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="编码" prop="code">
|
||||
<el-input v-model="form.code" maxlength="64" show-word-limit :disabled="!!editingId" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" prop="category">
|
||||
<el-select v-model="form.category" style="width: 100%">
|
||||
<el-option label="触发器" value="trigger" />
|
||||
<el-option label="动作" value="action" />
|
||||
<el-option label="条件" value="condition" />
|
||||
<el-option label="控制" value="control" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="代码块" prop="func">
|
||||
<el-input
|
||||
v-model="form.func"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="须定义 handler(*args, **kwargs),可接收 upstream、variables"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="位置参数" prop="args">
|
||||
<el-input v-model="form.args" placeholder="逗号分隔,如 a, b" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关键字参数" prop="kwargs">
|
||||
<el-input
|
||||
v-model="form.kwargs"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder='JSON,如 {"key": "v"}'
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort_order">
|
||||
<el-input-number v-model="form.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用" prop="is_active">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</EnhancedDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: 'WorkflowNodeType',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
|
||||
import PageSearch from '@/components/CURD/PageSearch.vue';
|
||||
import PageContent from '@/components/CURD/PageContent.vue';
|
||||
import EnhancedDialog from '@/components/CURD/EnhancedDialog.vue';
|
||||
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
|
||||
import { useCrudList } from '@/components/CURD/useCrudList';
|
||||
import OperationColumn from '@/components/OperationColumn/index.vue';
|
||||
import WorkflowNodeTypeAPI, {
|
||||
type WorkflowNodeTypeForm,
|
||||
type WorkflowNodeTypePageQuery,
|
||||
type WorkflowNodeTypeTable,
|
||||
} from '@/api/module_task/workflow/node-type';
|
||||
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增节点类型');
|
||||
const editingId = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const searchConfig = reactive<ISearchConfig>({
|
||||
permPrefix: 'module_task:workflow:node-type',
|
||||
colon: true,
|
||||
isExpandable: true,
|
||||
showNumber: 2,
|
||||
form: { labelWidth: 'auto' },
|
||||
formItems: [
|
||||
{
|
||||
prop: 'name',
|
||||
label: '名称',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '名称', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'code',
|
||||
label: '编码',
|
||||
type: 'input',
|
||||
attrs: { placeholder: '编码', clearable: true },
|
||||
},
|
||||
{
|
||||
prop: 'category',
|
||||
label: '分类',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '触发器', value: 'trigger' },
|
||||
{ label: '动作', value: 'action' },
|
||||
{ label: '条件', value: 'condition' },
|
||||
{ label: '控制', value: 'control' },
|
||||
],
|
||||
attrs: { placeholder: '全部', clearable: true, style: { width: '170px' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function normalizeNodeTypeQuery(params: Record<string, unknown>): WorkflowNodeTypePageQuery {
|
||||
const p = { ...params } as Record<string, unknown>;
|
||||
if (p.category === '' || p.category === null) p.category = undefined;
|
||||
return p as unknown as WorkflowNodeTypePageQuery;
|
||||
}
|
||||
|
||||
const contentConfig = reactive<IContentConfig<WorkflowNodeTypePageQuery>>({
|
||||
permPrefix: 'module_task:workflow:node-type',
|
||||
cols: [],
|
||||
hideColumnFilter: true,
|
||||
toolbar: [
|
||||
{ name: 'add', text: '新增', attrs: { icon: 'plus', type: 'success' }, perm: 'create' },
|
||||
'delete',
|
||||
],
|
||||
defaultToolbar: ['refresh'],
|
||||
initialFetch: false,
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
},
|
||||
request: { page_no: 'page_no', page_size: 'page_size' },
|
||||
indexAction: async (params) => {
|
||||
const res = await WorkflowNodeTypeAPI.getWorkflowNodeTypeList(
|
||||
normalizeNodeTypeQuery(params as unknown as Record<string, unknown>)
|
||||
);
|
||||
return {
|
||||
total: res.data.data.total,
|
||||
list: res.data.data.items,
|
||||
};
|
||||
},
|
||||
deleteAction: (ids) =>
|
||||
WorkflowNodeTypeAPI.deleteWorkflowNodeType(
|
||||
ids
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0)
|
||||
),
|
||||
deleteConfirm: {
|
||||
title: '警告',
|
||||
message: '确认删除选中的编排节点类型吗?',
|
||||
type: 'warning',
|
||||
},
|
||||
});
|
||||
|
||||
function handleRowDelete(id?: number) {
|
||||
if (id == null) return;
|
||||
contentRef.value?.handleDelete(id);
|
||||
}
|
||||
|
||||
function categoryLabel(c?: string) {
|
||||
const m: Record<string, string> = {
|
||||
trigger: '触发器',
|
||||
action: '动作',
|
||||
condition: '条件',
|
||||
control: '控制',
|
||||
};
|
||||
return c ? m[c] || c : '-';
|
||||
}
|
||||
|
||||
const defaultForm = (): WorkflowNodeTypeForm => ({
|
||||
name: '',
|
||||
code: '',
|
||||
category: 'action',
|
||||
func: '',
|
||||
args: '',
|
||||
kwargs: '{}',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const form = reactive<WorkflowNodeTypeForm>(defaultForm());
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入编码', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '请选择分类', trigger: 'change' }],
|
||||
func: [{ required: true, message: '请输入代码块', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, defaultForm());
|
||||
editingId.value = null;
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
|
||||
function handleCloseDialog() {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function openDialog(id?: number) {
|
||||
resetForm();
|
||||
dialogTitle.value = id ? '编辑节点类型' : '新增节点类型';
|
||||
editingId.value = id ?? null;
|
||||
if (id) {
|
||||
try {
|
||||
const res = await WorkflowNodeTypeAPI.getWorkflowNodeTypeDetail(id);
|
||||
const d = res.data?.data as WorkflowNodeTypeTable | undefined;
|
||||
if (d) {
|
||||
form.name = d.name || '';
|
||||
form.code = d.code || '';
|
||||
form.category = (d.category as WorkflowNodeTypeForm['category']) || 'action';
|
||||
form.func = d.func || '';
|
||||
form.args = d.args || '';
|
||||
form.kwargs = d.kwargs || '{}';
|
||||
form.sort_order = d.sort_order ?? 0;
|
||||
form.is_active = d.is_active ?? true;
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('加载详情失败');
|
||||
return;
|
||||
}
|
||||
}
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
if (form.kwargs?.trim()) {
|
||||
try {
|
||||
JSON.parse(form.kwargs);
|
||||
} catch {
|
||||
ElMessage.error('关键字参数须为合法 JSON');
|
||||
return;
|
||||
}
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await WorkflowNodeTypeAPI.updateWorkflowNodeType(editingId.value, form);
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
await WorkflowNodeTypeAPI.createWorkflowNodeType(form);
|
||||
ElMessage.success('创建成功');
|
||||
}
|
||||
dialogVisible.value = false;
|
||||
refreshList();
|
||||
} catch {
|
||||
ElMessage.error(editingId.value ? '更新失败' : '创建失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
export type NodeType =
|
||||
| 'input'
|
||||
| 'output'
|
||||
| 'trigger'
|
||||
| 'action'
|
||||
| 'condition'
|
||||
| 'control'
|
||||
| 'integration'
|
||||
| 'custom';
|
||||
|
||||
export type EdgeType = 'default' | 'straight' | 'step' | 'smoothstep' | 'bezier';
|
||||
|
||||
export type HandlePosition = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
export interface NodeConfigSchema {
|
||||
type: string;
|
||||
properties: Record<string, PropertySchema>;
|
||||
}
|
||||
|
||||
export interface PropertySchema {
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'textarea' | 'json' | 'code';
|
||||
label: string;
|
||||
description?: string;
|
||||
default?: any;
|
||||
required?: boolean;
|
||||
options?: Array<{ label: string; value: any }>;
|
||||
placeholder?: string;
|
||||
validation?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DynamicNodeData {
|
||||
label: string;
|
||||
nodeTypeCode: string;
|
||||
config: Record<string, any>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
type?: NodeType;
|
||||
data?: DynamicNodeData;
|
||||
label?: string;
|
||||
style?: CSSProperties;
|
||||
class?: string | string[];
|
||||
sourcePosition?: HandlePosition;
|
||||
targetPosition?: HandlePosition;
|
||||
hidden?: boolean;
|
||||
selected?: boolean;
|
||||
draggable?: boolean;
|
||||
connectable?: boolean;
|
||||
deletable?: boolean;
|
||||
selectable?: boolean;
|
||||
focusable?: boolean;
|
||||
dragHandle?: string;
|
||||
extent?: 'parent' | [number, number] | [[number, number], [number, number]];
|
||||
parentNode?: string;
|
||||
expandParent?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
type?: EdgeType;
|
||||
label?: string;
|
||||
labelStyle?: CSSProperties;
|
||||
labelShowBg?: boolean;
|
||||
labelBgStyle?: CSSProperties;
|
||||
labelBgPadding?: [number, number];
|
||||
labelBgBorderRadius?: number;
|
||||
style?: CSSProperties;
|
||||
class?: string | string[];
|
||||
animated?: boolean;
|
||||
hidden?: boolean;
|
||||
selected?: boolean;
|
||||
deletable?: boolean;
|
||||
selectable?: boolean;
|
||||
focusable?: boolean;
|
||||
updatable?: boolean | 'source' | 'target';
|
||||
markerStart?: Marker | string;
|
||||
markerEnd?: Marker | string;
|
||||
pathOptions?: {
|
||||
offset?: number;
|
||||
borderRadius?: number;
|
||||
curvature?: number;
|
||||
};
|
||||
interactionWidth?: number;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
type: 'arrow' | 'arrowclosed';
|
||||
color?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
orient?: 'auto' | 'auto-start-reverse';
|
||||
}
|
||||
|
||||
export interface WorkflowTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
export interface WorkflowStats {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
nodeTypes: Record<NodeType, number>;
|
||||
}
|
||||
|
||||
export interface NodeConfig {
|
||||
id: string;
|
||||
type: NodeType;
|
||||
data: DynamicNodeData;
|
||||
}
|
||||
|
||||
export interface EdgeConfig {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
type?: EdgeType;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeTypeDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
category: 'trigger' | 'action' | 'condition' | 'control' | 'integration' | 'custom';
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
configSchema: NodeConfigSchema;
|
||||
inputSchema?: Record<string, any>;
|
||||
outputSchema?: Record<string, any>;
|
||||
handler: string;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface NodeTemplate {
|
||||
id: string;
|
||||
nodeTypeCode: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
defaultConfig: Record<string, any>;
|
||||
isPublic: boolean;
|
||||
tags?: string[];
|
||||
thumbnail?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user