返回 AiToEarn
ProfileTab.tsx
根目录 / project / aitoearn-web / src / app / layout / SettingsModal / tabs / ProfileTab.tsx
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
254 lines Plain Text