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:
imeepos 2026-01-23 19:42:13 +08:00
parent e4b249f502
commit bf11241d68
5 changed files with 195 additions and 37 deletions

View File

@ -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>
)

View File

@ -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',
},
})

View File

@ -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'

104
hooks/use-update-profile.ts Normal file
View 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,
}
}

View File

@ -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