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

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