This commit is contained in:
Rao 2025-04-02 10:39:35 +08:00
commit c6b59483de
4 changed files with 481 additions and 105 deletions

View File

@ -11,12 +11,15 @@ export class ShareCodeService {
// 生成8位分享码使用易读的字符 // 生成8位分享码使用易读的字符
private readonly generateCode = customAlphabet( private readonly generateCode = customAlphabet(
'23456789ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789ABCDEFGHJKLMNPQRSTUVWXYZ',
8 8,
); );
constructor(private readonly resourceService: ResourceService) {} constructor(private readonly resourceService: ResourceService) {}
async generateShareCode(fileId: string, fileName?: string): Promise<GenerateShareCodeResponse> { async generateShareCode(
fileId: string,
fileName?: string,
): Promise<GenerateShareCodeResponse> {
try { try {
// 检查文件是否存在 // 检查文件是否存在
const resource = await this.resourceService.findUnique({ const resource = await this.resourceService.findUnique({
@ -46,9 +49,7 @@ export class ShareCodeService {
expiresAt, expiresAt,
isUsed: false, isUsed: false,
// 只在没有现有文件名且提供了新文件名时才更新文件名 // 只在没有现有文件名且提供了新文件名时才更新文件名
...(fileName && !existingShareCode.fileName ...(fileName && !existingShareCode.fileName ? { fileName } : {}),
? { fileName }
: {})
}, },
}); });
} else { } else {
@ -117,10 +118,7 @@ export class ShareCodeService {
try { try {
const result = await db.shareCode.deleteMany({ const result = await db.shareCode.deleteMany({
where: { where: {
OR: [ OR: [{ expiresAt: { lt: new Date() } }, { isUsed: true }],
{ expiresAt: { lt: new Date() } },
{ isUsed: true },
],
}, },
}); });

View File

@ -28,8 +28,22 @@ export default function DeptSettingPage() {
} }
}); });
// 清除已上传文件
const handleClearFile = () => {
setUploadedFileId('');
setUploadedFileName('');
setUploadedFiles([]);
setFileNameMap({});
};
// 处理文件上传 // 处理文件上传
const handleFileSelect = async (file: File) => { const handleFileSelect = async (file: File) => {
// 限制:如果已有上传文件,则提示用户
if (uploadedFiles.length > 0) {
message.warning('只能上传一个文件,请先删除已上传的文件');
return;
}
const fileKey = `file-${Date.now()}`; // 生成唯一的文件标识 const fileKey = `file-${Date.now()}`; // 生成唯一的文件标识
handleFileUpload( handleFileUpload(
@ -38,15 +52,13 @@ export default function DeptSettingPage() {
setUploadedFileId(result.fileId); setUploadedFileId(result.fileId);
setUploadedFileName(result.fileName); setUploadedFileName(result.fileName);
// 添加到已上传文件列表 // 添加到已上传文件列表
setUploadedFiles(prev => [...prev, {id: result.fileId, name: file.name}]); setUploadedFiles([{ id: result.fileId, name: file.name }]);
// 在前端保存文件名映射(用于当前会话) // 在前端保存文件名映射(用于当前会话)
setFileNameMap(prev => ({ setFileNameMap({
...prev,
[result.fileId]: file.name [result.fileId]: file.name
})); });
// 上传成功后保存原始文件名到数据库 // 上传成功后保存原始文件名到数据库
try { try {
@ -63,7 +75,6 @@ export default function DeptSettingPage() {
}), }),
}); });
const responseText = await response.text(); const responseText = await response.text();
console.log('保存文件名响应:', response.status, responseText); console.log('保存文件名响应:', response.status, responseText);
@ -87,11 +98,36 @@ export default function DeptSettingPage() {
); );
}; };
// 处理多个文件上传 // 处理多个文件上传 - 已移除
// const handleFilesUpload = (file: File) => { // const handleFilesUpload = (file: File) => {
// handleFileSelect(file); // handleFileSelect(file);
// }; // };
// 处理文件删除
const handleDeleteFile = async (fileId: string) => {
try {
// 可以添加删除文件的API调用
// const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/delete/${fileId}`, {
// method: 'DELETE'
// });
// if (!response.ok) {
// throw new Error('删除文件失败');
// }
// 无论服务器删除是否成功,前端都需要更新状态
setUploadedFiles([]);
setUploadedFileId('');
setUploadedFileName('');
setFileNameMap({});
message.success('文件已删除');
} catch (error) {
console.error('删除文件错误:', error);
message.error('删除文件失败');
}
};
// 拖拽相关处理函数 // 拖拽相关处理函数
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
@ -168,6 +204,9 @@ export default function DeptSettingPage() {
{/* 文件上传区域 */} {/* 文件上传区域 */}
<div style={{ marginBottom: '40px' }}> <div style={{ marginBottom: '40px' }}>
<h3></h3> <h3></h3>
{/* 如果没有已上传文件,显示上传区域 */}
{uploadedFiles.length === 0 ? (
<div <div
ref={dropRef} ref={dropRef}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
@ -186,20 +225,19 @@ export default function DeptSettingPage() {
> >
<InboxOutlined style={{ fontSize: '48px', color: isDragging ? '#1890ff' : '#d9d9d9' }} /> <InboxOutlined style={{ fontSize: '48px', color: isDragging ? '#1890ff' : '#d9d9d9' }} />
<p></p> <p></p>
<p style={{ fontSize: '12px', color: '#888' }}></p> <p style={{ fontSize: '12px', color: '#888' }}></p>
<input <input
type="file" type="file"
id="file-input" id="file-input"
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={(e) => { onChange={(e) => {
const file = e.target.files[0]; const file = e.target.files?.[0];
if (file) { if (file) {
handleFileSelect(file); handleFileSelect(file);
} }
}} }}
disabled={isUploading} disabled={isUploading}
/> />
<label <label
htmlFor="file-input" htmlFor="file-input"
@ -216,6 +254,21 @@ export default function DeptSettingPage() {
<UploadOutlined /> <UploadOutlined />
</label> </label>
</div> </div>
) : (
<div style={{ marginBottom: '20px' }}>
<div style={{
padding: '10px',
backgroundColor: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: '4px',
marginBottom: '10px'
}}>
<p style={{ color: '#52c41a', margin: 0 }}>
</p>
</div>
</div>
)}
{/* 已上传文件列表 */} {/* 已上传文件列表 */}
{uploadedFiles.length > 0 && ( {uploadedFiles.length > 0 && (
@ -224,13 +277,12 @@ export default function DeptSettingPage() {
borderRadius: '4px', borderRadius: '4px',
overflow: 'hidden' overflow: 'hidden'
}}> }}>
{uploadedFiles.map((file, index) => ( {uploadedFiles.map((file) => (
<div key={file.id} style={{ <div key={file.id} style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '10px 15px', padding: '10px 15px',
borderBottom: index < uploadedFiles.length - 1 ? '1px solid #f0f0f0' : 'none', backgroundColor: '#fafafa'
backgroundColor: index % 2 === 0 ? '#fafafa' : 'white'
}}> }}>
<div style={{ <div style={{
display: 'flex', display: 'flex',
@ -254,6 +306,8 @@ export default function DeptSettingPage() {
<Button <Button
type="text" type="text"
icon={<DeleteOutlined style={{ color: '#ff4d4f' }} />} icon={<DeleteOutlined style={{ color: '#ff4d4f' }} />}
onClick={() => handleDeleteFile(file.id)}
title="删除此文件"
/> />
</div> </div>
))} ))}

View File

@ -1,9 +1,11 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { api, useStaff } from "@nice/client"; import { api, useStaff } from "@nice/client";
import { Button, CascaderProps, message, Modal } from 'antd'; import { Button, CascaderProps, message, Modal, Input, Upload } from 'antd';
import { areaOptions } from '@web/src/app/main/staffinfo_write/area-options'; import { areaOptions } from '@web/src/app/main/staffinfo_write/area-options';
import StaffInfoWrite from '@web/src/app/main/staffinfo_write/staffinfo_write.page'; import StaffInfoWrite from '@web/src/app/main/staffinfo_write/staffinfo_write.page';
import { utils, writeFile, read } from 'xlsx';
import { UploadOutlined } from '@ant-design/icons';
function getAreaName(codes: string[], level?: number): string { function getAreaName(codes: string[], level?: number): string {
const result: string[] = []; const result: string[] = [];
@ -18,6 +20,24 @@ function getAreaName(codes: string[], level?: number): string {
return level ? result[level - 1] || '' : result.join(' / ') || codes.join('/'); return level ? result[level - 1] || '' : result.join(' / ') || codes.join('/');
} }
// 添加表头提取工具函数
function extractHeaders(columns: any[]): string[] {
const result: string[] = [];
const extractHeadersRecursive = (cols: any[]) => {
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 StaffMessage() { export default function StaffMessage() {
const [rowData, setRowData] = useState<any[]>([]); const [rowData, setRowData] = useState<any[]>([]);
const [columnDefs, setColumnDefs] = useState<any[]>([]); const [columnDefs, setColumnDefs] = useState<any[]>([]);
@ -26,6 +46,11 @@ export default function StaffMessage() {
const fields = useCustomFields(); const fields = useCustomFields();
const [isEditModalVisible, setIsEditModalVisible] = useState(false); const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [currentEditStaff, setCurrentEditStaff] = useState<any>(null); const [currentEditStaff, setCurrentEditStaff] = useState<any>(null);
const [gridApi, setGridApi] = useState<any>(null);
const [fileNameVisible, setFileNameVisible] = useState(false);
const [fileName, setFileName] = useState('');
const [defaultFileName] = useState(`员工数据_${new Date().toISOString().slice(0, 10)}`);
const [importVisible, setImportVisible] = useState(false);
// 获取数据 // 获取数据
const { data: staffData } = api.staff.findMany.useQuery({ const { data: staffData } = api.staff.findMany.useQuery({
@ -61,7 +86,6 @@ export default function StaffMessage() {
setCurrentEditStaff(selectedRows[0]); setCurrentEditStaff(selectedRows[0]);
setIsEditModalVisible(true); setIsEditModalVisible(true);
}, [selectedRows]); }, [selectedRows]);
console.log('选中行',currentEditStaff);
// 处理编辑完成 // 处理编辑完成
const handleEditComplete = useCallback(() => { const handleEditComplete = useCallback(() => {
setIsEditModalVisible(false); setIsEditModalVisible(false);
@ -150,6 +174,241 @@ export default function StaffMessage() {
staffData && setRowData(staffData); staffData && setRowData(staffData);
}, [staffData]); }, [staffData]);
// 修改导出模板处理函数
const handleExportTemplate = useCallback(() => {
const headerNames = extractHeaders(columnDefs);
// 创建示例数据行
let exampleRow: Record<string, string> = {};
// 检查是否有选中行
if (selectedRows.length > 0) {
// 使用第一条选中的记录作为模板数据
const templateData = selectedRows[0];
// 基础字段
exampleRow['姓名'] = templateData.showname || '';
exampleRow['所属部门'] = templateData.department?.name || '';
// 处理自定义字段
const fieldsList = Array.isArray(fields?.data) ? fields.data : [];
fieldsList.forEach((field: any) => {
const fieldValue = templateData.fieldValues?.find(
(fv: any) => fv.fieldId === field.id
)?.value;
let displayValue = fieldValue;
// 根据字段类型处理值
if (field.type === 'cascader' && fieldValue) {
displayValue = getAreaName(fieldValue.split('/'));
} else if (field.type === 'date' && fieldValue) {
displayValue = new Date(fieldValue).toLocaleDateString();
} else if (field.type === 'textarea' && fieldValue) {
displayValue = fieldValue.replace(/,/g, '\n');
}
exampleRow[field.label || field.name] = displayValue || '';
});
} else {
// 如果没有选中行,使用默认示例数据
exampleRow['姓名'] = '张三';
exampleRow['所属部门'] = '技术部';
// 添加所有自定义字段的空值
fieldsList.forEach((field: any) => {
exampleRow[field.label || field.name] = '';
});
}
// 创建空白行供用户填写
const emptyRow = headerNames.reduce((obj, header) => {
obj[header] = '';
return obj;
}, {} as Record<string, string>);
// 创建工作簿和工作表
const wb = utils.book_new();
const ws = utils.json_to_sheet([exampleRow], { header: headerNames });
// 设置列宽
const colWidth = headerNames.map(() => ({ wch: 20 }));
ws['!cols'] = colWidth;
// 在第二行添加提示文字
const rowIdx = 2; // 第二行索引
const cellRef = utils.encode_cell({ r: rowIdx, c: 0 }); // A3单元格
const tipText = selectedRows.length > 0
? '以上为选中人员数据,请在下方行填写实际数据'
: '以上为示例数据,请在下方行填写实际数据';
ws[cellRef] = { t: 's', v: tipText };
// 手动添加空白行
utils.sheet_add_json(ws, [emptyRow], { skipHeader: true, origin: rowIdx + 1 });
// 合并提示文字单元格
if (!ws['!merges']) ws['!merges'] = [];
ws['!merges'].push({
s: { r: rowIdx, c: 0 }, // 起始单元格 A3
e: { r: rowIdx, c: Math.min(5, headerNames.length - 1) } // 结束单元格,跨越多列
});
utils.book_append_sheet(wb, ws, "员工模板");
writeFile(wb, `员工数据模板_${new Date().toISOString().slice(0, 10)}.xlsx`);
}, [columnDefs, selectedRows, fields.data]);
// 导出数据处理函数
const handleConfirm = useCallback(() => {
setFileNameVisible(true);
}, []);
// 处理文件名确认
const handleFileNameConfirm = useCallback(() => {
setFileNameVisible(false);
if (!gridApi) {
console.error('Grid API 未正确初始化');
return;
}
try {
// 获取所有数据
const allData = selectedRows.length > 0 ? selectedRows : rowData;
// 格式化数据
const exportData = allData.map(row => {
const formattedRow: Record<string, any> = {};
// 基础字段
formattedRow['姓名'] = row.showname || '';
formattedRow['所属部门'] = row.department?.name || '';
// 动态字段
const fieldsList = Array.isArray(fields?.data) ? fields.data : [];
fieldsList.forEach((field: any) => {
const fieldValue = row.fieldValues?.find((fv: any) => fv.fieldId === field.id)?.value;
let displayValue = fieldValue;
// 根据字段类型处理值
if (field.type === 'cascader' && fieldValue) {
displayValue = getAreaName(fieldValue.split('/'));
} else if (field.type === 'date' && fieldValue) {
displayValue = new Date(fieldValue).toLocaleDateString();
} else if (field.type === 'textarea' && fieldValue) {
displayValue = fieldValue.replace(/,/g, '\n');
}
formattedRow[field.label || field.name] = displayValue || '';
});
return formattedRow;
});
// 生成工作表
const ws = utils.json_to_sheet(exportData);
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, "员工数据");
// 生成文件名
const finalFileName = fileName || defaultFileName;
writeFile(wb, `${finalFileName}.xlsx`);
} catch (error) {
console.error('导出失败:', error);
message.error('导出失败,请稍后重试');
}
}, [fileName, defaultFileName, rowData, selectedRows, fields.data, gridApi]);
// 处理导入数据
const createMany = api.staff.create.useMutation({
onSuccess: () => {
message.success('员工数据导入成功');
// 刷新数据
api.staff.findMany.useQuery();
setImportVisible(false);
},
onError: (error) => {
message.error(`导入失败: ${error.message}`);
}
});
// 处理Excel导入数据
const handleImportData = useCallback((excelData: any[]) => {
if (excelData.length === 0) {
message.warning('没有可导入的数据');
return;
}
try {
// 将Excel数据转换为API需要的格式
const staffData = excelData.map(row => {
const staff: any = { fieldValues: [] };
// 处理基础字段
if (row['姓名']) staff.showname = row['姓名'];
// 处理部门
if (row['所属部门']) {
// 简单存储部门名称,后续可能需要查询匹配
staff.departmentName = row['所属部门'];
}
// 处理自定义字段
const fieldsList = Array.isArray(fields?.data) ? fields.data : [];
fieldsList.forEach((field: any) => {
let value = row[field.label || field.name];
// 跳过空值
if (value === undefined || value === '') return;
// 根据字段类型处理输入值
switch (field.type) {
case 'cascader':
// 级联选择器可能需要将显示名称转回代码
// 这里简单保留原值,实际可能需要查询转换
break;
case 'date':
// 尝试将日期字符串转换为ISO格式
try {
const dateObj = new Date(value);
if (!isNaN(dateObj.getTime())) {
value = dateObj.toISOString();
}
} catch (e) {
console.error(`日期格式转换错误: ${value}`);
}
break;
case 'textarea':
// 将换行符替换回逗号进行存储
if (typeof value === 'string') {
value = value.replace(/\n/g, ',');
}
break;
// 可以根据需要添加其他字段类型的处理
}
// 添加到fieldValues数组
staff.fieldValues.push({
fieldId: field.id,
value: String(value)
});
});
return staff;
});
// 提交数据
createMany.mutate({ data: staffData });
message.info(`正在导入${staffData.length}条员工数据...`);
} catch (error) {
console.error('处理导入数据失败:', error);
message.error('数据格式错误,导入失败');
}
}, [fields.data, createMany]);
return ( return (
<> <>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
@ -158,6 +417,15 @@ export default function StaffMessage() {
onClick={handleDelete}></Button> onClick={handleDelete}></Button>
<Button className="bg-blue-500 hover:bg-blue-600 border-blue-500 text-white rounded-md px-4 py-2" <Button className="bg-blue-500 hover:bg-blue-600 border-blue-500 text-white rounded-md px-4 py-2"
onClick={handleEdit}></Button> onClick={handleEdit}></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-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
</Button>
<Button onClick={handleConfirm} className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600">
</Button>
</div> </div>
<Button className="bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700 rounded-md px-4 py-2" <Button className="bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700 rounded-md px-4 py-2"
onClick={() => { onClick={() => {
@ -180,6 +448,7 @@ export default function StaffMessage() {
headerHeight={40} headerHeight={40}
rowHeight={40} rowHeight={40}
domLayout="autoHeight" domLayout="autoHeight"
onGridReady={(params) => setGridApi(params.api)}
/> />
</div> </div>
</div> </div>
@ -203,6 +472,61 @@ export default function StaffMessage() {
/> />
)} )}
</Modal> </Modal>
{/* 导出文件名对话框 */}
<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>
</> </>
); );
} }