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:
@@ -154,7 +154,7 @@ export default function My() {
|
|||||||
{/* 个人信息区 */}
|
{/* 个人信息区 */}
|
||||||
<View style={styles.profileSection}>
|
<View style={styles.profileSection}>
|
||||||
<Image
|
<Image
|
||||||
source={require('@/assets/images/icon.png')}
|
source={session?.user?.image ? { uri: session.user.image } : require('@/assets/images/icon.png')}
|
||||||
style={styles.avatar}
|
style={styles.avatar}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
@@ -260,7 +260,10 @@ export default function My() {
|
|||||||
visible={editDrawerVisible}
|
visible={editDrawerVisible}
|
||||||
onClose={() => setEditDrawerVisible(false)}
|
onClose={() => setEditDrawerVisible(false)}
|
||||||
initialName={profileName}
|
initialName={profileName}
|
||||||
onSave={(name) => setProfileName(name)}
|
initialAvatar={session?.user?.image}
|
||||||
|
onSave={(data) => {
|
||||||
|
setProfileName(data.name)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { Image } from 'expo-image'
|
import { Image } from 'expo-image'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
@@ -16,44 +17,47 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
|
import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
|
||||||
import { CloseIcon, AvatarUploadIcon } from '@/components/icon'
|
import { CloseIcon, AvatarUploadIcon } from '@/components/icon'
|
||||||
import { LinearGradient } from 'expo-linear-gradient'
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
|
import { type ImagePickerAsset } from 'expo-image-picker'
|
||||||
|
import { useUpdateProfile } from '@/hooks'
|
||||||
|
|
||||||
|
|
||||||
interface EditProfileDrawerProps {
|
interface EditProfileDrawerProps {
|
||||||
visible: boolean
|
visible: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
initialName?: string
|
initialName?: string
|
||||||
initialAvatar?: any
|
initialAvatar?: string
|
||||||
onSave?: (name: string) => void
|
onSave?: (data: { name: string; avatar?: string }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditProfileDrawer({
|
export default function EditProfileDrawer({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
initialName = '乔乔乔',
|
initialName = '',
|
||||||
initialAvatar,
|
initialAvatar,
|
||||||
onSave,
|
onSave,
|
||||||
}: EditProfileDrawerProps) {
|
}: EditProfileDrawerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||||
const [name, setName] = useState(initialName)
|
const [name, setName] = useState(initialName)
|
||||||
|
const [avatar, setAvatar] = useState<string | undefined>(initialAvatar)
|
||||||
|
const [selectedImage, setSelectedImage] = useState<ImagePickerAsset | null>(null)
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
|
||||||
|
const { loading, pickImage, updateProfile } = useUpdateProfile()
|
||||||
|
|
||||||
const snapPoints = useMemo(() => [280], [])
|
const snapPoints = useMemo(() => [280], [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
bottomSheetRef.current?.expand()
|
bottomSheetRef.current?.expand()
|
||||||
|
// 重置为初始值
|
||||||
|
setName(initialName)
|
||||||
|
setAvatar(initialAvatar)
|
||||||
|
setSelectedImage(null)
|
||||||
} else {
|
} else {
|
||||||
bottomSheetRef.current?.close()
|
bottomSheetRef.current?.close()
|
||||||
}
|
}
|
||||||
}, [visible])
|
}, [visible, initialName, initialAvatar])
|
||||||
|
|
||||||
// 当抽屉打开时,重置名字为初始值
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
setName(initialName)
|
|
||||||
}
|
|
||||||
}, [visible, initialName])
|
|
||||||
|
|
||||||
const handleSheetChanges = useCallback((index: number) => {
|
const handleSheetChanges = useCallback((index: number) => {
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@@ -73,11 +77,40 @@ 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()
|
Keyboard.dismiss()
|
||||||
onSave?.(name)
|
|
||||||
|
// 构建更新数据
|
||||||
|
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()
|
onClose()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
@@ -90,7 +123,7 @@ export default function EditProfileDrawer({
|
|||||||
index={visible ? 0 : -1}
|
index={visible ? 0 : -1}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
onChange={handleSheetChanges}
|
onChange={handleSheetChanges}
|
||||||
enablePanDownToClose
|
enablePanDownToClose={!loading}
|
||||||
backgroundStyle={styles.bottomSheetBackground}
|
backgroundStyle={styles.bottomSheetBackground}
|
||||||
handleIndicatorStyle={styles.handleIndicator}
|
handleIndicatorStyle={styles.handleIndicator}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
@@ -109,6 +142,7 @@ export default function EditProfileDrawer({
|
|||||||
style={styles.closeButton}
|
style={styles.closeButton}
|
||||||
onPress={handleClose}
|
onPress={handleClose}
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -116,13 +150,21 @@ export default function EditProfileDrawer({
|
|||||||
<View style={styles.avatarContainer}>
|
<View style={styles.avatarContainer}>
|
||||||
<View style={styles.avatarWrapper}>
|
<View style={styles.avatarWrapper}>
|
||||||
<Image
|
<Image
|
||||||
source={initialAvatar || require('@/assets/images/icon.png')}
|
source={avatar ? { uri: avatar } : require('@/assets/images/icon.png')}
|
||||||
style={styles.avatar}
|
style={styles.avatar}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
/>
|
/>
|
||||||
<Pressable style={styles.cameraButton}>
|
<Pressable
|
||||||
|
style={styles.cameraButton}
|
||||||
|
onPress={handleAvatarPress}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
<View style={styles.cameraIconContainer}>
|
<View style={styles.cameraIconContainer}>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
<AvatarUploadIcon />
|
<AvatarUploadIcon />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -138,12 +180,14 @@ export default function EditProfileDrawer({
|
|||||||
returnKeyType="done"
|
returnKeyType="done"
|
||||||
onSubmitEditing={handleSave}
|
onSubmitEditing={handleSave}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
|
editable={!loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 保存按钮 */}
|
{/* 保存按钮 */}
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.saveButtonContainer}
|
style={[styles.saveButtonContainer, loading && styles.saveButtonDisabled]}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
|
disabled={loading || !name.trim()}
|
||||||
android_ripple={{ color: 'rgba(255, 255, 255, 0.1)' }}
|
android_ripple={{ color: 'rgba(255, 255, 255, 0.1)' }}
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
@@ -152,7 +196,11 @@ export default function EditProfileDrawer({
|
|||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={styles.saveButton}
|
style={styles.saveButton}
|
||||||
>
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
<Text style={styles.saveButtonText}>{t('editProfile.save')}</Text>
|
<Text style={styles.saveButtonText}>{t('editProfile.save')}</Text>
|
||||||
|
)}
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -242,6 +290,9 @@ const styles = StyleSheet.create({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
|
saveButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
saveButton: {
|
saveButton: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -255,4 +306,3 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export { useTags } from './use-tags'
|
|||||||
export { useDebounce } from './use-debounce'
|
export { useDebounce } from './use-debounce'
|
||||||
export { useWorksSearch } from './use-works-search'
|
export { useWorksSearch } from './use-works-search'
|
||||||
export { useChangePassword } from './use-change-password'
|
export { useChangePassword } from './use-change-password'
|
||||||
|
export { useUpdateProfile } from './use-update-profile'
|
||||||
|
|||||||
104
hooks/use-update-profile.ts
Normal file
104
hooks/use-update-profile.ts
Normal file
@@ -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
|
authClient
|
||||||
|
|
||||||
// 导出 loomart API(来自 createSkerClientPlugin)
|
// 导出 loomart API(来自 createSkerClientPlugin)
|
||||||
|
|||||||
Reference in New Issue
Block a user