feat: 实现用户名和头像编辑功能
使用 Better Auth 的 updateUser API 更新用户信息,使用 SDK 的 FileController 上传头像。 ## 新增功能 - 创建 useUpdateProfile hook 处理用户信息更新 - 支持选择图片上传头像(使用 expo-image-picker) - 先上传头像到 S3,再更新用户信息 ## 更新文件 - lib/auth.ts: 导出 updateUser 方法 - hooks/use-update-profile.ts: 新建更新资料 hook - hooks/index.ts: 导出 useUpdateProfile - components/drawer/EditProfileDrawer.tsx: - 添加头像选择功能(点击相机按钮) - 调用 updateProfile API 保存更改 - 添加加载状态和禁用状态 - 更新 onSave 回调参数类型 - app/(tabs)/my.tsx: - 传递 initialAvatar 给编辑抽屉 - 显示用户真实头像(如有) - 更新 onSave 回调处理 ## 功能流程 1. 点击相机按钮选择图片 2. 调用 uploadFile 上传到 S3 3. 调用 updateUser 更新用户信息 4. 保存成功后刷新 session Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
e4b249f502
commit
bf11241d68
|
|
@ -154,7 +154,7 @@ export default function My() {
|
|||
{/* 个人信息区 */}
|
||||
<View style={styles.profileSection}>
|
||||
<Image
|
||||
source={require('@/assets/images/icon.png')}
|
||||
source={session?.user?.image ? { uri: session.user.image } : require('@/assets/images/icon.png')}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
/>
|
||||
|
|
@ -260,7 +260,10 @@ export default function My() {
|
|||
visible={editDrawerVisible}
|
||||
onClose={() => setEditDrawerVisible(false)}
|
||||
initialName={profileName}
|
||||
onSave={(name) => setProfileName(name)}
|
||||
initialAvatar={session?.user?.image}
|
||||
onSave={(data) => {
|
||||
setProfileName(data.name)
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Platform,
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
|
|
@ -16,51 +17,54 @@ import { useTranslation } from 'react-i18next'
|
|||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
|
||||
import { CloseIcon, AvatarUploadIcon } from '@/components/icon'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { type ImagePickerAsset } from 'expo-image-picker'
|
||||
import { useUpdateProfile } from '@/hooks'
|
||||
|
||||
|
||||
interface EditProfileDrawerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
initialName?: string
|
||||
initialAvatar?: any
|
||||
onSave?: (name: string) => void
|
||||
initialAvatar?: string
|
||||
onSave?: (data: { name: string; avatar?: string }) => void
|
||||
}
|
||||
|
||||
export default function EditProfileDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
initialName = '乔乔乔',
|
||||
initialName = '',
|
||||
initialAvatar,
|
||||
onSave,
|
||||
}: EditProfileDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
const [name, setName] = useState(initialName)
|
||||
const [avatar, setAvatar] = useState<string | undefined>(initialAvatar)
|
||||
const [selectedImage, setSelectedImage] = useState<ImagePickerAsset | null>(null)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const { loading, pickImage, updateProfile } = useUpdateProfile()
|
||||
|
||||
const snapPoints = useMemo(() => [280], [])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.expand()
|
||||
// 重置为初始值
|
||||
setName(initialName)
|
||||
setAvatar(initialAvatar)
|
||||
setSelectedImage(null)
|
||||
} else {
|
||||
bottomSheetRef.current?.close()
|
||||
}
|
||||
}, [visible])
|
||||
}, [visible, initialName, initialAvatar])
|
||||
|
||||
// 当抽屉打开时,重置名字为初始值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setName(initialName)
|
||||
}
|
||||
}, [visible, initialName])
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
|
|
@ -73,10 +77,39 @@ export default function EditProfileDrawer({
|
|||
[]
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
// 处理头像选择
|
||||
const handleAvatarPress = useCallback(async () => {
|
||||
const image = await pickImage()
|
||||
if (image) {
|
||||
setSelectedImage(image)
|
||||
setAvatar(image.uri)
|
||||
}
|
||||
}, [pickImage])
|
||||
|
||||
const handleSave = async () => {
|
||||
Keyboard.dismiss()
|
||||
onSave?.(name)
|
||||
onClose()
|
||||
|
||||
// 构建更新数据
|
||||
const updateData: { name: string; image?: ImagePickerAsset } = {
|
||||
name: name.trim(),
|
||||
}
|
||||
|
||||
// 如果选择了新头像,添加到更新数据
|
||||
if (selectedImage) {
|
||||
updateData.image = selectedImage
|
||||
}
|
||||
|
||||
// 调用更新接口
|
||||
const result = await updateProfile(updateData)
|
||||
|
||||
if (!result.error) {
|
||||
// 通知父组件更新
|
||||
onSave?.({
|
||||
name: updateData.name,
|
||||
avatar: avatar,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -90,7 +123,7 @@ export default function EditProfileDrawer({
|
|||
index={visible ? 0 : -1}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
enablePanDownToClose
|
||||
enablePanDownToClose={!loading}
|
||||
backgroundStyle={styles.bottomSheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
backdropComponent={renderBackdrop}
|
||||
|
|
@ -109,6 +142,7 @@ export default function EditProfileDrawer({
|
|||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
disabled={loading}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
|
|
@ -116,34 +150,44 @@ export default function EditProfileDrawer({
|
|||
<View style={styles.avatarContainer}>
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Image
|
||||
source={initialAvatar || require('@/assets/images/icon.png')}
|
||||
source={avatar ? { uri: avatar } : require('@/assets/images/icon.png')}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Pressable style={styles.cameraButton}>
|
||||
<Pressable
|
||||
style={styles.cameraButton}
|
||||
onPress={handleAvatarPress}
|
||||
disabled={loading}
|
||||
>
|
||||
<View style={styles.cameraIconContainer}>
|
||||
<AvatarUploadIcon />
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<AvatarUploadIcon />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 输入框 */}
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t('editProfile.namePlaceholder')}
|
||||
placeholderTextColor="#666666"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t('editProfile.namePlaceholder')}
|
||||
placeholderTextColor="#666666"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
blurOnSubmit={true}
|
||||
editable={!loading}
|
||||
/>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<Pressable
|
||||
style={styles.saveButtonContainer}
|
||||
<Pressable
|
||||
style={[styles.saveButtonContainer, loading && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={loading || !name.trim()}
|
||||
android_ripple={{ color: 'rgba(255, 255, 255, 0.1)' }}
|
||||
>
|
||||
<LinearGradient
|
||||
|
|
@ -152,7 +196,11 @@ export default function EditProfileDrawer({
|
|||
end={{ x: 1, y: 0 }}
|
||||
style={styles.saveButton}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>{t('editProfile.save')}</Text>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>{t('editProfile.save')}</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
|
@ -242,6 +290,9 @@ const styles = StyleSheet.create({
|
|||
overflow: 'hidden',
|
||||
height: 48,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButton: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
|
|
@ -255,4 +306,3 @@ const styles = StyleSheet.create({
|
|||
fontWeight: '500',
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ export { useTags } from './use-tags'
|
|||
export { useDebounce } from './use-debounce'
|
||||
export { useWorksSearch } from './use-works-search'
|
||||
export { useChangePassword } from './use-change-password'
|
||||
export { useUpdateProfile } from './use-update-profile'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { ImagePickerAsset, launchImageLibraryAsync, MediaTypeOptions } from 'expo-image-picker'
|
||||
import Toast from '@/components/ui/Toast'
|
||||
import { updateUser } from '@/lib/auth'
|
||||
import { uploadFile } from '@/lib/uploadFile'
|
||||
import { type ApiError } from '@/lib/types'
|
||||
|
||||
export interface UpdateProfileParams {
|
||||
name?: string
|
||||
image?: ImagePickerAsset
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<ApiError | null>(null)
|
||||
|
||||
/**
|
||||
* 选择图片
|
||||
*/
|
||||
const pickImage = useCallback(async (): Promise<ImagePickerAsset | null> => {
|
||||
try {
|
||||
const result = await launchImageLibraryAsync({
|
||||
mediaTypes: MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
})
|
||||
|
||||
if (result.canceled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.assets[0] || null
|
||||
} catch (err) {
|
||||
console.error('选择图片失败:', err)
|
||||
Toast.show('选择图片失败')
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 更新用户资料
|
||||
*/
|
||||
const updateProfile = useCallback(async (params: UpdateProfileParams) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const updateData: Record<string, any> = {}
|
||||
|
||||
// 如果有新头像,先上传图片
|
||||
if (params.image) {
|
||||
Toast.showLoading({ title: '上传头像中...' })
|
||||
try {
|
||||
const imageUrl = await uploadFile({
|
||||
uri: params.image.uri,
|
||||
mimeType: params.image.mimeType || 'image/jpeg',
|
||||
fileName: `avatar_${Date.now()}.jpg`,
|
||||
})
|
||||
updateData.image = imageUrl
|
||||
Toast.hideLoading()
|
||||
} catch (err) {
|
||||
Toast.hideLoading()
|
||||
throw new Error('头像上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户名
|
||||
if (params.name) {
|
||||
updateData.name = params.name
|
||||
}
|
||||
|
||||
// 如果有数据需要更新
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
Toast.showLoading({ title: '保存中...' })
|
||||
const result = await updateUser(updateData)
|
||||
Toast.hideLoading()
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
Toast.show('保存成功')
|
||||
return { data: result.data, error: null }
|
||||
}
|
||||
|
||||
return { data: null, error: null }
|
||||
} catch (err) {
|
||||
const errorObj = err as ApiError
|
||||
setError(errorObj)
|
||||
Toast.show(errorObj.message || '保存失败,请稍后重试')
|
||||
return { data: null, error: errorObj }
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
pickImage,
|
||||
updateProfile,
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ export const authClient = createAuthClient({
|
|||
],
|
||||
})
|
||||
|
||||
export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp, changePassword } =
|
||||
export const { signIn, signUp, signOut, useSession, $Infer, admin, forgetPassword, resetPassword, emailOtp, changePassword, updateUser } =
|
||||
authClient
|
||||
|
||||
// 导出 loomart API(来自 createSkerClientPlugin)
|
||||
|
|
|
|||
Loading…
Reference in New Issue