origin/apps/web/src/app/main/staffpage/stafftable/page.tsx

732 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState } from 'react';
import { AgGridReact } from '@ag-grid-community/react';
import { ColDef, ColGroupDef } from '@ag-grid-community/core';
import { SetFilterModule } from '@ag-grid-enterprise/set-filter';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
import { areaOptions } from '@web/src/app/main/staffinformation/area-options';
import type { CascaderProps } from 'antd/es/cascader';
import { utils, writeFile, read } from 'xlsx';
import { Modal, Input, Button, Switch, Upload, message } from 'antd';
import { api } from '@nice/client';
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
import { UploadOutlined } from '@ant-design/icons';
// 修改函数类型定义
type ExcelColumn = { header: string; key: string };
function getAreaName(codes: string[], level?: number): string {
const result: string[] = [];
let currentLevel: CascaderProps['options'] = areaOptions;
for (const code of codes) {
const found = currentLevel?.find(opt => opt.value === code);
if (!found) break;
result.push(String(found.label));
currentLevel = found.children || [];
if (level && result.length >= level) break; // 添加层级控制
}
return level ? result[level - 1] || '' : result.join(' / ') || codes.join('/');
}
// 修改表头提取工具函数
function extractHeaders(columns: (ColDef | ColGroupDef)[]): string[] {
const result: string[] = [];
const extractHeadersRecursive = (cols: (ColDef | ColGroupDef)[]) => {
cols.forEach(col => {
if ('children' in col && col.children) {
extractHeadersRecursive(col.children);
} else if (col.headerName) {
result.push(col.headerName);
}
});
};
extractHeadersRecursive(columns);
return result;
}
export default function StaffTable() {
const { data: staffs, isLoading, refetch } = api.staff.findMany.useQuery({
where: { deletedAt: null },
include: { // 添加关联查询
department: true
}
});
const [gridApi, setGridApi] = useState<any>(null); // 添加gridApi状态
const [fileNameVisible, setFileNameVisible] = useState(false);
const [fileName, setFileName] = useState('');
const [defaultFileName] = useState(`员工数据_${new Date().toISOString().slice(0, 10)}`);
const [paginationEnabled, setPaginationEnabled] = useState(true);
const [importVisible, setImportVisible] = useState(false);
const handleConfirm = async () => {
setFileNameVisible(true);
};
// 添加导出处理函数
const handleFileNameConfirm = () => {
setFileNameVisible(false);
if (!gridApi || typeof gridApi.getRenderedNodes !== 'function') {
console.error('Grid API 未正确初始化');
return;
}
// 修改获取节点方式(使用更可靠的 getRenderedNodes
const rowNodes = gridApi.getRenderedNodes();
// 获取所有列定义
const flattenColumns = (cols: any[]): any[] => cols.flatMap(col => col.children ? flattenColumns(col.children) : col);
const allColDefs = flattenColumns(gridApi.getColumnDefs());
// 获取数据(兼容分页状态)
// 处理数据格式
const processRowData = (node: any) => {
const row: Record<string, any> = {};
allColDefs.forEach((colDef: any) => {
if (!colDef.field || !colDef.headerName) return;
// 修改字段访问方式,支持嵌套对象
const value = colDef.field.includes('.')
? colDef.field.split('.').reduce((obj: any, key: string) => (obj || {})[key], node.data)
: node.data[colDef.field];
let renderedValue = value;
// 应用列格式化
if (colDef.valueFormatter) {
renderedValue = colDef.valueFormatter({ value });
}
// 处理特殊数据类型
if (colDef.cellRenderer) {
const renderResult = colDef.cellRenderer({ value });
if (typeof renderResult === 'string') {
renderedValue = renderResult;
} else if (renderResult?.props?.dangerouslySetInnerHTML?.__html) {
renderedValue = renderResult.props.dangerouslySetInnerHTML.__html.replace(/<br\s*\/?>/gi, '\n');
}
}
// 统一布尔值显示
if (typeof renderedValue === 'boolean') {
renderedValue = renderedValue ? '是' : '否';
}
// 日期字段处理
if (['hireDate', 'rankDate', 'seniority'].includes(colDef.field) && value) {
renderedValue = new Date(value).toLocaleDateString('zh-CN');
}
// 特别处理部门名称显示
if (colDef.field === 'department.name' && renderedValue === undefined) {
renderedValue = node.data.department?.name || '';
}
row[colDef.headerName] = renderedValue;
});
return row;
};
try {
// 生成工作表
const rowData = rowNodes.map(processRowData);
const ws = utils.json_to_sheet(rowData);
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, "员工数据");
// 生成文件名
const finalFileName = fileName || `${defaultFileName}_${paginationEnabled ? '当前页' : '全部'}`;
writeFile(wb, `${finalFileName}.xlsx`);
} catch (error) {
console.error('导出失败:', error);
}
};
const handleResetFilters = () => {
if (gridApi) {
gridApi.setFilterModel(null);
gridApi.onFilterChanged(); // 触发筛选更新
}
};
const columnDefs: (ColDef | ColGroupDef)[] = [
{
field: 'username',
headerName: '姓名',
pinned: 'left',
// floatingFilter: true // 确保启用浮动过滤器
},
{
headerName: '个人基本信息',
children: [
{
field: 'idNumber',
headerName: '身份证号',
},
{
field: 'type',
headerName: '人员类型',
},
{ field: 'officerId', headerName: '警号'},
{ field: 'phoneNumber', headerName: '手机号' },
{ field: 'age', headerName: '年龄'},
{
field: 'sex', headerName: '性别',
cellRenderer: (params: any) => {
switch (params.value) {
case true:
return '男';
case false:
return '女';
default:
return '未知';
}
}
},
{ field: 'bloodType', headerName: '血型',},
{
field: 'birthplace',
headerName: '籍贯',
valueFormatter: (params) => params.value ? getAreaName(params.value.split('/')) : '',
},
{ field: 'source', headerName: '来源'},
]
},
{
headerName: '政治信息',
children: [
{ field: 'politicalStatus', headerName: '政治面貌', },
{ field: 'partyPosition', headerName: '党内职务', }
]
},
{
headerName: '职务信息',
children: [
{ field: 'department.name', headerName: '所属部门', },
{ field: 'rank', headerName: '衔职级别', },
{
field: 'rankDate', headerName: '衔职时间',
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ''
},
{ field: 'proxyPosition', headerName: '代理职务', },
{ field: 'post', headerName: '岗位', }
]
},
{
headerName: '入职信息',
children: [
{
field: 'hireDate', headerName: '入职时间',
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ''
},
{
field: 'seniority', headerName: '工龄认定时间',
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ''
},
{ field: 'sourceType', headerName: '来源类型', },
{
field: 'isReentry', headerName: '是否二次入职',
cellRenderer: (params: any) => params.value ? '是' : '否'
},
{
field: 'isExtended', headerName: '是否延期服役',
cellRenderer: (params: any) => params.value ? '是' : '否'
},
{
field: 'currentPositionDate', headerName: '现岗位开始时间',
valueFormatter: (params: any) => params.value ? new Date(params.value).toLocaleDateString() : ''
}
]
},
{
headerName: '教育背景',
children: [
{ field: 'education', headerName: '学历', },
{ field: 'educationType', headerName: '学历形式', },
{
field: 'isGraduated', headerName: '是否毕业',
cellRenderer: (params: any) => params.value ? '是' : '否'
},
{ field: 'major', headerName: '专业', },
{ field: 'foreignLang', headerName: '外语能力', }
]
},
{
headerName: '培训信息',
children: [
{ field: 'trainType', headerName: '培训类型', },
{ field: 'trainInstitute', headerName: '培训机构', },
{ field: 'trainMajor', headerName: '培训专业', },
{
field: 'hasTrain', headerName: '是否参加培训',
cellRenderer: (params: any) => params.value ? '是' : '否'
}
]
},
{
headerName: '鉴定信息',
children: [
{ field: 'certRank', headerName: '鉴定等级', },
{ field: 'certWork', headerName: '鉴定工种', },
{
field: 'hasCert', headerName: '是否参加鉴定',
cellRenderer: (params: any) => params.value ? '是' : '否'
}
]
},
{
headerName: '工作信息',
children: [
{
field: 'equipment',
headerName: '操作维护装备',
cellRenderer: (params: any) => (
<div
style={{ lineHeight: '24px' }}
dangerouslySetInnerHTML={{ __html: params.value?.replace(/,/g, '<br/>') || '' }}
/>
),
autoHeight: true
},
{
field: 'projects',
headerName: '演训任务经历',
cellRenderer: (params: any) => (
<div
style={{ lineHeight: '24px' }}
dangerouslySetInnerHTML={{ __html: params.value?.replace(/,/g, '<br/>') || '' }}
/>
),
autoHeight: true
},
// 修改剩余两个字段的cellRenderer为相同结构
{
field: 'awards',
headerName: '奖励信息',
cellRenderer: (params: any) => (
<div
style={{ lineHeight: '24px' }}
dangerouslySetInnerHTML={{ __html: params.value?.replace(/,/g, '<br/>') || '' }}
/>
),
autoHeight: true
},
{
field: 'punishments',
headerName: '处分信息',
cellRenderer: (params: any) => (
<div
style={{ lineHeight: '24px' }}
dangerouslySetInnerHTML={{ __html: params.value?.replace(/,/g, '<br/>') || '' }}
/>
),
autoHeight: true
}
]
}
];
const defaultColDef: ColDef = {
sortable: true,
filter: 'agSetColumnFilter',
resizable: false,
flex: 1,
minWidth: 200,
maxWidth: 600,
suppressMovable: true,
cellStyle: {
whiteSpace: 'normal',
overflowWrap: 'break-word'
},
wrapText: true,
autoHeight: true
};
// 修改导出模板处理函数
const handleExportTemplate = () => {
const headerNames = extractHeaders(columnDefs);
// 创建一个空白行对象,键为列名,值为空字符串
const emptyRow = headerNames.reduce((obj, header) => {
obj[header] = '';
return obj;
}, {} as Record<string, string>);
// 创建示例数据行
const exampleRow = headerNames.reduce((obj, header) => {
// 根据不同的表头设置不同的示例值
switch(header) {
// 基本信息示例
case '姓名': obj[header] = '张三'; break;
case '身份证号': obj[header] = '110101199001011234'; break;
case '人员类型': obj[header] = '在职'; break;
case '警号': obj[header] = '012345'; break;
case '手机号': obj[header] = '13800138000'; break;
case '年龄': obj[header] = '30'; break;
case '性别': obj[header] = '男'; break;
case '血型': obj[header] = 'A型'; break;
case '籍贯': obj[header] = '北京市海淀区'; break;
case '来源': obj[header] = '社会招聘'; break;
// 政治信息示例
case '政治面貌': obj[header] = '党员'; break;
case '党内职务': obj[header] = '支部书记'; break;
// 职务信息示例
case '所属部门': obj[header] = '技术部'; break;
case '衔职级别': obj[header] = '三级警司'; break;
case '衔职时间': obj[header] = '2020-01-01'; break;
case '代理职务': obj[header] = '技术组长'; break;
case '岗位': obj[header] = '技术开发'; break;
// 入职信息示例
case '入职时间': obj[header] = '2015-07-01'; break;
case '工龄认定时间': obj[header] = '2015-07-01'; break;
case '来源类型': obj[header] = '招聘'; break;
case '是否二次入职': obj[header] = '否'; break;
case '是否延期服役': obj[header] = '否'; break;
case '现岗位开始时间': obj[header] = '2018-05-01'; break;
// 教育背景示例
case '学历': obj[header] = '本科'; break;
case '学历形式': obj[header] = '全日制'; break;
case '是否毕业': obj[header] = '是'; break;
case '专业': obj[header] = '计算机科学与技术'; break;
case '外语能力': obj[header] = '英语四级'; break;
// 培训信息示例
case '培训类型': obj[header] = '专业技能'; break;
case '培训机构': obj[header] = '公安大学'; break;
case '培训专业': obj[header] = '网络安全'; break;
case '是否参加培训': obj[header] = '是'; break;
// 鉴定信息示例
case '鉴定等级': obj[header] = '高级'; break;
case '鉴定工种': obj[header] = '信息安全'; break;
case '是否参加鉴定': obj[header] = '是'; break;
// 工作信息示例
case '操作维护装备': obj[header] = '服务器,网络设备,安全设备'; break;
case '演训任务经历': obj[header] = '2019年网络安全演习,2020年数据恢复演练'; break;
case '奖励信息': obj[header] = '2018年度优秀员工,2020年技术创新奖'; break;
case '处分信息': obj[header] = ''; break;
default: obj[header] = `示例${header}`; break;
}
return obj;
}, {} as Record<string, string>);
// 创建工作簿和工作表,包含示例行和空白行
const wb = utils.book_new();
const ws = utils.json_to_sheet([exampleRow, emptyRow], { header: headerNames });
// 设置列宽
const colWidth = headerNames.map(() => ({ wch: 20 }));
ws['!cols'] = colWidth;
// 添加一些样式表示示例数据行
// XLSX.js 不直接支持样式,但我们可以添加注释
const note = { t: 's', v: '以上为示例数据,请在下方行填写实际数据' };
ws['A3'] = note;
utils.book_append_sheet(wb, ws, "员工模板");
writeFile(wb, `员工数据模板_${new Date().toISOString().slice(0, 10)}.xlsx`);
};
// 添加导入API钩子
const createManyMutation = api.staff.create.useMutation({
onSuccess: () => {
message.success('员工数据导入成功');
refetch(); // 刷新表格数据
setImportVisible(false);
},
onError: (error) => {
message.error(`导入失败: ${error.message}`);
}
});
// 处理Excel导入数据
const handleImportData = (excelData: any[]) => {
// 转换Excel数据为后端接受的格式
const staffData = excelData.map(row => {
// 创建一个标准的员工对象
const staff: any = {};
// 遍历列定义匹配Excel中的数据
columnDefs.forEach(colDef => {
if ('children' in colDef && colDef.children) {
colDef.children.forEach(childCol => {
if ('field' in childCol && childCol.headerName) {
// 使用表头名称查找Excel数据
const value = row[childCol.headerName];
if (value !== undefined) {
// 处理嵌套属性 (如 department.name)
if (childCol.field.includes('.')) {
const [parent, child] = childCol.field.split('.');
// 对于department.name特殊处理
if (parent === 'department' && child === 'name') {
// 仅存储部门名称,后续可处理
staff.departmentName = value;
}
} else {
// 根据字段类型进行处理
processFieldValue(staff, childCol.field, value);
}
}
}
});
} else if ('field' in colDef && colDef.headerName) {
const value = row[colDef.headerName];
if (value !== undefined) {
processFieldValue(staff, colDef.field, value);
}
}
});
return staff;
});
// 调用后端API保存数据
if (staffData.length > 0) {
// 逐个创建员工记录
staffData.forEach(staff => {
createManyMutation.mutate({
data: staff
});
});
message.success(`已提交${staffData.length}条员工数据导入请求`);
} else {
message.warning('没有可导入的有效数据');
}
};
// 字段值处理函数
const processFieldValue = (staff: any, field: string, value: any) => {
// 跳过空值
if (value === null || value === undefined || value === '') {
return;
}
// 根据字段类型分别处理
switch (field) {
// 字符串字段 - 确保转为字符串
case 'idNumber':
case 'officerId':
case 'phoneNumber':
case 'username':
case 'password':
case 'showname':
case 'avatar':
case 'type':
case 'bloodType':
case 'birthplace':
case 'source':
case 'politicalStatus':
case 'partyPosition':
case 'rank':
case 'proxyPosition':
case 'post':
case 'sourceType':
case 'education':
case 'educationType':
case 'major':
case 'foreignLang':
case 'trainType':
case 'trainInstitute':
case 'trainMajor':
case 'certRank':
case 'certWork':
case 'equipment':
case 'projects':
case 'awards':
case 'punishments':
case 'domainId':
case 'deptId':
staff[field] = String(value);
break;
// 布尔字段 - 转为布尔值
case 'sex':
staff[field] = value === '男' ? true : value === '女' ? false : null;
break;
case 'enabled':
case 'isReentry':
case 'isExtended':
case 'isGraduated':
case 'hasTrain':
case 'hasCert':
if (typeof value === 'string') {
staff[field] = value === '是' || value === '√' || value === 'true' || value === '1';
} else {
staff[field] = Boolean(value);
}
break;
// 数值字段 - 转为数字
case 'age':
staff[field] = parseInt(value, 10);
break;
case 'order':
staff[field] = parseFloat(value);
break;
// 日期字段 - 转为日期格式
case 'rankDate':
case 'hireDate':
case 'seniority':
case 'currentPositionDate':
// 尝试将日期字符串转换为日期对象
try {
// Excel日期可能以不同格式导出
let dateValue = value;
// 如果是Excel序列号格式的日期
if (typeof value === 'number') {
// Excel日期是从1900年1月1日开始的天数
// 需要转换为JavaScript日期
const excelEpoch = new Date(1899, 11, 30);
dateValue = new Date(excelEpoch.getTime() + value * 86400000);
}
// 如果是字符串格式的日期
else if (typeof value === 'string') {
// 尝试解析常见日期格式
dateValue = new Date(value);
}
// 检查日期是否有效
if (dateValue instanceof Date && !isNaN(dateValue.getTime())) {
staff[field] = dateValue.toISOString();
} else {
console.warn(`无效的日期格式: ${field} = ${value}`);
}
} catch (e) {
console.error(`日期转换错误 (${field}): ${e}`);
}
break;
// 默认情况下保持原值
default:
staff[field] = value;
}
};
return (
<div className="ag-theme-alpine w-full h-[calc(100vh-100px)]"
style={{
width: '100%',
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)'
}}
>
{!isLoading && (
<div className="flex items-center gap-4 mb-2">
<Button
onClick={handleConfirm}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Excel
</Button>
<Button onClick={() => setImportVisible(true)} className="bg-orange-500 text-white px-4 py-2 rounded hover:bg-orange-600">
Excel
</Button>
<Button onClick={handleExportTemplate} className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
</Button>
<Button
onClick={handleResetFilters}
className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"
>
</Button>
<div className="flex items-center gap-2">
<span className="text-gray-600"></span>
<Switch
checked={paginationEnabled}
onChange={(checked) => setPaginationEnabled(checked)}
checkedChildren="开"
unCheckedChildren="关"
/>
</div>
</div>
)}
<Modal
title="输入文件名"
open={fileNameVisible}
onOk={handleFileNameConfirm}
onCancel={() => setFileNameVisible(false)}
okText="导出"
cancelText="取消"
>
<Input
placeholder={`默认名称: ${defaultFileName}`}
value={fileName}
onChange={(e) => setFileName(e.target.value)}
/>
</Modal>
<Modal
title="导入员工数据"
open={importVisible}
onCancel={() => setImportVisible(false)}
footer={null}
>
<Upload
accept=".xlsx,.xls"
beforeUpload={file => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const wb = read(e.target?.result);
const data = utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]);
if (data.length === 0) {
message.warning('Excel文件中没有数据');
return;
}
message.info(`读取到${data.length}条数据,正在处理...`);
handleImportData(data);
} catch (error) {
console.error('解析Excel文件失败:', error);
message.error('Excel文件格式错误请确保使用正确的模板');
}
};
reader.readAsArrayBuffer(file);
return false;
}}
>
<Button icon={<UploadOutlined />}>Excel文件</Button>
</Upload>
<div className="mt-4 text-gray-500 text-sm">
使
</div>
</Modal>
{isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="text-gray-600 text-xl">...</div>
</div>
) : (
<AgGridReact
modules={[SetFilterModule, ClientSideRowModelModule]}
onGridReady={(params) => setGridApi(params.api)} // 添加gridApi回调
rowData={staffs}
columnDefs={columnDefs}
defaultColDef={{
...defaultColDef,
filterParams: {
textCustomComparator: (_, value) => value !== '',
}
}}
enableCellTextSelection={true}
pagination={paginationEnabled}
paginationAutoPageSize={true}
cacheQuickFilter={true}
/>
)}
</div>
);
}