生产管理系统 - 员工管理列表联调
This commit is contained in:
@@ -108,6 +108,9 @@ export interface AuditRecord {
|
||||
*/
|
||||
export async function fetchAuditLogs(params: AuditLogsQueryParams = {}): Promise<AuditLogsApiResponse> {
|
||||
try {
|
||||
// 调用计数器
|
||||
console.log(`[API] fetchAuditLogs 调用次数: ${++fetchAuditLogs.callCount || (fetchAuditLogs.callCount = 1)}`, params);
|
||||
|
||||
// 构建查询参数对象
|
||||
const queryParams: any = {};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useReducer, useEffect, useMemo, useState } from 'react';
|
||||
import { useReducer, useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -128,15 +128,16 @@ const initialState: AuditHistoryState = {
|
||||
|
||||
export default function AuditHistoryPage() {
|
||||
const [state, dispatch] = useReducer(auditHistoryReducer, initialState);
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
// 加载审核历史数据
|
||||
const loadAuditHistory = async (resetPage = false) => {
|
||||
const loadAuditHistory = useCallback(async () => {
|
||||
try {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
const params: AuditLogsQueryParams = {
|
||||
search: state.filters.search_keyword || undefined,
|
||||
page: resetPage ? 1 : state.pagination.page,
|
||||
page: state.pagination.page,
|
||||
size: state.pagination.size
|
||||
};
|
||||
|
||||
@@ -164,7 +165,7 @@ export default function AuditHistoryPage() {
|
||||
payload: error instanceof Error ? error.message : '加载审核历史失败'
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []); // 空依赖数组,函数内部使用最新状态
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (value: string) => {
|
||||
@@ -212,28 +213,33 @@ export default function AuditHistoryPage() {
|
||||
// 刷新数据
|
||||
const handleRefresh = () => {
|
||||
dispatch({ type: 'REFRESH_DATA' });
|
||||
loadAuditHistory(true);
|
||||
dispatch({ type: 'SET_PAGINATION', payload: { page: 1 } });
|
||||
toast.success('数据已刷新');
|
||||
};
|
||||
|
||||
// 初始化和监听器
|
||||
// 合并所有状态变化,统一处理数据加载
|
||||
useEffect(() => {
|
||||
loadAuditHistory();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadAuditHistory();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [state.filters.search_keyword, state.filters.typeFilter, state.filters.resultFilter, state.filters.dateRange, state.sortBy, state.sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.pagination.page > 1) {
|
||||
if (isFirstLoad.current) {
|
||||
// 首次加载
|
||||
isFirstLoad.current = false;
|
||||
loadAuditHistory();
|
||||
} else {
|
||||
// 后续状态变化,使用防抖
|
||||
const timer = setTimeout(() => {
|
||||
loadAuditHistory();
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.pagination.page]);
|
||||
}, [
|
||||
state.filters.search_keyword,
|
||||
state.filters.typeFilter,
|
||||
state.filters.resultFilter,
|
||||
state.filters.dateRange,
|
||||
state.sortBy,
|
||||
state.sortOrder,
|
||||
state.pagination.page,
|
||||
state.pagination.size
|
||||
]);
|
||||
|
||||
// 工具函数
|
||||
const getActionBadge = (action: string) => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useReducer, useEffect, useMemo } from 'react';
|
||||
import { useReducer, useEffect, useMemo, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Building2, RefreshCw } from 'lucide-react';
|
||||
|
||||
@@ -117,6 +117,7 @@ const initialState: AuditState = {
|
||||
|
||||
export default function EnterpriseAuditPage() {
|
||||
const [state, dispatch] = useReducer(auditReducer, initialState);
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
// 加载企业数据
|
||||
const loadEnterprises = async (resetPage = false) => {
|
||||
@@ -157,14 +158,27 @@ export default function EnterpriseAuditPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载
|
||||
// 首次加载数据
|
||||
useEffect(() => {
|
||||
loadEnterprises(true);
|
||||
if (isFirstLoad.current) {
|
||||
isFirstLoad.current = false;
|
||||
loadEnterprises(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 监听筛选和排序变化(排除首次加载)
|
||||
useEffect(() => {
|
||||
if (!isFirstLoad.current) {
|
||||
const timer = setTimeout(() => {
|
||||
loadEnterprises(true);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state.filters.search, state.filters.audit_status, state.sortBy, state.sortOrder]);
|
||||
|
||||
// 分页加载
|
||||
useEffect(() => {
|
||||
if (state.pagination.page > 1) {
|
||||
if (!isFirstLoad.current && state.pagination.page > 1) {
|
||||
loadEnterprises(false);
|
||||
}
|
||||
}, [state.pagination.page]);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
getUsersApiV1UsersGet,
|
||||
listSystemUsersApiV1UsersSystemUsersGet,
|
||||
} from "@/lib/api/sdk.gen";
|
||||
|
||||
// API返回的用户数据类型
|
||||
@@ -18,7 +18,8 @@ export interface UserData {
|
||||
username: string;
|
||||
full_name: string | null;
|
||||
phone: string | null;
|
||||
is_active: boolean;
|
||||
is_active?: boolean;
|
||||
status?: string;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
created_at: string;
|
||||
@@ -101,7 +102,7 @@ export async function fetchUsers(params: UsersQueryParams = {}): Promise<UsersAp
|
||||
const token = getAuthToken();
|
||||
console.log('用户管理API调用参数:', queryParams);
|
||||
|
||||
const response = await getUsersApiV1UsersGet({
|
||||
const response = await listSystemUsersApiV1UsersSystemUsersGet({
|
||||
query: {
|
||||
...queryParams,
|
||||
// 添加时间戳防止缓存
|
||||
@@ -147,7 +148,7 @@ export function transformUserData(user: UserData): User {
|
||||
email: user.email,
|
||||
fullName: user.full_name,
|
||||
phone: user.phone,
|
||||
isActive: user.is_active,
|
||||
isActive: user.is_active !== undefined ? user.is_active : user.status === 'active',
|
||||
isSuperuser: user.is_superuser,
|
||||
isVerified: user.is_verified,
|
||||
createdAt: formatDate(user.created_at),
|
||||
|
||||
@@ -106,23 +106,14 @@ export default function TenantUserManagementPage() {
|
||||
// 加载用户数据
|
||||
const loadUsers = async (resetPage = false) => {
|
||||
try {
|
||||
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
const params: UsersQueryParams = {
|
||||
search: state.filters.searchKeyword || undefined,
|
||||
page: resetPage ? 1 : state.pagination.page,
|
||||
size: state.pagination.size,
|
||||
order_by: state.sortBy,
|
||||
sort_order: state.sortOrder,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
// 根据状态筛选器设置 is_active 参数
|
||||
if (state.filters.statusFilter === 'active') {
|
||||
params.is_active = true;
|
||||
} else if (state.filters.statusFilter === 'inactive') {
|
||||
params.is_active = false;
|
||||
}
|
||||
|
||||
const response: UsersApiResponse = await fetchUsers(params);
|
||||
const transformedUsers = response.data.map(transformUserData);
|
||||
|
||||
@@ -150,15 +141,15 @@ export default function TenantUserManagementPage() {
|
||||
};
|
||||
|
||||
// 加载企业数据(这里暂时使用mock数据,后续可以添加企业API)
|
||||
const loadEnterprises = () => {
|
||||
// 这里可以添加企业API调用,现在使用mock数据
|
||||
const mockEnterprises: Enterprise[] = [
|
||||
{ id: 'ent-1', name: '丰收现代农业集团' },
|
||||
{ id: 'ent-2', name: '绿色种植科技有限公司' },
|
||||
{ id: 'ent-3', name: '智慧农业示范区' },
|
||||
];
|
||||
dispatch({ type: 'SET_ENTERPRISES', payload: mockEnterprises });
|
||||
};
|
||||
// const loadEnterprises = () => {
|
||||
// // 这里可以添加企业API调用,现在使用mock数据
|
||||
// const mockEnterprises: Enterprise[] = [
|
||||
// { id: 'ent-1', name: '丰收现代农业集团' },
|
||||
// { id: 'ent-2', name: '绿色种植科技有限公司' },
|
||||
// { id: 'ent-3', name: '智慧农业示范区' },
|
||||
// ];
|
||||
// dispatch({ type: 'SET_ENTERPRISES', payload: mockEnterprises });
|
||||
// };
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (value: string) => {
|
||||
@@ -262,18 +253,8 @@ export default function TenantUserManagementPage() {
|
||||
},
|
||||
];
|
||||
|
||||
// 初始化和监听器
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
loadEnterprises();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
loadUsers();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [state.filters.searchKeyword, state.filters.statusFilter, state.filters.typeFilter, state.sortBy, state.sortOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* filekorolheader: 部门删除对话框组件 - 部门删除确认界面
|
||||
* 功能:删除确认、影响说明、操作处理
|
||||
* 路径:/central-config/user/department/components/DepartmentDeleteDialog
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用shadcn/ui组件,TypeScript类型安全
|
||||
*/
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Department } from '../types';
|
||||
|
||||
interface DepartmentDeleteDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
deletingDepartment: Department | null;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DepartmentDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
deletingDepartment,
|
||||
onConfirm
|
||||
}: DepartmentDeleteDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
await onConfirm();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete department:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-red-100 dark:bg-red-900">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogDescription className="text-left">
|
||||
{deletingDepartment && (
|
||||
<div className="space-y-4">
|
||||
{/* 部门信息 */}
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<span className="text-2xl">🏢</span>
|
||||
<div>
|
||||
<div className="font-medium">{deletingDepartment.name}</div>
|
||||
<div className="text-sm text-muted-foreground">部门编码:{deletingDepartment.code}</div>
|
||||
{deletingDepartment.manager && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
负责人:{deletingDepartment.manager}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除影响说明 */}
|
||||
<div className="p-4 bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0 text-orange-600 dark:text-orange-400" />
|
||||
<div>
|
||||
<div className="font-medium mb-2 text-orange-800 dark:text-orange-200">
|
||||
删除影响:
|
||||
</div>
|
||||
<ul className="text-sm space-y-1 text-orange-700 dark:text-orange-300">
|
||||
<li>• 部门将永久删除,此操作不可恢复</li>
|
||||
<li>• 部门下的所有相关信息将被清除</li>
|
||||
<li>• 如有员工归属于此部门,需要重新分配</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 确认提示 */}
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">
|
||||
确定要删除部门 <strong>{deletingDepartment.name}</strong> 吗?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* filekorolheader: 部门表单对话框组件 - 部门添加/编辑表单界面
|
||||
* 功能:部门信息表单、输入验证、提交处理
|
||||
* 路径:/central-config/user/department/components/DepartmentFormDialog
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用shadcn/ui组件,TypeScript类型安全
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Department } from '../types';
|
||||
|
||||
interface DepartmentFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingDepartment: Department | null;
|
||||
parentDepartment: Department | null;
|
||||
onSave: (formData: Partial<Department>) => void;
|
||||
}
|
||||
|
||||
export function DepartmentFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingDepartment,
|
||||
parentDepartment,
|
||||
onSave
|
||||
}: DepartmentFormDialogProps) {
|
||||
const [formData, setFormData] = useState<Partial<Department>>({
|
||||
status: 'active',
|
||||
sort: 0,
|
||||
level: parentDepartment ? (parentDepartment.level || 1) + 1 : 1,
|
||||
parentId: parentDepartment?.id,
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 当编辑部门或父部门变化时,重置表单数据
|
||||
useState(() => {
|
||||
if (editingDepartment) {
|
||||
setFormData({
|
||||
...editingDepartment,
|
||||
children: undefined, // 排除children字段
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
parentId: parentDepartment?.id,
|
||||
level: parentDepartment ? (parentDepartment.level || 1) + 1 : 1,
|
||||
status: 'active',
|
||||
sort: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleInputChange = (field: keyof Department, value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.code) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
// 重置表单
|
||||
setFormData({
|
||||
status: 'active',
|
||||
sort: 0,
|
||||
level: parentDepartment ? (parentDepartment.level || 1) + 1 : 1,
|
||||
parentId: parentDepartment?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save department:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
onOpenChange(false);
|
||||
// 重置表单
|
||||
setFormData({
|
||||
status: 'active',
|
||||
sort: 0,
|
||||
level: parentDepartment ? (parentDepartment.level || 1) + 1 : 1,
|
||||
parentId: parentDepartment?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const title = editingDepartment
|
||||
? '编辑部门'
|
||||
: parentDepartment
|
||||
? `添加子部门(父级:${parentDepartment.name})`
|
||||
: '添加一级部门';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editingDepartment ? '编辑部门信息' : '添加新部门'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="name">部门名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="请输入部门名称"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="code">部门编码 *</Label>
|
||||
<Input
|
||||
id="code"
|
||||
value={formData.code || ''}
|
||||
onChange={(e) => handleInputChange('code', e.target.value.toUpperCase())}
|
||||
placeholder="请输入部门编码,如:TECH"
|
||||
className="font-mono"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manager">负责人</Label>
|
||||
<Input
|
||||
id="manager"
|
||||
value={formData.manager || ''}
|
||||
onChange={(e) => handleInputChange('manager', e.target.value)}
|
||||
placeholder="请输入负责人姓名"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">联系电话</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="请输入联系电话"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sort">排序号 *</Label>
|
||||
<Input
|
||||
id="sort"
|
||||
type="number"
|
||||
value={formData.sort || 0}
|
||||
onChange={(e) => handleInputChange('sort', parseInt(e.target.value) || 0)}
|
||||
placeholder="数字越小越靠前"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="status">状态 *</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value: 'active' | 'inactive') => handleInputChange('status', value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger id="status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">启用</SelectItem>
|
||||
<SelectItem value="inactive">停用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">部门描述</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="请输入部门描述"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={loading}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !formData.name?.trim() || !formData.code?.trim()}
|
||||
className="bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* filekorolheader: 部门管理头部组件 - 页面标题和操作按钮
|
||||
* 功能:页面标题显示、添加一级部门功能
|
||||
* 路径:/central-config/user/department/components/DepartmentHeader
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用shadcn/ui组件,TypeScript类型安全
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Building2, Plus } from 'lucide-react';
|
||||
|
||||
interface DepartmentHeaderProps {
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export function DepartmentHeader({ onAdd }: DepartmentHeaderProps) {
|
||||
return (
|
||||
<Card className="p-6 bg-gradient-to-r from-green-50 dark:from-green-950 to-emerald-50 dark:to-emerald-950 border-green-200 dark:border-green-800">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Building2 className="w-6 h-6 text-green-600 dark:text-green-400 flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h2 className="mb-2">部门管理</h2>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
树形结构管理企业部门信息,支持拖动排序
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-flex items-center text-sm bg-white dark:bg-gray-800 px-3 py-1 rounded-full">
|
||||
树形结构
|
||||
</span>
|
||||
<span className="inline-flex items-center text-sm bg-white dark:bg-gray-800 px-3 py-1 rounded-full">
|
||||
拖动排序
|
||||
</span>
|
||||
<span className="inline-flex items-center text-sm bg-white dark:bg-gray-800 px-3 py-1 rounded-full">
|
||||
层级管理
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onAdd} className="bg-green-600 hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加一级部门
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* filekorolheader: 部门管理说明组件 - 功能使用说明界面
|
||||
* 功能:功能说明、操作指引、注意事项
|
||||
* 路径:/central-config/user/department/components/DepartmentInstructions
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用shadcn/ui组件,TypeScript类型安全
|
||||
*/
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Building2, GripVertical, AlertCircle, Users } from 'lucide-react';
|
||||
|
||||
export function DepartmentInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-900">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Building2 className="w-5 h-5 text-blue-900 dark:text-blue-400" />
|
||||
<h4 className="text-blue-900 dark:text-blue-400 font-medium">部门管理说明</h4>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2 text-sm text-blue-800 dark:text-blue-300">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 dark:text-blue-400 mt-0.5">•</span>
|
||||
<div>
|
||||
<strong className="text-blue-900 dark:text-blue-400">树形结构:</strong>
|
||||
支持二级部门管理(一级部门 → 二级部门)
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-2">
|
||||
<GripVertical className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong className="text-blue-900 dark:text-blue-400">拖动排序:</strong>
|
||||
按住部门左侧的 ⋮⋮ 图标拖动,可调整同级部门的顺序
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 dark:text-blue-400 mt-0.5">•</span>
|
||||
<div>
|
||||
<strong className="text-blue-900 dark:text-blue-400">部门编码:</strong>
|
||||
建议使用英文大写字母,如TECH、ADMIN等
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-2">
|
||||
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong className="text-blue-900 dark:text-blue-400">员工关联:</strong>
|
||||
在员工管理中新增员工时,可选择此处维护的部门
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<strong className="text-blue-900 dark:text-blue-400">删除限制:</strong>
|
||||
删除部门前请先删除其所有子部门
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-blue-600 dark:text-blue-400 mt-0.5">•</span>
|
||||
<div>
|
||||
<strong className="text-blue-900 dark:text-blue-400">状态管理:</strong>
|
||||
部门状态为"停用"时,该部门仍可查看但不可用于新建员工
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* filekorolheader: 部门管理统计卡片组件 - 部门统计数据展示界面
|
||||
* 功能:一级部门、二级部门、部门总数统计展示
|
||||
* 路径:/central-config/user/department/components/DepartmentStatsCards
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用shadcn/ui组件,TypeScript类型安全
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Building2, Users, Layers } from 'lucide-react';
|
||||
import { DepartmentStats } from '../types';
|
||||
|
||||
interface DepartmentStatsCardsProps {
|
||||
stats: DepartmentStats;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function DepartmentStatsCards({
|
||||
stats,
|
||||
loading = false
|
||||
}: DepartmentStatsCardsProps) {
|
||||
const statsData = [
|
||||
{
|
||||
label: '一级部门',
|
||||
value: stats.level1,
|
||||
icon: <Building2 className="w-5 h-5" />,
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bg: 'bg-blue-50 dark:bg-blue-950',
|
||||
},
|
||||
{
|
||||
label: '二级部门',
|
||||
value: stats.level2,
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bg: 'bg-green-50 dark:bg-green-950',
|
||||
},
|
||||
{
|
||||
label: '部门总数',
|
||||
value: stats.total,
|
||||
icon: <Layers className="w-5 h-5" />,
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bg: 'bg-orange-50 dark:bg-orange-950',
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{statsData.map((_, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{statsData.map((stat, index) => (
|
||||
<Card key={index} className={`p-4 ${stat.bg}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={stat.color}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* filekorolheader: 部门树组件 - 部门树形结构展示界面
|
||||
* 功能:部门树形展示、展开收起、拖拽排序、操作按钮
|
||||
* 路径:/central-config/user/department/components/DepartmentTree
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用shadcn/ui组件,TypeScript类型安全
|
||||
*/
|
||||
|
||||
import { Department } from '../types';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Building2,
|
||||
GripVertical
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DepartmentTreeProps {
|
||||
departments: Department[];
|
||||
expandedIds: Set<string>;
|
||||
loading: boolean;
|
||||
draggedItem: {
|
||||
dept: Department;
|
||||
index: number;
|
||||
parentId?: string;
|
||||
} | null;
|
||||
dragOverItem: {
|
||||
index: number;
|
||||
parentId?: string;
|
||||
} | null;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onExpandAll: () => void;
|
||||
onCollapseAll: () => void;
|
||||
onAdd: (parent?: Department) => void;
|
||||
onEdit: (dept: Department) => void;
|
||||
onDelete: (dept: Department) => void;
|
||||
onDragStart: (dept: Department, index: number, parentId?: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent, index: number, parentId?: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, hoverIndex: number, parentId?: string) => void;
|
||||
}
|
||||
|
||||
export function DepartmentTree({
|
||||
departments,
|
||||
expandedIds,
|
||||
loading,
|
||||
draggedItem,
|
||||
dragOverItem,
|
||||
onToggleExpand,
|
||||
onExpandAll,
|
||||
onCollapseAll,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop
|
||||
}: DepartmentTreeProps) {
|
||||
const getStatusBadge = (status: string) => {
|
||||
return status === 'active' ? (
|
||||
<Badge className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">启用</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">停用</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染部门树
|
||||
const renderDepartmentTree = (items: Department[], level: number = 0, parentId?: string) => {
|
||||
return items.map((dept, index) => {
|
||||
const isExpanded = expandedIds.has(dept.id);
|
||||
const hasChildren = dept.children && dept.children.length > 0;
|
||||
const indent = level * 24;
|
||||
|
||||
const isDragOver = dragOverItem?.index === index && dragOverItem?.parentId === parentId;
|
||||
const isDragging = draggedItem?.dept.id === dept.id;
|
||||
|
||||
return (
|
||||
<div key={dept.id}>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(dept, index, parentId)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, index, parentId)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, index, parentId)}
|
||||
className={`flex items-center justify-between p-3 border rounded-lg mb-2 transition-all ${
|
||||
isDragging
|
||||
? 'opacity-50 bg-muted'
|
||||
: isDragOver
|
||||
? 'bg-green-50 dark:bg-green-950/30 border-green-300 dark:border-green-700'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
style={{ marginLeft: `${indent}px`, cursor: 'move' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
{/* 拖动手柄 */}
|
||||
<div className="cursor-grab active:cursor-grabbing" title="拖动调整顺序">
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground hover:text-green-600 dark:hover:text-green-400" />
|
||||
</div>
|
||||
|
||||
{/* 展开/收起图标 */}
|
||||
<div className="w-5 flex items-center justify-center">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => onToggleExpand(dept.id)}
|
||||
className="hover:bg-muted rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 部门图标 */}
|
||||
<Building2 className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
|
||||
{/* 部门信息 */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{dept.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({dept.code})</span>
|
||||
</div>
|
||||
{dept.manager && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
负责人:{dept.manager}
|
||||
{dept.phone && ` · ${dept.phone}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态标签 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(dept.status)}
|
||||
<span className="text-xs text-muted-foreground">排序: {dept.sort}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
{level < 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAdd(dept)}
|
||||
title="添加子部门"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(dept)}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(dept)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 递归渲染子部门 */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div className="mt-2">
|
||||
{renderDepartmentTree(dept.children!, level + 1, dept.id)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-muted-foreground">加载中...</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3>部门结构</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onExpandAll}
|
||||
>
|
||||
展开全部
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCollapseAll}
|
||||
>
|
||||
收起全部
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{departments.length > 0 ? (
|
||||
renderDepartmentTree(departments)
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
暂无部门数据,点击上方按钮添加一级部门
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
591
crop-x/src/app/(app)/central-config/user/department/page.tsx
Normal file
591
crop-x/src/app/(app)/central-config/user/department/page.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* filekorolheader: 部门管理页面 - 企业部门树形结构管理页面
|
||||
* 功能:部门树形管理、拖拽排序、增删改查、层级管理
|
||||
* 路径:/central-config/user/department
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用useReducer状态管理,API集成,shadcn语义化样式
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useReducer, useEffect, useRef } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Department } from './types';
|
||||
import { DepartmentHeader } from './components/DepartmentHeader';
|
||||
import { DepartmentStatsCards } from './components/DepartmentStatsCards';
|
||||
import { DepartmentTree } from './components/DepartmentTree';
|
||||
import { DepartmentFormDialog } from './components/DepartmentFormDialog';
|
||||
import { DepartmentDeleteDialog } from './components/DepartmentDeleteDialog';
|
||||
import { DepartmentInstructions } from './components/DepartmentInstructions';
|
||||
|
||||
// 部门管理状态管理
|
||||
interface DepartmentManagementState {
|
||||
departments: Department[];
|
||||
expandedIds: Set<string>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
showForm: boolean;
|
||||
showDeleteDialog: boolean;
|
||||
editingDepartment: Department | null;
|
||||
parentDepartment: Department | null;
|
||||
deletingDepartment: Department | null;
|
||||
draggedItem: {
|
||||
dept: Department;
|
||||
index: number;
|
||||
parentId?: string;
|
||||
} | null;
|
||||
dragOverItem: {
|
||||
index: number;
|
||||
parentId?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type DepartmentManagementAction =
|
||||
| { type: 'SET_DEPARTMENTS'; payload: Department[] }
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'TOGGLE_EXPAND'; payload: string }
|
||||
| { type: 'SET_EXPANDED_IDS'; payload: Set<string> }
|
||||
| { type: 'TOGGLE_FORM'; payload: boolean }
|
||||
| { type: 'TOGGLE_DELETE_DIALOG'; payload: boolean }
|
||||
| { type: 'SET_EDITING_DEPARTMENT'; payload: Department | null }
|
||||
| { type: 'SET_PARENT_DEPARTMENT'; payload: Department | null }
|
||||
| { type: 'SET_DELETING_DEPARTMENT'; payload: Department | null }
|
||||
| { type: 'SET_DRAGGED_ITEM'; payload: { dept: Department; index: number; parentId?: string } | null }
|
||||
| { type: 'SET_DRAG_OVER_ITEM'; payload: { index: number; parentId?: string } | null }
|
||||
| { type: 'REFRESH_DATA' };
|
||||
|
||||
const departmentManagementReducer = (state: DepartmentManagementState, action: DepartmentManagementAction): DepartmentManagementState => {
|
||||
switch (action.type) {
|
||||
case 'SET_DEPARTMENTS':
|
||||
return { ...state, departments: action.payload, loading: false, error: null };
|
||||
case 'SET_LOADING':
|
||||
return { ...state, loading: action.payload };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload, loading: false };
|
||||
case 'TOGGLE_EXPAND':
|
||||
const newExpanded = new Set(state.expandedIds);
|
||||
if (newExpanded.has(action.payload)) {
|
||||
newExpanded.delete(action.payload);
|
||||
} else {
|
||||
newExpanded.add(action.payload);
|
||||
}
|
||||
return { ...state, expandedIds: newExpanded };
|
||||
case 'SET_EXPANDED_IDS':
|
||||
return { ...state, expandedIds: action.payload };
|
||||
case 'TOGGLE_FORM':
|
||||
return { ...state, showForm: !state.showForm };
|
||||
case 'TOGGLE_DELETE_DIALOG':
|
||||
return { ...state, showDeleteDialog: !state.showDeleteDialog };
|
||||
case 'SET_EDITING_DEPARTMENT':
|
||||
return { ...state, editingDepartment: action.payload };
|
||||
case 'SET_PARENT_DEPARTMENT':
|
||||
return { ...state, parentDepartment: action.payload };
|
||||
case 'SET_DELETING_DEPARTMENT':
|
||||
return { ...state, deletingDepartment: action.payload };
|
||||
case 'SET_DRAGGED_ITEM':
|
||||
return { ...state, draggedItem: action.payload };
|
||||
case 'SET_DRAG_OVER_ITEM':
|
||||
return { ...state, dragOverItem: action.payload };
|
||||
case 'REFRESH_DATA':
|
||||
return { ...state, error: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const initialState: DepartmentManagementState = {
|
||||
departments: [],
|
||||
expandedIds: new Set(),
|
||||
loading: false,
|
||||
error: null,
|
||||
showForm: false,
|
||||
showDeleteDialog: false,
|
||||
editingDepartment: null,
|
||||
parentDepartment: null,
|
||||
deletingDepartment: null,
|
||||
draggedItem: null,
|
||||
dragOverItem: null,
|
||||
};
|
||||
|
||||
export default function DepartmentManagementPage() {
|
||||
const [state, dispatch] = useReducer(departmentManagementReducer, initialState);
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
// 加载部门数据
|
||||
const loadDepartments = async () => {
|
||||
try {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
// 暂时使用mock数据,后续可以替换为API调用
|
||||
const mockDepartments: Department[] = [
|
||||
{
|
||||
id: 'dept-1',
|
||||
name: '技术部',
|
||||
code: 'TECH',
|
||||
level: 1,
|
||||
manager: '王技术',
|
||||
phone: '13800138001',
|
||||
email: 'tech@example.com',
|
||||
description: '负责技术研发和系统维护',
|
||||
sort: 1,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
children: [
|
||||
{
|
||||
id: 'dept-1-1',
|
||||
parentId: 'dept-1',
|
||||
name: '研发组',
|
||||
code: 'TECH-RD',
|
||||
level: 2,
|
||||
manager: '李研发',
|
||||
phone: '13800138011',
|
||||
description: '负责系统研发',
|
||||
sort: 1,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dept-1-2',
|
||||
parentId: 'dept-1',
|
||||
name: '运维组',
|
||||
code: 'TECH-OPS',
|
||||
level: 2,
|
||||
manager: '张运维',
|
||||
phone: '13800138012',
|
||||
description: '负责系统运维',
|
||||
sort: 2,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dept-2',
|
||||
name: '管理部',
|
||||
code: 'ADMIN',
|
||||
level: 1,
|
||||
manager: '赵管理',
|
||||
phone: '13800138002',
|
||||
email: 'admin@example.com',
|
||||
description: '负责行政管理',
|
||||
sort: 2,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
children: [
|
||||
{
|
||||
id: 'dept-2-1',
|
||||
parentId: 'dept-2',
|
||||
name: '人事组',
|
||||
code: 'ADMIN-HR',
|
||||
level: 2,
|
||||
manager: '孙人事',
|
||||
phone: '13800138021',
|
||||
description: '负责人力资源管理',
|
||||
sort: 1,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dept-2-2',
|
||||
parentId: 'dept-2',
|
||||
name: '财务组',
|
||||
code: 'ADMIN-FIN',
|
||||
level: 2,
|
||||
manager: '周财务',
|
||||
phone: '13800138022',
|
||||
description: '负责财务管理',
|
||||
sort: 2,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dept-3',
|
||||
name: '作业部',
|
||||
code: 'OPS',
|
||||
level: 1,
|
||||
manager: '吴作业',
|
||||
phone: '13800138003',
|
||||
email: 'ops@example.com',
|
||||
description: '负责农机作业管理',
|
||||
sort: 3,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
children: [
|
||||
{
|
||||
id: 'dept-3-1',
|
||||
parentId: 'dept-3',
|
||||
name: '第一作业组',
|
||||
code: 'OPS-T1',
|
||||
level: 2,
|
||||
manager: '郑组长',
|
||||
phone: '13800138031',
|
||||
description: '负责区域A作业',
|
||||
sort: 1,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'dept-3-2',
|
||||
parentId: 'dept-3',
|
||||
name: '第二作业组',
|
||||
code: 'OPS-T2',
|
||||
level: 2,
|
||||
manager: '钱组长',
|
||||
phone: '13800138032',
|
||||
description: '负责区域B作业',
|
||||
sort: 2,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
dispatch({ type: 'SET_DEPARTMENTS', payload: mockDepartments });
|
||||
// 默认展开所有一级部门
|
||||
dispatch({ type: 'SET_EXPANDED_IDS', payload: new Set(mockDepartments.map(d => d.id)) });
|
||||
} catch (error) {
|
||||
console.error('Failed to load departments:', error);
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: error instanceof Error ? error.message : '加载部门数据失败'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 统计部门数量
|
||||
const countDepartments = (depts: Department[]): { level1: number; level2: number; total: number } => {
|
||||
let level1 = 0;
|
||||
let level2 = 0;
|
||||
|
||||
depts.forEach(dept => {
|
||||
if (!dept.parentId) {
|
||||
level1++;
|
||||
if (dept.children) {
|
||||
level2 += dept.children.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { level1, level2, total: level1 + level2 };
|
||||
};
|
||||
|
||||
const stats = countDepartments(state.departments);
|
||||
|
||||
// 展开/收起部门
|
||||
const toggleExpand = (id: string) => {
|
||||
dispatch({ type: 'TOGGLE_EXPAND', payload: id });
|
||||
};
|
||||
|
||||
// 展开全部
|
||||
const expandAll = () => {
|
||||
const allIds = new Set<string>();
|
||||
const collectIds = (depts: Department[]) => {
|
||||
depts.forEach(dept => {
|
||||
allIds.add(dept.id);
|
||||
if (dept.children) {
|
||||
collectIds(dept.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
collectIds(state.departments);
|
||||
dispatch({ type: 'SET_EXPANDED_IDS', payload: allIds });
|
||||
};
|
||||
|
||||
// 收起全部
|
||||
const collapseAll = () => {
|
||||
dispatch({ type: 'SET_EXPANDED_IDS', payload: new Set() });
|
||||
};
|
||||
|
||||
// 添加部门
|
||||
const handleAdd = (parent?: Department) => {
|
||||
dispatch({ type: 'SET_EDITING_DEPARTMENT', payload: null });
|
||||
dispatch({ type: 'SET_PARENT_DEPARTMENT', payload: parent || null });
|
||||
dispatch({ type: 'TOGGLE_FORM', payload: true });
|
||||
};
|
||||
|
||||
// 编辑部门
|
||||
const handleEdit = (dept: Department) => {
|
||||
dispatch({ type: 'SET_EDITING_DEPARTMENT', payload: dept });
|
||||
dispatch({ type: 'SET_PARENT_DEPARTMENT', payload: null });
|
||||
dispatch({ type: 'TOGGLE_FORM', payload: true });
|
||||
};
|
||||
|
||||
// 删除部门
|
||||
const handleDelete = (dept: Department) => {
|
||||
if (dept.children && dept.children.length > 0) {
|
||||
toast.error('请先删除该部门下的子部门');
|
||||
return;
|
||||
}
|
||||
dispatch({ type: 'SET_DELETING_DEPARTMENT', payload: dept });
|
||||
dispatch({ type: 'TOGGLE_DELETE_DIALOG', payload: true });
|
||||
};
|
||||
|
||||
// 保存部门
|
||||
const handleSave = (formData: Partial<Department>) => {
|
||||
if (!formData.name || !formData.code) {
|
||||
toast.error('请填写必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (state.editingDepartment) {
|
||||
// 更新部门
|
||||
const updateInTree = (items: Department[]): Department[] => {
|
||||
return items.map(item => {
|
||||
if (item.id === state.editingDepartment!.id) {
|
||||
return {
|
||||
...item,
|
||||
...formData,
|
||||
updatedAt: now,
|
||||
children: item.children,
|
||||
} as Department;
|
||||
}
|
||||
if (item.children) {
|
||||
return {
|
||||
...item,
|
||||
children: updateInTree(item.children),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
const updated = updateInTree(state.departments);
|
||||
dispatch({ type: 'SET_DEPARTMENTS', payload: updated });
|
||||
toast.success('部门更新成功');
|
||||
} else {
|
||||
// 新增部门
|
||||
const newDept: Department = {
|
||||
id: `dept-${Date.now()}`,
|
||||
...formData as Department,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (state.parentDepartment) {
|
||||
// 添加到父部门下
|
||||
const addToParent = (items: Department[]): Department[] => {
|
||||
return items.map(item => {
|
||||
if (item.id === state.parentDepartment!.id) {
|
||||
return {
|
||||
...item,
|
||||
children: [...(item.children || []), newDept],
|
||||
};
|
||||
}
|
||||
if (item.children) {
|
||||
return {
|
||||
...item,
|
||||
children: addToParent(item.children),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
const updated = addToParent(state.departments);
|
||||
dispatch({ type: 'SET_DEPARTMENTS', payload: updated });
|
||||
dispatch({ type: 'TOGGLE_EXPAND', payload: state.parentDepartment.id });
|
||||
} else {
|
||||
// 添加为一级部门
|
||||
const updated = [...state.departments, newDept];
|
||||
dispatch({ type: 'SET_DEPARTMENTS', payload: updated });
|
||||
}
|
||||
|
||||
toast.success('部门添加成功');
|
||||
}
|
||||
|
||||
dispatch({ type: 'TOGGLE_FORM', payload: false });
|
||||
};
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = () => {
|
||||
if (!state.deletingDepartment) return;
|
||||
|
||||
const deleteFromTree = (items: Department[]): Department[] => {
|
||||
return items
|
||||
.filter(item => item.id !== state.deletingDepartment!.id)
|
||||
.map(item => {
|
||||
if (item.children) {
|
||||
return {
|
||||
...item,
|
||||
children: deleteFromTree(item.children),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
const updated = deleteFromTree(state.departments);
|
||||
dispatch({ type: 'SET_DEPARTMENTS', payload: updated });
|
||||
toast.success('部门删除成功');
|
||||
|
||||
dispatch({ type: 'TOGGLE_DELETE_DIALOG', payload: false });
|
||||
dispatch({ type: 'SET_DELETING_DEPARTMENT', payload: null });
|
||||
};
|
||||
|
||||
// 拖拽功能
|
||||
const handleDragStart = (dept: Department, index: number, parentId?: string) => {
|
||||
dispatch({ type: 'SET_DRAGGED_ITEM', payload: { dept, index, parentId } });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
dispatch({ type: 'SET_DRAGGED_ITEM', payload: null });
|
||||
dispatch({ type: 'SET_DRAG_OVER_ITEM', payload: null });
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number, parentId?: string) => {
|
||||
e.preventDefault();
|
||||
if (state.draggedItem && state.draggedItem.parentId === parentId) {
|
||||
dispatch({ type: 'SET_DRAG_OVER_ITEM', payload: { index, parentId } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
dispatch({ type: 'SET_DRAG_OVER_ITEM', payload: null });
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, hoverIndex: number, parentId?: string) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!state.draggedItem) return;
|
||||
|
||||
if (state.draggedItem.parentId !== parentId) {
|
||||
toast.error('不能跨级别拖动部门');
|
||||
dispatch({ type: 'SET_DRAG_OVER_ITEM', payload: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const dragIndex = state.draggedItem.index;
|
||||
if (dragIndex === hoverIndex) {
|
||||
dispatch({ type: 'SET_DRAG_OVER_ITEM', payload: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let updated: Department[];
|
||||
|
||||
if (!parentId) {
|
||||
// 一级部门
|
||||
const newDepts = [...state.departments];
|
||||
const [removed] = newDepts.splice(dragIndex, 1);
|
||||
newDepts.splice(hoverIndex, 0, removed);
|
||||
|
||||
updated = newDepts.map((item, index) => ({
|
||||
...item,
|
||||
sort: index + 1,
|
||||
}));
|
||||
} else {
|
||||
// 二级部门
|
||||
const updateInTree = (items: Department[]): Department[] => {
|
||||
return items.map(item => {
|
||||
if (item.id === parentId && item.children) {
|
||||
const newChildren = [...item.children];
|
||||
const [removed] = newChildren.splice(dragIndex, 1);
|
||||
newChildren.splice(hoverIndex, 0, removed);
|
||||
|
||||
return {
|
||||
...item,
|
||||
children: newChildren.map((child, index) => ({
|
||||
...child,
|
||||
sort: index + 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (item.children) {
|
||||
return {
|
||||
...item,
|
||||
children: updateInTree(item.children),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
updated = updateInTree(state.departments);
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_DEPARTMENTS', payload: updated });
|
||||
toast.success('部门顺序已更新');
|
||||
dispatch({ type: 'SET_DRAG_OVER_ITEM', payload: null });
|
||||
};
|
||||
|
||||
// 合并所有状态变化,统一处理数据加载
|
||||
useEffect(() => {
|
||||
if (isFirstLoad.current) {
|
||||
// 首次加载
|
||||
isFirstLoad.current = false;
|
||||
loadDepartments();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题 */}
|
||||
<DepartmentHeader onAdd={() => handleAdd()} />
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<DepartmentStatsCards stats={stats} />
|
||||
|
||||
{/* 部门树 */}
|
||||
<DepartmentTree
|
||||
departments={state.departments}
|
||||
expandedIds={state.expandedIds}
|
||||
loading={state.loading}
|
||||
draggedItem={state.draggedItem}
|
||||
dragOverItem={state.dragOverItem}
|
||||
onToggleExpand={toggleExpand}
|
||||
onExpandAll={expandAll}
|
||||
onCollapseAll={collapseAll}
|
||||
onAdd={handleAdd}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
|
||||
{/* 表单对话框 */}
|
||||
<DepartmentFormDialog
|
||||
open={state.showForm}
|
||||
onOpenChange={(open) => dispatch({ type: 'TOGGLE_FORM', payload: open })}
|
||||
editingDepartment={state.editingDepartment}
|
||||
parentDepartment={state.parentDepartment}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DepartmentDeleteDialog
|
||||
open={state.showDeleteDialog}
|
||||
onOpenChange={(open) => dispatch({ type: 'TOGGLE_DELETE_DIALOG', payload: open })}
|
||||
deletingDepartment={state.deletingDepartment}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
|
||||
{/* 功能说明 */}
|
||||
<DepartmentInstructions />
|
||||
|
||||
{/* 错误显示 */}
|
||||
{state.error && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<span>{state.error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
crop-x/src/app/(app)/central-config/user/department/types.ts
Normal file
60
crop-x/src/app/(app)/central-config/user/department/types.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* filekorolheader: 部门管理类型定义 - 部门数据类型和接口定义
|
||||
* 功能:TypeScript类型定义、接口规范、数据结构
|
||||
* 路径:/central-config/user/department/types
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,TypeScript类型安全
|
||||
*/
|
||||
|
||||
// 部门状态枚举
|
||||
export type DepartmentStatus = 'active' | 'inactive';
|
||||
|
||||
// 部门接口定义
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
level: number;
|
||||
manager?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
sort: number;
|
||||
status: DepartmentStatus;
|
||||
parentId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
children?: Department[];
|
||||
}
|
||||
|
||||
// 创建部门表单数据类型
|
||||
export interface CreateDepartmentForm {
|
||||
name: string;
|
||||
code: string;
|
||||
manager?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
description?: string;
|
||||
sort: number;
|
||||
status: DepartmentStatus;
|
||||
parentId?: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// 部门统计数据类型
|
||||
export interface DepartmentStats {
|
||||
level1: number;
|
||||
level2: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 拖拽项目类型
|
||||
export interface DraggedItem {
|
||||
dept: Department;
|
||||
index: number;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface DragOverItem {
|
||||
index: number;
|
||||
parentId?: string;
|
||||
}
|
||||
@@ -31,6 +31,19 @@ export function EmployeeDetailDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const getAuditStatusBadge = (auditStatus?: string) => {
|
||||
switch (auditStatus) {
|
||||
case 'pending':
|
||||
return <Badge className="bg-yellow-100 text-yellow-700">待审核</Badge>;
|
||||
case 'approved':
|
||||
return <Badge className="bg-green-100 text-green-700">审核通过</Badge>;
|
||||
case 'rejected':
|
||||
return <Badge className="bg-red-100 text-red-700">已驳回</Badge>;
|
||||
default:
|
||||
return <Badge className="bg-gray-100 text-gray-700">未知</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedEmployee) return null;
|
||||
|
||||
return (
|
||||
@@ -42,68 +55,106 @@ export function EmployeeDetailDialog({
|
||||
查看员工的详细信息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>姓名</Label>
|
||||
<div className="mt-1">{selectedEmployee.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>用户名</Label>
|
||||
<div className="mt-1">{selectedEmployee.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>电话</Label>
|
||||
<div className="mt-1">{selectedEmployee.phone}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>邮箱</Label>
|
||||
<div className="mt-1">{selectedEmployee.email || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>部门</Label>
|
||||
<div className="mt-1">{selectedEmployee.department || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>职位</Label>
|
||||
<div className="mt-1">{selectedEmployee.position || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>状态</Label>
|
||||
<div className="mt-1">{getStatusBadge(selectedEmployee.status)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label>角色</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{selectedEmployee.roles && selectedEmployee.roles.length > 0 ? (
|
||||
selectedEmployee.roles.map((role, index) => (
|
||||
<Badge key={index} className="bg-purple-100 text-purple-700">
|
||||
{role}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">未分配角色</span>
|
||||
)}
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-green-800">基本信息</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>姓名</Label>
|
||||
<div className="mt-1 field-value-inline">{selectedEmployee.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>用户名</Label>
|
||||
<div className="mt-1 field-value-inline">{selectedEmployee.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<div className="mt-1 field-value-inline">{selectedEmployee.phone}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>邮箱</Label>
|
||||
<div className="mt-1 field-value-inline">{selectedEmployee.email || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedEmployee.lastLoginTime && (
|
||||
</div>
|
||||
|
||||
{/* 工作信息 */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-green-800">工作信息</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>最后登录</Label>
|
||||
<div className="mt-1">
|
||||
{new Date(selectedEmployee.lastLoginTime).toLocaleString('zh-CN')}
|
||||
<Label>部门</Label>
|
||||
<div className="mt-1 field-value-inline">{selectedEmployee.department || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>账号状态</Label>
|
||||
<div className="mt-1">{getStatusBadge(selectedEmployee.status)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>审核状态</Label>
|
||||
<div className="mt-1">{getAuditStatusBadge(selectedEmployee.auditStatus)}</div>
|
||||
</div>
|
||||
{selectedEmployee.auditReason && (
|
||||
<div>
|
||||
<Label>审核意见</Label>
|
||||
<div className="mt-1 field-value-inline">{selectedEmployee.auditReason}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedEmployee.auditor && (
|
||||
<div>
|
||||
<Label>审核人</Label>
|
||||
<div className="mt-1 field-value-inline">{selectedEmployee.auditor}</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedEmployee.auditTime && (
|
||||
<div>
|
||||
<Label>审核时间</Label>
|
||||
<div className="mt-1 field-value-inline">{new Date(selectedEmployee.auditTime).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 角色权限 */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-green-800">角色权限</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedEmployee.roles && selectedEmployee.roles.length > 0 ? (
|
||||
selectedEmployee.roles.map((role, index) => (
|
||||
<Badge key={index} className="bg-purple-100 text-purple-700">
|
||||
{role}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">未分配角色</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统信息 */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-green-800">系统信息</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedEmployee.lastLoginTime && (
|
||||
<div>
|
||||
<Label>最后登录</Label>
|
||||
<div className="mt-1 field-value-inline">
|
||||
{new Date(selectedEmployee.lastLoginTime).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>创建时间</Label>
|
||||
<div className="mt-1 field-value-inline">
|
||||
{new Date(selectedEmployee.createdAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>创建时间</Label>
|
||||
<div className="mt-1">
|
||||
{new Date(selectedEmployee.createdAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>更新时间</Label>
|
||||
<div className="mt-1">
|
||||
{new Date(selectedEmployee.updatedAt).toLocaleString('zh-CN')}
|
||||
<div>
|
||||
<Label>更新时间</Label>
|
||||
<div className="mt-1 field-value-inline">
|
||||
{new Date(selectedEmployee.updatedAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,62 +37,82 @@ export function EmployeeFormDialog({
|
||||
{editingEmployee ? '编辑员工信息' : '添加新员工'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="username">用户名 *</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, username: e.target.value })}
|
||||
placeholder="登录用户名"
|
||||
/>
|
||||
<div className="space-y-4 max-h-96 overflow-y-auto">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-green-800">基本信息</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="username">用户名 *</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, username: e.target.value })}
|
||||
placeholder="登录用户名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">姓名 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, name: e.target.value })}
|
||||
placeholder="真实姓名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone">手机号 *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, phone: e.target.value })}
|
||||
placeholder="11位手机号码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, email: e.target.value })}
|
||||
placeholder="电子邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="idCard">身份证号</Label>
|
||||
<Input
|
||||
id="idCard"
|
||||
value={formData.idCard || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, idCard: e.target.value })}
|
||||
placeholder="18位身份证号码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="address">住址</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, address: e.target.value })}
|
||||
placeholder="详细住址"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="name">姓名 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, name: e.target.value })}
|
||||
placeholder="真实姓名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="phone">电话 *</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, phone: e.target.value })}
|
||||
placeholder="手机号码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, email: e.target.value })}
|
||||
placeholder="电子邮箱"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="department">部门</Label>
|
||||
<Input
|
||||
id="department"
|
||||
value={formData.department || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, department: e.target.value })}
|
||||
placeholder="所属部门"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="position">职位</Label>
|
||||
<Input
|
||||
id="position"
|
||||
value={formData.position || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, position: e.target.value })}
|
||||
placeholder="职位名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 工作信息 */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-green-800">工作信息</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="department">部门</Label>
|
||||
<Input
|
||||
id="department"
|
||||
value={formData.department || ''}
|
||||
onChange={(e) => onFormDataChange({ ...formData, department: e.target.value })}
|
||||
placeholder="所属部门"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,28 +5,52 @@ import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Eye, Edit, Lock, Trash2, UserX, UserCheck } from 'lucide-react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
PaginationEllipsis
|
||||
} from '@/components/ui/pagination';
|
||||
import { Eye, Edit, Lock, Trash2, UserX, UserCheck, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { Employee, UserStatus } from '../types';
|
||||
import { PaginationState } from './employeeApi';
|
||||
|
||||
interface EmployeeListProps {
|
||||
employees: Employee[];
|
||||
loading?: boolean;
|
||||
pagination?: PaginationState;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
onViewDetail: (employee: Employee) => void;
|
||||
onEdit: (employee: Employee) => void;
|
||||
onResetPassword: (employee: Employee) => void;
|
||||
onToggleStatus: (employee: Employee) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onAudit?: (employee: Employee, action: 'approve' | 'reject') => void;
|
||||
}
|
||||
|
||||
export function EmployeeList({
|
||||
employees,
|
||||
loading = false,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onViewDetail,
|
||||
onEdit,
|
||||
onResetPassword,
|
||||
onToggleStatus,
|
||||
onDelete
|
||||
onDelete,
|
||||
onAudit
|
||||
}: EmployeeListProps) {
|
||||
const getStatusBadge = (status: UserStatus) => {
|
||||
switch (status) {
|
||||
const getStatusBadge = (isActive: boolean, status?: UserStatus) => {
|
||||
// 优先使用isActive字段(来自API),其次使用status字段(兼容旧数据)
|
||||
const finalStatus = isActive !== undefined ? (isActive ? 'active' : 'frozen') : status;
|
||||
|
||||
switch (finalStatus) {
|
||||
case 'active':
|
||||
return <Badge className="bg-green-100 text-green-700">正常</Badge>;
|
||||
case 'frozen':
|
||||
@@ -34,7 +58,20 @@ export function EmployeeList({
|
||||
case 'inactive':
|
||||
return <Badge className="bg-red-100 text-red-700">停用</Badge>;
|
||||
default:
|
||||
return <Badge>{status}</Badge>;
|
||||
return <Badge>{finalStatus}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuditStatusBadge = (auditStatus?: string) => {
|
||||
switch (auditStatus) {
|
||||
case 'pending':
|
||||
return <Badge className="bg-yellow-100 text-yellow-700">待审核</Badge>;
|
||||
case 'approved':
|
||||
return <Badge className="bg-green-100 text-green-700">审核通过</Badge>;
|
||||
case 'rejected':
|
||||
return <Badge className="bg-red-100 text-red-700">已驳回</Badge>;
|
||||
default:
|
||||
return <Badge className="bg-gray-100 text-gray-700">未知</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,14 +84,23 @@ export function EmployeeList({
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>电话</TableHead>
|
||||
<TableHead>部门</TableHead>
|
||||
<TableHead>职位</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>账号状态</TableHead>
|
||||
<TableHead>审核状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.length === 0 ? (
|
||||
{loading && employees.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : employees.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
暂无数据
|
||||
@@ -62,20 +108,40 @@ export function EmployeeList({
|
||||
</TableRow>
|
||||
) : (
|
||||
employees.map((employee) => (
|
||||
<TableRow key={employee.id}>
|
||||
<TableCell>{employee.name}</TableCell>
|
||||
<TableRow key={employee.id} className={loading ? 'opacity-50' : ''}>
|
||||
<TableCell>{employee.displayName || employee.name || employee.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.username}</TableCell>
|
||||
<TableCell>{employee.phone}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.department || '-'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.position || '-'}</TableCell>
|
||||
<TableCell>{employee.phone || '-'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{employee.departmentName || employee.department || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{employee.roles && employee.roles.length > 0
|
||||
? employee.roles.join(', ')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(employee.status)}</TableCell>
|
||||
<TableCell>{getStatusBadge(employee.isActive, employee.status)}</TableCell>
|
||||
<TableCell>{getAuditStatusBadge(employee.auditStatus)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{employee.auditStatus === 'pending' && onAudit && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAudit(employee, 'approve')}
|
||||
title="审核通过"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAudit(employee, 'reject')}
|
||||
title="驳回"
|
||||
>
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -102,7 +168,7 @@ export function EmployeeList({
|
||||
size="sm"
|
||||
onClick={() => onToggleStatus(employee)}
|
||||
>
|
||||
{employee.status === 'active' ? (
|
||||
{(employee.isActive || employee.status === 'active') ? (
|
||||
<UserX className="w-4 h-4 text-orange-600" />
|
||||
) : (
|
||||
<UserCheck className="w-4 h-4 text-green-600" />
|
||||
@@ -122,6 +188,86 @@ export function EmployeeList({
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 分页组件 */}
|
||||
{pagination && (
|
||||
<div className="flex items-center justify-between px-2 py-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<span>显示第 {((pagination.page - 1) * pagination.size) + 1} - {Math.min(pagination.page * pagination.size, pagination.total)} 条,共 {pagination.total} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<span className="whitespace-nowrap">每页显示</span>
|
||||
<Select
|
||||
value={pagination.size.toString()}
|
||||
onValueChange={(value) => onPageSizeChange?.(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={pagination.size.toString()} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map((size) => (
|
||||
<SelectItem key={size} value={size.toString()}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>条</span>
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className={pagination.hasPrev ? "cursor-pointer" : "pointer-events-none opacity-50"}
|
||||
onClick={() => pagination.hasPrev && onPageChange?.(pagination.page - 1)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{/* 生成页码 */}
|
||||
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
|
||||
let pageNum;
|
||||
if (pagination.totalPages <= 5) {
|
||||
pageNum = i + 1;
|
||||
} else if (pagination.page <= 3) {
|
||||
pageNum = i + 1;
|
||||
} else if (pagination.page >= pagination.totalPages - 2) {
|
||||
pageNum = pagination.totalPages - 4 + i;
|
||||
} else {
|
||||
pageNum = pagination.page - 2 + i;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={pageNum === pagination.page}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onPageChange?.(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{pagination.totalPages > 5 && pagination.page < pagination.totalPages - 2 && (
|
||||
<PaginationItem>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className={pagination.hasNext ? "cursor-pointer" : "pointer-events-none opacity-50"}
|
||||
onClick={() => pagination.hasNext && onPageChange?.(pagination.page + 1)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -9,18 +9,29 @@ import { EmployeeFilters } from '../types';
|
||||
|
||||
interface EmployeeManagementFiltersProps {
|
||||
filters: EmployeeFilters;
|
||||
onFiltersChange: (filters: EmployeeFilters) => void;
|
||||
onFiltersChange?: (filters: EmployeeFilters) => void;
|
||||
onSearchChange?: (searchKeyword: string) => void;
|
||||
onStatusFilterChange?: (statusFilter: string) => void;
|
||||
}
|
||||
|
||||
export function EmployeeManagementFilters({
|
||||
filters,
|
||||
onFiltersChange
|
||||
onFiltersChange,
|
||||
onSearchChange,
|
||||
onStatusFilterChange
|
||||
}: EmployeeManagementFiltersProps) {
|
||||
const updateFilter = (key: keyof EmployeeFilters, value: string) => {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[key]: value
|
||||
});
|
||||
// 优先使用新的回调函数
|
||||
if (key === 'searchKeyword' && onSearchChange) {
|
||||
onSearchChange(value);
|
||||
} else if (key === 'statusFilter' && onStatusFilterChange) {
|
||||
onStatusFilterChange(value);
|
||||
} else if (onFiltersChange) {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
[key]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* filekorolheader: 员工管理API接口 - 员工数据查询接口服务
|
||||
* 功能:API请求封装、数据转换、错误处理、分页查询
|
||||
* 路径:/central-config/user/employee/components/employeeApi
|
||||
* 规范:遵循crop-x/docs/开发项目规范.md,使用SDK API调用,TypeScript类型安全
|
||||
*/
|
||||
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import { getUsersApiV1UsersGet } from "@/lib/api/sdk.gen";
|
||||
|
||||
// API返回的员工数据类型
|
||||
export interface EmployeeApiData {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
full_name: string | null;
|
||||
phone: string | null;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at: string | null;
|
||||
avatar_url: string | null;
|
||||
bio: string | null;
|
||||
display_name: string | null;
|
||||
department_id: string | null;
|
||||
department_name: string | null;
|
||||
scope: string;
|
||||
company_name: string | null;
|
||||
}
|
||||
|
||||
// API响应接口
|
||||
export interface EmployeesApiResponse {
|
||||
data: EmployeeApiData[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
// 查询参数接口
|
||||
export interface EmployeesQueryParams {
|
||||
search?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// 页面使用的员工数据类型(转换后的)
|
||||
export interface Employee {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
phone: string | null;
|
||||
isActive: boolean;
|
||||
isSuperuser: boolean;
|
||||
isVerified: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
avatarUrl: string | null;
|
||||
bio: string | null;
|
||||
displayName: string | null;
|
||||
departmentId: string | null;
|
||||
departmentName: string | null;
|
||||
scope: string;
|
||||
companyName: string | null;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取员工列表数据
|
||||
*/
|
||||
export async function fetchEmployees(params: EmployeesQueryParams = {}): Promise<EmployeesApiResponse> {
|
||||
try {
|
||||
// 构建查询参数对象
|
||||
const queryParams: any = {};
|
||||
|
||||
if (params.search) queryParams.search = params.search;
|
||||
if (params.page) queryParams.page = params.page;
|
||||
if (params.size) queryParams.size = params.size;
|
||||
if (params.sort_order) queryParams.sort_order = params.sort_order;
|
||||
|
||||
// 默认参数
|
||||
if (!params.page) queryParams.page = 1;
|
||||
if (!params.size) queryParams.size = 10;
|
||||
if (!params.sort_order) queryParams.sort_order = 'desc';
|
||||
|
||||
// 获取认证token
|
||||
const token = getAuthToken();
|
||||
console.log('员工管理API调用参数:', queryParams);
|
||||
|
||||
// 使用SDK API调用用户查询接口
|
||||
const response = await getUsersApiV1UsersGet({
|
||||
query: {
|
||||
...queryParams,
|
||||
// 添加时间戳防止缓存
|
||||
_t: Date.now(),
|
||||
},
|
||||
headers: token ? {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(`API error: ${response.error.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const data = response.data as any;
|
||||
console.log('员工管理API响应:', data);
|
||||
|
||||
// 根据实际API响应格式处理数据
|
||||
// 如果API直接返回数组,我们需要模拟分页响应
|
||||
if (Array.isArray(data)) {
|
||||
// 如果API返回数组,假设是当前页的数据
|
||||
return {
|
||||
data: data,
|
||||
total: data.length, // 这种情况下无法获取总数,使用当前页数据量
|
||||
page: params.page || 1,
|
||||
size: params.size || 10,
|
||||
total_pages: 1, // 无法确定总页数
|
||||
has_next: false,
|
||||
has_prev: (params.page || 1) > 1,
|
||||
};
|
||||
} else if (data && typeof data === 'object' && data.data) {
|
||||
// 如果API返回分页格式(和你提供的响应一致)
|
||||
return {
|
||||
data: data.data || [],
|
||||
total: data.total || 0,
|
||||
page: data.page || 1,
|
||||
size: data.size || 10,
|
||||
total_pages: data.total_pages || 0,
|
||||
has_next: data.has_next || false,
|
||||
has_prev: data.has_prev || false,
|
||||
};
|
||||
} else {
|
||||
// 其他情况,返回空结果
|
||||
return {
|
||||
data: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 10,
|
||||
total_pages: 0,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch employees:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将API数据转换为页面所需的员工数据格式
|
||||
* 优先显示display_name,其次full_name,最后username
|
||||
*/
|
||||
export function transformEmployeeData(employee: EmployeeApiData): Employee {
|
||||
return {
|
||||
id: employee.id,
|
||||
username: employee.username,
|
||||
email: employee.email,
|
||||
fullName: employee.full_name,
|
||||
phone: employee.phone,
|
||||
isActive: employee.is_active,
|
||||
isSuperuser: employee.is_superuser,
|
||||
isVerified: employee.is_verified,
|
||||
createdAt: formatDate(employee.created_at),
|
||||
updatedAt: formatDate(employee.updated_at),
|
||||
lastLoginAt: employee.last_login_at ? formatDate(employee.last_login_at) : null,
|
||||
avatarUrl: employee.avatar_url,
|
||||
bio: employee.bio,
|
||||
displayName: employee.display_name || employee.full_name || employee.username,
|
||||
departmentId: employee.department_id,
|
||||
departmentName: employee.department_name,
|
||||
scope: employee.scope,
|
||||
companyName: employee.company_name,
|
||||
tenantId: employee.tenant_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量转换员工数据
|
||||
*/
|
||||
export function transformEmployeesList(employees: EmployeeApiData[]): Employee[] {
|
||||
return employees.map(transformEmployeeData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).replace(/\//g, '-');
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination state interface for page components
|
||||
export interface PaginationState {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
}
|
||||
@@ -10,10 +10,25 @@ import { EmployeeList } from './components/EmployeeList';
|
||||
import { EmployeeFormDialog } from './components/EmployeeFormDialog';
|
||||
import { EmployeeDetailDialog } from './components/EmployeeDetailDialog';
|
||||
import { Employee, Role, EmployeeFilters, EmployeeFormData } from './types';
|
||||
import {
|
||||
fetchEmployees,
|
||||
transformEmployeesList,
|
||||
PaginationState,
|
||||
EmployeesQueryParams
|
||||
} from './components/employeeApi';
|
||||
|
||||
export default function EmployeeManagementPage() {
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false,
|
||||
});
|
||||
const [filters, setFilters] = useState<EmployeeFilters>({
|
||||
searchKeyword: '',
|
||||
statusFilter: 'all'
|
||||
@@ -26,13 +41,16 @@ export default function EmployeeManagementPage() {
|
||||
enterpriseId: 'ent-2',
|
||||
enterpriseName: '丰收现代农业集团',
|
||||
status: 'active',
|
||||
auditStatus: 'pending',
|
||||
roleIds: [],
|
||||
idCard: '',
|
||||
address: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadEmployees();
|
||||
loadRoles();
|
||||
}, []);
|
||||
}, [pagination.page, pagination.size,filters.searchKeyword, filters.statusFilter]);
|
||||
|
||||
const loadRoles = () => {
|
||||
const data = localStorage.getItem('smart_agriculture_roles');
|
||||
@@ -41,79 +59,83 @@ export default function EmployeeManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadEmployees = () => {
|
||||
const data = localStorage.getItem('smart_agriculture_employees');
|
||||
if (data) {
|
||||
setEmployees(JSON.parse(data));
|
||||
} else {
|
||||
// 初始化示例数据
|
||||
const mockEmployees: Employee[] = [
|
||||
{
|
||||
id: 'emp-1',
|
||||
enterpriseId: 'ent-2',
|
||||
enterpriseName: '丰收现代农业集团',
|
||||
username: 'zhangsan',
|
||||
name: '张三',
|
||||
phone: '13800138001',
|
||||
email: 'zhangsan@example.com',
|
||||
department: '技术部',
|
||||
position: '农机操作员',
|
||||
roleIds: ['role-3'],
|
||||
roles: ['操作员'],
|
||||
status: 'active',
|
||||
createdAt: '2024-10-01T08:00:00',
|
||||
updatedAt: '2024-10-01T08:00:00',
|
||||
lastLoginTime: '2024-10-14T09:30:00',
|
||||
},
|
||||
{
|
||||
id: 'emp-2',
|
||||
enterpriseId: 'ent-2',
|
||||
enterpriseName: '丰收现代农业集团',
|
||||
username: 'lisi',
|
||||
name: '李四',
|
||||
phone: '13900139002',
|
||||
email: 'lisi@example.com',
|
||||
department: '管理部',
|
||||
position: '部门主管',
|
||||
roleIds: ['role-2'],
|
||||
roles: ['企业管理员'],
|
||||
status: 'active',
|
||||
createdAt: '2024-10-02T10:00:00',
|
||||
updatedAt: '2024-10-02T10:00:00',
|
||||
lastLoginTime: '2024-10-14T08:15:00',
|
||||
},
|
||||
{
|
||||
id: 'emp-3',
|
||||
enterpriseId: 'ent-2',
|
||||
enterpriseName: '丰收现代农业集团',
|
||||
username: 'wangwu',
|
||||
name: '王五',
|
||||
phone: '13700137003',
|
||||
department: '维修部',
|
||||
position: '维修技师',
|
||||
roleIds: ['role-3'],
|
||||
roles: ['操作员'],
|
||||
status: 'frozen',
|
||||
createdAt: '2024-09-28T14:00:00',
|
||||
updatedAt: '2024-10-10T16:00:00',
|
||||
},
|
||||
];
|
||||
localStorage.setItem('smart_agriculture_employees', JSON.stringify(mockEmployees));
|
||||
setEmployees(mockEmployees);
|
||||
const loadEmployees = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const queryParams: EmployeesQueryParams = {
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
sort_order: 'desc'
|
||||
};
|
||||
|
||||
// 如果有搜索关键词,添加到查询参数
|
||||
if (filters.searchKeyword) {
|
||||
queryParams.search = filters.searchKeyword;
|
||||
}
|
||||
|
||||
// 如果有状态筛选,添加到查询参数
|
||||
if (filters.statusFilter !== 'all') {
|
||||
// 注意:API可能不支持直接的状态筛选,这里暂时在客户端过滤
|
||||
}
|
||||
|
||||
const response = await fetchEmployees(queryParams);
|
||||
|
||||
// 转换数据格式
|
||||
const transformedEmployees = transformEmployeesList(response.data);
|
||||
|
||||
// 应用状态筛选(如果API不支持)
|
||||
const filteredEmployees = filters.statusFilter === 'all'
|
||||
? transformedEmployees
|
||||
: transformedEmployees.filter(emp => {
|
||||
const status = emp.isActive ? 'active' : 'frozen';
|
||||
return status === filters.statusFilter;
|
||||
});
|
||||
|
||||
setEmployees(filteredEmployees);
|
||||
setPagination({
|
||||
page: response.page,
|
||||
size: response.size,
|
||||
total: response.total,
|
||||
totalPages: response.total_pages,
|
||||
hasNext: response.has_next,
|
||||
hasPrev: response.has_prev,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load employees:', error);
|
||||
toast.error('加载员工数据失败');
|
||||
|
||||
// 如果API失败,使用localStorage中的数据
|
||||
const data = localStorage.getItem('smart_agriculture_employees');
|
||||
if (data) {
|
||||
setEmployees(JSON.parse(data));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEmployees = employees.filter(emp => {
|
||||
const matchKeyword = !filters.searchKeyword ||
|
||||
emp.name.includes(filters.searchKeyword) ||
|
||||
emp.username.includes(filters.searchKeyword) ||
|
||||
emp.phone.includes(filters.searchKeyword) ||
|
||||
(emp.department && emp.department.includes(filters.searchKeyword));
|
||||
// 搜索处理函数
|
||||
const handleSearch = (searchKeyword: string) => {
|
||||
setFilters(prev => ({ ...prev, searchKeyword }));
|
||||
// 重置到第一页
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
};
|
||||
|
||||
const matchStatus = filters.statusFilter === 'all' || emp.status === filters.statusFilter;
|
||||
// 状态筛选处理函数
|
||||
const handleStatusFilter = (statusFilter: string) => {
|
||||
setFilters(prev => ({ ...prev, statusFilter }));
|
||||
// 重置到第一页
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
};
|
||||
|
||||
return matchKeyword && matchStatus;
|
||||
});
|
||||
// 分页处理函数
|
||||
const handlePageChange = (page: number) => {
|
||||
setPagination(prev => ({ ...prev, page }));
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
setPagination(prev => ({ ...prev, size, page: 1 }));
|
||||
};
|
||||
|
||||
const handleAddEmployee = () => {
|
||||
setEditingEmployee(null);
|
||||
@@ -121,7 +143,10 @@ export default function EmployeeManagementPage() {
|
||||
enterpriseId: 'ent-2',
|
||||
enterpriseName: '丰收现代农业集团',
|
||||
status: 'active',
|
||||
auditStatus: 'pending',
|
||||
roleIds: [],
|
||||
idCard: '',
|
||||
address: '',
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
@@ -169,6 +194,7 @@ export default function EmployeeManagementPage() {
|
||||
id: `emp-${Date.now()}`,
|
||||
...formData as Employee,
|
||||
roles: roleNames,
|
||||
auditStatus: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -212,6 +238,44 @@ export default function EmployeeManagementPage() {
|
||||
setShowDetailDialog(true);
|
||||
};
|
||||
|
||||
const handleAudit = (employee: Employee, action: 'approve' | 'reject') => {
|
||||
if (action === 'approve') {
|
||||
const updated = employees.map(emp =>
|
||||
emp.id === employee.id
|
||||
? {
|
||||
...emp,
|
||||
auditStatus: 'approved' as const,
|
||||
auditTime: new Date().toISOString(),
|
||||
auditor: '当前用户',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: emp
|
||||
);
|
||||
setEmployees(updated);
|
||||
localStorage.setItem('smart_agriculture_employees', JSON.stringify(updated));
|
||||
toast.success('审核通过');
|
||||
} else {
|
||||
const reason = prompt('请输入驳回原因:');
|
||||
if (reason) {
|
||||
const updated = employees.map(emp =>
|
||||
emp.id === employee.id
|
||||
? {
|
||||
...emp,
|
||||
auditStatus: 'rejected' as const,
|
||||
auditReason: reason,
|
||||
auditTime: new Date().toISOString(),
|
||||
auditor: '当前用户',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: emp
|
||||
);
|
||||
setEmployees(updated);
|
||||
localStorage.setItem('smart_agriculture_employees', JSON.stringify(updated));
|
||||
toast.success('已驳回');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EmployeeManagementHeader
|
||||
@@ -224,17 +288,23 @@ export default function EmployeeManagementPage() {
|
||||
{/* 搜索和筛选 */}
|
||||
<EmployeeManagementFilters
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onSearchChange={handleSearch}
|
||||
onStatusFilterChange={handleStatusFilter}
|
||||
/>
|
||||
|
||||
{/* 员工列表 */}
|
||||
<EmployeeList
|
||||
employees={filteredEmployees}
|
||||
employees={employees}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onViewDetail={handleViewDetail}
|
||||
onEdit={handleEdit}
|
||||
onResetPassword={handleResetPassword}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDelete}
|
||||
onAudit={handleAudit}
|
||||
/>
|
||||
|
||||
{/* 添加/编辑表单 */}
|
||||
|
||||
@@ -2,19 +2,38 @@
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
enterpriseId: string;
|
||||
enterpriseName: string;
|
||||
username: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
roleIds: string[];
|
||||
roles?: string[];
|
||||
status: UserStatus;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
phone: string | null;
|
||||
isActive: boolean;
|
||||
isSuperuser: boolean;
|
||||
isVerified: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
avatarUrl: string | null;
|
||||
bio: string | null;
|
||||
displayName: string | null;
|
||||
departmentId: string | null;
|
||||
departmentName: string | null;
|
||||
scope: string;
|
||||
companyName: string | null;
|
||||
tenantId: string;
|
||||
|
||||
// 兼容现有表单和操作的字段
|
||||
enterpriseId?: string;
|
||||
enterpriseName?: string;
|
||||
name?: string;
|
||||
department?: string;
|
||||
position?: string;
|
||||
roleIds?: string[];
|
||||
roles?: string[];
|
||||
status?: UserStatus;
|
||||
auditStatus?: 'pending' | 'approved' | 'rejected';
|
||||
auditReason?: string;
|
||||
auditTime?: string;
|
||||
auditor?: string;
|
||||
lastLoginTime?: string;
|
||||
}
|
||||
|
||||
@@ -62,4 +81,7 @@ export interface EmployeeFormData {
|
||||
enterpriseName?: string;
|
||||
status?: UserStatus;
|
||||
roleIds?: string[];
|
||||
idCard?: string;
|
||||
address?: string;
|
||||
auditStatus?: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
@@ -130,6 +130,11 @@ const centralConfigData = {
|
||||
url: "/central-config/user",
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
items: [
|
||||
{
|
||||
title: "部门管理",
|
||||
url: "/central-config/user/department",
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
title: "员工管理",
|
||||
url: "/central-config/user/employee",
|
||||
|
||||
@@ -70,7 +70,7 @@ const PaginationPrevious = ({
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
<span>上一页</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
@@ -85,7 +85,7 @@ const PaginationNext = ({
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<span>下一页</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user