import { DeleteOutlined, RotateLeftOutlined, RotateRightOutlined, UploadOutlined } from '@ant-design/icons'; import { PageContainer, ProDescriptions, ProTable, type ProColumns } from '@ant-design/pro-components'; import { Avatar, Button, Card, Col, Form, Image, Input, message, Modal, Popconfirm, Radio, Row, Slider, Space, Tabs, Upload, type UploadProps } from 'antd'; import { useCallback, useEffect, useMemo, useState, type ComponentType } from 'react'; import CropperModule, { type Area, type CropperProps, type Point } from 'react-easy-crop'; import type { OnlineVO } from '@/api/monitor/online/types'; import type { SocialAuthVO } from '@/api/system/social/types'; import type { UserInfoVO, UserProfileForm } from '@/api/system/user/types'; import { delOnline, getOnline } from '@/api/monitor/online'; import { uploadOss } from '@/api/system/oss'; import { authRouterUrl, authUnlock, getAuthList } from '@/api/system/social/auth'; import { getUserProfile, updateUserProfile, updateUserPwd } from '@/api/system/user'; import giteeIcon from '@/assets/icons/svg/gitee.svg'; import githubIcon from '@/assets/icons/svg/github.svg'; import maxkeyIcon from '@/assets/icons/svg/maxkey.svg'; import topiamIcon from '@/assets/icons/svg/topiam.svg'; import wechatIcon from '@/assets/icons/svg/wechat.svg'; import defaultAvatar from '@/assets/images/profile.jpg'; import DictTag from '@/components/common/DictTag'; import { useDict } from '@/hooks/useDict'; import { useLoading } from '@/hooks/useLoading'; import { usePermissionStore } from '@/stores/permissionStore'; import { dictOptions } from '@/utils/dict'; import { getUploadErrorMessage, validateUploadFile } from '@/utils/upload'; // react-easy-crop 6.2.x 的 CommonJS 类型声明遗漏了默认导出,运行时仍为默认组件。 type CropperComponentProps = Partial & Pick; const Cropper = CropperModule as unknown as ComponentType; const socialProviders = [ { source: 'wechat', label: '微信', icon: wechatIcon }, { source: 'maxkey', label: 'MaxKey', icon: maxkeyIcon }, { source: 'topiam', label: 'TopIam', icon: topiamIcon }, { source: 'gitee', label: 'Gitee', icon: giteeIcon }, { source: 'github', label: 'GitHub', icon: githubIcon } ]; const avatarFileTypes = ['png', 'jpg', 'jpeg']; const avatarOutputSize = 200; const initialAvatarCrop: Point = { x: 0, y: 0 }; function socialProvider(source?: string) { return socialProviders.find(item => item.source === source); } function socialLabel(source?: string) { return socialProvider(source)?.label || source || '-'; } function socialIcon(source?: string) { const provider = socialProvider(source); if (!provider) return null; return ; } function socialProviderNode(source?: string) { return ( {socialIcon(source)} {socialLabel(source)} ); } function createImage(url: string) { return new Promise((resolve, reject) => { const image = new window.Image(); image.addEventListener('load', () => resolve(image)); image.addEventListener('error', error => reject(error)); image.src = url; }); } function getRadianAngle(degreeValue: number) { return (degreeValue * Math.PI) / 180; } function rotateSize(width: number, height: number, rotation: number) { const rotRad = getRadianAngle(rotation); return { width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height) }; } async function getCroppedAvatarBlob(imageSrc: string, crop: Area, rotation: number) { const image = await createImage(imageSrc); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return null; const rotRad = getRadianAngle(rotation); const { width: bBoxWidth, height: bBoxHeight } = rotateSize(image.naturalWidth, image.naturalHeight, rotation); canvas.width = bBoxWidth; canvas.height = bBoxHeight; ctx.translate(bBoxWidth / 2, bBoxHeight / 2); ctx.rotate(rotRad); ctx.translate(-image.naturalWidth / 2, -image.naturalHeight / 2); ctx.drawImage(image, 0, 0); const croppedCanvas = document.createElement('canvas'); const croppedCtx = croppedCanvas.getContext('2d'); if (!croppedCtx) return null; croppedCanvas.width = avatarOutputSize; croppedCanvas.height = avatarOutputSize; croppedCtx.drawImage(canvas, crop.x, crop.y, crop.width, crop.height, 0, 0, avatarOutputSize, avatarOutputSize); return new Promise(resolve => croppedCanvas.toBlob(resolve, 'image/png')); } function avatarUploadFileName(fileName: string) { return fileName.replace(/\.[^.]+$/, '') || 'avatar'; } function normalizeOnlineDevices(payload: OnlineVO[] | { rows?: OnlineVO[] } | undefined) { if (Array.isArray(payload)) return payload; return payload?.rows || []; } export default function Profile() { const [userForm] = Form.useForm(); const [pwdForm] = Form.useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>(); const reloadMenus = usePermissionStore(state => state.reloadMenus); const dicts = useDict('sys_user_gender', 'sys_device_type'); const [profile, setProfile] = useState(); const [devices, setDevices] = useState([]); const [auths, setAuths] = useState([]); const { loading, withLoading } = useLoading(); const { loading: avatarUploading, withLoading: withAvatarUploading } = useLoading(); const [avatarCropOpen, setAvatarCropOpen] = useState(false); const [avatarCropUrl, setAvatarCropUrl] = useState(''); const [avatarFileName, setAvatarFileName] = useState('avatar.png'); const [avatarCrop, setAvatarCrop] = useState(initialAvatarCrop); const [avatarZoom, setAvatarZoom] = useState(1); const [avatarRotation, setAvatarRotation] = useState(0); const [avatarCroppedAreaPixels, setAvatarCroppedAreaPixels] = useState(); const user = profile?.user || {}; const genderOptions = useMemo(() => dictOptions(dicts.sys_user_gender), [dicts.sys_user_gender]); const onAvatarCropComplete = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { setAvatarCroppedAreaPixels(croppedAreaPixels); }, []); const loadProfile = useCallback(async () => { await withLoading(async () => { const [profileRes, authRes, onlineRes] = await Promise.all([getUserProfile(), getAuthList(), getOnline()]); setProfile(profileRes.data); setAuths(authRes.data || []); setDevices(normalizeOnlineDevices(onlineRes.data)); userForm.setFieldsValue(profileRes.data.user || {}); }); }, [userForm, withLoading]); useEffect(() => { loadProfile(); }, [loadProfile]); const submitUserInfo = async () => { const values = await userForm.validateFields(); const profileForm: UserProfileForm = { nickName: values.nickName, phoneNumber: values.phoneNumber, email: values.email, gender: values.gender }; await updateUserProfile(profileForm); message.success('修改成功'); await reloadMenus(); await loadProfile(); }; const submitPwd = async () => { const values = await pwdForm.validateFields(); await updateUserPwd(values.oldPassword, values.newPassword); message.success('修改成功'); pwdForm.resetFields(); }; const uploadProps: UploadProps = { showUploadList: false, accept: avatarFileTypes.map(item => `.${item}`).join(','), beforeUpload: file => { if ( !validateUploadFile(file, { fileTypes: avatarFileTypes, maxSizeMB: 5, fileKind: '头像图片', matchMime: true }) ) { return Upload.LIST_IGNORE; } if (avatarCropUrl) { window.URL.revokeObjectURL(avatarCropUrl); } setAvatarFileName(file.name || 'avatar.png'); setAvatarCrop(initialAvatarCrop); setAvatarZoom(1); setAvatarRotation(0); setAvatarCroppedAreaPixels(undefined); setAvatarCropUrl(window.URL.createObjectURL(file)); setAvatarCropOpen(true); return Upload.LIST_IGNORE; } }; const closeAvatarCrop = () => { if (avatarCropUrl) { window.URL.revokeObjectURL(avatarCropUrl); } setAvatarCropOpen(false); setAvatarCropUrl(''); setAvatarCrop(initialAvatarCrop); setAvatarZoom(1); setAvatarRotation(0); setAvatarCroppedAreaPixels(undefined); }; const submitAvatarCrop = async () => { if (!avatarCropUrl || !avatarCroppedAreaPixels) { message.warning('请先选择头像图片'); return; } const blob = await getCroppedAvatarBlob(avatarCropUrl, avatarCroppedAreaPixels, avatarRotation); if (!blob) { message.error('头像裁剪失败'); return; } const formData = new FormData(); formData.append('file', blob, `${avatarUploadFileName(avatarFileName)}.png`); await withAvatarUploading(async () => { try { const res = await uploadOss(formData); if (!res.data.ossId) { throw new Error('头像上传失败'); } await updateUserProfile({ avatar: res.data.ossId }); message.success('修改成功'); closeAvatarCrop(); await reloadMenus(); await loadProfile(); } catch (error) { message.error(getUploadErrorMessage(error, '头像上传失败')); } }); }; const deleteDevice = async (row: OnlineVO) => { await delOnline(row.tokenId); message.success('删除成功'); const res = await getOnline(); setDevices(normalizeOnlineDevices(res.data)); }; const unlockAuth = async (row: SocialAuthVO) => { await authUnlock(row.id); message.success('解绑成功'); const res = await getAuthList(); setAuths(res.data || []); }; const bindAuth = async (source: string) => { const res = await authRouterUrl(source); window.location.href = res.data; }; const deviceColumns: ProColumns[] = [ { title: '设备类型', dataIndex: 'deviceType', width: 120, render: (_, row) => }, { title: '主机', dataIndex: 'ipaddr', ellipsis: true }, { title: '登录地点', dataIndex: 'loginLocation', ellipsis: true }, { title: '操作系统', dataIndex: 'os', ellipsis: true }, { title: '浏览器', dataIndex: 'browser', ellipsis: true }, { title: '登录时间', dataIndex: 'loginTime', valueType: 'dateTime', width: 170 }, { title: '操作', valueType: 'option', width: 80, fixed: 'right', render: (_, row) => ( deleteDevice(row)}> ) }, { key: 'resetPwd', label: '修改密码', children: (
"'|\\]+$/, message: '不能包含非法字符:< > " \' \\ |' } ]} > ({ validator(_, value) { if (!value || getFieldValue('newPassword') === value) return Promise.resolve(); return Promise.reject(new Error('两次输入的密码不一致')); } }) ]} >
) }, { key: 'thirdParty', label: '第三方应用', children: ( rowKey="id" columns={authColumns} scroll={{ x: 900 }} options={false} dataSource={auths} search={false} pagination={false} /> {socialProviders.map(item => ( ))} ) }, { key: 'onlineDevice', label: '在线设备', children: ( rowKey="tokenId" columns={deviceColumns} scroll={{ x: 1000 }} options={false} dataSource={devices} search={false} pagination={false} /> ) } ]} />
{avatarCropUrl && (
)}
); }