增加审批组件

This commit is contained in:
dengqichen 2025-10-24 19:46:49 +08:00
parent dbcda1a192
commit dfa6abf0a9
13 changed files with 1965 additions and 1218 deletions

View 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;

View 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;

View 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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -1,468 +1,483 @@
import React, { useEffect, useState } from 'react';
import { Button, Modal, Form, Input, Space, message, Switch, TreeSelect, Select, Tag, Dropdown } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, TeamOutlined, MoreOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useTableData } from '@/hooks/useTableData';
import * as service from './service';
import type { UserResponse, UserRequest, UserQuery, Role } from './types';
import type { DepartmentResponse } from '../Department/types';
import { getDepartmentTree } from '../Department/service';
import React, { useState, useEffect, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
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 {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
interface Column {
accessorKey?: keyof UserResponse;
id?: string;
header: string;
size: number;
cell?: (props: { row: { original: UserResponse } }) => React.ReactNode;
}
interface TreeNode {
title: string;
value: number;
children?: TreeNode[];
}
Loader2, Plus, Search, Edit, Trash2, KeyRound, Users as UsersIcon,
UserCheck, UserX, Activity
} from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { getUsers, deleteUser, resetPassword, assignRoles, getAllRoles } from './service';
import { getDepartmentTree } from '../Department/service';
import type { UserResponse, UserQuery, Role } from './types';
import type { DepartmentResponse } from '../Department/types';
import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import EditModal from './components/EditModal';
import ResetPasswordDialog from './components/ResetPasswordDialog';
import AssignRolesDialog from './components/AssignRolesDialog';
import DeleteDialog from './components/DeleteDialog';
import dayjs from 'dayjs';
/**
*
*/
const UserPage: React.FC = () => {
const [form] = Form.useForm();
const [passwordForm] = Form.useForm();
const [modalVisible, setModalVisible] = useState(false);
const [resetPasswordModalVisible, setResetPasswordModalVisible] = useState(false);
const [editingUser, setEditingUser] = useState<UserResponse | null>(null);
const [departments, setDepartments] = useState<DepartmentResponse[]>([]);
const [roleModalVisible, setRoleModalVisible] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserResponse | null>(null);
const [selectedRoles, setSelectedRoles] = useState<number[]>([]);
const [allRoles, setAllRoles] = useState<Role[]>([]);
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Page<UserResponse> | null>(null);
const [departments, setDepartments] = useState<DepartmentResponse[]>([]);
const [allRoles, setAllRoles] = useState<Role[]>([]);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<UserResponse>();
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
const [resetPasswordRecord, setResetPasswordRecord] = useState<UserResponse | null>(null);
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,
loading,
pagination,
handleTableChange,
handleCreate,
handleUpdate,
handleDelete,
refresh
} = useTableData<UserResponse, UserQuery, UserRequest, UserRequest>({
service: {
list: service.getUsers,
create: service.createUser,
update: service.updateUser,
delete: service.deleteUser
},
defaultParams: {
sortField: 'createTime',
sortOrder: 'desc'
},
config: {
message: {
createSuccess: '创建用户成功',
updateSuccess: '更新用户成功',
deleteSuccess: '删除用户成功'
}
}
// 加载数据
const loadData = async () => {
setLoading(true);
try {
const result = await getUsers(query);
setData(result);
} catch (error) {
console.error('加载用户列表失败:', error);
} finally {
setLoading(false);
}
};
// 加载部门和角色
const loadDepartmentsAndRoles = async () => {
try {
const [deptData, roleData] = await Promise.all([
getDepartmentTree(),
getAllRoles()
]);
setDepartments(deptData);
setAllRoles(roleData);
} catch (error) {
console.error('加载部门和角色失败:', error);
}
};
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));
loadDepartmentTree();
}, []);
// 新建
const handleCreate = () => {
setEditRecord(undefined);
setEditModalOpen(true);
};
const loadDepartmentTree = async () => {
try {
const data = await getDepartmentTree();
setDepartments(data);
} catch (error) {
message.error('加载部门数据失败');
}
};
// 编辑
const handleEdit = (record: UserResponse) => {
setEditRecord(record);
setEditModalOpen(true);
};
const getTreeData = (departments: DepartmentResponse[]): TreeNode[] => {
return departments.map(dept => ({
title: dept.name,
value: dept.id,
children: dept.children ? getTreeData(dept.children) : undefined
}));
};
// 重置密码
const handleResetPassword = (record: UserResponse) => {
setResetPasswordRecord(record);
setResetPasswordDialogOpen(true);
};
const handleAdd = () => {
setEditingUser(null);
form.resetFields();
form.setFieldsValue({
enabled: true
});
setModalVisible(true);
};
const confirmResetPassword = async (password: string) => {
if (!resetPasswordRecord) return;
try {
await resetPassword(resetPasswordRecord.id, password);
toast({
title: '重置成功',
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);
form.setFieldsValue({
...record,
password: undefined // 不显示密码
});
setModalVisible(true);
};
// 分配角色
const handleAssignRoles = (record: UserResponse) => {
setAssignRolesRecord(record);
setAssignRolesDialogOpen(true);
};
const handleResetPassword = (record: UserResponse) => {
setEditingUser(record);
passwordForm.resetFields();
setResetPasswordModalVisible(true);
};
const confirmAssignRoles = async (roleIds: number[]) => {
if (!assignRolesRecord) return;
try {
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 values = await passwordForm.validateFields();
if (editingUser) {
await service.resetPassword(editingUser.id, values.password);
message.success('密码重置成功');
setResetPasswordModalVisible(false);
refresh();
}
} catch (error: any) {
message.error(error.message || '密码重置失败');
}
};
// 删除
const handleDelete = (record: UserResponse) => {
setDeleteRecord(record);
setDeleteDialogOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
if (editingUser) {
await handleUpdate(editingUser.id, values);
} else {
await handleCreate(values);
}
setModalVisible(false);
refresh();
} catch (error: any) {
message.error(error.message || '操作失败');
}
};
const confirmDelete = async () => {
if (!deleteRecord) return;
try {
await deleteUser(deleteRecord.id);
toast({
title: '删除成功',
description: `用户 "${deleteRecord.username}" 已删除`,
});
loadData();
setDeleteDialogOpen(false);
setDeleteRecord(null);
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
const handleAssignRoles = (record: UserResponse) => {
setSelectedUser(record);
setSelectedRoles(record.roles?.map(role => role.id) || []);
setRoleModalVisible(true);
};
const handleAssignRoleSubmit = async () => {
if (selectedUser) {
try {
await service.assignRoles(selectedUser.id, selectedRoles);
message.success('角色分配成功');
setRoleModalVisible(false);
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 getStatusBadge = (enabled: boolean) => {
return enabled ? (
<Badge variant="success" className="inline-flex items-center gap-1">
<UserCheck className="h-3 w-3" />
</Badge>
) : (
<Badge variant="secondary" className="inline-flex items-center gap-1">
<UserX className="h-3 w-3" />
</Badge>
);
};
// 统计数据
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;

View File

@ -1,7 +1,6 @@
import request from '@/utils/request';
import type { UserResponse, UserRequest, UserQuery } from './types';
import {Page} from "@/types/base.ts";
import {RoleResponse} from "@/pages/System/Role/types";
import type { UserResponse, UserRequest, UserQuery, Role } from './types';
import { Page } from '@/types/base';
const BASE_URL = '/api/v1/user';
const ROLE_BASE_URL = '/api/v1/role';
@ -32,4 +31,4 @@ export const assignRoles = (userId: number, roleIds: number[]) =>
// 获取所有角色列表(不分页)
export const getAllRoles = () =>
request.get<RoleResponse[]>(`${ROLE_BASE_URL}/list`);
request.get<Role[]>(`${ROLE_BASE_URL}/list`);

View File

@ -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;

View File

@ -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;

View File

@ -6,513 +6,482 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { DataTablePagination } from '@/components/ui/pagination';
import {
Loader2, Plus, Search, Edit, Trash2, Play, CheckCircle2,
Clock, Activity, Workflow, MoreHorizontal, AlertCircle, Eye
Clock, Activity, Workflow, Eye, Pencil
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useToast } from '@/components/ui/use-toast';
import * as service from './service';
import { getDefinitions, getWorkflowCategoryList, deleteDefinition, publishDefinition, startWorkflowInstance } from './service';
import type { WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategoryResponse } from './types';
import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import EditModal from './components/EditModal';
import DeleteDialog from './components/DeleteDialog';
import DeployDialog from './components/DeployDialog';
/**
*
*/
const WorkflowDefinitionList: React.FC = () => {
const navigate = useNavigate();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [pageData, setPageData] = useState<{
content: WorkflowDefinition[];
totalElements: number;
size: number;
number: number;
} | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<WorkflowDefinition>();
const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null);
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
const [deployRecord, setDeployRecord] = useState<WorkflowDefinition | null>(null);
const [query, setQuery] = useState<WorkflowDefinitionQuery>({
pageNum: DEFAULT_CURRENT - 1,
pageSize: DEFAULT_PAGE_SIZE,
name: '',
categoryId: undefined,
status: undefined
const navigate = useNavigate();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Page<WorkflowDefinition> | null>(null);
const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editRecord, setEditRecord] = useState<WorkflowDefinition>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null);
const [deployDialogOpen, setDeployDialogOpen] = useState(false);
const [deployRecord, setDeployRecord] = useState<WorkflowDefinition | null>(null);
const [query, setQuery] = useState<WorkflowDefinitionQuery>({
pageNum: DEFAULT_CURRENT - 1,
pageSize: DEFAULT_PAGE_SIZE,
name: '',
categoryId: undefined,
status: undefined
});
// 加载数据
const loadData = async () => {
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);
try {
const data = await service.getDefinitions(params);
setPageData(data);
} catch (error) {
if (error instanceof Error) {
toast({
title: '加载失败',
description: error.message,
variant: 'destructive'
});
}
} finally {
setLoading(false);
}
// 新建
const handleCreate = () => {
setEditRecord(undefined);
setEditModalVisible(true);
};
// 编辑
const handleEdit = (record: WorkflowDefinition) => {
setEditRecord(record);
setEditModalVisible(true);
};
// 设计
const handleDesign = (record: WorkflowDefinition) => {
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 loadCategories = async () => {
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;
const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
const Icon = statusInfo.icon;
return (
<div className="p-6">
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground"></h1>
</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={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>
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{statusInfo.text}
</Badge>
);
};
// 统计数据
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;

View File

@ -1,137 +1,322 @@
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Space, Empty } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useState, useEffect, useMemo } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
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 { WorkflowTemplateWithInstances } from './types';
import { Page } from '@/types/base';
// DetailModal 暂时移除,因为数据结构不匹配 (需要 WorkflowHistoricalInstance 而不是 WorkflowTemplateWithInstances)
import type { WorkflowTemplateWithInstances } from './types';
import type { Page } from '@/types/base';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import HistoryModal from './components/HistoryModal';
import dayjs from 'dayjs';
/**
*
*/
const WorkflowInstanceList: React.FC = () => {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Page<WorkflowTemplateWithInstances> | null>(null);
const [query, setQuery] = useState({
current: 1,
pageSize: 10,
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Page<WorkflowTemplateWithInstances> | null>(null);
const [historyVisible, setHistoryVisible] = useState(false);
const [selectedWorkflowDefinitionId, setSelectedWorkflowDefinitionId] = useState<number>();
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);
try {
const result = await getWorkflowInstances(params);
setData(result);
} catch (error) {
console.error('加载流程实例失败:', error);
} finally {
setLoading(false);
}
// 查看历史
const handleViewHistory = (record: WorkflowTemplateWithInstances) => {
setSelectedWorkflowDefinitionId(record.id);
setHistoryVisible(true);
};
// 终止流程TODO需要后端接口
const handleTerminate = async (record: WorkflowTemplateWithInstances) => {
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 },
};
useEffect(() => {
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,
});
};
const statusInfo = statusMap[status] || { variant: 'outline', text: status || '未知', icon: Clock };
const Icon = statusInfo.icon;
return (
<Card title="流程实例">
<Table
columns={columns}
dataSource={data?.content || []}
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>
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" />
{statusInfo.text}
</Badge>
);
};
// 统计数据
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;

View File

@ -1,5 +1,8 @@
import { BaseQuery } from '@/types/base';
/**
*
*/
export interface WorkflowTemplateWithInstances {
id: number;
name: string;
@ -8,10 +11,17 @@ export interface WorkflowTemplateWithInstances {
lastExecutionStatus: string;
}
export interface WorkflowTemplateWithInstancesQuery {
/**
*
*/
export interface WorkflowTemplateWithInstancesQuery extends BaseQuery {
businessKey?: string;
status?: string;
}
/**
*
*/
export interface WorkflowInstanceStage {
id: number | null;
nodeId: string;
@ -22,6 +32,9 @@ export interface WorkflowInstanceStage {
endTime: string | null;
}
/**
*
*/
export interface WorkflowGraphNode {
id: string;
nodeCode: string;
@ -36,6 +49,9 @@ export interface WorkflowGraphNode {
outputs: any[];
}
/**
*
*/
export interface WorkflowGraphEdge {
id: string;
from: string;
@ -51,11 +67,17 @@ export interface WorkflowGraphEdge {
vertices: any[];
}
/**
*
*/
export interface WorkflowGraph {
nodes: WorkflowGraphNode[];
edges: WorkflowGraphEdge[];
}
/**
*
*/
export interface WorkflowHistoricalInstance {
id: number;
processInstanceId: string;
@ -68,6 +90,9 @@ export interface WorkflowHistoricalInstance {
graph?: WorkflowGraph;
}
/**
*
*/
export interface WorkflowHistoricalInstanceQuery extends BaseQuery {
workflowDefinitionId?: number;
}