增加审批组件
This commit is contained in:
parent
dbcda1a192
commit
dfa6abf0a9
110
frontend/src/pages/System/User/components/AssignRolesDialog.tsx
Normal file
110
frontend/src/pages/System/User/components/AssignRolesDialog.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Users } from 'lucide-react';
|
||||||
|
import type { UserResponse, Role } from '../types';
|
||||||
|
|
||||||
|
interface AssignRolesDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record: UserResponse | null;
|
||||||
|
allRoles: Role[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: (roleIds: number[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分配角色对话框
|
||||||
|
*/
|
||||||
|
const AssignRolesDialog: React.FC<AssignRolesDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
allRoles,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && record) {
|
||||||
|
setSelectedRoles(record.roles?.map(r => r.id) || []);
|
||||||
|
}
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
const handleToggle = (roleId: number) => {
|
||||||
|
setSelectedRoles(prev =>
|
||||||
|
prev.includes(roleId)
|
||||||
|
? prev.filter(id => id !== roleId)
|
||||||
|
: [...prev, roleId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await onConfirm(selectedRoles);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Users className="h-5 w-5" /> 分配角色
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
为用户 "<span className="font-semibold text-foreground">{record.username}</span>" 分配角色
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>选择角色</Label>
|
||||||
|
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-3">
|
||||||
|
{allRoles.length > 0 ? (
|
||||||
|
allRoles.map(role => (
|
||||||
|
<div key={role.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`role-${role.id}`}
|
||||||
|
checked={selectedRoles.includes(role.id)}
|
||||||
|
onCheckedChange={() => handleToggle(role.id)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`role-${role.id}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
{role.description && (
|
||||||
|
<span className="text-muted-foreground ml-2">({role.description})</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
暂无可用角色
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>确定</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssignRolesDialog;
|
||||||
|
|
||||||
75
frontend/src/pages/System/User/components/DeleteDialog.tsx
Normal file
75
frontend/src/pages/System/User/components/DeleteDialog.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import type { UserResponse } from '../types';
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record: UserResponse | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除确认对话框
|
||||||
|
*/
|
||||||
|
const DeleteDialog: React.FC<DeleteDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<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">{record.username}</span>" 吗?
|
||||||
|
此操作不可逆。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
{record.nickname && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">昵称:</span> {record.nickname}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{record.email && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">邮箱:</span> {record.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{record.departmentName && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">部门:</span> {record.departmentName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteDialog;
|
||||||
|
|
||||||
241
frontend/src/pages/System/User/components/EditModal.tsx
Normal file
241
frontend/src/pages/System/User/components/EditModal.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
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 { Switch } from '@/components/ui/switch';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { createUser, updateUser } from '../service';
|
||||||
|
import type { UserResponse, UserRequest } from '../types';
|
||||||
|
import type { DepartmentResponse } from '../../Department/types';
|
||||||
|
|
||||||
|
interface EditModalProps {
|
||||||
|
open: boolean;
|
||||||
|
record?: UserResponse;
|
||||||
|
departments: DepartmentResponse[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户编辑弹窗
|
||||||
|
*/
|
||||||
|
const EditModal: React.FC<EditModalProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
departments,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [formData, setFormData] = React.useState<Partial<UserRequest>>({
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (record) {
|
||||||
|
setFormData({
|
||||||
|
username: record.username,
|
||||||
|
nickname: record.nickname,
|
||||||
|
email: record.email,
|
||||||
|
phone: record.phone,
|
||||||
|
departmentId: record.departmentId,
|
||||||
|
enabled: record.enabled,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({ enabled: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, record]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
// 验证
|
||||||
|
if (!formData.username?.trim()) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入用户名',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record && !formData.password) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入密码',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record && formData.password && formData.password.length < 6) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '密码长度不能小于6位',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
toast({
|
||||||
|
title: '提示',
|
||||||
|
description: '请输入正确的邮箱格式',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
await updateUser(record.id, formData as UserRequest);
|
||||||
|
toast({
|
||||||
|
title: '更新成功',
|
||||||
|
description: `用户 "${formData.username}" 已更新`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createUser(formData as UserRequest);
|
||||||
|
toast({
|
||||||
|
title: '创建成功',
|
||||||
|
description: `用户 "${formData.username}" 已创建`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '保存失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 扁平化部门列表
|
||||||
|
const flattenDepartments = (depts: DepartmentResponse[]): DepartmentResponse[] => {
|
||||||
|
const result: DepartmentResponse[] = [];
|
||||||
|
const traverse = (dept: DepartmentResponse, level: number = 0) => {
|
||||||
|
result.push({ ...dept, name: ' '.repeat(level) + dept.name });
|
||||||
|
if (dept.children) {
|
||||||
|
dept.children.forEach(child => traverse(child, level + 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
depts.forEach(dept => traverse(dept));
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flatDepartments = flattenDepartments(departments);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{record ? '编辑用户' : '新增用户'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="username">用户名 *</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={formData.username || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
disabled={!!record}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!record && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="password">密码 *</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
placeholder="请输入密码(至少6位)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="nickname">昵称</Label>
|
||||||
|
<Input
|
||||||
|
id="nickname"
|
||||||
|
value={formData.nickname || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="phone">手机号</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
value={formData.phone || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="department">所属部门</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.departmentId?.toString() || undefined}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, departmentId: Number(value) })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择所属部门" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{flatDepartments.map(dept => (
|
||||||
|
<SelectItem key={dept.id} value={dept.id.toString()}>
|
||||||
|
{dept.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="enabled">启用状态</Label>
|
||||||
|
<Switch
|
||||||
|
id="enabled"
|
||||||
|
checked={formData.enabled}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>确定</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditModal;
|
||||||
|
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
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 { KeyRound } from 'lucide-react';
|
||||||
|
import type { UserResponse } from '../types';
|
||||||
|
|
||||||
|
interface ResetPasswordDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record: UserResponse | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: (password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置密码对话框
|
||||||
|
*/
|
||||||
|
const ResetPasswordDialog: React.FC<ResetPasswordDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPassword('');
|
||||||
|
setConfirmPassword('');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// 验证
|
||||||
|
if (!password) {
|
||||||
|
setError('请输入新密码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError('密码长度不能小于6位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('两次输入的密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onConfirm(password);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="h-5 w-5" /> 重置密码
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
为用户 "<span className="font-semibold text-foreground">{record.username}</span>" 重置密码
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="newPassword">新密码</Label>
|
||||||
|
<Input
|
||||||
|
id="newPassword"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
placeholder="请输入新密码(至少6位)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setConfirmPassword(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-red-600">{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit}>确认重置</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPasswordDialog;
|
||||||
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
.role-modal {
|
|
||||||
min-width: 520px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-list {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-info {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-name {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-description {
|
|
||||||
color: #666;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import { Modal, Transfer, message, Spin } from 'antd';
|
|
||||||
import type { TransferDirection, TransferProps } from 'antd/es/transfer';
|
|
||||||
import { getRoles } from '../../Role/service';
|
|
||||||
import { getUserRoleIds, updateUserRoles } from '../service';
|
|
||||||
import type { RoleDTO } from '../../Role/types';
|
|
||||||
|
|
||||||
interface RoleModalProps {
|
|
||||||
userId: number;
|
|
||||||
visible: boolean;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoleTransferItem {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RoleModal: React.FC<RoleModalProps> = ({
|
|
||||||
userId,
|
|
||||||
visible,
|
|
||||||
onCancel,
|
|
||||||
onSuccess
|
|
||||||
}) => {
|
|
||||||
const [roles, setRoles] = useState<RoleDTO[]>([]);
|
|
||||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const [allRoles, userRoleIds] = await Promise.all([
|
|
||||||
getRoles(),
|
|
||||||
getUserRoleIds(userId)
|
|
||||||
]);
|
|
||||||
setRoles(allRoles);
|
|
||||||
setSelectedKeys(userRoleIds.map(String));
|
|
||||||
} catch (error) {
|
|
||||||
message.error('获取角色数据失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
fetchData();
|
|
||||||
}
|
|
||||||
}, [visible, userId]);
|
|
||||||
|
|
||||||
const handleChange: TransferProps<RoleTransferItem>['onChange'] = (nextTargetKeys) => {
|
|
||||||
setSelectedKeys(nextTargetKeys as string[]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOk = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await updateUserRoles(userId, selectedKeys.map(Number));
|
|
||||||
message.success('角色分配成功');
|
|
||||||
onSuccess();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('角色分配失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="分配角色"
|
|
||||||
open={visible}
|
|
||||||
onOk={handleOk}
|
|
||||||
onCancel={onCancel}
|
|
||||||
width={600}
|
|
||||||
confirmLoading={loading}
|
|
||||||
destroyOnClose
|
|
||||||
>
|
|
||||||
<Spin spinning={loading}>
|
|
||||||
<Transfer<RoleTransferItem>
|
|
||||||
dataSource={roles.map(role => ({
|
|
||||||
key: role.id.toString(),
|
|
||||||
title: role.name,
|
|
||||||
description: role.code,
|
|
||||||
disabled: role.code === 'ROLE_ADMIN'
|
|
||||||
}))}
|
|
||||||
titles={['未选择', '已选择']}
|
|
||||||
targetKeys={selectedKeys}
|
|
||||||
onChange={handleChange}
|
|
||||||
render={item => item.title}
|
|
||||||
listStyle={{
|
|
||||||
width: 250,
|
|
||||||
height: 400,
|
|
||||||
}}
|
|
||||||
showSearch
|
|
||||||
filterOption={(inputValue, item) =>
|
|
||||||
(item.title?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1) ||
|
|
||||||
(item.description?.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Spin>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RoleModal;
|
|
||||||
@ -1,468 +1,483 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Button, Modal, Form, Input, Space, message, Switch, TreeSelect, Select, Tag, Dropdown } from 'antd';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, TeamOutlined, MoreOutlined } from '@ant-design/icons';
|
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table';
|
||||||
import type { MenuProps } from 'antd';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useTableData } from '@/hooks/useTableData';
|
import { Button } from '@/components/ui/button';
|
||||||
import * as service from './service';
|
import { Input } from '@/components/ui/input';
|
||||||
import type { UserResponse, UserRequest, UserQuery, Role } from './types';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { DepartmentResponse } from '../Department/types';
|
import { DataTablePagination } from '@/components/ui/pagination';
|
||||||
import { getDepartmentTree } from '../Department/service';
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Loader2, Plus, Search, Edit, Trash2, KeyRound, Users as UsersIcon,
|
||||||
TableHeader,
|
UserCheck, UserX, Activity
|
||||||
TableBody,
|
} from 'lucide-react';
|
||||||
TableHead,
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
TableRow,
|
import { getUsers, deleteUser, resetPassword, assignRoles, getAllRoles } from './service';
|
||||||
TableCell,
|
import { getDepartmentTree } from '../Department/service';
|
||||||
} from "@/components/ui/table";
|
import type { UserResponse, UserQuery, Role } from './types';
|
||||||
|
import type { DepartmentResponse } from '../Department/types';
|
||||||
interface Column {
|
import type { Page } from '@/types/base';
|
||||||
accessorKey?: keyof UserResponse;
|
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||||
id?: string;
|
import EditModal from './components/EditModal';
|
||||||
header: string;
|
import ResetPasswordDialog from './components/ResetPasswordDialog';
|
||||||
size: number;
|
import AssignRolesDialog from './components/AssignRolesDialog';
|
||||||
cell?: (props: { row: { original: UserResponse } }) => React.ReactNode;
|
import DeleteDialog from './components/DeleteDialog';
|
||||||
}
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
interface TreeNode {
|
|
||||||
title: string;
|
|
||||||
value: number;
|
|
||||||
children?: TreeNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理页面
|
||||||
|
*/
|
||||||
const UserPage: React.FC = () => {
|
const UserPage: React.FC = () => {
|
||||||
const [form] = Form.useForm();
|
const { toast } = useToast();
|
||||||
const [passwordForm] = Form.useForm();
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [data, setData] = useState<Page<UserResponse> | null>(null);
|
||||||
const [resetPasswordModalVisible, setResetPasswordModalVisible] = useState(false);
|
const [departments, setDepartments] = useState<DepartmentResponse[]>([]);
|
||||||
const [editingUser, setEditingUser] = useState<UserResponse | null>(null);
|
const [allRoles, setAllRoles] = useState<Role[]>([]);
|
||||||
const [departments, setDepartments] = useState<DepartmentResponse[]>([]);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [roleModalVisible, setRoleModalVisible] = useState(false);
|
const [editRecord, setEditRecord] = useState<UserResponse>();
|
||||||
const [selectedUser, setSelectedUser] = useState<UserResponse | null>(null);
|
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||||||
const [selectedRoles, setSelectedRoles] = useState<number[]>([]);
|
const [resetPasswordRecord, setResetPasswordRecord] = useState<UserResponse | null>(null);
|
||||||
const [allRoles, setAllRoles] = useState<Role[]>([]);
|
const [assignRolesDialogOpen, setAssignRolesDialogOpen] = useState(false);
|
||||||
|
const [assignRolesRecord, setAssignRolesRecord] = useState<UserResponse | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteRecord, setDeleteRecord] = useState<UserResponse | null>(null);
|
||||||
|
const [query, setQuery] = useState<UserQuery>({
|
||||||
|
pageNum: DEFAULT_CURRENT - 1,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
enabled: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
// 加载数据
|
||||||
list,
|
const loadData = async () => {
|
||||||
loading,
|
setLoading(true);
|
||||||
pagination,
|
try {
|
||||||
handleTableChange,
|
const result = await getUsers(query);
|
||||||
handleCreate,
|
setData(result);
|
||||||
handleUpdate,
|
} catch (error) {
|
||||||
handleDelete,
|
console.error('加载用户列表失败:', error);
|
||||||
refresh
|
} finally {
|
||||||
} = useTableData<UserResponse, UserQuery, UserRequest, UserRequest>({
|
setLoading(false);
|
||||||
service: {
|
}
|
||||||
list: service.getUsers,
|
};
|
||||||
create: service.createUser,
|
|
||||||
update: service.updateUser,
|
// 加载部门和角色
|
||||||
delete: service.deleteUser
|
const loadDepartmentsAndRoles = async () => {
|
||||||
},
|
try {
|
||||||
defaultParams: {
|
const [deptData, roleData] = await Promise.all([
|
||||||
sortField: 'createTime',
|
getDepartmentTree(),
|
||||||
sortOrder: 'desc'
|
getAllRoles()
|
||||||
},
|
]);
|
||||||
config: {
|
setDepartments(deptData);
|
||||||
message: {
|
setAllRoles(roleData);
|
||||||
createSuccess: '创建用户成功',
|
} catch (error) {
|
||||||
updateSuccess: '更新用户成功',
|
console.error('加载部门和角色失败:', error);
|
||||||
deleteSuccess: '删除用户成功'
|
}
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDepartmentsAndRoles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
setQuery(prev => ({ ...prev, pageNum: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
setQuery({
|
||||||
|
pageNum: 0,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
enabled: undefined,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// 新建
|
||||||
service.getAllRoles().then(roles => setAllRoles(roles));
|
const handleCreate = () => {
|
||||||
loadDepartmentTree();
|
setEditRecord(undefined);
|
||||||
}, []);
|
setEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const loadDepartmentTree = async () => {
|
// 编辑
|
||||||
try {
|
const handleEdit = (record: UserResponse) => {
|
||||||
const data = await getDepartmentTree();
|
setEditRecord(record);
|
||||||
setDepartments(data);
|
setEditModalOpen(true);
|
||||||
} catch (error) {
|
};
|
||||||
message.error('加载部门数据失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTreeData = (departments: DepartmentResponse[]): TreeNode[] => {
|
// 重置密码
|
||||||
return departments.map(dept => ({
|
const handleResetPassword = (record: UserResponse) => {
|
||||||
title: dept.name,
|
setResetPasswordRecord(record);
|
||||||
value: dept.id,
|
setResetPasswordDialogOpen(true);
|
||||||
children: dept.children ? getTreeData(dept.children) : undefined
|
};
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
const confirmResetPassword = async (password: string) => {
|
||||||
setEditingUser(null);
|
if (!resetPasswordRecord) return;
|
||||||
form.resetFields();
|
try {
|
||||||
form.setFieldsValue({
|
await resetPassword(resetPasswordRecord.id, password);
|
||||||
enabled: true
|
toast({
|
||||||
});
|
title: '重置成功',
|
||||||
setModalVisible(true);
|
description: `用户 "${resetPasswordRecord.username}" 的密码已重置`,
|
||||||
};
|
});
|
||||||
|
setResetPasswordDialogOpen(false);
|
||||||
|
setResetPasswordRecord(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('重置密码失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '重置失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (record: UserResponse) => {
|
// 分配角色
|
||||||
setEditingUser(record);
|
const handleAssignRoles = (record: UserResponse) => {
|
||||||
form.setFieldsValue({
|
setAssignRolesRecord(record);
|
||||||
...record,
|
setAssignRolesDialogOpen(true);
|
||||||
password: undefined // 不显示密码
|
};
|
||||||
});
|
|
||||||
setModalVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPassword = (record: UserResponse) => {
|
const confirmAssignRoles = async (roleIds: number[]) => {
|
||||||
setEditingUser(record);
|
if (!assignRolesRecord) return;
|
||||||
passwordForm.resetFields();
|
try {
|
||||||
setResetPasswordModalVisible(true);
|
await assignRoles(assignRolesRecord.id, roleIds);
|
||||||
};
|
toast({
|
||||||
|
title: '分配成功',
|
||||||
|
description: `已为用户 "${assignRolesRecord.username}" 分配角色`,
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
setAssignRolesDialogOpen(false);
|
||||||
|
setAssignRolesRecord(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('分配角色失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '分配失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleResetPasswordSubmit = async () => {
|
// 删除
|
||||||
try {
|
const handleDelete = (record: UserResponse) => {
|
||||||
const values = await passwordForm.validateFields();
|
setDeleteRecord(record);
|
||||||
if (editingUser) {
|
setDeleteDialogOpen(true);
|
||||||
await service.resetPassword(editingUser.id, values.password);
|
};
|
||||||
message.success('密码重置成功');
|
|
||||||
setResetPasswordModalVisible(false);
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error.message || '密码重置失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const confirmDelete = async () => {
|
||||||
try {
|
if (!deleteRecord) return;
|
||||||
const values = await form.validateFields();
|
try {
|
||||||
if (editingUser) {
|
await deleteUser(deleteRecord.id);
|
||||||
await handleUpdate(editingUser.id, values);
|
toast({
|
||||||
} else {
|
title: '删除成功',
|
||||||
await handleCreate(values);
|
description: `用户 "${deleteRecord.username}" 已删除`,
|
||||||
}
|
});
|
||||||
setModalVisible(false);
|
loadData();
|
||||||
refresh();
|
setDeleteDialogOpen(false);
|
||||||
} catch (error: any) {
|
setDeleteRecord(null);
|
||||||
message.error(error.message || '操作失败');
|
} catch (error) {
|
||||||
}
|
console.error('删除失败:', error);
|
||||||
};
|
toast({
|
||||||
|
title: '删除失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAssignRoles = (record: UserResponse) => {
|
// 状态徽章
|
||||||
setSelectedUser(record);
|
const getStatusBadge = (enabled: boolean) => {
|
||||||
setSelectedRoles(record.roles?.map(role => role.id) || []);
|
return enabled ? (
|
||||||
setRoleModalVisible(true);
|
<Badge variant="success" className="inline-flex items-center gap-1">
|
||||||
};
|
<UserCheck className="h-3 w-3" />
|
||||||
|
启用
|
||||||
const handleAssignRoleSubmit = async () => {
|
</Badge>
|
||||||
if (selectedUser) {
|
) : (
|
||||||
try {
|
<Badge variant="secondary" className="inline-flex items-center gap-1">
|
||||||
await service.assignRoles(selectedUser.id, selectedRoles);
|
<UserX className="h-3 w-3" />
|
||||||
message.success('角色分配成功');
|
禁用
|
||||||
setRoleModalVisible(false);
|
</Badge>
|
||||||
refresh();
|
|
||||||
} catch (error) {
|
|
||||||
message.error('角色分配失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: Column[] = [
|
|
||||||
{
|
|
||||||
accessorKey: 'id',
|
|
||||||
header: 'ID',
|
|
||||||
size: 60,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'username',
|
|
||||||
header: '用户名',
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'nickname',
|
|
||||||
header: '昵称',
|
|
||||||
size: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'email',
|
|
||||||
header: '邮箱',
|
|
||||||
size: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'departmentName',
|
|
||||||
header: '部门',
|
|
||||||
size: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'enabled',
|
|
||||||
header: '状态',
|
|
||||||
size: 100,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Tag color={row.original.enabled ? 'success' : 'error'}>
|
|
||||||
{row.original.enabled ? '启用' : '禁用'}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'phone',
|
|
||||||
header: '手机号',
|
|
||||||
size: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: '角色',
|
|
||||||
size: 120,
|
|
||||||
cell: ({ row }) => row.original.roles?.map(role => role.name).join(', ') || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'createTime',
|
|
||||||
header: '创建时间',
|
|
||||||
size: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'updateTime',
|
|
||||||
header: '更新时间',
|
|
||||||
size: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: '操作',
|
|
||||||
size: 180,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const record = row.original;
|
|
||||||
const items: MenuProps['items'] = [
|
|
||||||
{
|
|
||||||
key: 'resetPassword',
|
|
||||||
icon: <KeyOutlined />,
|
|
||||||
label: '重置密码',
|
|
||||||
onClick: () => handleResetPassword(record)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'assignRoles',
|
|
||||||
icon: <TeamOutlined />,
|
|
||||||
label: '分配角色',
|
|
||||||
onClick: () => handleAssignRoles(record),
|
|
||||||
disabled: record.username === 'admin'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (record.username !== 'admin') {
|
|
||||||
items.push({
|
|
||||||
key: 'delete',
|
|
||||||
icon: <DeleteOutlined />,
|
|
||||||
label: '删除',
|
|
||||||
danger: true,
|
|
||||||
onClick: () => handleDelete(record.id)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Space>
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<EditOutlined />}
|
|
||||||
onClick={() => handleEdit(record)}
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
<Dropdown menu={{ items }} trigger={['click']}>
|
|
||||||
<Button type="link" icon={<MoreOutlined />} />
|
|
||||||
</Dropdown>
|
|
||||||
</Space>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleAdd}
|
|
||||||
>
|
|
||||||
新增用户
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border bg-white">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<TableHead
|
|
||||||
key={column.accessorKey || column.id}
|
|
||||||
style={{ width: column.size }}
|
|
||||||
>
|
|
||||||
{column.header}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{list.map((row) => (
|
|
||||||
<TableRow key={row.id}>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<TableCell key={column.accessorKey || column.id}>
|
|
||||||
{column.cell
|
|
||||||
? column.cell({ row: { original: row } })
|
|
||||||
: column.accessorKey
|
|
||||||
? String(row[column.accessorKey])
|
|
||||||
: null}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={editingUser ? '编辑用户' : '新增用户'}
|
|
||||||
open={modalVisible}
|
|
||||||
onOk={handleSubmit}
|
|
||||||
onCancel={() => setModalVisible(false)}
|
|
||||||
width={600}
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
name="username"
|
|
||||||
label="用户名"
|
|
||||||
rules={[{ required: true, message: '请输入用户名' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入用户名" disabled={!!editingUser} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{!editingUser && (
|
|
||||||
<Form.Item
|
|
||||||
name="password"
|
|
||||||
label="密码"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请输入密码' },
|
|
||||||
{ min: 6, message: '密码长度不能小于6位' }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="请输入密码" />
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="nickname"
|
|
||||||
label="昵称"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入昵称" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="email"
|
|
||||||
label="邮箱"
|
|
||||||
rules={[{ type: 'email', message: '请输入正确的邮箱格式' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入邮箱" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="phone"
|
|
||||||
label="手机号"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入手机号" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="departmentId"
|
|
||||||
label="所属部门"
|
|
||||||
>
|
|
||||||
<TreeSelect
|
|
||||||
treeData={getTreeData(departments)}
|
|
||||||
placeholder="请选择所属部门"
|
|
||||||
allowClear
|
|
||||||
treeDefaultExpandAll
|
|
||||||
showSearch
|
|
||||||
treeNodeFilterProp="title"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="enabled"
|
|
||||||
label="状态"
|
|
||||||
valuePropName="checked"
|
|
||||||
initialValue={true}
|
|
||||||
>
|
|
||||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="重置密码"
|
|
||||||
open={resetPasswordModalVisible}
|
|
||||||
onOk={handleResetPasswordSubmit}
|
|
||||||
onCancel={() => setResetPasswordModalVisible(false)}
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
form={passwordForm}
|
|
||||||
layout="vertical"
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
name="password"
|
|
||||||
label="新密码"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请输入新密码' },
|
|
||||||
{ min: 6, message: '密码长度不能小于6位' }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="请输入新密码" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="confirmPassword"
|
|
||||||
label="确认密码"
|
|
||||||
dependencies={['password']}
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请确认新密码' },
|
|
||||||
({ getFieldValue }) => ({
|
|
||||||
validator(_, value) {
|
|
||||||
if (!value || getFieldValue('password') === value) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('两次输入的密码不一致'));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input.Password placeholder="请确认新密码" />
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
title="分配角色"
|
|
||||||
open={roleModalVisible}
|
|
||||||
onOk={handleAssignRoleSubmit}
|
|
||||||
onCancel={() => setRoleModalVisible(false)}
|
|
||||||
width={600}
|
|
||||||
>
|
|
||||||
<Form layout="vertical">
|
|
||||||
<Form.Item label="选择角色">
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
placeholder="请选择角色"
|
|
||||||
value={selectedRoles}
|
|
||||||
onChange={setSelectedRoles}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
>
|
|
||||||
{allRoles.map(role => (
|
|
||||||
<Select.Option key={role.id} value={role.id}>
|
|
||||||
{role.name}
|
|
||||||
</Select.Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = data?.totalElements || 0;
|
||||||
|
const enabledCount = data?.content?.filter(d => d.enabled).length || 0;
|
||||||
|
const disabledCount = data?.content?.filter(d => !d.enabled).length || 0;
|
||||||
|
return { total, enabledCount, disabledCount };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const pageCount = data?.totalElements ? Math.ceil(data.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">用户管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
管理系统用户账号,设置权限和角色。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-blue-700">总用户数</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.total}</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>
|
||||||
|
<UserCheck className="h-4 w-4 text-green-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.enabledCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">可正常登录的用户</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-gray-500/10 to-gray-500/5 border-gray-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-700">禁用用户</CardTitle>
|
||||||
|
<UserX className="h-4 w-4 text-gray-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.disabledCount}</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={handleCreate}>
|
||||||
|
<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-xs">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名"
|
||||||
|
value={query.username}
|
||||||
|
onChange={(e) => setQuery(prev => ({ ...prev, username: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 max-w-xs">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索邮箱"
|
||||||
|
value={query.email}
|
||||||
|
onChange={(e) => setQuery(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={query.enabled === undefined ? 'all' : query.enabled ? 'true' : 'false'}
|
||||||
|
onValueChange={(value) => setQuery(prev => ({
|
||||||
|
...prev,
|
||||||
|
enabled: value === 'all' ? undefined : value === 'true'
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[130px] h-9">
|
||||||
|
<SelectValue placeholder="全部状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="true">启用</SelectItem>
|
||||||
|
<SelectItem value="false">禁用</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-[120px]">用户名</TableHead>
|
||||||
|
<TableHead className="w-[150px]">昵称</TableHead>
|
||||||
|
<TableHead className="w-[200px]">邮箱</TableHead>
|
||||||
|
<TableHead className="w-[120px]">手机号</TableHead>
|
||||||
|
<TableHead className="w-[120px]">部门</TableHead>
|
||||||
|
<TableHead className="w-[150px]">角色</TableHead>
|
||||||
|
<TableHead className="w-[100px]">状态</TableHead>
|
||||||
|
<TableHead className="w-[180px]">创建时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} 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>
|
||||||
|
) : data?.content && data.content.length > 0 ? (
|
||||||
|
data.content.map((record) => {
|
||||||
|
const isAdmin = record.username === 'admin';
|
||||||
|
return (
|
||||||
|
<TableRow key={record.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{record.username}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{record.nickname || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm">{record.email || '-'}</TableCell>
|
||||||
|
<TableCell className="text-sm">{record.phone || '-'}</TableCell>
|
||||||
|
<TableCell>{record.departmentName || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{record.roles && record.roles.length > 0 ? (
|
||||||
|
record.roles.map(role => (
|
||||||
|
<Badge key={role.id} variant="outline" className="text-xs">
|
||||||
|
{role.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(record.enabled)}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{record.createTime ? dayjs(record.createTime).format('YYYY-MM-DD HH:mm') : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleResetPassword(record)}
|
||||||
|
title="重置密码"
|
||||||
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAssignRoles(record)}
|
||||||
|
title="分配角色"
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||||
|
disabled={isAdmin}
|
||||||
|
>
|
||||||
|
<UsersIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{!isAdmin && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="h-24 text-center">
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<UsersIcon 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
|
||||||
|
open={editModalOpen}
|
||||||
|
record={editRecord}
|
||||||
|
departments={departments}
|
||||||
|
onOpenChange={setEditModalOpen}
|
||||||
|
onSuccess={loadData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 重置密码对话框 */}
|
||||||
|
<ResetPasswordDialog
|
||||||
|
open={resetPasswordDialogOpen}
|
||||||
|
record={resetPasswordRecord}
|
||||||
|
onOpenChange={setResetPasswordDialogOpen}
|
||||||
|
onConfirm={confirmResetPassword}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 分配角色对话框 */}
|
||||||
|
<AssignRolesDialog
|
||||||
|
open={assignRolesDialogOpen}
|
||||||
|
record={assignRolesRecord}
|
||||||
|
allRoles={allRoles}
|
||||||
|
onOpenChange={setAssignRolesDialogOpen}
|
||||||
|
onConfirm={confirmAssignRoles}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
record={deleteRecord}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UserPage;
|
export default UserPage;
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import request from '@/utils/request';
|
import request from '@/utils/request';
|
||||||
import type { UserResponse, UserRequest, UserQuery } from './types';
|
import type { UserResponse, UserRequest, UserQuery, Role } from './types';
|
||||||
import {Page} from "@/types/base.ts";
|
import { Page } from '@/types/base';
|
||||||
import {RoleResponse} from "@/pages/System/Role/types";
|
|
||||||
|
|
||||||
const BASE_URL = '/api/v1/user';
|
const BASE_URL = '/api/v1/user';
|
||||||
const ROLE_BASE_URL = '/api/v1/role';
|
const ROLE_BASE_URL = '/api/v1/role';
|
||||||
@ -32,4 +31,4 @@ export const assignRoles = (userId: number, roleIds: number[]) =>
|
|||||||
|
|
||||||
// 获取所有角色列表(不分页)
|
// 获取所有角色列表(不分页)
|
||||||
export const getAllRoles = () =>
|
export const getAllRoles = () =>
|
||||||
request.get<RoleResponse[]>(`${ROLE_BASE_URL}/list`);
|
request.get<Role[]>(`${ROLE_BASE_URL}/list`);
|
||||||
|
|||||||
@ -0,0 +1,94 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { AlertCircle, Clock, CheckCircle2 } from 'lucide-react';
|
||||||
|
import type { WorkflowDefinition } from '../types';
|
||||||
|
|
||||||
|
interface DeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record: WorkflowDefinition | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除确认对话框
|
||||||
|
*/
|
||||||
|
const DeleteDialog: React.FC<DeleteDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
// 状态徽章
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const statusMap: Record<string, {
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
|
||||||
|
text: string;
|
||||||
|
icon: React.ElementType
|
||||||
|
}> = {
|
||||||
|
DRAFT: { variant: 'outline', text: '草稿', icon: Clock },
|
||||||
|
PUBLISHED: { variant: 'success', text: '已发布', icon: CheckCircle2 },
|
||||||
|
};
|
||||||
|
const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
|
||||||
|
const Icon = statusInfo.icon;
|
||||||
|
return (
|
||||||
|
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1 w-fit">
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{statusInfo.text}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<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">{record.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">
|
||||||
|
{record.key}
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium">版本:</span>{' '}
|
||||||
|
<span className="font-mono text-sm">{record.flowVersion || 1}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">状态:</span> {getStatusBadge(record.status || 'DRAFT')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={onConfirm}>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeleteDialog;
|
||||||
|
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
import type { WorkflowDefinition } from '../types';
|
||||||
|
|
||||||
|
interface DeployDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
record: WorkflowDefinition | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布确认对话框
|
||||||
|
*/
|
||||||
|
const DeployDialog: React.FC<DeployDialogProps> = ({
|
||||||
|
open,
|
||||||
|
record,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
}) => {
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<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">{record.name}</span>" 吗?
|
||||||
|
发布后将可以启动执行。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm}>确认发布</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeployDialog;
|
||||||
|
|
||||||
@ -6,513 +6,482 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
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 { DataTablePagination } from '@/components/ui/pagination';
|
||||||
import {
|
import {
|
||||||
Loader2, Plus, Search, Edit, Trash2, Play, CheckCircle2,
|
Loader2, Plus, Search, Edit, Trash2, Play, CheckCircle2,
|
||||||
Clock, Activity, Workflow, MoreHorizontal, AlertCircle, Eye
|
Clock, Activity, Workflow, Eye, Pencil
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import * as service from './service';
|
import { getDefinitions, getWorkflowCategoryList, deleteDefinition, publishDefinition, startWorkflowInstance } from './service';
|
||||||
import type { WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategoryResponse } from './types';
|
import type { WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategoryResponse } from './types';
|
||||||
|
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 EditModal from './components/EditModal';
|
import EditModal from './components/EditModal';
|
||||||
|
import DeleteDialog from './components/DeleteDialog';
|
||||||
|
import DeployDialog from './components/DeployDialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流定义列表页
|
||||||
|
*/
|
||||||
const WorkflowDefinitionList: React.FC = () => {
|
const WorkflowDefinitionList: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageData, setPageData] = useState<{
|
const [data, setData] = useState<Page<WorkflowDefinition> | null>(null);
|
||||||
content: WorkflowDefinition[];
|
const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
|
||||||
totalElements: number;
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
size: number;
|
const [editRecord, setEditRecord] = useState<WorkflowDefinition>();
|
||||||
number: number;
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
} | null>(null);
|
const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null);
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
|
||||||
const [currentRecord, setCurrentRecord] = useState<WorkflowDefinition>();
|
const [deployRecord, setDeployRecord] = useState<WorkflowDefinition | null>(null);
|
||||||
const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
|
const [query, setQuery] = useState<WorkflowDefinitionQuery>({
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
pageNum: DEFAULT_CURRENT - 1,
|
||||||
const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null);
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
|
name: '',
|
||||||
const [deployRecord, setDeployRecord] = useState<WorkflowDefinition | null>(null);
|
categoryId: undefined,
|
||||||
const [query, setQuery] = useState<WorkflowDefinitionQuery>({
|
status: undefined
|
||||||
pageNum: DEFAULT_CURRENT - 1,
|
});
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
name: '',
|
// 加载数据
|
||||||
categoryId: undefined,
|
const loadData = async () => {
|
||||||
status: undefined
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getDefinitions(query);
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载工作流定义失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载分类
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getWorkflowCategoryList();
|
||||||
|
setCategories(result || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
setQuery(prev => ({ ...prev, pageNum: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
setQuery({
|
||||||
|
pageNum: 0,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
name: '',
|
||||||
|
categoryId: undefined,
|
||||||
|
status: undefined
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadData = async (params: WorkflowDefinitionQuery) => {
|
// 新建
|
||||||
setLoading(true);
|
const handleCreate = () => {
|
||||||
try {
|
setEditRecord(undefined);
|
||||||
const data = await service.getDefinitions(params);
|
setEditModalVisible(true);
|
||||||
setPageData(data);
|
};
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
// 编辑
|
||||||
toast({
|
const handleEdit = (record: WorkflowDefinition) => {
|
||||||
title: '加载失败',
|
setEditRecord(record);
|
||||||
description: error.message,
|
setEditModalVisible(true);
|
||||||
variant: 'destructive'
|
};
|
||||||
});
|
|
||||||
}
|
// 设计
|
||||||
} finally {
|
const handleDesign = (record: WorkflowDefinition) => {
|
||||||
setLoading(false);
|
navigate(`/workflow/design/${record.id}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// 发布
|
||||||
|
const handleDeploy = (record: WorkflowDefinition) => {
|
||||||
|
setDeployRecord(record);
|
||||||
|
setDeployDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeploy = async () => {
|
||||||
|
if (!deployRecord) return;
|
||||||
|
try {
|
||||||
|
await publishDefinition(deployRecord.id);
|
||||||
|
toast({
|
||||||
|
title: '发布成功',
|
||||||
|
description: `工作流 "${deployRecord.name}" 已发布`,
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
setDeployDialogOpen(false);
|
||||||
|
setDeployRecord(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发布失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '发布失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = (record: WorkflowDefinition) => {
|
||||||
|
setDeleteRecord(record);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deleteRecord) return;
|
||||||
|
try {
|
||||||
|
await deleteDefinition(deleteRecord.id);
|
||||||
|
toast({
|
||||||
|
title: '删除成功',
|
||||||
|
description: `工作流 "${deleteRecord.name}" 已删除`,
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteRecord(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '删除失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动
|
||||||
|
const handleStart = async (record: WorkflowDefinition) => {
|
||||||
|
try {
|
||||||
|
await startWorkflowInstance(record.key, record.categoryId);
|
||||||
|
toast({
|
||||||
|
title: '启动成功',
|
||||||
|
description: `工作流 "${record.name}" 已启动`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('启动失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '启动失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态徽章
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const statusMap: Record<string, {
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
|
||||||
|
text: string;
|
||||||
|
icon: React.ElementType
|
||||||
|
}> = {
|
||||||
|
DRAFT: { variant: 'outline', text: '草稿', icon: Clock },
|
||||||
|
PUBLISHED: { variant: 'success', text: '已发布', icon: CheckCircle2 },
|
||||||
|
DISABLED: { variant: 'secondary', text: '已停用', icon: Clock },
|
||||||
};
|
};
|
||||||
|
const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
|
||||||
const loadCategories = async () => {
|
const Icon = statusInfo.icon;
|
||||||
try {
|
|
||||||
const data = await service.getWorkflowCategoryList();
|
|
||||||
setCategories(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载工作流分类失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadCategories();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData(query);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const handleCreateFlow = () => {
|
|
||||||
setCurrentRecord(undefined);
|
|
||||||
setModalVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditFlow = (record: WorkflowDefinition) => {
|
|
||||||
setCurrentRecord(record);
|
|
||||||
setModalVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDesignFlow = (record: WorkflowDefinition) => {
|
|
||||||
navigate(`/workflow/design/${record.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModalClose = () => {
|
|
||||||
setModalVisible(false);
|
|
||||||
setCurrentRecord(undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
setQuery(prev => ({
|
|
||||||
...prev,
|
|
||||||
pageNum: 0,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setQuery({
|
|
||||||
pageNum: 0,
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
name: '',
|
|
||||||
categoryId: undefined,
|
|
||||||
status: undefined
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeploy = (record: WorkflowDefinition) => {
|
|
||||||
setDeployRecord(record);
|
|
||||||
setDeployDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDeploy = async () => {
|
|
||||||
if (!deployRecord) return;
|
|
||||||
try {
|
|
||||||
await service.publishDefinition(deployRecord.id);
|
|
||||||
toast({
|
|
||||||
title: '发布成功',
|
|
||||||
description: `工作流 "${deployRecord.name}" 已发布`,
|
|
||||||
});
|
|
||||||
loadData(query);
|
|
||||||
setDeployDialogOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast({
|
|
||||||
title: '发布失败',
|
|
||||||
description: error.message,
|
|
||||||
variant: 'destructive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (record: WorkflowDefinition) => {
|
|
||||||
setDeleteRecord(record);
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!deleteRecord) return;
|
|
||||||
try {
|
|
||||||
await service.deleteDefinition(deleteRecord.id);
|
|
||||||
toast({
|
|
||||||
title: '删除成功',
|
|
||||||
description: `工作流 "${deleteRecord.name}" 已删除`,
|
|
||||||
});
|
|
||||||
loadData(query);
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast({
|
|
||||||
title: '删除失败',
|
|
||||||
description: error.message,
|
|
||||||
variant: 'destructive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStartFlow = async (record: WorkflowDefinition) => {
|
|
||||||
try {
|
|
||||||
await service.startWorkflowInstance(record.key, record.categoryId);
|
|
||||||
toast({
|
|
||||||
title: '启动成功',
|
|
||||||
description: `工作流 "${record.name}" 已启动`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
toast({
|
|
||||||
title: '启动失败',
|
|
||||||
description: error.message,
|
|
||||||
variant: 'destructive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 状态徽章
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
const statusMap: Record<string, {
|
|
||||||
variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
|
|
||||||
text: string;
|
|
||||||
icon: React.ElementType
|
|
||||||
}> = {
|
|
||||||
DRAFT: { variant: 'outline', text: '草稿', icon: Clock },
|
|
||||||
PUBLISHED: { variant: 'success', text: '已发布', icon: CheckCircle2 },
|
|
||||||
};
|
|
||||||
const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
|
|
||||||
const Icon = statusInfo.icon;
|
|
||||||
return (
|
|
||||||
<Badge variant={statusInfo.variant} className="flex items-center gap-1">
|
|
||||||
<Icon className="h-3 w-3" />
|
|
||||||
{statusInfo.text}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 统计数据
|
|
||||||
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 (
|
||||||
<div className="p-6">
|
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
|
||||||
<div className="mb-6">
|
<Icon className="h-3 w-3" />
|
||||||
<h1 className="text-3xl font-bold text-foreground">工作流定义管理</h1>
|
{statusInfo.text}
|
||||||
</div>
|
</Badge>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
|
||||||
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-blue-700">总工作流</CardTitle>
|
|
||||||
<Activity className="h-4 w-4 text-blue-500" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{stats.total}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">全部工作流定义</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="bg-gradient-to-br from-yellow-500/10 to-yellow-500/5 border-yellow-500/20">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium text-yellow-700">草稿</CardTitle>
|
|
||||||
<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
|
|
||||||
visible={modalVisible}
|
|
||||||
onClose={handleModalClose}
|
|
||||||
onSuccess={() => loadData(query)}
|
|
||||||
record={currentRecord}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 发布确认对话框 */}
|
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = data?.totalElements || 0;
|
||||||
|
const draftCount = data?.content?.filter(d => d.status === 'DRAFT').length || 0;
|
||||||
|
const publishedCount = data?.content?.filter(d => d.status === 'PUBLISHED').length || 0;
|
||||||
|
return { total, draftCount, publishedCount };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const pageCount = data?.totalElements ? Math.ceil(data.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">工作流定义管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
创建和管理工作流定义,设计流程图并发布上线。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-blue-700">总工作流</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.total}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">全部工作流定义</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-yellow-500/10 to-yellow-500/5 border-yellow-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-yellow-700">草稿</CardTitle>
|
||||||
|
<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={handleCreate}>
|
||||||
|
<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-[120px]">状态</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>
|
||||||
|
) : data?.content && data.content.length > 0 ? (
|
||||||
|
data.content.map((record) => {
|
||||||
|
const categoryInfo = categories.find(c => c.id === record.categoryId);
|
||||||
|
const isDraft = record.status === 'DRAFT';
|
||||||
|
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">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{isDraft ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDesign(record)}
|
||||||
|
title="设计"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeploy(record)}
|
||||||
|
title="发布"
|
||||||
|
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleStart(record)}
|
||||||
|
title="启动"
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDesign(record)}
|
||||||
|
title="查看"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
visible={editModalVisible}
|
||||||
|
onClose={() => {
|
||||||
|
setEditModalVisible(false);
|
||||||
|
setEditRecord(undefined);
|
||||||
|
}}
|
||||||
|
onSuccess={loadData}
|
||||||
|
record={editRecord}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 发布确认对话框 */}
|
||||||
|
<DeployDialog
|
||||||
|
open={deployDialogOpen}
|
||||||
|
record={deployRecord}
|
||||||
|
onOpenChange={setDeployDialogOpen}
|
||||||
|
onConfirm={confirmDeploy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<DeleteDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
record={deleteRecord}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WorkflowDefinitionList;
|
export default WorkflowDefinitionList;
|
||||||
@ -1,137 +1,322 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { Card, Table, Tag, Space, Empty } from 'antd';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
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 { DataTablePagination } from '@/components/ui/pagination';
|
||||||
|
import {
|
||||||
|
Loader2, Search, Eye, StopCircle, Activity, PlayCircle,
|
||||||
|
CheckCircle2, Clock, Workflow, XCircle, Pause
|
||||||
|
} from 'lucide-react';
|
||||||
import { getWorkflowInstances } from './service';
|
import { getWorkflowInstances } from './service';
|
||||||
import { WorkflowTemplateWithInstances } from './types';
|
import type { WorkflowTemplateWithInstances } from './types';
|
||||||
import { Page } from '@/types/base';
|
import type { Page } from '@/types/base';
|
||||||
// DetailModal 暂时移除,因为数据结构不匹配 (需要 WorkflowHistoricalInstance 而不是 WorkflowTemplateWithInstances)
|
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||||
import HistoryModal from './components/HistoryModal';
|
import HistoryModal from './components/HistoryModal';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流实例列表页
|
||||||
|
*/
|
||||||
const WorkflowInstanceList: React.FC = () => {
|
const WorkflowInstanceList: React.FC = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<Page<WorkflowTemplateWithInstances> | null>(null);
|
const [data, setData] = useState<Page<WorkflowTemplateWithInstances> | null>(null);
|
||||||
const [query, setQuery] = useState({
|
const [historyVisible, setHistoryVisible] = useState(false);
|
||||||
current: 1,
|
const [selectedWorkflowDefinitionId, setSelectedWorkflowDefinitionId] = useState<number>();
|
||||||
pageSize: 10,
|
const [query, setQuery] = useState({
|
||||||
|
pageNum: DEFAULT_CURRENT - 1,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
businessKey: '',
|
||||||
|
status: undefined as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getWorkflowInstances(query);
|
||||||
|
setData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载流程实例失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
setQuery(prev => ({
|
||||||
|
...prev,
|
||||||
|
pageNum: 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
setQuery({
|
||||||
|
pageNum: 0,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
businessKey: '',
|
||||||
|
status: undefined,
|
||||||
});
|
});
|
||||||
// 移除未使用的详情弹窗相关状态
|
};
|
||||||
// const [detailVisible, setDetailVisible] = useState(false);
|
|
||||||
// const [selectedInstance, setSelectedInstance] = useState<WorkflowTemplateWithInstances>();
|
|
||||||
const [historyVisible, setHistoryVisible] = useState(false);
|
|
||||||
const [selectedWorkflowDefinitionId, setSelectedWorkflowDefinitionId] = useState<number>();
|
|
||||||
|
|
||||||
const loadData = async (params: any) => {
|
// 查看历史
|
||||||
setLoading(true);
|
const handleViewHistory = (record: WorkflowTemplateWithInstances) => {
|
||||||
try {
|
setSelectedWorkflowDefinitionId(record.id);
|
||||||
const result = await getWorkflowInstances(params);
|
setHistoryVisible(true);
|
||||||
setData(result);
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('加载流程实例失败:', error);
|
// 终止流程(TODO:需要后端接口)
|
||||||
} finally {
|
const handleTerminate = async (record: WorkflowTemplateWithInstances) => {
|
||||||
setLoading(false);
|
if (!confirm(`确定要终止工作流 "${record.name}" 吗?`)) return;
|
||||||
}
|
console.log('终止流程', record);
|
||||||
|
// TODO: 调用终止接口
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态徽章
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const statusMap: Record<string, {
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
|
||||||
|
text: string;
|
||||||
|
icon: React.ElementType
|
||||||
|
}> = {
|
||||||
|
NOT_STARTED: { variant: 'outline', text: '未启动', icon: Clock },
|
||||||
|
CREATED: { variant: 'secondary', text: '已创建', icon: PlayCircle },
|
||||||
|
RUNNING: { variant: 'default', text: '运行中', icon: Activity },
|
||||||
|
SUSPENDED: { variant: 'secondary', text: '已挂起', icon: Pause },
|
||||||
|
COMPLETED: { variant: 'success', text: '已完成', icon: CheckCircle2 },
|
||||||
|
TERMINATED: { variant: 'destructive', text: '已终止', icon: StopCircle },
|
||||||
|
FAILED: { variant: 'destructive', text: '失败', icon: XCircle },
|
||||||
};
|
};
|
||||||
|
const statusInfo = statusMap[status] || { variant: 'outline', text: status || '未知', icon: Clock };
|
||||||
useEffect(() => {
|
const Icon = statusInfo.icon;
|
||||||
loadData(query);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const handleViewHistory = (record: WorkflowTemplateWithInstances) => {
|
|
||||||
setSelectedWorkflowDefinitionId(record.id);
|
|
||||||
setHistoryVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnsType<WorkflowTemplateWithInstances> = [
|
|
||||||
{
|
|
||||||
title: '流程名称',
|
|
||||||
dataIndex: 'name',
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '业务标识',
|
|
||||||
dataIndex: 'businessKey',
|
|
||||||
key: 'businessKey',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最后执行时间',
|
|
||||||
dataIndex: 'lastExecutionTime',
|
|
||||||
key: 'lastExecutionTime',
|
|
||||||
render: (time: string) => time || '暂无'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '最后执行状态',
|
|
||||||
dataIndex: 'lastExecutionStatus',
|
|
||||||
key: 'lastExecutionStatus',
|
|
||||||
render: (status: string) => {
|
|
||||||
if (!status) return '暂无';
|
|
||||||
const statusMap: Record<string, { color: string; text: string }> = {
|
|
||||||
TERMINATED: { color: 'warning', text: '已终止' },
|
|
||||||
COMPLETED: { color: 'success', text: '已完成' },
|
|
||||||
RUNNING: { color: 'processing', text: '运行中' },
|
|
||||||
FAILED: { color: 'error', text: '失败' },
|
|
||||||
NOT_STARTED: { color: 'default', text: '未执行' }
|
|
||||||
};
|
|
||||||
const statusInfo = statusMap[status] || { color: 'default', text: status };
|
|
||||||
return <Tag color={statusInfo.color}>{statusInfo.text}</Tag>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'action',
|
|
||||||
fixed: 'right',
|
|
||||||
width: 200,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space size="middle">
|
|
||||||
<a onClick={() => handleViewHistory(record)}>历史执行</a>
|
|
||||||
{record?.lastExecutionStatus === 'RUNNING' && (
|
|
||||||
<a onClick={() => console.log('终止流程', record)}>终止流程</a>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleTableChange = (pagination: any) => {
|
|
||||||
setQuery({
|
|
||||||
...query,
|
|
||||||
current: pagination.current,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="流程实例">
|
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
|
||||||
<Table
|
<Icon className="h-3 w-3" />
|
||||||
columns={columns}
|
{statusInfo.text}
|
||||||
dataSource={data?.content || []}
|
</Badge>
|
||||||
loading={loading}
|
|
||||||
rowKey="id"
|
|
||||||
scroll={{ x: 1200 }}
|
|
||||||
locale={{
|
|
||||||
emptyText: <Empty description="暂无数据" />
|
|
||||||
}}
|
|
||||||
pagination={{
|
|
||||||
current: query.current,
|
|
||||||
pageSize: query.pageSize,
|
|
||||||
total: data?.totalElements || 0,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showQuickJumper: true,
|
|
||||||
}}
|
|
||||||
onChange={handleTableChange}
|
|
||||||
/>
|
|
||||||
{/* DetailModal 暂时移除,因为数据结构不匹配 */}
|
|
||||||
{/* <DetailModal
|
|
||||||
visible={detailVisible}
|
|
||||||
onCancel={() => setDetailVisible(false)}
|
|
||||||
instanceData={selectedInstance}
|
|
||||||
/> */}
|
|
||||||
<HistoryModal
|
|
||||||
visible={historyVisible}
|
|
||||||
onCancel={() => setHistoryVisible(false)}
|
|
||||||
workflowDefinitionId={selectedWorkflowDefinitionId}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = data?.totalElements || 0;
|
||||||
|
const runningCount = data?.content?.filter(d => d.lastExecutionStatus === 'RUNNING').length || 0;
|
||||||
|
const completedCount = data?.content?.filter(d => d.lastExecutionStatus === 'COMPLETED').length || 0;
|
||||||
|
const failedCount = data?.content?.filter(d => d.lastExecutionStatus === 'FAILED' || d.lastExecutionStatus === 'TERMINATED').length || 0;
|
||||||
|
return { total, runningCount, completedCount, failedCount };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const pageCount = data?.totalElements ? Math.ceil(data.totalElements / query.pageSize) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-foreground">工作流实例管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
查看和管理工作流实例的运行状态和执行历史。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-blue-700">总实例数</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-blue-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.total}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">所有工作流实例</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-500/5 border-purple-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-purple-700">运行中</CardTitle>
|
||||||
|
<PlayCircle className="h-4 w-4 text-purple-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.runningCount}</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.completedCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">成功执行完成</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-gradient-to-br from-red-500/10 to-red-500/5 border-red-500/20">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-red-700">失败/终止</CardTitle>
|
||||||
|
<XCircle className="h-4 w-4 text-red-500" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.failedCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">执行失败或被终止</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>实例列表</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||||
|
<div className="flex-1 max-w-md">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索业务标识"
|
||||||
|
value={query.businessKey}
|
||||||
|
onChange={(e) => setQuery(prev => ({ ...prev, businessKey: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={query.status || undefined}
|
||||||
|
onValueChange={(value) => setQuery(prev => ({ ...prev, status: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] h-9">
|
||||||
|
<SelectValue placeholder="全部状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="NOT_STARTED">未启动</SelectItem>
|
||||||
|
<SelectItem value="CREATED">已创建</SelectItem>
|
||||||
|
<SelectItem value="RUNNING">运行中</SelectItem>
|
||||||
|
<SelectItem value="SUSPENDED">已挂起</SelectItem>
|
||||||
|
<SelectItem value="COMPLETED">已完成</SelectItem>
|
||||||
|
<SelectItem value="TERMINATED">已终止</SelectItem>
|
||||||
|
<SelectItem value="FAILED">失败</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-[180px]">最后执行时间</TableHead>
|
||||||
|
<TableHead className="w-[120px]">执行状态</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} 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>
|
||||||
|
) : data?.content && data.content.length > 0 ? (
|
||||||
|
data.content.map((record) => (
|
||||||
|
<TableRow key={record.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-medium">{record.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{record.businessKey ? (
|
||||||
|
<code className="relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold">
|
||||||
|
{record.businessKey}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-sm">
|
||||||
|
{record.lastExecutionTime
|
||||||
|
? dayjs(record.lastExecutionTime).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
: '-'}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(record.lastExecutionStatus)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewHistory(record)}
|
||||||
|
title="查看历史"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{record.lastExecutionStatus === 'RUNNING' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTerminate(record)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
title="终止流程"
|
||||||
|
>
|
||||||
|
<StopCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} 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 + 1}
|
||||||
|
pageSize={query.pageSize}
|
||||||
|
pageCount={pageCount}
|
||||||
|
onPageChange={(page) => setQuery(prev => ({
|
||||||
|
...prev,
|
||||||
|
pageNum: page - 1
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 历史记录弹窗 */}
|
||||||
|
<HistoryModal
|
||||||
|
visible={historyVisible}
|
||||||
|
onCancel={() => setHistoryVisible(false)}
|
||||||
|
workflowDefinitionId={selectedWorkflowDefinitionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WorkflowInstanceList;
|
export default WorkflowInstanceList;
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import { BaseQuery } from '@/types/base';
|
import { BaseQuery } from '@/types/base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流模板及其实例信息
|
||||||
|
*/
|
||||||
export interface WorkflowTemplateWithInstances {
|
export interface WorkflowTemplateWithInstances {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@ -8,10 +11,17 @@ export interface WorkflowTemplateWithInstances {
|
|||||||
lastExecutionStatus: string;
|
lastExecutionStatus: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowTemplateWithInstancesQuery {
|
/**
|
||||||
|
* 工作流实例查询参数
|
||||||
|
*/
|
||||||
|
export interface WorkflowTemplateWithInstancesQuery extends BaseQuery {
|
||||||
|
businessKey?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流实例阶段
|
||||||
|
*/
|
||||||
export interface WorkflowInstanceStage {
|
export interface WorkflowInstanceStage {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
@ -22,6 +32,9 @@ export interface WorkflowInstanceStage {
|
|||||||
endTime: string | null;
|
endTime: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流图节点
|
||||||
|
*/
|
||||||
export interface WorkflowGraphNode {
|
export interface WorkflowGraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
nodeCode: string;
|
nodeCode: string;
|
||||||
@ -36,6 +49,9 @@ export interface WorkflowGraphNode {
|
|||||||
outputs: any[];
|
outputs: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流图边
|
||||||
|
*/
|
||||||
export interface WorkflowGraphEdge {
|
export interface WorkflowGraphEdge {
|
||||||
id: string;
|
id: string;
|
||||||
from: string;
|
from: string;
|
||||||
@ -51,11 +67,17 @@ export interface WorkflowGraphEdge {
|
|||||||
vertices: any[];
|
vertices: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流图
|
||||||
|
*/
|
||||||
export interface WorkflowGraph {
|
export interface WorkflowGraph {
|
||||||
nodes: WorkflowGraphNode[];
|
nodes: WorkflowGraphNode[];
|
||||||
edges: WorkflowGraphEdge[];
|
edges: WorkflowGraphEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流历史实例
|
||||||
|
*/
|
||||||
export interface WorkflowHistoricalInstance {
|
export interface WorkflowHistoricalInstance {
|
||||||
id: number;
|
id: number;
|
||||||
processInstanceId: string;
|
processInstanceId: string;
|
||||||
@ -68,6 +90,9 @@ export interface WorkflowHistoricalInstance {
|
|||||||
graph?: WorkflowGraph;
|
graph?: WorkflowGraph;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作流历史实例查询参数
|
||||||
|
*/
|
||||||
export interface WorkflowHistoricalInstanceQuery extends BaseQuery {
|
export interface WorkflowHistoricalInstanceQuery extends BaseQuery {
|
||||||
workflowDefinitionId?: number;
|
workflowDefinitionId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user