feat: 重构前端项目结构并优化代码

refactor: 迁移前端资源文件至web目录
feat: 新增多种图标资源
style: 统一代码风格和格式化配置
docs: 更新README和文档说明
chore: 更新依赖和配置文件
fix: 修复部分类型定义和枚举
perf: 优化路由和组件加载逻辑
This commit is contained in:
zhangtao
2026-05-01 01:01:17 +08:00
parent 1355a09441
commit 80263e98e1
887 changed files with 116349 additions and 33825 deletions
@@ -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;
}