| 1 | /** |
| 2 | * ProfileTab - 个人资料设置 Tab |
| 3 | */ |
| 4 | |
| 5 | 'use client' |
| 6 | |
| 7 | import { Camera } from 'lucide-react' |
| 8 | import { useRef, useState } from 'react' |
| 9 | import { useShallow } from 'zustand/shallow' |
| 10 | import { updateUserInfoApi } from '@/api/auth/auth.api' |
| 11 | import { uploadToOss } from '@/api/materials/material.api' |
| 12 | import { useTransClient } from '@/app/i18n/client' |
| 13 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' |
| 14 | import { Button } from '@/components/ui/button' |
| 15 | import { useUserStore } from '@/store/user' |
| 16 | import { cn } from '@/utils/className' |
| 17 | import { getOssUrl } from '@/utils/oss' |
| 18 | import { toast } from '@/utils/ui/toast' |
| 19 | import { AvatarCropModal } from './components/AvatarCropModal' |
| 20 | |
| 21 | interface ProfileTabProps { |
| 22 | onClose: () => void |
| 23 | } |
| 24 | |
| 25 | export function ProfileTab(_props: ProfileTabProps) { |
| 26 | const { t } = useTransClient('settings') |
| 27 | const { t: tCommon } = useTransClient('common') |
| 28 | const fileInputRef = useRef<HTMLInputElement>(null) |
| 29 | |
| 30 | const { userInfo, getUserInfo } = useUserStore( |
| 31 | useShallow(state => ({ |
| 32 | userInfo: state.userInfo, |
| 33 | getUserInfo: state.getUserInfo, |
| 34 | })), |
| 35 | ) |
| 36 | |
| 37 | const [isEditingName, setIsEditingName] = useState(false) |
| 38 | const [editName, setEditName] = useState(userInfo?.name || '') |
| 39 | const [isSaving, setIsSaving] = useState(false) |
| 40 | const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) |
| 41 | const [cropModalOpen, setCropModalOpen] = useState(false) |
| 42 | const [selectedFile, setSelectedFile] = useState<File | null>(null) |
| 43 | |
| 44 | const avatarUrl = userInfo?.avatar ? getOssUrl(userInfo.avatar) : '' |
| 45 | |
| 46 | const handleAvatarClick = () => { |
| 47 | fileInputRef.current?.click() |
| 48 | } |
| 49 | |
| 50 | const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { |
| 51 | const file = e.target.files?.[0] |
| 52 | if (!file) |
| 53 | return |
| 54 | |
| 55 | if (!file.type.startsWith('image/')) { |
| 56 | toast.error(t('profile.avatarTypeError')) |
| 57 | return |
| 58 | } |
| 59 | |
| 60 | if (file.size > 5 * 1024 * 1024) { |
| 61 | toast.error(t('profile.avatarSizeError')) |
| 62 | return |
| 63 | } |
| 64 | |
| 65 | setSelectedFile(file) |
| 66 | setCropModalOpen(true) |
| 67 | |
| 68 | if (fileInputRef.current) { |
| 69 | fileInputRef.current.value = '' |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | const handleCropComplete = async (blob: Blob) => { |
| 74 | setIsUploadingAvatar(true) |
| 75 | try { |
| 76 | const file = new File([blob], `avatar_${Date.now()}.png`, { type: 'image/png' }) |
| 77 | const ossPath = await uploadToOss(file) |
| 78 | |
| 79 | const response = await updateUserInfoApi({ |
| 80 | name: userInfo?.name || '', |
| 81 | avatar: ossPath, |
| 82 | }) |
| 83 | |
| 84 | if (response?.code === 0 && response.data) { |
| 85 | await getUserInfo() |
| 86 | toast.success(t('profile.avatarUpdateSuccess')) |
| 87 | setCropModalOpen(false) |
| 88 | setSelectedFile(null) |
| 89 | } |
| 90 | else { |
| 91 | toast.error(response?.message || t('profile.avatarUpdateFailed')) |
| 92 | } |
| 93 | } |
| 94 | catch (error) { |
| 95 | console.error('头像上传失败:', error) |
| 96 | toast.error(t('profile.avatarUpdateFailed')) |
| 97 | } |
| 98 | finally { |
| 99 | setIsUploadingAvatar(false) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | const handleCropModalClose = () => { |
| 104 | setCropModalOpen(false) |
| 105 | setSelectedFile(null) |
| 106 | } |
| 107 | |
| 108 | const handleSaveName = async () => { |
| 109 | if (!editName.trim()) { |
| 110 | toast.error(t('profile.nameRequired')) |
| 111 | return |
| 112 | } |
| 113 | |
| 114 | if (editName.length < 2 || editName.length > 20) { |
| 115 | toast.error(t('profile.nameLengthError')) |
| 116 | return |
| 117 | } |
| 118 | |
| 119 | setIsSaving(true) |
| 120 | try { |
| 121 | const response = await updateUserInfoApi({ |
| 122 | name: editName.trim(), |
| 123 | avatar: userInfo?.avatar, |
| 124 | }) |
| 125 | |
| 126 | if (response?.code === 0 && response.data) { |
| 127 | await getUserInfo() |
| 128 | toast.success(t('profile.nameUpdateSuccess')) |
| 129 | setIsEditingName(false) |
| 130 | } |
| 131 | else { |
| 132 | toast.error(response?.message || t('profile.nameUpdateFailed')) |
| 133 | } |
| 134 | } |
| 135 | catch (error) { |
| 136 | toast.error(t('profile.nameUpdateFailed')) |
| 137 | } |
| 138 | finally { |
| 139 | setIsSaving(false) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | return ( |
| 144 | <div className="w-full space-y-6"> |
| 145 | {/* 用户信息卡片 */} |
| 146 | <div className="flex items-center gap-4"> |
| 147 | <div className="group relative shrink-0 cursor-pointer" onClick={handleAvatarClick}> |
| 148 | <Avatar className="h-16 w-16"> |
| 149 | <AvatarImage src={avatarUrl} alt={userInfo?.name || ''} /> |
| 150 | <AvatarFallback>{userInfo?.name?.charAt(0)?.toUpperCase() || 'U'}</AvatarFallback> |
| 151 | </Avatar> |
| 152 | <div |
| 153 | className={cn( |
| 154 | 'absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity', |
| 155 | isUploadingAvatar ? 'opacity-100' : 'opacity-0 group-hover:opacity-100', |
| 156 | )} |
| 157 | > |
| 158 | {isUploadingAvatar ? ( |
| 159 | <div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" /> |
| 160 | ) : ( |
| 161 | <Camera size={20} className="text-white" /> |
| 162 | )} |
| 163 | </div> |
| 164 | <input |
| 165 | ref={fileInputRef} |
| 166 | type="file" |
| 167 | accept="image/*" |
| 168 | className="hidden" |
| 169 | onChange={handleFileSelect} |
| 170 | /> |
| 171 | </div> |
| 172 | |
| 173 | <div className="min-w-0 flex-1"> |
| 174 | {isEditingName ? ( |
| 175 | <div className="flex items-center gap-2"> |
| 176 | <input |
| 177 | type="text" |
| 178 | value={editName} |
| 179 | onChange={e => setEditName(e.target.value)} |
| 180 | className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20" |
| 181 | autoFocus |
| 182 | onKeyDown={(e) => { |
| 183 | if (e.key === 'Enter') |
| 184 | handleSaveName() |
| 185 | if (e.key === 'Escape') { |
| 186 | setIsEditingName(false) |
| 187 | setEditName(userInfo?.name || '') |
| 188 | } |
| 189 | }} |
| 190 | /> |
| 191 | <Button size="sm" onClick={handleSaveName} disabled={isSaving} className="h-8"> |
| 192 | {isSaving ? t('profile.saving') : t('profile.save')} |
| 193 | </Button> |
| 194 | <Button |
| 195 | size="sm" |
| 196 | variant="ghost" |
| 197 | onClick={() => { |
| 198 | setIsEditingName(false) |
| 199 | setEditName(userInfo?.name || '') |
| 200 | }} |
| 201 | className="h-8" |
| 202 | > |
| 203 | {t('profile.cancel')} |
| 204 | </Button> |
| 205 | </div> |
| 206 | ) : ( |
| 207 | <div className="flex flex-col gap-0.5"> |
| 208 | <div |
| 209 | className="cursor-pointer text-lg font-semibold text-foreground transition-colors hover:text-primary" |
| 210 | onClick={() => setIsEditingName(true)} |
| 211 | > |
| 212 | {userInfo?.name || tCommon('unknownUser')} |
| 213 | </div> |
| 214 | </div> |
| 215 | )} |
| 216 | <p className="mt-1 truncate text-sm text-muted-foreground"> |
| 217 | {userInfo?.mail || (userInfo?.phone ? userInfo.phone.replace(/^(.{3}).*(.{4})$/, '$1****$2') : '-')} |
| 218 | </p> |
| 219 | </div> |
| 220 | </div> |
| 221 | |
| 222 | {/* Docs & GitHub Stars */} |
| 223 | <div className="flex flex-wrap items-center gap-3"> |
| 224 | <Button asChild variant="outline" size="sm"> |
| 225 | <a href="https://docs.aitoearn.ai/" target="_blank" rel="noopener noreferrer"> |
| 226 | {tCommon('docs')} |
| 227 | </a> |
| 228 | </Button> |
| 229 | <a |
| 230 | href="https://github.com/yikart/AttAiToEarn" |
| 231 | target="_blank" |
| 232 | rel="noopener noreferrer" |
| 233 | className="inline-flex items-center" |
| 234 | > |
| 235 | <img |
| 236 | src="https://camo.githubusercontent.com/9feb948af77cdb3d349cafc64266332d8d24243f6bd72de0980b75c9de719cd8/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f73746172732f79696b6172742f4174744169546f4561726e3f636f6c6f723d666136343730" |
| 237 | alt={t('profile.githubStars')} |
| 238 | className="h-5" |
| 239 | /> |
| 240 | </a> |
| 241 | </div> |
| 242 | |
| 243 | {/* 头像裁剪弹窗 */} |
| 244 | <AvatarCropModal |
| 245 | open={cropModalOpen} |
| 246 | onClose={handleCropModalClose} |
| 247 | imageFile={selectedFile} |
| 248 | onCropComplete={handleCropComplete} |
| 249 | isUploading={isUploadingAvatar} |
| 250 | /> |
| 251 | </div> |
| 252 | ) |
| 253 | } |
| 254 |