表单CRUD

This commit is contained in:
dengqichen 2025-10-24 14:28:25 +08:00
parent d22285bc95
commit 6d8afea807
11 changed files with 1361 additions and 524 deletions

View File

@ -1,19 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { FormDesigner } from '@/components/FormDesigner'; import { FormDesigner } from '@/components/FormDesigner';
import type { FormSchema } from '@/components/FormDesigner'; import type { FormSchema } from '@/components/FormDesigner';
import { ArrowLeft, FileText, Tag, Folder, AlignLeft, Info } from 'lucide-react'; import { ArrowLeft, Workflow } from 'lucide-react';
import { Separator } from '@/components/ui/separator'; import { getDefinitionById, updateDefinition } from './service';
import { getDefinitionById, createDefinition, updateDefinition } from './service';
import { getEnabledCategories } from '../Category/service';
import type { FormDefinitionRequest } from './types'; import type { FormDefinitionRequest } from './types';
import type { FormCategoryResponse } from '../Category/types';
/** /**
* *
@ -21,79 +14,49 @@ import type { FormCategoryResponse } from '../Category/types';
const FormDesignerPage: React.FC = () => { const FormDesignerPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const isEdit = !!id;
const [categories, setCategories] = useState<FormCategoryResponse[]>([]);
const [formMeta, setFormMeta] = useState({
name: '',
key: '',
categoryId: undefined as number | undefined,
description: '',
});
const [formSchema, setFormSchema] = useState<FormSchema | null>(null); const [formSchema, setFormSchema] = useState<FormSchema | null>(null);
const [formDefinition, setFormDefinition] = useState<any>(null);
// 加载分类列表
const loadCategories = async () => {
try {
const result = await getEnabledCategories();
setCategories(result || []);
} catch (error) {
console.error('加载分类失败:', error);
}
};
// 加载表单定义 // 加载表单定义
useEffect(() => { useEffect(() => {
loadCategories(); if (id) {
if (isEdit && id) {
loadFormDefinition(Number(id)); loadFormDefinition(Number(id));
} }
}, [id, isEdit]); }, [id]);
const loadFormDefinition = async (definitionId: number) => { const loadFormDefinition = async (definitionId: number) => {
try { try {
const result = await getDefinitionById(definitionId); const result = await getDefinitionById(definitionId);
setFormMeta({ setFormDefinition(result);
name: result.name,
key: result.key,
categoryId: result.categoryId,
description: result.description || '',
});
setFormSchema(result.schema); setFormSchema(result.schema);
} catch (error) { } catch (error) {
console.error('加载表单定义失败:', error); console.error('加载表单定义失败:', error);
} }
}; };
// 保存表单 // 保存表单(只更新 schema不修改基本信息
const handleSave = async (schema: FormSchema) => { const handleSave = async (schema: FormSchema) => {
if (!formMeta.name.trim()) { if (!id || !formDefinition) {
alert('请输入表单名称'); alert('表单信息加载失败');
return;
}
if (!formMeta.key.trim()) {
alert('请输入表单标识');
return; return;
} }
const request: FormDefinitionRequest = { const request: FormDefinitionRequest = {
name: formMeta.name, name: formDefinition.name,
key: formMeta.key, key: formDefinition.key,
categoryId: formMeta.categoryId, categoryId: formDefinition.categoryId,
description: formMeta.description, description: formDefinition.description,
isTemplate: formDefinition.isTemplate,
schema, schema,
status: 'PUBLISHED', status: formDefinition.status || 'DRAFT',
}; };
try { try {
if (isEdit && id) { await updateDefinition(Number(id), request);
await updateDefinition(Number(id), request); alert('保存成功');
} else {
await createDefinition(request);
}
navigate('/form/definitions');
} catch (error) { } catch (error) {
console.error('保存表单失败:', error); console.error('保存表单失败:', error);
alert('保存失败');
} }
}; };
@ -106,13 +69,18 @@ const FormDesignerPage: React.FC = () => {
<div className="flex flex-col gap-6 p-6"> <div className="flex flex-col gap-6 p-6">
{/* 页面标题 */} {/* 页面标题 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight"> <div className="p-2 rounded-lg bg-blue-500/10">
{isEdit ? '编辑表单定义' : '创建表单定义'} <Workflow className="h-6 w-6 text-blue-600" />
</h1> </div>
<p className="text-sm text-muted-foreground mt-2"> <div>
{isEdit ? '修改表单的基本信息和字段配置' : '设计您的自定义表单,添加字段并配置验证规则'} <h1 className="text-3xl font-bold tracking-tight">
</p> {formDefinition?.name || '表单设计'}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{formDefinition?.description || '拖拽左侧组件到画布,配置字段属性和验证规则'}
</p>
</div>
</div> </div>
<Button variant="outline" onClick={handleBack}> <Button variant="outline" onClick={handleBack}>
<ArrowLeft className="h-4 w-4 mr-2" /> <ArrowLeft className="h-4 w-4 mr-2" />
@ -120,133 +88,9 @@ const FormDesignerPage: React.FC = () => {
</Button> </Button>
</div> </div>
{/* 基本信息卡片 */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-primary/10">
<FileText className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-1">
</CardDescription>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="pt-6">
<div className="space-y-6">
{/* 第一行:表单名称 + 表单标识 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
placeholder="例如:员工请假申请表"
value={formMeta.name}
onChange={(e) => setFormMeta(prev => ({ ...prev, name: e.target.value }))}
className="h-10"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="key" className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
{isEdit && <span className="text-xs text-muted-foreground">()</span>}
</Label>
<Input
id="key"
placeholder="例如employee-leave-form"
value={formMeta.key}
onChange={(e) => setFormMeta(prev => ({ ...prev, key: e.target.value }))}
disabled={isEdit}
className="h-10 font-mono"
/>
<p className="text-xs text-muted-foreground">
线 API
</p>
</div>
</div>
{/* 第二行:分类 */}
<div className="space-y-2">
<Label htmlFor="category" className="flex items-center gap-2">
<Folder className="h-4 w-4 text-muted-foreground" />
</Label>
<Select
value={formMeta.categoryId?.toString() || undefined}
onValueChange={(value) => setFormMeta(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="选择表单所属分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
<div className="flex items-center gap-2">
<Folder className="h-3.5 w-3.5" />
{cat.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 第三行:描述(跨整行) */}
<div className="space-y-2">
<Label htmlFor="description" className="flex items-center gap-2">
<AlignLeft className="h-4 w-4 text-muted-foreground" />
</Label>
<Textarea
id="description"
placeholder="简要说明此表单的用途和填写注意事项..."
value={formMeta.description}
onChange={(e) => setFormMeta(prev => ({ ...prev, description: e.target.value }))}
className="min-h-[100px] resize-none"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</CardContent>
</Card>
{/* 表单设计器区域 */} {/* 表单设计器区域 */}
<Card> <Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-blue-500/10">
<Info className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-1">
</CardDescription>
</div>
</div>
</CardHeader>
<Separator />
<CardContent className="p-0"> <CardContent className="p-0">
{/* 表单设计器 */}
<div className="rounded-lg"> <div className="rounded-lg">
<FormDesigner <FormDesigner
value={formSchema || undefined} value={formSchema || undefined}

View File

@ -0,0 +1,283 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { FileText, Tag, Folder, AlignLeft, Copy, Loader2 } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { createDefinition } from '../service';
import { getEnabledCategories } from '../../Category/service';
import type { FormDefinitionRequest } from '../types';
import type { FormCategoryResponse } from '../../Category/types';
interface CreateModalProps {
visible: boolean;
onClose: () => void;
onSuccess: (id: number) => void;
}
/**
*
*/
const CreateModal: React.FC<CreateModalProps> = ({ visible, onClose, onSuccess }) => {
const navigate = useNavigate();
const { toast } = useToast();
const [submitting, setSubmitting] = useState(false);
const [categories, setCategories] = useState<FormCategoryResponse[]>([]);
const [formData, setFormData] = useState({
name: '',
key: '',
categoryId: undefined as number | undefined,
description: '',
isTemplate: false,
});
// 加载分类列表
useEffect(() => {
if (visible) {
loadCategories();
}
}, [visible]);
const loadCategories = async () => {
try {
const result = await getEnabledCategories();
setCategories(result || []);
} catch (error) {
console.error('加载分类失败:', error);
}
};
// 重置表单
const resetForm = () => {
setFormData({
name: '',
key: '',
categoryId: undefined,
description: '',
isTemplate: false,
});
};
// 关闭弹窗
const handleClose = () => {
resetForm();
onClose();
};
// 提交表单
const handleSubmit = async () => {
// 验证
if (!formData.name.trim()) {
toast({
title: '验证失败',
description: '请输入表单名称',
variant: 'destructive'
});
return;
}
if (!formData.key.trim()) {
toast({
title: '验证失败',
description: '请输入表单标识',
variant: 'destructive'
});
return;
}
// 验证表单标识格式(英文字母、数字、中划线)
if (!/^[a-zA-Z0-9-]+$/.test(formData.key)) {
toast({
title: '验证失败',
description: '表单标识只能包含英文字母、数字和中划线',
variant: 'destructive'
});
return;
}
setSubmitting(true);
try {
// 创建表单定义只保存基本信息schema 为空)
const request: FormDefinitionRequest = {
name: formData.name,
key: formData.key,
categoryId: formData.categoryId,
description: formData.description,
isTemplate: formData.isTemplate,
status: 'DRAFT',
schema: {
version: '1.0',
formConfig: {
labelAlign: 'right',
size: 'middle'
},
fields: []
}
};
const result = await createDefinition(request);
toast({
title: '创建成功',
description: '表单基本信息已保存,现在可以开始设计表单'
});
// 跳转到设计器页面
navigate(`/form/definitions/${result.id}/design`);
handleClose();
onSuccess(result.id);
} catch (error) {
console.error('创建表单失败:', error);
toast({
title: '创建失败',
description: error instanceof Error ? error.message : '创建表单失败',
variant: 'destructive'
});
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={visible} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
"下一步"
</DialogDescription>
</DialogHeader>
<Separator />
<div className="space-y-6">
{/* 第一行:表单名称 + 表单标识 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="create-name" className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
</Label>
<Input
id="create-name"
placeholder="例如:员工请假申请表"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="h-10"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="create-key" className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
</Label>
<Input
id="create-key"
placeholder="例如employee-leave-form"
value={formData.key}
onChange={(e) => setFormData(prev => ({ ...prev, key: e.target.value }))}
className="h-10 font-mono"
/>
<p className="text-xs text-muted-foreground">
线 API
</p>
</div>
</div>
{/* 第二行:分类 + 设为模板 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="create-category" className="flex items-center gap-2">
<Folder className="h-4 w-4 text-muted-foreground" />
</Label>
<Select
value={formData.categoryId?.toString() || undefined}
onValueChange={(value) => setFormData(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger id="create-category" className="h-10">
<SelectValue placeholder="选择表单所属分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
<div className="flex items-center gap-2">
<Folder className="h-3.5 w-3.5" />
{cat.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label htmlFor="create-isTemplate" className="flex items-center gap-2">
<Copy className="h-4 w-4 text-muted-foreground" />
</Label>
<div className="flex items-center h-10 rounded-lg border px-4 bg-muted/30">
<div className="flex-1">
<span className="text-sm"></span>
</div>
<Switch
id="create-isTemplate"
checked={formData.isTemplate}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isTemplate: checked }))}
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
{/* 第三行:描述 */}
<div className="space-y-2">
<Label htmlFor="create-description" className="flex items-center gap-2">
<AlignLeft className="h-4 w-4 text-muted-foreground" />
</Label>
<Textarea
id="create-description"
placeholder="简要说明此表单的用途和填写注意事项..."
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="min-h-[100px] resize-none"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default CreateModal;

View File

@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Loader2, FileText, Tag, Folder, AlignLeft, Copy } from 'lucide-react';
import { getEnabledCategories } from '../../Category/service';
import { updateDefinition } from '../service';
import type { FormCategoryResponse } from '../../Category/types';
import type { FormDefinitionRequest, FormDefinitionResponse } from '../types';
interface EditBasicInfoModalProps {
visible: boolean;
record: FormDefinitionResponse | null;
onClose: () => void;
onSuccess: () => void;
}
const EditBasicInfoModal: React.FC<EditBasicInfoModalProps> = ({ visible, record, onClose, onSuccess }) => {
const [submitting, setSubmitting] = useState(false);
const [categories, setCategories] = useState<FormCategoryResponse[]>([]);
const [formData, setFormData] = useState({
name: '',
key: '',
categoryId: undefined as number | undefined,
description: '',
isTemplate: false,
});
useEffect(() => {
const loadCategories = async () => {
try {
const result = await getEnabledCategories();
setCategories(result || []);
} catch (error) {
console.error('加载分类失败:', error);
}
};
if (visible) {
loadCategories();
if (record) {
setFormData({
name: record.name,
key: record.key,
categoryId: record.categoryId,
description: record.description || '',
isTemplate: record.isTemplate || false,
});
}
}
}, [visible, record]);
const handleClose = () => {
if (!submitting) {
onClose();
}
};
const handleSubmit = async () => {
if (!record) return;
if (!formData.name.trim()) {
alert('请输入表单名称');
return;
}
if (!formData.key.trim()) {
alert('请输入表单标识');
return;
}
if (!/^[a-zA-Z0-9-]+$/.test(formData.key)) {
alert('表单标识只能包含英文字母、数字和中划线');
return;
}
setSubmitting(true);
try {
const request: FormDefinitionRequest = {
name: formData.name,
key: formData.key,
categoryId: formData.categoryId,
description: formData.description,
schema: record.schema,
status: record.status,
isTemplate: formData.isTemplate,
};
await updateDefinition(record.id, request);
alert('更新成功');
onSuccess();
onClose();
} catch (error) {
console.error('更新表单失败:', error);
alert('更新表单失败: ' + (error as Error).message);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={visible} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
{/* 第一行:表单名称 + 表单标识 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="edit-name" className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
</Label>
<Input
id="edit-name"
placeholder="例如:员工请假申请表"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="h-10"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-key" className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<span className="text-destructive">*</span>
</Label>
<Input
id="edit-key"
placeholder="例如employee-leave-form"
value={formData.key}
onChange={(e) => setFormData(prev => ({ ...prev, key: e.target.value }))}
className="h-10 font-mono"
/>
<p className="text-xs text-muted-foreground">
线
</p>
</div>
</div>
{/* 第二行:分类 + 设为模板 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="edit-category" className="flex items-center gap-2">
<Folder className="h-4 w-4 text-muted-foreground" />
</Label>
<Select
value={formData.categoryId?.toString() || undefined}
onValueChange={(value) => setFormData(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger id="edit-category" className="h-10">
<SelectValue placeholder="选择表单所属分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
<div className="flex items-center gap-2">
<Folder className="h-3.5 w-3.5" />
{cat.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-isTemplate" className="flex items-center gap-2">
<Copy className="h-4 w-4 text-muted-foreground" />
</Label>
<div className="flex items-center h-10 rounded-lg border px-4 bg-muted/30">
<div className="flex-1">
<span className="text-sm"></span>
</div>
<Switch
id="edit-isTemplate"
checked={formData.isTemplate}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isTemplate: checked }))}
/>
</div>
</div>
</div>
{/* 第三行:描述 */}
<div className="space-y-2">
<Label htmlFor="edit-description" className="flex items-center gap-2">
<AlignLeft className="h-4 w-4 text-muted-foreground" />
</Label>
<Textarea
id="edit-description"
placeholder="简要说明此表单的用途和填写注意事项..."
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
className="min-h-[100px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default EditBasicInfoModal;

View File

@ -12,7 +12,7 @@ import { FormRenderer } from '@/components/FormDesigner';
import { import {
Loader2, Plus, Search, Eye, Edit, FileText, Ban, Trash2, Loader2, Plus, Search, Eye, Edit, FileText, Ban, Trash2,
Database, MoreHorizontal, CheckCircle2, XCircle, Clock, AlertCircle, Database, MoreHorizontal, CheckCircle2, XCircle, Clock, AlertCircle,
Folder, Activity Folder, Activity, Settings
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@ -28,6 +28,8 @@ import type { FormDefinitionResponse, FormDefinitionStatus } from './types';
import type { FormCategoryResponse } from '../Category/types'; import type { FormCategoryResponse } from '../Category/types';
import type { Page } from '@/types/base'; import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page'; import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import CreateModal from './components/CreateModal';
import EditBasicInfoModal from './components/EditBasicInfoModal';
/** /**
* *
@ -45,6 +47,13 @@ const FormDefinitionList: React.FC = () => {
status: undefined as FormDefinitionStatus | undefined, status: undefined as FormDefinitionStatus | undefined,
}); });
// 创建表单弹窗
const [createModalVisible, setCreateModalVisible] = useState(false);
// 编辑基本信息弹窗
const [editBasicInfoVisible, setEditBasicInfoVisible] = useState(false);
const [editBasicInfoRecord, setEditBasicInfoRecord] = useState<FormDefinitionResponse | null>(null);
// 预览弹窗 // 预览弹窗
const [previewVisible, setPreviewVisible] = useState(false); const [previewVisible, setPreviewVisible] = useState(false);
const [previewForm, setPreviewForm] = useState<FormDefinitionResponse | null>(null); const [previewForm, setPreviewForm] = useState<FormDefinitionResponse | null>(null);
@ -116,12 +125,18 @@ const FormDefinitionList: React.FC = () => {
// 创建表单 // 创建表单
const handleCreate = () => { const handleCreate = () => {
navigate('/form/definitions/create'); setCreateModalVisible(true);
}; };
// 编辑表单 // 编辑表单设计
const handleEdit = (record: FormDefinitionResponse) => { const handleEdit = (record: FormDefinitionResponse) => {
navigate(`/form/definitions/${record.id}/edit`); navigate(`/form/definitions/${record.id}/design`);
};
// 编辑基本信息
const handleEditBasicInfo = (record: FormDefinitionResponse) => {
setEditBasicInfoRecord(record);
setEditBasicInfoVisible(true);
}; };
// 预览表单 // 预览表单
@ -427,6 +442,10 @@ const FormDefinitionList: React.FC = () => {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]"> <DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem onClick={() => handleEditBasicInfo(record)}>
<Settings className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleViewData(record)}> <DropdownMenuItem onClick={() => handleViewData(record)}>
<Database className="h-4 w-4 mr-2" /> <Database className="h-4 w-4 mr-2" />
@ -564,6 +583,29 @@ const FormDefinitionList: React.FC = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
{/* 创建表单弹窗 */}
<CreateModal
visible={createModalVisible}
onClose={() => setCreateModalVisible(false)}
onSuccess={() => {
setCreateModalVisible(false);
loadData();
}}
/>
{/* 编辑基本信息弹窗 */}
<EditBasicInfoModal
visible={editBasicInfoVisible}
record={editBasicInfoRecord}
onClose={() => {
setEditBasicInfoVisible(false);
setEditBasicInfoRecord(null);
}}
onSuccess={() => {
loadData();
}}
/>
</div> </div>
); );
}; };

View File

@ -1,7 +1,20 @@
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {Modal, Form, Input, message, Select} from 'antd'; import {
import type {WorkflowDefinition, WorkflowCategory} from '../types'; Dialog,
import {saveDefinition, updateDefinition, getWorkflowCategories} from '../service'; DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Loader2, FileText, Tag, AlignLeft, Folder } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import type { WorkflowDefinition, WorkflowCategoryResponse } from '../types';
import { saveDefinition, updateDefinition, getWorkflowCategoryList } from '../service';
interface EditModalProps { interface EditModalProps {
visible: boolean; visible: boolean;
@ -10,181 +23,262 @@ interface EditModalProps {
record?: WorkflowDefinition; record?: WorkflowDefinition;
} }
const EditModal: React.FC<EditModalProps> = ({visible, onClose, onSuccess, record}) => { const EditModal: React.FC<EditModalProps> = ({ visible, onClose, onSuccess, record }) => {
const [form] = Form.useForm(); const { toast } = useToast();
const isEdit = !!record; const isEdit = !!record;
const [categories, setCategories] = useState<WorkflowCategory[]>([]); const [submitting, setSubmitting] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<WorkflowCategory>(); const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
const [formData, setFormData] = useState({
name: '',
key: '',
categoryId: undefined as number | undefined,
description: '',
triggers: [] as string[],
});
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
loadCategories(); loadCategories();
if (record) {
setFormData({
name: record.name,
key: record.key,
categoryId: record.categoryId,
description: record.description || '',
triggers: record.triggers || [],
});
} else {
setFormData({
name: '',
key: '',
categoryId: undefined,
description: '',
triggers: [],
});
}
} }
}, [visible]); }, [visible, record]);
useEffect(() => {
if (visible && record) {
// 找到当前分类
const category = categories.find(c => c.code === record.category);
setSelectedCategory(category);
// 设置表单值使用lable显示
form.setFieldsValue({
...record,
// 分类显示lable
category: {
label: category?.lable,
value: record.category
},
// 触发器显示lable
triggers: record.triggers?.map(triggerCode => ({
label: category?.supportedTriggers.find(t => t.code === triggerCode)?.lable,
value: triggerCode
})) || []
});
}
}, [visible, record, categories]);
const loadCategories = async () => { const loadCategories = async () => {
try { try {
const data = await getWorkflowCategories(); const data = await getWorkflowCategoryList();
setCategories(data); setCategories(data);
} catch (error) { } catch (error) {
console.error('加载工作流分类失败:', error); console.error('加载工作流分类失败:', error);
message.error('加载工作流分类失败'); toast({
title: '加载失败',
description: '加载工作流分类失败',
variant: 'destructive',
});
} }
}; };
const handleCategoryChange = (selected: { value: string, label: string }) => { const handleClose = () => {
const category = categories.find(c => c.code === selected.value); if (!submitting) {
setSelectedCategory(category); onClose();
// 当切换分类时,清空触发器选择 }
form.setFieldValue('triggers', []);
}; };
const handleOk = async () => { const handleSubmit = async () => {
try { // 验证
const values = await form.validateFields(); if (!formData.name.trim()) {
const submitData = { toast({
...values, title: '验证失败',
// 提取code值提交给后端 description: '请输入流程名称',
category: values.category.value, variant: 'destructive',
triggers: values.triggers.map((t: {value: string}) => t.value), });
flowVersion: isEdit ? record.flowVersion : 1, return;
status: isEdit ? record.status : 'DRAFT' }
}; if (!formData.key.trim()) {
toast({
title: '验证失败',
description: '请输入流程标识',
variant: 'destructive',
});
return;
}
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(formData.key)) {
toast({
title: '验证失败',
description: '流程标识只能包含字母、数字、下划线和连字符,且必须以字母或下划线开头',
variant: 'destructive',
});
return;
}
if (/^xml/i.test(formData.key)) {
toast({
title: '验证失败',
description: '流程标识不能以xml开头',
variant: 'destructive',
});
return;
}
if (!formData.categoryId) {
toast({
title: '验证失败',
description: '请选择流程分类',
variant: 'destructive',
});
return;
}
if (isEdit) { setSubmitting(true);
await updateDefinition(record.id, { try {
...record, const submitData: WorkflowDefinition = {
...submitData, ...formData,
id: record?.id || 0,
flowVersion: isEdit ? record.flowVersion : 1,
status: isEdit ? record.status : 'DRAFT',
graph: record?.graph || { nodes: [], edges: [] },
formConfig: record?.formConfig || { formItems: [] },
} as WorkflowDefinition;
if (isEdit && record) {
await updateDefinition(record.id, submitData);
toast({
title: '更新成功',
description: `工作流 "${formData.name}" 已更新`,
}); });
} else { } else {
await saveDefinition(submitData as WorkflowDefinition); await saveDefinition(submitData);
toast({
title: '创建成功',
description: `工作流 "${formData.name}" 已创建`,
});
} }
message.success(isEdit ? '更新成功' : '保存成功');
onSuccess?.(); onSuccess?.();
onClose(); onClose();
form.resetFields();
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
message.error(error.message); toast({
title: isEdit ? '更新失败' : '创建失败',
description: error.message,
variant: 'destructive',
});
} }
} finally {
setSubmitting(false);
} }
}; };
const selectedCategory = categories.find(c => c.id === formData.categoryId);
return ( return (
<Modal <Dialog open={visible} onOpenChange={handleClose}>
title={isEdit ? '编辑流程' : '新建流程'} <DialogContent className="sm:max-w-[600px]">
open={visible} <DialogHeader>
onOk={handleOk} <DialogTitle>{isEdit ? '编辑流程' : '新建流程'}</DialogTitle>
onCancel={() => { </DialogHeader>
onClose(); <div className="grid gap-6 py-4">
form.resetFields(); {/* 流程分类 */}
}} <div className="space-y-2">
destroyOnClose <Label htmlFor="categoryId" className="flex items-center gap-2">
> <Folder className="h-4 w-4 text-muted-foreground" />
<Form <span className="text-destructive">*</span>
form={form} </Label>
layout="vertical" <Select
preserve={false} value={formData.categoryId?.toString() || undefined}
> onValueChange={(value) => {
<Form.Item setFormData(prev => ({
name="category" ...prev,
label="流程分类" categoryId: Number(value),
rules={[{required: true, message: '请选择流程分类'}]} triggers: [], // 切换分类时清空触发器
> }));
<Select }}
placeholder="请选择流程分类" disabled={isEdit}
onChange={handleCategoryChange} >
disabled={isEdit} <SelectTrigger id="categoryId" className="h-10">
labelInValue <SelectValue placeholder="请选择流程分类" />
> </SelectTrigger>
{(categories || []).map(category => ( <SelectContent>
<Select.Option key={category.code} value={category.code}> {categories.map(cat => (
{category.lable} <SelectItem key={cat.id} value={cat.id.toString()}>
</Select.Option> {cat.name}
))} </SelectItem>
</Select> ))}
</Form.Item> </SelectContent>
<Form.Item </Select>
name="triggers" {isEdit && (
label="触发方式" <p className="text-xs text-muted-foreground">
rules={[{required: true, message: '请选择触发方式'}]}
> </p>
<Select )}
mode="multiple" </div>
placeholder="请选择触发方式"
disabled={!selectedCategory || isEdit} {/* 流程名称 */}
labelInValue <div className="space-y-2">
> <Label htmlFor="name" className="flex items-center gap-2">
{(selectedCategory?.supportedTriggers || []).map(trigger => ( <FileText className="h-4 w-4 text-muted-foreground" />
<Select.Option key={trigger.code} value={trigger.code}> <span className="text-destructive">*</span>
{trigger.lable} </Label>
</Select.Option> <Input
))} id="name"
</Select> placeholder="例如Jenkins 构建流程"
</Form.Item> value={formData.name}
<Form.Item onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
name="name" className="h-10"
label="流程名称" />
rules={[{required: true, message: '请输入流程名称'}]} </div>
>
<Input placeholder="请输入流程名称"/> {/* 流程标识 */}
</Form.Item> <div className="space-y-2">
<Form.Item <Label htmlFor="key" className="flex items-center gap-2">
name="key" <Tag className="h-4 w-4 text-muted-foreground" />
label="流程标识" <span className="text-destructive">*</span>
rules={[ {isEdit && <span className="text-xs text-muted-foreground">()</span>}
{ required: true, message: '请输入流程标识' }, </Label>
{ <Input
pattern: /^[a-zA-Z_][a-zA-Z0-9_-]*$/, id="key"
message: '流程标识只能包含字母、数字、下划线(_)和连字符(-),且必须以字母或下划线开头' placeholder="例如jenkins_build_workflow"
}, value={formData.key}
{ onChange={(e) => setFormData(prev => ({ ...prev, key: e.target.value }))}
pattern: /^(?!xml)/i, disabled={isEdit}
message: '流程标识不能以xml开头不区分大小写' className="h-10 font-mono"
}, />
{ <p className="text-xs text-muted-foreground">
pattern: /^[^.\s]*$/, 线使线
message: '流程标识不能包含空格和点号' </p>
}, </div>
{
max: 64, {/* 描述 */}
message: '流程标识长度不能超过64个字符' <div className="space-y-2">
} <Label htmlFor="description" className="flex items-center gap-2">
]} <AlignLeft className="h-4 w-4 text-muted-foreground" />
>
<Input placeholder="请输入流程标识,建议使用小写字母和下划线" disabled={isEdit}/> </Label>
</Form.Item> <Textarea
<Form.Item id="description"
name="description" placeholder="简要说明此工作流的用途..."
label="描述" value={formData.description}
> onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
<Input.TextArea placeholder="请输入流程描述"/> className="min-h-[100px] resize-none"
</Form.Item> />
</Form> </div>
</Modal>
{/* 提示:触发方式在设计阶段配置 */}
{selectedCategory && selectedCategory.supportedTriggers && selectedCategory.supportedTriggers.length > 0 && (
<div className="rounded-lg border p-4 bg-muted/30">
<p className="text-sm text-muted-foreground">
<strong></strong> {selectedCategory.supportedTriggers.join(', ')}
</p>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isEdit ? '更新' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };

View File

@ -1,16 +1,33 @@
import React, {useState, useEffect} from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import {Table, Card, Button, Space, Tag, message, Modal} from 'antd'; import { useNavigate } from 'react-router-dom';
import {PlusOutlined, ExclamationCircleOutlined} from '@ant-design/icons'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import {useNavigate} from 'react-router-dom'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { DataTablePagination } from '@/components/ui/pagination';
import {
Loader2, Plus, Search, Edit, Trash2, Play, CheckCircle2,
Clock, Activity, Workflow, MoreHorizontal, AlertCircle, Eye
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import * as service from './service'; import * as service from './service';
import type {WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategory} from './types'; import type { WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategoryResponse } from './types';
import {DEFAULT_PAGE_SIZE, DEFAULT_CURRENT} from '@/utils/page'; import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import EditModal from './components/EditModal'; import EditModal from './components/EditModal';
const {confirm} = Modal;
const WorkflowDefinitionList: React.FC = () => { const WorkflowDefinitionList: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [pageData, setPageData] = useState<{ const [pageData, setPageData] = useState<{
content: WorkflowDefinition[]; content: WorkflowDefinition[];
@ -20,10 +37,17 @@ const WorkflowDefinitionList: React.FC = () => {
} | null>(null); } | null>(null);
const [modalVisible, setModalVisible] = useState(false); const [modalVisible, setModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<WorkflowDefinition>(); const [currentRecord, setCurrentRecord] = useState<WorkflowDefinition>();
const [categories, setCategories] = useState<WorkflowCategory[]>([]); const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null);
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
const [deployRecord, setDeployRecord] = useState<WorkflowDefinition | null>(null);
const [query, setQuery] = useState<WorkflowDefinitionQuery>({ const [query, setQuery] = useState<WorkflowDefinitionQuery>({
pageNum: DEFAULT_CURRENT - 1, pageNum: DEFAULT_CURRENT - 1,
pageSize: DEFAULT_PAGE_SIZE pageSize: DEFAULT_PAGE_SIZE,
name: '',
categoryId: undefined,
status: undefined
}); });
const loadData = async (params: WorkflowDefinitionQuery) => { const loadData = async (params: WorkflowDefinitionQuery) => {
@ -33,7 +57,11 @@ const WorkflowDefinitionList: React.FC = () => {
setPageData(data); setPageData(data);
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
message.error(error.message); toast({
title: '加载失败',
description: error.message,
variant: 'destructive'
});
} }
} finally { } finally {
setLoading(false); setLoading(false);
@ -42,17 +70,19 @@ const WorkflowDefinitionList: React.FC = () => {
const loadCategories = async () => { const loadCategories = async () => {
try { try {
const data = await service.getWorkflowCategories(); const data = await service.getWorkflowCategoryList();
setCategories(data); setCategories(data);
} catch (error) { } catch (error) {
console.error('加载工作流分类失败:', error); console.error('加载工作流分类失败:', error);
message.error('加载工作流分类失败');
} }
}; };
useEffect(() => { useEffect(() => {
loadData(query);
loadCategories(); loadCategories();
}, []);
useEffect(() => {
loadData(query);
}, [query]); }, [query]);
const handleCreateFlow = () => { const handleCreateFlow = () => {
@ -74,169 +104,414 @@ const WorkflowDefinitionList: React.FC = () => {
setCurrentRecord(undefined); setCurrentRecord(undefined);
}; };
const handleDeploy = async (id: number) => { const handleSearch = () => {
confirm({ setQuery(prev => ({
title: '确认发布', ...prev,
icon: <ExclamationCircleOutlined/>, pageNum: 0,
content: '确定要发布该流程定义吗?发布后将不能修改。', }));
onOk: async () => { };
try {
await service.publishDefinition(id); const handleReset = () => {
message.success('发布成功'); setQuery({
loadData(query); pageNum: 0,
} catch (error) { pageSize: DEFAULT_PAGE_SIZE,
if (error instanceof Error) { name: '',
message.error(error.message); categoryId: undefined,
} status: undefined
}
},
}); });
}; };
const handleDelete = async (id: number) => { const handleDeploy = (record: WorkflowDefinition) => {
confirm({ setDeployRecord(record);
title: '确认删除', setDeployDialogOpen(true);
icon: <ExclamationCircleOutlined/>,
content: '确定要删除该流程定义吗?删除后不可恢复。',
onOk: async () => {
try {
await service.deleteDefinition(id);
message.success('删除成功');
loadData(query);
} catch (error) {
if (error instanceof Error) {
message.error(error.message);
}
}
},
});
}; };
const handleStartFlow = async (record: WorkflowDefinition) => { const confirmDeploy = async () => {
if (!deployRecord) return;
try { try {
await service.startWorkflowInstance(record.key, record.category); await service.publishDefinition(deployRecord.id);
message.success('流程启动成功'); toast({
} catch (error) { title: '发布成功',
if (error instanceof Error) { description: `工作流 "${deployRecord.name}" 已发布`,
message.error(error.message); });
loadData(query);
setDeployDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
toast({
title: '发布失败',
description: error.message,
variant: 'destructive'
});
} }
} }
}; };
const columns = [ const handleDelete = (record: WorkflowDefinition) => {
{ setDeleteRecord(record);
title: '流程名称', setDeleteDialogOpen(true);
dataIndex: 'name', };
key: 'name',
}, const confirmDelete = async () => {
{ if (!deleteRecord) return;
title: '流程标识', try {
dataIndex: 'key', await service.deleteDefinition(deleteRecord.id);
key: 'key', toast({
}, title: '删除成功',
{ description: `工作流 "${deleteRecord.name}" 已删除`,
title: '流程分类', });
dataIndex: 'category', loadData(query);
key: 'category', setDeleteDialogOpen(false);
render: (category: string) => { } catch (error) {
const categoryInfo = categories.find(c => c.code === category); if (error instanceof Error) {
return categoryInfo?.label || category; toast({
} title: '删除失败',
}, description: error.message,
{ variant: 'destructive'
title: '触发方式',
dataIndex: 'triggers',
key: 'triggers',
render: (triggers: string[], record: WorkflowDefinition) => {
const categoryInfo = categories.find(c => c.code === record.category);
return (triggers || [])?.map(triggerCode => {
const triggerInfo = categoryInfo?.supportedTriggers?.find(t => t.code === triggerCode);
return (
<Tag key={triggerCode}>
{triggerInfo?.lable || triggerCode}
</Tag>
);
}); });
} }
}, }
{ };
title: '版本',
dataIndex: 'flowVersion', const handleStartFlow = async (record: WorkflowDefinition) => {
key: 'flowVersion', try {
}, await service.startWorkflowInstance(record.key, record.categoryId);
{ toast({
title: '状态', title: '启动成功',
dataIndex: 'status', description: `工作流 "${record.name}" 已启动`,
key: 'status', });
render: (status: string) => ( } catch (error) {
<Tag color={status === 'DRAFT' ? 'orange' : 'green'}> if (error instanceof Error) {
{status === 'DRAFT' ? '草稿' : '已发布'} toast({
</Tag> title: '启动失败',
), description: error.message,
}, variant: 'destructive'
{ });
title: '描述', }
dataIndex: 'description', }
key: 'description', };
ellipsis: true,
}, // 状态徽章
{ const getStatusBadge = (status: string) => {
title: '操作', const statusMap: Record<string, {
key: 'action', variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
fixed: 'right', text: string;
width: 200, icon: React.ElementType
render: (_: any, record: WorkflowDefinition) => ( }> = {
<Space size="middle"> DRAFT: { variant: 'outline', text: '草稿', icon: Clock },
{record.status === 'DRAFT' && ( PUBLISHED: { variant: 'success', text: '已发布', icon: CheckCircle2 },
<> };
<a onClick={() => handleEditFlow(record)}></a> const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
<a onClick={() => handleDesignFlow(record)}></a> const Icon = statusInfo.icon;
</> return (
)} <Badge variant={statusInfo.variant} className="flex items-center gap-1">
{record.status === 'DRAFT' && ( <Icon className="h-3 w-3" />
<a onClick={() => handleDeploy(record.id)}></a> {statusInfo.text}
)} </Badge>
<a onClick={() => handleDelete(record.id)}></a> );
{record.status !== 'DRAFT' && ( };
<a onClick={() => handleStartFlow(record)}></a>
)} // 统计数据
</Space> const stats = useMemo(() => {
), const total = pageData?.totalElements || 0;
}, const draftCount = pageData?.content?.filter(d => d.status === 'DRAFT').length || 0;
]; const publishedCount = pageData?.content?.filter(d => d.status !== 'DRAFT').length || 0;
return { total, draftCount, publishedCount };
}, [pageData]);
const pageCount = pageData?.totalElements ? Math.ceil(pageData.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0;
return ( return (
<Card <div className="p-6">
title={ <div className="mb-6">
<Button type="primary" icon={<PlusOutlined/>} onClick={handleCreateFlow}> <h1 className="text-3xl font-bold text-foreground"></h1>
</div>
</Button>
} {/* 统计卡片 */}
> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<Table <Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
columns={columns} <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
dataSource={pageData?.content} <CardTitle className="text-sm font-medium text-blue-700"></CardTitle>
loading={loading} <Activity className="h-4 w-4 text-blue-500" />
rowKey="id" </CardHeader>
scroll={{ x: 1300 }} <CardContent>
pagination={{ <div className="text-2xl font-bold">{stats.total}</div>
current: (query.pageNum || 0) + 1, <p className="text-xs text-muted-foreground mt-1"></p>
pageSize: query.pageSize, </CardContent>
total: pageData?.totalElements || 0, </Card>
onChange: (page, pageSize) => setQuery({ <Card className="bg-gradient-to-br from-yellow-500/10 to-yellow-500/5 border-yellow-500/20">
...query, <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
pageNum: page - 1, <CardTitle className="text-sm font-medium text-yellow-700">稿</CardTitle>
pageSize <Clock className="h-4 w-4 text-yellow-500" />
}), </CardHeader>
}} <CardContent>
/> <div className="text-2xl font-bold">{stats.draftCount}</div>
<p className="text-xs text-muted-foreground mt-1"></p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-green-500/5 border-green-500/20">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-green-700"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.publishedCount}</div>
<p className="text-xs text-muted-foreground mt-1">使</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
<Button onClick={handleCreateFlow}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</CardHeader>
<CardContent>
{/* 搜索栏 */}
<div className="flex flex-wrap items-center gap-4 mb-4">
<div className="flex-1 max-w-md">
<Input
placeholder="搜索工作流名称"
value={query.name}
onChange={(e) => setQuery(prev => ({ ...prev, name: e.target.value }))}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="h-9"
/>
</div>
<Select
value={query.categoryId?.toString() || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, categoryId: Number(value) }))}
>
<SelectTrigger className="w-[160px] h-9">
<SelectValue placeholder="全部分类" />
</SelectTrigger>
<SelectContent>
{categories.map(cat => (
<SelectItem key={cat.id} value={cat.id.toString()}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={query.status || undefined}
onValueChange={(value) => setQuery(prev => ({ ...prev, status: value }))}
>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">稿</SelectItem>
<SelectItem value="PUBLISHED"></SelectItem>
</SelectContent>
</Select>
<Button onClick={handleSearch} className="h-9">
<Search className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleReset} className="h-9">
</Button>
</div>
{/* 表格 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">...</span>
</div>
</TableCell>
</TableRow>
) : pageData?.content && pageData.content.length > 0 ? (
pageData.content.map((record) => {
const categoryInfo = categories.find(c => c.id === record.categoryId);
return (
<TableRow key={record.id} className="hover:bg-muted/50">
<TableCell className="font-medium">{record.name}</TableCell>
<TableCell>
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
{record.key}
</code>
</TableCell>
<TableCell>
{categoryInfo ? (
<Badge variant="outline">
{categoryInfo.name}
</Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
<span className="font-mono text-sm">{record.flowVersion || 1}</span>
</TableCell>
<TableCell>{getStatusBadge(record.status || 'DRAFT')}</TableCell>
<TableCell>
<span className="text-sm line-clamp-1">{record.description || '-'}</span>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{record.status === 'DRAFT' && (
<>
<DropdownMenuItem onClick={() => handleEditFlow(record)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDesignFlow(record)}>
<Workflow className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeploy(record)}>
<CheckCircle2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</>
)}
{record.status !== 'DRAFT' && (
<>
<DropdownMenuItem onClick={() => handleStartFlow(record)}>
<Play className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDesignFlow(record)}>
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDelete(record)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Workflow className="w-16 h-16 mb-4 text-muted-foreground/50" />
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">"新建工作流"</div>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{pageCount > 1 && (
<DataTablePagination
pageIndex={(query.pageNum || 0) + 1}
pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
pageCount={pageCount}
onPageChange={(page) => setQuery(prev => ({
...prev,
pageNum: page - 1
}))}
/>
)}
</CardContent>
</Card>
{/* EditModal */}
<EditModal <EditModal
visible={modalVisible} visible={modalVisible}
onClose={handleModalClose} onClose={handleModalClose}
onSuccess={() => loadData(query)} onSuccess={() => loadData(query)}
record={currentRecord} record={currentRecord}
/> />
</Card>
{/* 发布确认对话框 */}
{deployRecord && (
<Dialog open={deployDialogOpen} onOpenChange={setDeployDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
"<span className="font-semibold text-foreground">{deployRecord.name}</span>"
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeployDialogOpen(false)}>
</Button>
<Button onClick={confirmDeploy}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* 删除确认对话框 */}
{deleteRecord && (
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertCircle className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
"<span className="font-semibold text-foreground">{deleteRecord.name}</span>"
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-muted-foreground">
<p>
<span className="font-medium">:</span> <code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">{deleteRecord.key}</code>
</p>
<p>
<span className="font-medium">:</span> <span className="font-mono text-sm">{deleteRecord.flowVersion || 1}</span>
</p>
<p>
<span className="font-medium">:</span> {getStatusBadge(deleteRecord.status || 'DRAFT')}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={confirmDelete}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
); );
}; };

View File

@ -1,10 +1,16 @@
import request from '@/utils/request'; import request from '@/utils/request';
import {WorkflowDefinition, WorkflowDefinitionQuery} from './types'; import {
WorkflowDefinition,
WorkflowDefinitionQuery,
WorkflowCategoryResponse,
WorkflowCategoryQuery,
WorkflowCategoryRequest
} from './types';
import {Page} from '@/types/base'; import {Page} from '@/types/base';
import {WorkflowCategory} from './types'; // Add this line
const DEFINITION_URL = '/api/v1/workflow/definition'; const DEFINITION_URL = '/api/v1/workflow/definition';
const INSTANCE_URL = '/api/v1/workflow/instance'; const INSTANCE_URL = '/api/v1/workflow/instance';
const CATEGORY_URL = '/api/v1/workflow/categories';
export const getDefinitions = (params?: WorkflowDefinitionQuery) => export const getDefinitions = (params?: WorkflowDefinitionQuery) =>
request.get<Page<WorkflowDefinition>>(`${DEFINITION_URL}/page`, {params}); request.get<Page<WorkflowDefinition>>(`${DEFINITION_URL}/page`, {params});
@ -36,21 +42,62 @@ export const publishDefinition = (id: number) =>
request.post<void>(`${DEFINITION_URL}/${id}/published`); request.post<void>(`${DEFINITION_URL}/${id}/published`);
/** /**
* *
* @returns Promise<WorkflowCategory[]> * @param params
* @returns Promise<Page<WorkflowCategoryResponse>>
*/ */
export const getWorkflowCategories = () => export const getWorkflowCategories = (params?: WorkflowCategoryQuery) =>
request.get<WorkflowCategory[]>(`${DEFINITION_URL}/categories`); request.get<Page<WorkflowCategoryResponse>>(`${CATEGORY_URL}/page`, {params});
/**
*
* @returns Promise<WorkflowCategoryResponse[]>
*/
export const getWorkflowCategoryList = () =>
request.get<WorkflowCategoryResponse[]>(`${CATEGORY_URL}/list`);
/**
*
* @param id ID
* @returns Promise<WorkflowCategoryResponse>
*/
export const getWorkflowCategoryById = (id: number) =>
request.get<WorkflowCategoryResponse>(`${CATEGORY_URL}/${id}`);
/**
*
* @param data
* @returns Promise<WorkflowCategoryResponse>
*/
export const createWorkflowCategory = (data: WorkflowCategoryRequest) =>
request.post<WorkflowCategoryResponse>(CATEGORY_URL, data);
/**
*
* @param id ID
* @param data
* @returns Promise<WorkflowCategoryResponse>
*/
export const updateWorkflowCategory = (id: number, data: WorkflowCategoryRequest) =>
request.put<WorkflowCategoryResponse>(`${CATEGORY_URL}/${id}`, data);
/**
*
* @param id ID
* @returns Promise<void>
*/
export const deleteWorkflowCategory = (id: number) =>
request.delete<void>(`${CATEGORY_URL}/${id}`);
/** /**
* *
* @param processKey key * @param processKey key
* @param categoryCode * @param categoryId ID
* @returns Promise<void> * @returns Promise<void>
*/ */
export const startWorkflowInstance = (processKey: string, categoryCode: string) => export const startWorkflowInstance = (processKey: string, categoryId?: number) =>
request.post<void>(`${INSTANCE_URL}/start`, { request.post<void>(`${INSTANCE_URL}/start`, {
processKey, processKey,
businessKey: `${categoryCode}_${Date.now()}`, businessKey: `workflow_${Date.now()}`,
categoryId
}); });

View File

@ -7,7 +7,7 @@ export interface WorkflowDefinition extends BaseResponse {
description?: string; description?: string;
flowVersion?: number; flowVersion?: number;
status?: string; status?: string;
category: string; categoryId?: number; // 分类ID
triggers: string[]; triggers: string[];
graph: { graph: {
nodes: WorkflowDefinitionNode[]; nodes: WorkflowDefinitionNode[];
@ -60,23 +60,46 @@ export interface WorkflowDefinitionNode {
export interface WorkflowDefinitionQuery extends BaseQuery { export interface WorkflowDefinitionQuery extends BaseQuery {
name?: string; name?: string;
key?: string; key?: string;
categoryId?: number; // 分类ID筛选
status?: string; status?: string;
} }
/** /**
* *
*/ */
export interface WorkflowTrigger { export interface WorkflowCategoryResponse {
id: number;
name: string;
code: string; code: string;
label: string; description?: string;
icon?: string;
sort: number;
supportedTriggers?: string[]; // 触发方式代码列表,如 ["MANUAL","SCHEDULED"]
enabled: boolean;
createBy?: string;
createTime?: string;
updateBy?: string;
updateTime?: string;
} }
/** /**
* *
*/ */
export interface WorkflowCategory { export interface WorkflowCategoryQuery extends BaseQuery {
code: string; name?: string;
label: string; code?: string;
description: string; enabled?: boolean;
supportedTriggers: WorkflowTrigger[]; }
/**
* /
*/
export interface WorkflowCategoryRequest {
name: string;
code: string;
description?: string;
icon?: string;
sort?: number;
supportedTriggers?: string[];
enabled?: boolean;
} }

View File

@ -220,7 +220,7 @@ const DetailModal: React.FC<DetailModalProps> = ({ visible, onCancel, instanceDa
/> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{instanceData.graph ? ( {instanceData.graph ? (
<Card> <Card>
@ -272,9 +272,9 @@ const DetailModal: React.FC<DetailModalProps> = ({ visible, onCancel, instanceDa
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-center py-10 text-muted-foreground"> <div className="text-center py-10 text-muted-foreground">
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)} )}
</TabsContent> </TabsContent>
@ -308,7 +308,7 @@ const DetailModal: React.FC<DetailModalProps> = ({ visible, onCancel, instanceDa
<span className="text-xs text-muted-foreground font-normal"> <span className="text-xs text-muted-foreground font-normal">
{getNodeTypeText(stage.nodeType)} {getNodeTypeText(stage.nodeType)}
</span> </span>
</div> </div>
<div className="text-sm text-muted-foreground mb-2"> <div className="text-sm text-muted-foreground mb-2">
{stage.startTime && dayjs(stage.startTime).format('YYYY-MM-DD HH:mm:ss')} {stage.startTime && dayjs(stage.startTime).format('YYYY-MM-DD HH:mm:ss')}
{stage.endTime && `${dayjs(stage.endTime).format('HH:mm:ss')}`} {stage.endTime && `${dayjs(stage.endTime).format('HH:mm:ss')}`}
@ -322,7 +322,7 @@ const DetailModal: React.FC<DetailModalProps> = ({ visible, onCancel, instanceDa
</div> </div>
</div> </div>
))} ))}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
@ -353,7 +353,7 @@ const DetailModal: React.FC<DetailModalProps> = ({ visible, onCancel, instanceDa
<DescriptionItem label="执行节点数" value={instanceData.stages.length} /> <DescriptionItem label="执行节点数" value={instanceData.stages.length} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</DialogPrimitive.Content> </DialogPrimitive.Content>

View File

@ -129,7 +129,7 @@ const HistoryModal: React.FC<HistoryModalProps> = ({ visible, onCancel, workflow
pageSize={query.pageSize} pageSize={query.pageSize}
pageCount={pageCount} pageCount={pageCount}
onPageChange={(page) => setQuery(prev => ({ onPageChange={(page) => setQuery(prev => ({
...prev, ...prev,
pageNum: page - 1 pageNum: page - 1
}))} }))}
/> />

View File

@ -172,7 +172,7 @@ const router = createBrowserRouter([
) )
}, },
{ {
path: 'definitions/:id/edit', path: 'definitions/:id/design',
element: ( element: (
<Suspense fallback={<LoadingComponent/>}> <Suspense fallback={<LoadingComponent/>}>
<FormDefinitionDesigner/> <FormDefinitionDesigner/>