import { useDeferredValue, useEffect, useMemo, useState } from 'react'; import { Button, DatePicker, Empty, Input, Modal, Spin, Table, Tree, message } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { UserOutlined } from '@ant-design/icons'; import zhCN from 'antd/es/date-picker/locale/zh_CN'; import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import { useNavigate } from 'react-router-dom'; import PageBackButton from '@/components/PageBackButton'; import { getProjectExecutionInfo } from '@/api/projectExecution'; import { deptTreeSelect, getUserProfile, listUser } from '@/api/system/user'; import './user-project.css'; const { RangePicker } = DatePicker; interface RoleRow { roleName?: string; } interface UserRow { _rowKey?: string; userId?: string | number; nickName?: string; userName?: string; phonenumber?: string; dept?: { deptName?: string }; roles?: RoleRow[]; [key: string]: unknown; } interface ProjectExecutionRow { projectId?: string | number; projectName?: string; allWorkTime?: string | number; detailList?: Array; [key: string]: unknown; } interface DeptTreeNode { key: string; title: string; rawId: string | number; children?: DeptTreeNode[]; } const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; const normalizeResponseData = (response: unknown) => isObject(response) && response.data !== undefined ? response.data : response; const toNumber = (value: unknown, fallback = 0) => { const num = Number(value); return Number.isFinite(num) ? num : fallback; }; const getDefaultRange = (): [Dayjs, Dayjs] => [dayjs().startOf('month'), dayjs().endOf('month')]; const normalizeExecutionRows = (payload: unknown): ProjectExecutionRow[] => { const data = normalizeResponseData(payload); const rows = Array.isArray(data) ? data : isObject(data) && Array.isArray(data.rows) ? data.rows : []; return rows.filter(isObject) as ProjectExecutionRow[]; }; const normalizeUserRows = (payload: unknown): { rows: UserRow[]; total: number } => { const data = normalizeResponseData(payload); const rows = Array.isArray(data) ? data : isObject(data) && Array.isArray(data.rows) ? data.rows : []; const total = isObject(data) ? toNumber(data.total, rows.length) : rows.length; return { rows: rows.filter(isObject).map((row, index) => ({ ...(row as UserRow), _rowKey: String((row as UserRow).userId ?? (row as UserRow).userName ?? `user_${index}`), })) as UserRow[], total, }; }; const normalizeDeptTreeNodes = (payload: unknown): DeptTreeNode[] => { const data = normalizeResponseData(payload); if (!Array.isArray(data)) { return []; } const mapNodes = (nodes: unknown[]): DeptTreeNode[] => nodes.flatMap((node, index) => { if (!isObject(node)) { return []; } const rawId = node.id ?? node.deptId ?? node.value ?? `dept_${index}`; const normalizedId = typeof rawId === 'string' || typeof rawId === 'number' ? rawId : String(rawId); return [ { key: String(normalizedId), rawId: normalizedId, title: String(node.label ?? node.title ?? node.deptName ?? normalizedId), children: Array.isArray(node.children) ? mapNodes(node.children) : undefined, }, ]; }); return mapNodes(data); }; const collectDeptKeys = (nodes: DeptTreeNode[]): string[] => nodes.flatMap((node) => [node.key, ...(Array.isArray(node.children) ? collectDeptKeys(node.children) : [])]); const getUserDisplayName = (user?: UserRow | null) => String(user?.nickName ?? user?.userName ?? '-'); const matchesUserKeyword = (user: UserRow, keyword: string) => { const normalizedKeyword = keyword.trim(); if (!normalizedKeyword) { return true; } return [user.nickName, user.userName, user.phonenumber].some((field) => String(field ?? '') .toLowerCase() .includes(normalizedKeyword.toLowerCase()), ); }; const UserProjectPage = () => { const navigate = useNavigate(); const [loading, setLoading] = useState(false); const [userLoading, setUserLoading] = useState(false); const [deptLoading, setDeptLoading] = useState(false); const [userModalOpen, setUserModalOpen] = useState(false); const [userKeyword, setUserKeyword] = useState(''); const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>(getDefaultRange()); const [selectedUser, setSelectedUser] = useState(null); const [selectedUserId, setSelectedUserId] = useState(''); const [selectedUserName, setSelectedUserName] = useState(''); const [pendingUserId, setPendingUserId] = useState(''); const [executionData, setExecutionData] = useState([]); const [userListData, setUserListData] = useState([]); const [deptTree, setDeptTree] = useState([]); const [expandedDeptKeys, setExpandedDeptKeys] = useState([]); const [selectedDeptId, setSelectedDeptId] = useState(''); const [userPageNum, setUserPageNum] = useState(1); const [userPageSize, setUserPageSize] = useState(10); const [userTotal, setUserTotal] = useState(0); const deferredUserKeyword = useDeferredValue(userKeyword); useEffect(() => { const fetchCurrentUser = async () => { try { const response = await getUserProfile(); const payload = normalizeResponseData(response); const user = isObject(payload) && isObject(payload.user) ? payload.user : payload; if (!isObject(user)) { return; } const nextUser: UserRow = { userId: user.userId as string | number | undefined, nickName: user.nickName as string | undefined, userName: user.userName as string | undefined, }; setSelectedUser(nextUser); setSelectedUserId(nextUser.userId ?? ''); setPendingUserId(nextUser.userId ?? ''); setSelectedUserName(getUserDisplayName(nextUser)); } catch (error) { console.error('Failed to fetch current user profile for user project page:', error); message.error('获取当前用户失败'); } }; void fetchCurrentUser(); }, []); useEffect(() => { const fetchUserProject = async () => { if (!selectedUserId || !dateRange[0] || !dateRange[1]) { return; } setLoading(true); try { const response = await getProjectExecutionInfo({ startDate: `${dateRange[0].format('YYYY-MM-DD')} 00:00:00`, endDate: `${dateRange[1].format('YYYY-MM-DD')} 00:00:00`, userId: selectedUserId, }); setExecutionData(normalizeExecutionRows(response)); } catch (error) { console.error('Failed to fetch user project table:', error); message.error('获取人员项目表失败'); setExecutionData([]); } finally { setLoading(false); } }; void fetchUserProject(); }, [dateRange, selectedUserId]); useEffect(() => { if (!userModalOpen) { return; } const fetchDeptTree = async () => { setDeptLoading(true); try { const response = await deptTreeSelect(); const treeNodes = normalizeDeptTreeNodes(response); setDeptTree(treeNodes); setExpandedDeptKeys(collectDeptKeys(treeNodes)); } catch (error) { console.error('Failed to fetch department tree for user project page:', error); setDeptTree([]); setExpandedDeptKeys([]); } finally { setDeptLoading(false); } }; void fetchDeptTree(); }, [userModalOpen]); useEffect(() => { if (!userModalOpen) { return; } const fetchUserList = async () => { setUserLoading(true); try { const normalizedKeyword = deferredUserKeyword.trim(); const response = await listUser({ pageNum: userPageNum, pageSize: normalizedKeyword ? 1000 : userPageSize, deptId: selectedDeptId || undefined, }); const { rows, total } = normalizeUserRows(response); const filteredRows = normalizedKeyword ? rows.filter((item) => matchesUserKeyword(item, normalizedKeyword)) : rows; setUserListData(filteredRows); setUserTotal(normalizedKeyword ? filteredRows.length : total); } catch (error) { console.error('Failed to fetch user list for user project page:', error); message.error('获取用户列表失败'); setUserListData([]); setUserTotal(0); } finally { setUserLoading(false); } }; void fetchUserList(); }, [deferredUserKeyword, selectedDeptId, userModalOpen, userPageNum, userPageSize]); const pendingUser = useMemo( () => userListData.find((item) => String(item.userId ?? '') === String(pendingUserId ?? '')) ?? selectedUser, [pendingUserId, selectedUser, userListData], ); const openUserModal = () => { setPendingUserId(selectedUserId); setUserPageNum(1); setSelectedDeptId(''); setUserModalOpen(true); }; const applySelectedUser = (user?: UserRow | null) => { setSelectedUser(user ?? null); setSelectedUserId(user?.userId ?? ''); setSelectedUserName(getUserDisplayName(user)); setPendingUserId(user?.userId ?? ''); setUserModalOpen(false); }; const dynamicColumns = useMemo>(() => { const columns: ColumnsType = [ { title: '项目', dataIndex: 'projectName', key: 'projectName', fixed: 'left', width: 180, render: (value: unknown, row) => ( ), }, { title: '统计工时\n(天)', dataIndex: 'allWorkTime', key: 'allWorkTime', fixed: 'left', width: 140, render: (value: unknown) => toNumber(value, 0), }, ]; let cursor = dateRange[0].startOf('day'); let index = 0; while (!cursor.isAfter(dateRange[1], 'day')) { const columnIndex = index; const key = cursor.format('M/D'); const weekLabel = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][cursor.day()]; columns.push({ title: {`${weekLabel}\n${key}`}, key: `detail-${key}`, dataIndex: 'detailList', width: 100, render: (value: unknown) => { const detailList = Array.isArray(value) ? value : []; return detailList[columnIndex] ?? ''; }, }); cursor = cursor.add(1, 'day'); index += 1; } return columns; }, [dateRange, navigate]); const summaryValues = useMemo(() => { const dayCount = dateRange[1].startOf('day').diff(dateRange[0].startOf('day'), 'day') + 1; const totalWorkTime = executionData.reduce((sum, row) => sum + toNumber(row.allWorkTime, 0), 0); const dayTotals = Array.from({ length: Math.max(dayCount, 0) }, (_item, index) => Number( executionData .reduce((sum, row) => sum + toNumber(Array.isArray(row.detailList) ? row.detailList[index] : 0, 0), 0) .toFixed(2), ), ); return { totalWorkTime: Number(totalWorkTime.toFixed(2)), dayTotals }; }, [dateRange, executionData]); return (

人员项目表

选择人员 } onClick={openUserModal} />
统计时间: { if (!values || values.length !== 2 || !values[0] || !values[1]) { return; } setDateRange([values[0], values[1]]); }} allowClear={false} locale={zhCN} format="YYYY-MM-DD" />
className="user-project-table" rowKey={(row) => String(row.projectId ?? row.projectName ?? '')} columns={dynamicColumns} dataSource={executionData} pagination={false} locale={{ emptyText: }} scroll={{ x: Math.max(dynamicColumns.length * 100, 960), y: 600 }} summary={() => ( 合计工时(天) {summaryValues.totalWorkTime} {summaryValues.dayTotals.map((item, index) => ( {item} ))} )} />
setUserModalOpen(false)} width={980} wrapClassName="user-select-dialog" destroyOnHidden footer={[ , , ]} >
setExpandedDeptKeys(keys as string[])} onSelect={(keys) => { setSelectedDeptId(String(keys[0] ?? '')); setUserPageNum(1); }} />
{ const nextValue = event.target.value; setUserPageNum(1); setUserKeyword(nextValue); }} allowClear size="large" />
rowKey={(row) => String(row._rowKey ?? row.userId ?? row.userName ?? '')} loading={userLoading} dataSource={userListData} size="middle" scroll={{ y: 420 }} rowClassName={(record) => String(record.userId ?? '') === String(pendingUserId ?? '') ? 'user-select-row is-active' : 'user-select-row' } onRow={(record) => ({ onClick: () => { setPendingUserId(record.userId ?? ''); }, onDoubleClick: () => { applySelectedUser(record); }, })} locale={{ emptyText: }} pagination={{ current: userPageNum, pageSize: userPageSize, total: userTotal, showSizeChanger: true, showTotal: (total) => `共 ${total} 条`, onChange: (page, pageSize) => { setUserPageNum(page); setUserPageSize(pageSize); }, }} columns={[ { title: '序号', key: 'index', width: 90, render: (_value, _row, index) => (userPageNum - 1) * userPageSize + index + 1, }, { title: '姓名', dataIndex: 'nickName', key: 'nickName', width: 180, render: (_value, row) => (
{getUserDisplayName(row)}
), }, { title: '部门', dataIndex: ['dept', 'deptName'], key: 'deptName', width: 220, render: (value: unknown) => String(value ?? '-'), }, { title: '角色', dataIndex: 'roles', key: 'roles', render: (value: unknown) => Array.isArray(value) && value.length ? value.map((item) => String((item as RoleRow)?.roleName ?? '')).filter(Boolean).join('、') : '-', }, ]} />
); }; export default UserProjectPage;