增加审批组件

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,466 +1,481 @@
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 [editingUser, setEditingUser] = useState<UserResponse | null>(null);
const [departments, setDepartments] = useState<DepartmentResponse[]>([]); 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 [allRoles, setAllRoles] = useState<Role[]>([]);
const [editModalOpen, setEditModalOpen] = useState(false);
const { const [editRecord, setEditRecord] = useState<UserResponse>();
list, const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
loading, const [resetPasswordRecord, setResetPasswordRecord] = useState<UserResponse | null>(null);
pagination, const [assignRolesDialogOpen, setAssignRolesDialogOpen] = useState(false);
handleTableChange, const [assignRolesRecord, setAssignRolesRecord] = useState<UserResponse | null>(null);
handleCreate, const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
handleUpdate, const [deleteRecord, setDeleteRecord] = useState<UserResponse | null>(null);
handleDelete, const [query, setQuery] = useState<UserQuery>({
refresh pageNum: DEFAULT_CURRENT - 1,
} = useTableData<UserResponse, UserQuery, UserRequest, UserRequest>({ pageSize: DEFAULT_PAGE_SIZE,
service: { username: '',
list: service.getUsers, email: '',
create: service.createUser, enabled: undefined,
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(() => { useEffect(() => {
service.getAllRoles().then(roles => setAllRoles(roles)); loadDepartmentsAndRoles();
loadDepartmentTree();
}, []); }, []);
const loadDepartmentTree = async () => { useEffect(() => {
try { loadData();
const data = await getDepartmentTree(); }, [query]);
setDepartments(data);
} catch (error) { // 搜索
message.error('加载部门数据失败'); const handleSearch = () => {
} setQuery(prev => ({ ...prev, pageNum: 0 }));
}; };
const getTreeData = (departments: DepartmentResponse[]): TreeNode[] => { // 重置
return departments.map(dept => ({ const handleReset = () => {
title: dept.name, setQuery({
value: dept.id, pageNum: 0,
children: dept.children ? getTreeData(dept.children) : undefined pageSize: DEFAULT_PAGE_SIZE,
})); username: '',
}; email: '',
enabled: undefined,
const handleAdd = () => {
setEditingUser(null);
form.resetFields();
form.setFieldsValue({
enabled: true
}); });
setModalVisible(true);
}; };
// 新建
const handleCreate = () => {
setEditRecord(undefined);
setEditModalOpen(true);
};
// 编辑
const handleEdit = (record: UserResponse) => { const handleEdit = (record: UserResponse) => {
setEditingUser(record); setEditRecord(record);
form.setFieldsValue({ setEditModalOpen(true);
...record,
password: undefined // 不显示密码
});
setModalVisible(true);
}; };
// 重置密码
const handleResetPassword = (record: UserResponse) => { const handleResetPassword = (record: UserResponse) => {
setEditingUser(record); setResetPasswordRecord(record);
passwordForm.resetFields(); setResetPasswordDialogOpen(true);
setResetPasswordModalVisible(true);
}; };
const handleResetPasswordSubmit = async () => { const confirmResetPassword = async (password: string) => {
if (!resetPasswordRecord) return;
try { try {
const values = await passwordForm.validateFields(); await resetPassword(resetPasswordRecord.id, password);
if (editingUser) { toast({
await service.resetPassword(editingUser.id, values.password); title: '重置成功',
message.success('密码重置成功'); description: `用户 "${resetPasswordRecord.username}" 的密码已重置`,
setResetPasswordModalVisible(false); });
refresh(); setResetPasswordDialogOpen(false);
} setResetPasswordRecord(null);
} catch (error: any) {
message.error(error.message || '密码重置失败');
}
};
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 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) { } catch (error) {
message.error('角色分配失败'); console.error('重置密码失败:', error);
} toast({
} title: '重置失败',
}; description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
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> const handleAssignRoles = (record: UserResponse) => {
<Button setAssignRolesRecord(record);
type="link" setAssignRolesDialogOpen(true);
icon={<EditOutlined />} };
onClick={() => handleEdit(record)}
> const confirmAssignRoles = async (roleIds: number[]) => {
if (!assignRolesRecord) return;
</Button> try {
<Dropdown menu={{ items }} trigger={['click']}> await assignRoles(assignRolesRecord.id, roleIds);
<Button type="link" icon={<MoreOutlined />} /> toast({
</Dropdown> title: '分配成功',
</Space> description: `已为用户 "${assignRolesRecord.username}" 分配角色`,
});
loadData();
setAssignRolesDialogOpen(false);
setAssignRolesRecord(null);
} catch (error) {
console.error('分配角色失败:', error);
toast({
title: '分配失败',
description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive'
});
}
};
// 删除
const handleDelete = (record: UserResponse) => {
setDeleteRecord(record);
setDeleteDialogOpen(true);
};
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 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 ( return (
<div className="space-y-4"> <div className="p-6">
<div className="flex justify-between items-center"> <div className="mb-6">
<Button <h1 className="text-3xl font-bold text-foreground"></h1>
type="primary" <p className="text-muted-foreground mt-2">
icon={<PlusOutlined />}
onClick={handleAdd} </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> </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>
<div className="rounded-md border bg-white"> {/* 表格 */}
<div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{columns.map((column) => ( <TableHead className="w-[120px]"></TableHead>
<TableHead <TableHead className="w-[150px]"></TableHead>
key={column.accessorKey || column.id} <TableHead className="w-[200px]"></TableHead>
style={{ width: column.size }} <TableHead className="w-[120px]"></TableHead>
> <TableHead className="w-[120px]"></TableHead>
{column.header} <TableHead className="w-[150px]"></TableHead>
</TableHead> <TableHead className="w-[100px]"></TableHead>
))} <TableHead className="w-[180px]"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{list.map((row) => ( {loading ? (
<TableRow key={row.id}> <TableRow>
{columns.map((column) => ( <TableCell colSpan={9} className="h-24 text-center">
<TableCell key={column.accessorKey || column.id}> <div className="flex items-center justify-center">
{column.cell <Loader2 className="h-6 w-6 animate-spin mr-2" />
? column.cell({ row: { original: row } }) <span className="text-sm text-muted-foreground">...</span>
: column.accessorKey </div>
? String(row[column.accessorKey])
: null}
</TableCell> </TableCell>
))}
</TableRow> </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> </TableBody>
</Table> </Table>
</div> </div>
<Modal {/* 分页 */}
title={editingUser ? '编辑用户' : '新增用户'} {pageCount > 1 && (
open={modalVisible} <DataTablePagination
onOk={handleSubmit} pageIndex={(query.pageNum || 0) + 1}
onCancel={() => setModalVisible(false)} pageSize={query.pageSize || DEFAULT_PAGE_SIZE}
width={600} pageCount={pageCount}
> onPageChange={(page) => setQuery(prev => ({
<Form ...prev,
form={form} pageNum: page - 1
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> )}
</CardContent>
</Card>
<Form.Item {/* 编辑弹窗 */}
name="enabled" <EditModal
label="状态" open={editModalOpen}
valuePropName="checked" record={editRecord}
initialValue={true} departments={departments}
> onOpenChange={setEditModalOpen}
<Switch checkedChildren="启用" unCheckedChildren="禁用" /> onSuccess={loadData}
</Form.Item> />
</Form>
</Modal>
<Modal {/* 重置密码对话框 */}
title="重置密码" <ResetPasswordDialog
open={resetPasswordModalVisible} open={resetPasswordDialogOpen}
onOk={handleResetPasswordSubmit} record={resetPasswordRecord}
onCancel={() => setResetPasswordModalVisible(false)} onOpenChange={setResetPasswordDialogOpen}
> onConfirm={confirmResetPassword}
<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" <AssignRolesDialog
label="确认密码" open={assignRolesDialogOpen}
dependencies={['password']} record={assignRolesRecord}
rules={[ allRoles={allRoles}
{ required: true, message: '请确认新密码' }, onOpenChange={setAssignRolesDialogOpen}
({ getFieldValue }) => ({ onConfirm={confirmAssignRoles}
validator(_, value) { />
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password placeholder="请确认新密码" />
</Form.Item>
</Form>
</Modal>
<Modal {/* 删除确认对话框 */}
title="分配角色" <DeleteDialog
open={roleModalVisible} open={deleteDialogOpen}
onOk={handleAssignRoleSubmit} record={deleteRecord}
onCancel={() => setRoleModalVisible(false)} onOpenChange={setDeleteDialogOpen}
width={600} onConfirm={confirmDelete}
> />
<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> </div>
); );
}; };

View File

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

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,38 +6,31 @@ 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[];
totalElements: number;
size: number;
number: number;
} | null>(null);
const [modalVisible, setModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<WorkflowDefinition>();
const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]); const [categories, setCategories] = useState<WorkflowCategoryResponse[]>([]);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editRecord, setEditRecord] = useState<WorkflowDefinition>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null); const [deleteRecord, setDeleteRecord] = useState<WorkflowDefinition | null>(null);
const [deployDialogOpen, setDeployDialogOpen] = useState(false); const [deployDialogOpen, setDeployDialogOpen] = useState(false);
@ -50,30 +43,26 @@ const WorkflowDefinitionList: React.FC = () => {
status: undefined status: undefined
}); });
const loadData = async (params: WorkflowDefinitionQuery) => { // 加载数据
const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const data = await service.getDefinitions(params); const result = await getDefinitions(query);
setPageData(data); setData(result);
} catch (error) { } catch (error) {
if (error instanceof Error) { console.error('加载工作流定义失败:', error);
toast({
title: '加载失败',
description: error.message,
variant: 'destructive'
});
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 加载分类
const loadCategories = async () => { const loadCategories = async () => {
try { try {
const data = await service.getWorkflowCategoryList(); const result = await getWorkflowCategoryList();
setCategories(data); setCategories(result || []);
} catch (error) { } catch (error) {
console.error('加载工作流分类失败:', error); console.error('加载分类失败:', error);
} }
}; };
@ -82,35 +71,15 @@ const WorkflowDefinitionList: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
loadData(query); 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 = () => { const handleSearch = () => {
setQuery(prev => ({ setQuery(prev => ({ ...prev, pageNum: 0 }));
...prev,
pageNum: 0,
}));
}; };
// 重置
const handleReset = () => { const handleReset = () => {
setQuery({ setQuery({
pageNum: 0, pageNum: 0,
@ -121,6 +90,24 @@ const WorkflowDefinitionList: React.FC = () => {
}); });
}; };
// 新建
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) => { const handleDeploy = (record: WorkflowDefinition) => {
setDeployRecord(record); setDeployRecord(record);
setDeployDialogOpen(true); setDeployDialogOpen(true);
@ -129,24 +116,25 @@ const WorkflowDefinitionList: React.FC = () => {
const confirmDeploy = async () => { const confirmDeploy = async () => {
if (!deployRecord) return; if (!deployRecord) return;
try { try {
await service.publishDefinition(deployRecord.id); await publishDefinition(deployRecord.id);
toast({ toast({
title: '发布成功', title: '发布成功',
description: `工作流 "${deployRecord.name}" 已发布`, description: `工作流 "${deployRecord.name}" 已发布`,
}); });
loadData(query); loadData();
setDeployDialogOpen(false); setDeployDialogOpen(false);
setDeployRecord(null);
} catch (error) { } catch (error) {
if (error instanceof Error) { console.error('发布失败:', error);
toast({ toast({
title: '发布失败', title: '发布失败',
description: error.message, description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive' variant: 'destructive'
}); });
} }
}
}; };
// 删除
const handleDelete = (record: WorkflowDefinition) => { const handleDelete = (record: WorkflowDefinition) => {
setDeleteRecord(record); setDeleteRecord(record);
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
@ -155,40 +143,40 @@ const WorkflowDefinitionList: React.FC = () => {
const confirmDelete = async () => { const confirmDelete = async () => {
if (!deleteRecord) return; if (!deleteRecord) return;
try { try {
await service.deleteDefinition(deleteRecord.id); await deleteDefinition(deleteRecord.id);
toast({ toast({
title: '删除成功', title: '删除成功',
description: `工作流 "${deleteRecord.name}" 已删除`, description: `工作流 "${deleteRecord.name}" 已删除`,
}); });
loadData(query); loadData();
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setDeleteRecord(null);
} catch (error) { } catch (error) {
if (error instanceof Error) { console.error('删除失败:', error);
toast({ toast({
title: '删除失败', title: '删除失败',
description: error.message, description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive' variant: 'destructive'
}); });
} }
}
}; };
const handleStartFlow = async (record: WorkflowDefinition) => { // 启动
const handleStart = async (record: WorkflowDefinition) => {
try { try {
await service.startWorkflowInstance(record.key, record.categoryId); await startWorkflowInstance(record.key, record.categoryId);
toast({ toast({
title: '启动成功', title: '启动成功',
description: `工作流 "${record.name}" 已启动`, description: `工作流 "${record.name}" 已启动`,
}); });
} catch (error) { } catch (error) {
if (error instanceof Error) { console.error('启动失败:', error);
toast({ toast({
title: '启动失败', title: '启动失败',
description: error.message, description: error instanceof Error ? error.message : '未知错误',
variant: 'destructive' variant: 'destructive'
}); });
} }
}
}; };
// 状态徽章 // 状态徽章
@ -200,11 +188,12 @@ const WorkflowDefinitionList: React.FC = () => {
}> = { }> = {
DRAFT: { variant: 'outline', text: '草稿', icon: Clock }, DRAFT: { variant: 'outline', text: '草稿', icon: Clock },
PUBLISHED: { variant: 'success', text: '已发布', icon: CheckCircle2 }, PUBLISHED: { variant: 'success', text: '已发布', icon: CheckCircle2 },
DISABLED: { variant: 'secondary', text: '已停用', icon: Clock },
}; };
const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock }; const statusInfo = statusMap[status] || { variant: 'outline', text: status, icon: Clock };
const Icon = statusInfo.icon; const Icon = statusInfo.icon;
return ( return (
<Badge variant={statusInfo.variant} className="flex items-center gap-1"> <Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
<Icon className="h-3 w-3" /> <Icon className="h-3 w-3" />
{statusInfo.text} {statusInfo.text}
</Badge> </Badge>
@ -213,18 +202,21 @@ const WorkflowDefinitionList: React.FC = () => {
// 统计数据 // 统计数据
const stats = useMemo(() => { const stats = useMemo(() => {
const total = pageData?.totalElements || 0; const total = data?.totalElements || 0;
const draftCount = pageData?.content?.filter(d => d.status === 'DRAFT').length || 0; const draftCount = data?.content?.filter(d => d.status === 'DRAFT').length || 0;
const publishedCount = pageData?.content?.filter(d => d.status !== 'DRAFT').length || 0; const publishedCount = data?.content?.filter(d => d.status === 'PUBLISHED').length || 0;
return { total, draftCount, publishedCount }; return { total, draftCount, publishedCount };
}, [pageData]); }, [data]);
const pageCount = pageData?.totalElements ? Math.ceil(pageData.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0; const pageCount = data?.totalElements ? Math.ceil(data.totalElements / (query.pageSize || DEFAULT_PAGE_SIZE)) : 0;
return ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-3xl font-bold text-foreground"></h1> <h1 className="text-3xl font-bold text-foreground"></h1>
<p className="text-muted-foreground mt-2">
线
</p>
</div> </div>
{/* 统计卡片 */} {/* 统计卡片 */}
@ -264,7 +256,7 @@ const WorkflowDefinitionList: React.FC = () => {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle> <CardTitle></CardTitle>
<Button onClick={handleCreateFlow}> <Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
</Button> </Button>
@ -324,9 +316,9 @@ const WorkflowDefinitionList: React.FC = () => {
<TableRow> <TableRow>
<TableHead className="w-[200px]"></TableHead> <TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[120px]"></TableHead> <TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead> <TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead> <TableHead className="w-[120px]"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="text-right"></TableHead> <TableHead className="text-right"></TableHead>
</TableRow> </TableRow>
@ -341,9 +333,10 @@ const WorkflowDefinitionList: React.FC = () => {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : pageData?.content && pageData.content.length > 0 ? ( ) : data?.content && data.content.length > 0 ? (
pageData.content.map((record) => { data.content.map((record) => {
const categoryInfo = categories.find(c => c.id === record.categoryId); const categoryInfo = categories.find(c => c.id === record.categoryId);
const isDraft = record.status === 'DRAFT';
return ( return (
<TableRow key={record.id} className="hover:bg-muted/50"> <TableRow key={record.id} className="hover:bg-muted/50">
<TableCell className="font-medium">{record.name}</TableCell> <TableCell className="font-medium">{record.name}</TableCell>
@ -354,9 +347,7 @@ const WorkflowDefinitionList: React.FC = () => {
</TableCell> </TableCell>
<TableCell> <TableCell>
{categoryInfo ? ( {categoryInfo ? (
<Badge variant="outline"> <Badge variant="outline">{categoryInfo.name}</Badge>
{categoryInfo.name}
</Badge>
) : ( ) : (
<Badge variant="outline"></Badge> <Badge variant="outline"></Badge>
)} )}
@ -369,46 +360,66 @@ const WorkflowDefinitionList: React.FC = () => {
<span className="text-sm line-clamp-1">{record.description || '-'}</span> <span className="text-sm line-clamp-1">{record.description || '-'}</span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<DropdownMenu> <div className="flex justify-end gap-2">
<DropdownMenuTrigger asChild> {isDraft ? (
<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)}> <Button
<Edit className="h-4 w-4 mr-2" /> variant="ghost"
</DropdownMenuItem> size="sm"
<DropdownMenuItem onClick={() => handleDesignFlow(record)}> onClick={() => handleEdit(record)}
<Workflow className="h-4 w-4 mr-2" /> title="编辑"
</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" /> <Edit className="h-4 w-4" />
</DropdownMenuItem> </Button>
</DropdownMenuContent> <Button
</DropdownMenu> 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> </TableCell>
</TableRow> </TableRow>
); );
@ -443,74 +454,32 @@ const WorkflowDefinitionList: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
{/* EditModal */} {/* 编辑弹窗 */}
<EditModal <EditModal
visible={modalVisible} visible={editModalVisible}
onClose={handleModalClose} onClose={() => {
onSuccess={() => loadData(query)} setEditModalVisible(false);
record={currentRecord} setEditRecord(undefined);
}}
onSuccess={loadData}
record={editRecord}
/> />
{/* 发布确认对话框 */} {/* 发布确认对话框 */}
{deployRecord && ( <DeployDialog
<Dialog open={deployDialogOpen} onOpenChange={setDeployDialogOpen}> open={deployDialogOpen}
<DialogContent> record={deployRecord}
<DialogHeader> onOpenChange={setDeployDialogOpen}
<DialogTitle className="flex items-center gap-2 text-green-600"> onConfirm={confirmDeploy}
<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 && ( <DeleteDialog
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> open={deleteDialogOpen}
<DialogContent> record={deleteRecord}
<DialogHeader> onOpenChange={setDeleteDialogOpen}
<DialogTitle className="flex items-center gap-2 text-red-600"> onConfirm={confirmDelete}
<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> </div>
); );
}; };

View File

@ -1,29 +1,42 @@
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({
current: 1,
pageSize: 10,
});
// 移除未使用的详情弹窗相关状态
// const [detailVisible, setDetailVisible] = useState(false);
// const [selectedInstance, setSelectedInstance] = useState<WorkflowTemplateWithInstances>();
const [historyVisible, setHistoryVisible] = useState(false); const [historyVisible, setHistoryVisible] = useState(false);
const [selectedWorkflowDefinitionId, setSelectedWorkflowDefinitionId] = useState<number>(); 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 (params: any) => { // 加载数据
const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const result = await getWorkflowInstances(params); const result = await getWorkflowInstances(query);
setData(result); setData(result);
} catch (error) { } catch (error) {
console.error('加载流程实例失败:', error); console.error('加载流程实例失败:', error);
@ -33,104 +46,276 @@ const WorkflowInstanceList: React.FC = () => {
}; };
useEffect(() => { useEffect(() => {
loadData(query); loadData();
}, [query]); }, [query]);
// 搜索
const handleSearch = () => {
setQuery(prev => ({
...prev,
pageNum: 0,
}));
};
// 重置搜索
const handleReset = () => {
setQuery({
pageNum: 0,
pageSize: DEFAULT_PAGE_SIZE,
businessKey: '',
status: undefined,
});
};
// 查看历史
const handleViewHistory = (record: WorkflowTemplateWithInstances) => { const handleViewHistory = (record: WorkflowTemplateWithInstances) => {
setSelectedWorkflowDefinitionId(record.id); setSelectedWorkflowDefinitionId(record.id);
setHistoryVisible(true); setHistoryVisible(true);
}; };
const columns: ColumnsType<WorkflowTemplateWithInstances> = [ // 终止流程TODO需要后端接口
{ const handleTerminate = async (record: WorkflowTemplateWithInstances) => {
title: '流程名称', if (!confirm(`确定要终止工作流 "${record.name}" 吗?`)) return;
dataIndex: 'name', console.log('终止流程', record);
key: 'name', // TODO: 调用终止接口
},
{
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({ const getStatusBadge = (status: string) => {
...query, const statusMap: Record<string, {
current: pagination.current, variant: 'default' | 'secondary' | 'destructive' | 'success' | 'outline';
pageSize: pagination.pageSize, 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 };
const Icon = statusInfo.icon;
return (
<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 ( return (
<Card title="流程实例"> <div className="p-6">
<Table <div className="mb-6">
columns={columns} <h1 className="text-3xl font-bold text-foreground"></h1>
dataSource={data?.content || []} <p className="text-muted-foreground mt-2">
loading={loading}
rowKey="id" </p>
scroll={{ x: 1200 }} </div>
locale={{
emptyText: <Empty description="暂无数据" /> {/* 统计卡片 */}
}} <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
pagination={{ <Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
current: query.current, <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
pageSize: query.pageSize, <CardTitle className="text-sm font-medium text-blue-700"></CardTitle>
total: data?.totalElements || 0, <Activity className="h-4 w-4 text-blue-500" />
showSizeChanger: true, </CardHeader>
showQuickJumper: true, <CardContent>
}} <div className="text-2xl font-bold">{stats.total}</div>
onChange={handleTableChange} <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"
/> />
{/* DetailModal 暂时移除,因为数据结构不匹配 */} </div>
{/* <DetailModal <Select
visible={detailVisible} value={query.status || undefined}
onCancel={() => setDetailVisible(false)} onValueChange={(value) => setQuery(prev => ({ ...prev, status: value }))}
instanceData={selectedInstance} >
/> */} <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 <HistoryModal
visible={historyVisible} visible={historyVisible}
onCancel={() => setHistoryVisible(false)} onCancel={() => setHistoryVisible(false)}
workflowDefinitionId={selectedWorkflowDefinitionId} workflowDefinitionId={selectedWorkflowDefinitionId}
/> />
</Card> </div>
); );
}; };

View File

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