Files
expo-popcore-app/app/(tabs)/my.tsx
imeepos bf11241d68 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>
2026-01-23 19:42:13 +08:00

429 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon'
import EditProfileDrawer from '@/components/drawer/EditProfileDrawer'
import Dropdown from '@/components/ui/dropdown'
import Toast from '@/components/ui/Toast'
import { signOut } from '@/lib/auth'
import { useTemplateGenerations, type TemplateGeneration } from '@/hooks'
import { MySkeleton } from '@/components/skeleton/MySkeleton'
import { useSession } from '@/lib/auth'
const { width: screenWidth } = Dimensions.get('window')
const GALLERY_GAP = 2
const GALLERY_HORIZONTAL_PADDING = 0
const GALLERY_ITEM_SIZE = Math.floor(
(screenWidth - GALLERY_HORIZONTAL_PADDING * 2 - GALLERY_GAP * 2) / 3
)
// 获取作品封面图
const getCoverUrl = (item: TemplateGeneration) =>
item.resultUrl?.[0] || item.template?.coverImageUrl
export default function My() {
const router = useRouter()
const { t, i18n } = useTranslation()
const [editDrawerVisible, setEditDrawerVisible] = useState(false)
// 获取当前登录用户信息
const { data: session } = useSession()
const userName = session?.user?.name || session?.user?.username || '用户'
const [profileName, setProfileName] = useState(userName)
// 当 session 变化时更新用户名
useEffect(() => {
if (session?.user?.name || session?.user?.username) {
setProfileName(session?.user?.name || session?.user?.username || '用户')
}
}, [session])
// 使用 useTemplateGenerations hook 获取用户作品列表
const {
generations,
loading,
error,
execute: loadGenerations,
refetch,
} = useTemplateGenerations()
// 初始化加载作品列表
useEffect(() => {
loadGenerations({ page: 1, limit: 50 })
}, [])
// 处理设置菜单选择
const handleSettingsSelect = async (value: string) => {
if (value === 'changePassword') {
router.push('/changePassword' as any)
} else if (value === 'language') {
// 切换语言
const newLang = i18n.language === 'zh-CN' ? 'en-US' : 'zh-CN'
i18n.changeLanguage(newLang)
} else if (value === 'logout') {
// 退出登录
console.log('🚪 点击退出登录')
const confirmText = i18n.language === 'zh-CN' ? '确定' : 'OK'
const cancelText = i18n.language === 'zh-CN' ? '取消' : 'Cancel'
const message = i18n.language === 'zh-CN' ? '确定要退出登录吗?' : 'Are you sure you want to logout?'
Toast.showActionSheet({
itemList: [message, confirmText, cancelText]
}).then(async (index) => {
// index 1 是确定按钮
if (index === 1) {
console.log('🚪 开始执行退出登录')
try {
Toast.showLoading({ title: i18n.language === 'zh-CN' ? '退出中...' : 'Logging out...' })
// 调用 better-auth 的 signOut 方法
await signOut()
Toast.hideLoading()
console.log('✅ 退出登录成功,跳转到登录页')
Toast.show(i18n.language === 'zh-CN' ? '退出登录成功' : 'Logged out successfully')
// 跳转到登录页面(注意:路由是 /auth 不是 /login
router.replace('/auth')
} catch (error) {
Toast.hideLoading()
console.error('❌ 退出登录失败:', error)
Toast.show(i18n.language === 'zh-CN' ? '退出登录失败,请稍后重试' : 'Failed to logout, please try again later')
}
}
}).catch(() => {
console.log('❌ 取消退出登录')
})
}
}
// 设置菜单选项
const getLanguageLabel = () => {
if (i18n.language === 'zh-CN') {
return t('my.languageSwitch')
} else {
return t('my.languageSwitchEn')
}
}
const settingsOptions = [
{ label: t('my.changePassword'), value: 'changePassword' },
{ label: getLanguageLabel(), value: 'language' },
{ label: t('my.logout'), value: 'logout' },
]
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 顶部积分与设置 */}
<View style={styles.topBar}>
<Pressable
style={styles.pointsPill}
onPress={() => router.push('/membership' as any)}
>
<PointsIcon />
<Text style={styles.pointsPillText}>60</Text>
</Pressable>
<Dropdown
options={settingsOptions}
onSelect={(value) => handleSettingsSelect(value)}
renderTrigger={(selectedOption, isOpen, toggle) => (
<Pressable onPress={toggle}>
<SettingsIcon />
</Pressable>
)}
dropdownStyle={{
minWidth: 160,
right: 10,
backgroundColor: '#2A2A2A80',
}}
/>
</View>
{/* 个人信息区 */}
<View style={styles.profileSection}>
<Image
source={session?.user?.image ? { uri: session.user.image } : require('@/assets/images/icon.png')}
style={styles.avatar}
contentFit="cover"
/>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{profileName}</Text>
<Text style={styles.profileSubTitle}>ID {session?.user?.id?.slice(0, 8) || '--------'}</Text>
</View>
<Pressable
style={styles.editButton}
onPress={() => setEditDrawerVisible(true)}
>
<Text style={styles.editButtonText}>{t('my.editProfile')}</Text>
</Pressable>
</View>
{/* "生成作品" 标题行 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('my.generatedWorks')}</Text>
<Pressable
style={styles.sectionMoreButton}
onPress={() => router.push('/worksList' as any)}
>
<SearchIcon />
</Pressable>
</View>
{/* 作品九宫格 */}
{loading ? (
<MySkeleton />
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.galleryGrid}>
{generations.map((item, index) => (
<Pressable
key={item.id}
style={[
styles.galleryItem,
index % 3 !== 2 && styles.galleryItemMarginRight,
styles.galleryItemMarginBottom,
]}
onPress={() => {
if (item.status === 'completed') {
router.push({
pathname: '/generationRecord' as any,
params: { id: item.id },
})
}
}}
disabled={item.status !== 'completed'}
>
<Image
source={getCoverUrl(item) ? { uri: getCoverUrl(item) } : require('@/assets/images/membership.png')}
style={styles.galleryImage}
contentFit="cover"
/>
{/* 遮罩:非完成状态 */}
{item.status !== 'completed' && (
<View style={styles.generatingOverlay} />
)}
{/* 数量角标:已完成且有结果 */}
{item.status === 'completed' && (item.resultUrl?.length || 0) > 0 && (
<View style={styles.counterBadge}>
<Text style={styles.counterText}>
{item.resultUrl?.length || 1}
</Text>
</View>
)}
{/* 状态角标 */}
{item.status === 'running' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>{t('my.generating')}</Text>
</View>
)}
{item.status === 'pending' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>{t('my.queuing')}</Text>
</View>
)}
</Pressable>
))}
{/* 空状态提示 */}
{!loading && generations.length === 0 && (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
{t('my.noWorks')}
</Text>
</View>
)}
</View>
</ScrollView>
)}
{/* 编辑资料抽屉 */}
<EditProfileDrawer
visible={editDrawerVisible}
onClose={() => setEditDrawerVisible(false)}
initialName={profileName}
initialAvatar={session?.user?.image}
onSave={(data) => {
setProfileName(data.name)
}}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
topBar: {
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
paddingTop: 19,
paddingRight: 16,
flexDirection: 'row',
justifyContent: 'flex-end',
backgroundColor: '#090A0B',
gap: 12,
},
pointsPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 1,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 100,
backgroundColor: '#1C1E22',
},
pointsPillText: {
color: '#FFCF00',
fontSize: 12,
fontWeight: '600',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
},
profileSection: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 16,
paddingRight: 16,
marginTop: 20,
marginBottom: 32,
backgroundColor: '#090A0B',
},
avatar: {
width: 64,
height: 64,
borderRadius: 32,
overflow: 'hidden',
marginRight: 16,
},
profileInfo: {
flex: 1,
},
profileName: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
profileSubTitle: {
color: '#FFFFFF',
fontSize: 12,
opacity: 0.7,
},
editButton: {
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 6,
backgroundColor: '#1C1E22',
},
editButtonText: {
color: '#FFFFFF',
fontSize: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
marginHorizontal: 12,
backgroundColor: '#090A0B',
},
sectionTitle: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
sectionMoreButton: {
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
galleryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 10,
},
galleryItem: {
width: GALLERY_ITEM_SIZE,
aspectRatio: 1,
overflow: 'hidden',
backgroundColor: '#1C1E22',
position: 'relative',
},
galleryItemMarginRight: {
marginRight: GALLERY_GAP,
},
galleryItemMarginBottom: {
marginBottom: GALLERY_GAP,
},
galleryImage: {
width: '100%',
height: undefined,
aspectRatio: 1,
},
generatingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#00000080',
},
counterBadge: {
position: 'absolute',
right: 8,
bottom: 8,
paddingHorizontal: 10,
paddingVertical: 1,
borderRadius: 6,
backgroundColor: '#16181B1A',
},
counterText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
generatingBadge: {
position: 'absolute',
left: 8,
bottom: 8,
},
generatingBadgeText: {
color: '#F5F5F5',
fontSize: 9,
fontWeight: '500',
},
emptyState: {
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
emptyStateText: {
color: '#FFFFFF80',
fontSize: 14,
},
})