fix: bug
This commit is contained in:
@@ -4,7 +4,21 @@
|
|||||||
"WebFetch(domain:docs.expo.dev)",
|
"WebFetch(domain:docs.expo.dev)",
|
||||||
"WebFetch(domain:docs.expo.dev)",
|
"WebFetch(domain:docs.expo.dev)",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(npx eas:*)",
|
||||||
|
"Bash(npx @expo/eas-cli@latest:*)",
|
||||||
|
"Bash(npx expo install:*)",
|
||||||
|
"Bash(eas --version:*)",
|
||||||
|
"Bash(eas build:configure:*)",
|
||||||
|
"Bash(bunx:*)",
|
||||||
|
"Bash(./gradlew:*)",
|
||||||
|
"Bash(java:*)",
|
||||||
|
"Bash(where:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(set:*)",
|
||||||
|
"Bash(git --version:*)",
|
||||||
|
"Bash(export:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
11
android-keystore.json
Normal file
11
android-keystore.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"android": {
|
||||||
|
"keystore": {
|
||||||
|
"alias": "default",
|
||||||
|
"keyAlias": "default",
|
||||||
|
"keystorePath": "bw-expo-app-v3.keystore",
|
||||||
|
"keyPassword": "defaultpassword",
|
||||||
|
"storePassword": "defaultpassword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app.json
8
app.json
@@ -44,6 +44,12 @@
|
|||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true,
|
"typedRoutes": true,
|
||||||
"reactCompiler": true
|
"reactCompiler": true
|
||||||
}
|
},
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "191f0396-3e0c-40ec-bd06-b15f58278b8f"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "imeepos"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ export default function TabLayout() {
|
|||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="profile"
|
||||||
|
options={{
|
||||||
|
title: 'Profile',
|
||||||
|
tabBarIcon: ({ color }) => <IconSymbol size={28} name="person.fill" color={color} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export default function HomeScreen() {
|
|||||||
Alert.alert('模板详情', `您选择了: ${template.title}`);
|
Alert.alert('模板详情', `您选择了: ${template.title}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVideoChange = (template: Template, index: number) => {
|
||||||
|
console.log('视频切换到:', template.title, '索引:', index);
|
||||||
|
};
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
@@ -169,6 +173,7 @@ export default function HomeScreen() {
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
onTemplatePress={handleTemplatePress}
|
onTemplatePress={handleTemplatePress}
|
||||||
|
onVideoChange={handleVideoChange}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
|
|||||||
527
app/(tabs)/profile.tsx
Normal file
527
app/(tabs)/profile.tsx
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { StyleSheet, ScrollView, Alert, TouchableOpacity, Animated, View } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
|
|
||||||
|
// 用户头像组件 - 优化版本
|
||||||
|
const UserAvatar = ({ image, name, size = 80 }: { image?: string; name: string; size?: number }) => {
|
||||||
|
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||||
|
const textColor = useThemeColor({}, 'text');
|
||||||
|
const animatedScale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(animatedScale, {
|
||||||
|
toValue: 0.95,
|
||||||
|
duration: 100,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(animatedScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 100,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={styles.avatarContainer}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.avatar,
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: size / 2,
|
||||||
|
backgroundColor: placeholderColor,
|
||||||
|
transform: [{ scale: animatedScale }],
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.15,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{image ? (
|
||||||
|
<ThemedText>图片</ThemedText>
|
||||||
|
) : (
|
||||||
|
<ThemedText style={{
|
||||||
|
fontSize: size / 2.5,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: textColor,
|
||||||
|
}}>
|
||||||
|
{getInitials(name)}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 个人统计组件
|
||||||
|
const UserStats = () => {
|
||||||
|
const cardColor = useThemeColor({}, 'card');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.statsContainer}>
|
||||||
|
<View style={[styles.statCard, { backgroundColor: cardColor }]}>
|
||||||
|
<IconSymbol name="heart.fill" size={20} color="#ff6b6b" />
|
||||||
|
<ThemedText style={styles.statNumber}>128</ThemedText>
|
||||||
|
<ThemedText style={styles.statLabel}>创作作品</ThemedText>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statCard, { backgroundColor: cardColor }]}>
|
||||||
|
<IconSymbol name="clock.fill" size={20} color="#4ECDC4" />
|
||||||
|
<ThemedText style={styles.statNumber}>48</ThemedText>
|
||||||
|
<ThemedText style={styles.statLabel}>使用时长(h)</ThemedText>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.statCard, { backgroundColor: cardColor }]}>
|
||||||
|
<IconSymbol name="star.fill" size={20} color="#FFD700" />
|
||||||
|
<ThemedText style={styles.statNumber}>VIP</ThemedText>
|
||||||
|
<ThemedText style={styles.statLabel}>会员等级</ThemedText>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 余额卡片组件 - 优化版本
|
||||||
|
const BalanceCard = () => {
|
||||||
|
return (
|
||||||
|
<View style={styles.balanceCardContainer}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#4ECDC4', '#44A3A0', '#3D8B87']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.balanceCardGradient}
|
||||||
|
>
|
||||||
|
<View style={styles.balanceCardHeader}>
|
||||||
|
<IconSymbol name="dollarsign.circle.fill" size={28} color="rgba(255,255,255,0.9)" />
|
||||||
|
<ThemedText style={styles.balanceCardTitle}>账户余额</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.balanceAmountContainer}>
|
||||||
|
<ThemedText style={styles.balanceCurrency}>¥</ThemedText>
|
||||||
|
<ThemedText style={styles.balanceAmount}>0.00</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.rechargeButton}
|
||||||
|
onPress={() => router.push('/recharge')}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<IconSymbol name="plus.circle.fill" size={18} color="rgba(255,255,255,0.9)" />
|
||||||
|
<ThemedText style={styles.rechargeButtonText}>立即充值</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.balanceCardFooter}>
|
||||||
|
<IconSymbol name="info.circle" size={14} color="rgba(255,255,255,0.7)" />
|
||||||
|
<ThemedText style={styles.balanceTipsText}>
|
||||||
|
余额用于支付生成内容的消费
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简单的设置列表组件
|
||||||
|
const SettingsList = () => {
|
||||||
|
const tintColor = useThemeColor({}, 'tint');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.sectionContent}>
|
||||||
|
<ThemedView style={styles.sectionHeader}>
|
||||||
|
<IconSymbol name="gearshape" size={24} color={tintColor} />
|
||||||
|
<ThemedText style={styles.sectionTitle}>设置</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingItem}
|
||||||
|
onPress={() => router.push('/settings/account')}
|
||||||
|
>
|
||||||
|
<IconSymbol name="person.circle" size={20} color={tintColor} />
|
||||||
|
<ThemedText style={styles.settingText}>账户设置</ThemedText>
|
||||||
|
<IconSymbol name="chevron.right" size={16} color="#ccc" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingItem}
|
||||||
|
onPress={() => router.push('/settings/notification')}
|
||||||
|
>
|
||||||
|
<IconSymbol name="bell" size={20} color={tintColor} />
|
||||||
|
<ThemedText style={styles.settingText}>通知设置</ThemedText>
|
||||||
|
<IconSymbol name="chevron.right" size={16} color="#ccc" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingItem}
|
||||||
|
onPress={() => router.push('/settings/about')}
|
||||||
|
>
|
||||||
|
<IconSymbol name="info.circle" size={20} color={tintColor} />
|
||||||
|
<ThemedText style={styles.settingText}>关于我们</ThemedText>
|
||||||
|
<IconSymbol name="chevron.right" size={16} color="#ccc" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const { user, session } = useAuth();
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
const cardColor = useThemeColor({}, 'card');
|
||||||
|
const borderColor = useThemeColor({}, 'cardBorder');
|
||||||
|
const headerAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.timing(headerAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: false,
|
||||||
|
}).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
Alert.alert(
|
||||||
|
'退出登录',
|
||||||
|
'确定要退出登录吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '确定',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
// TODO: 实现登出逻辑
|
||||||
|
router.replace('/(auth)/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor }]}>
|
||||||
|
{/* 渐变头部背景 */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.headerBackground,
|
||||||
|
{
|
||||||
|
opacity: headerAnim,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#ff6b6b', '#ff8e53', '#ff6b35']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.headerGradient}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{/* 头部信息区域 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<UserAvatar
|
||||||
|
image={user?.image}
|
||||||
|
name={user?.name || user?.username || '用户'}
|
||||||
|
size={90}
|
||||||
|
/>
|
||||||
|
<ThemedText style={styles.userName}>
|
||||||
|
{user?.name || user?.username || '用户'}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={styles.userEmail}>
|
||||||
|
{user?.email || '未设置邮箱'}
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 个人统计 */}
|
||||||
|
<UserStats />
|
||||||
|
|
||||||
|
{/* 余额卡片 */}
|
||||||
|
<View style={[styles.section, styles.balanceSection]}>
|
||||||
|
<BalanceCard />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 生成记录 */}
|
||||||
|
<ThemedView style={[styles.section, { backgroundColor: cardColor, borderColor }]}>
|
||||||
|
<ThemedView style={styles.sectionContent}>
|
||||||
|
<ThemedView style={styles.sectionHeader}>
|
||||||
|
<IconSymbol name="clock.fill" size={24} color="#4ECDC4" />
|
||||||
|
<ThemedText style={styles.sectionTitle}>生成记录</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
<ThemedView style={styles.emptyContainer}>
|
||||||
|
<IconSymbol name="doc.text.fill" size={48} color="#ccc" />
|
||||||
|
<ThemedText style={styles.emptyText}>暂无生成记录</ThemedText>
|
||||||
|
<ThemedText style={styles.emptySubText}>开始创建你的第一个作品吧</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</ThemedView>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
{/* 设置列表 */}
|
||||||
|
<ThemedView style={[styles.section, { backgroundColor: cardColor, borderColor }]}>
|
||||||
|
<SettingsList />
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||||
|
<IconSymbol name="arrow.right.square" size={20} color="#FF3B30" />
|
||||||
|
<ThemedText style={[styles.logoutText, { color: '#FF3B30' }]}>退出登录</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerBackground: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 320,
|
||||||
|
zIndex: -1,
|
||||||
|
},
|
||||||
|
headerGradient: {
|
||||||
|
flex: 1,
|
||||||
|
borderBottomLeftRadius: 40,
|
||||||
|
borderBottomRightRadius: 40,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 4,
|
||||||
|
color: '#fff',
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.2)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
fontSize: 16,
|
||||||
|
opacity: 0.8,
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
},
|
||||||
|
statsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
marginBottom: 24,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
statCard: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
borderRadius: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: 0.7,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
padding: 20,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 24,
|
||||||
|
elevation: 4,
|
||||||
|
},
|
||||||
|
balanceSection: {
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
shadowColor: 'transparent',
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
// 余额卡片样式
|
||||||
|
balanceCardContainer: {
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
borderRadius: 20,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#4ECDC4',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
balanceCardGradient: {
|
||||||
|
padding: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
balanceCardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
balanceCardTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
balanceAmountContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
balanceCurrency: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
balanceAmount: {
|
||||||
|
fontSize: 56,
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#fff',
|
||||||
|
lineHeight: 56,
|
||||||
|
},
|
||||||
|
rechargeButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 32,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderRadius: 25,
|
||||||
|
gap: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
rechargeButtonText: {
|
||||||
|
color: 'rgba(255,255,255,0.95)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
balanceCardFooter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
balanceTipsText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'rgba(255,255,255,0.7)',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 其他样式
|
||||||
|
sectionContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
marginBottom: 20,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 18,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#f5f5f5',
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
settingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
flex: 1,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
logoutButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 18,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
gap: 16,
|
||||||
|
marginTop: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
logoutText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
33
app/recharge.tsx
Normal file
33
app/recharge.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, ScrollView } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
|
|
||||||
|
export default function RechargeScreen() {
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||||
|
<ThemedView style={styles.content}>
|
||||||
|
<ThemedText type="title">充值页面</ThemedText>
|
||||||
|
<ThemedText>此页面正在开发中...</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
31
app/settings/about.tsx
Normal file
31
app/settings/about.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
|
||||||
|
export default function AboutScreen() {
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||||
|
<ThemedView style={styles.content}>
|
||||||
|
<ThemedText type="title">关于我们</ThemedText>
|
||||||
|
<ThemedText>此页面正在开发中...</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
31
app/settings/account.tsx
Normal file
31
app/settings/account.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
|
||||||
|
export default function AccountSettingsScreen() {
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||||
|
<ThemedView style={styles.content}>
|
||||||
|
<ThemedText type="title">账户设置</ThemedText>
|
||||||
|
<ThemedText>此页面正在开发中...</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
31
app/settings/notification.tsx
Normal file
31
app/settings/notification.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, ScrollView } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
|
||||||
|
export default function NotificationSettingsScreen() {
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={[styles.container, { backgroundColor }]}>
|
||||||
|
<ThemedView style={styles.content}>
|
||||||
|
<ThemedText type="title">通知设置</ThemedText>
|
||||||
|
<ThemedText>此页面正在开发中...</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -234,29 +234,43 @@ export default function TemplateRunScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
// 转换表单数据为 API 期望的格式
|
// 转换表单数据为 API 期望的格式
|
||||||
const transformedData = transformFormData(formData);
|
const transformedData = transformFormData(formData);
|
||||||
console.log('转换后的数据:', transformedData);
|
console.log('转换后的数据:', transformedData);
|
||||||
|
// 这个我后端取 获取月
|
||||||
const identify = await subscription.meterEvent({
|
const list = await subscription.list()
|
||||||
meter_id: `mtr_test_61TR3BkyDcen9OrBm412fzUa9k2QB732`,
|
const listData = list.data;
|
||||||
event_name: `token_usage`,
|
if (listData && listData.length > 0) {
|
||||||
payload: {
|
const item = listData[0]
|
||||||
value: `100`
|
const subscriptionId = item?.stripeSubscriptionId!;
|
||||||
}
|
const summary = await subscription.credit.summary({
|
||||||
})
|
subscriptionId: subscriptionId,
|
||||||
console.log({ identify })
|
filter: {
|
||||||
// error
|
type: `applicability_scope`,
|
||||||
const orid = identify.data?.identifier
|
applicability_scope: {
|
||||||
console.log({ orid })
|
price_type: `metered`
|
||||||
if (orid) {
|
}
|
||||||
try {
|
}
|
||||||
setCurrentStep('progress');
|
});
|
||||||
executeTemplate(template!.id, transformedData);
|
const identify = await subscription.meterEvent({
|
||||||
} catch (e) {
|
event_name: `token_usage`,
|
||||||
// 退款orid
|
payload: {
|
||||||
|
value: `100`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// error
|
||||||
|
const orid = identify.data?.identifier
|
||||||
|
if (orid) {
|
||||||
|
try {
|
||||||
|
setCurrentStep('progress');
|
||||||
|
executeTemplate(template!.id, transformedData);
|
||||||
|
} catch (e) {
|
||||||
|
// 退款orid
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 跳转到订阅
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
247
components/image/fullscreen-image-modal.tsx
Normal file
247
components/image/fullscreen-image-modal.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
PanResponder,
|
||||||
|
Platform,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export interface FullscreenImageModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
imageUrl: string;
|
||||||
|
onPrevious?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
hasNext?: boolean;
|
||||||
|
hasPrevious?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FullscreenImageModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
imageUrl,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
hasNext = false,
|
||||||
|
hasPrevious = false,
|
||||||
|
}: FullscreenImageModalProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
|
||||||
|
// 重置状态当模态框打开/关闭时
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setShowControls(true);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 处理图片加载完成
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图片加载错误
|
||||||
|
const handleImageError = (error: any) => {
|
||||||
|
console.error('图片加载错误:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片点击处理(直接关闭)
|
||||||
|
const handleImagePress = () => {
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建手势处理器
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||||
|
// 只有水平移动时才响应
|
||||||
|
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gestureState) => {
|
||||||
|
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
|
||||||
|
|
||||||
|
if (gestureState.dx > threshold && hasPrevious) {
|
||||||
|
// 向右滑动,显示上一个
|
||||||
|
onPrevious?.();
|
||||||
|
} else if (gestureState.dx < -threshold && hasNext) {
|
||||||
|
// 向左滑动,显示下一个
|
||||||
|
onNext?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// 处理返回键(Android)
|
||||||
|
useEffect(() => {
|
||||||
|
const backHandler = () => {
|
||||||
|
if (visible) {
|
||||||
|
handleClose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在实际应用中,这里需要添加返回键监听
|
||||||
|
// 对于演示,我们只做概念性实现
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 清理返回键监听
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={false}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
statusBarTranslucent={true}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 状态栏处理 */}
|
||||||
|
{Platform.OS === 'android' && <StatusBar hidden />}
|
||||||
|
|
||||||
|
{/* 图片容器 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.imageContainer}
|
||||||
|
onPress={handleImagePress}
|
||||||
|
activeOpacity={1}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={styles.imageWrapper}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
>
|
||||||
|
{/* 图片显示 */}
|
||||||
|
<Image
|
||||||
|
source={{ uri: imageUrl }}
|
||||||
|
style={styles.image}
|
||||||
|
contentFit="contain"
|
||||||
|
onLoad={handleImageLoad}
|
||||||
|
onError={handleImageError}
|
||||||
|
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 加载指示器 */}
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 左右导航指示器 */}
|
||||||
|
{hasPrevious && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.leftIndicator}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPrevious?.();
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.indicatorIcon}>‹</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{hasNext && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.rightIndicator}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNext?.();
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.indicatorIcon}>›</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
imageContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
imageWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenHeight,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
leftIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 20,
|
||||||
|
top: '50%',
|
||||||
|
marginTop: -20,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
rightIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 20,
|
||||||
|
top: '50%',
|
||||||
|
marginTop: -20,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
indicatorIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FullscreenImageModal;
|
||||||
309
components/media/fullscreen-media-modal.tsx
Normal file
309
components/media/fullscreen-media-modal.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { VideoPlayer } from '@/components/video/video-player';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { Template } from '@/lib/types/template';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { ResizeMode } from 'expo-av';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
PanResponder,
|
||||||
|
Platform,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { isVideoTemplate, canLoadMedia, getEffectiveMediaUrl } from '@/utils/media-utils';
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export interface FullscreenMediaModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentIndex: number;
|
||||||
|
templates: Template[];
|
||||||
|
onIndexChanged: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function FullscreenMediaModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
currentIndex,
|
||||||
|
templates,
|
||||||
|
onIndexChanged,
|
||||||
|
}: FullscreenMediaModalProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
|
|
||||||
|
// 获取当前模板
|
||||||
|
const currentTemplate = templates[currentIndex];
|
||||||
|
const isCurrentVideo = currentTemplate ? isVideoTemplate(currentTemplate) : false;
|
||||||
|
const canLoadCurrentMedia = currentTemplate ? canLoadMedia(currentTemplate) : false;
|
||||||
|
|
||||||
|
// 重置状态当模态框打开/关闭时
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setIsLoading(true);
|
||||||
|
// 添加备用机制:3秒后自动隐藏loading,防止卡住
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [visible, currentIndex]);
|
||||||
|
|
||||||
|
// 处理媒体加载完成(移动平台)
|
||||||
|
const handleMediaLoad = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理Web平台视频加载完成
|
||||||
|
const handleWebVideoLoad = () => {
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理媒体加载错误
|
||||||
|
const handleMediaError = (error: any) => {
|
||||||
|
console.error('媒体加载错误:', {
|
||||||
|
template: currentTemplate?.title,
|
||||||
|
mediaType: isCurrentVideo ? 'video' : 'image',
|
||||||
|
url: getEffectiveMediaUrl(currentTemplate),
|
||||||
|
error: error
|
||||||
|
});
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 媒体点击处理(直接关闭)
|
||||||
|
const handleMediaPress = () => {
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统一的切换处理函数
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const newIndex = currentIndex - 1;
|
||||||
|
onIndexChanged(newIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentIndex < templates.length - 1) {
|
||||||
|
const newIndex = currentIndex + 1;
|
||||||
|
onIndexChanged(newIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建手势处理器
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||||
|
// 只有水平移动时才响应
|
||||||
|
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gestureState) => {
|
||||||
|
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
|
||||||
|
|
||||||
|
if (gestureState.dx > threshold && currentIndex > 0) {
|
||||||
|
// 向右滑动,显示上一个
|
||||||
|
handlePrevious();
|
||||||
|
} else if (gestureState.dx < -threshold && currentIndex < templates.length - 1) {
|
||||||
|
// 向左滑动,显示下一个
|
||||||
|
handleNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// 处理返回键(Android)
|
||||||
|
useEffect(() => {
|
||||||
|
const backHandler = () => {
|
||||||
|
if (visible) {
|
||||||
|
handleClose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 清理返回键监听
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible || !currentTemplate) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={false}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
statusBarTranslucent={true}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 状态栏处理 */}
|
||||||
|
{Platform.OS === 'android' && <StatusBar hidden />}
|
||||||
|
|
||||||
|
{/* 媒体容器 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.mediaContainer}
|
||||||
|
onPress={handleMediaPress}
|
||||||
|
activeOpacity={1}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={styles.mediaWrapper}
|
||||||
|
pointerEvents="auto"
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
>
|
||||||
|
{/* 根据模板类型显示对应内容 */}
|
||||||
|
{isCurrentVideo && canLoadCurrentMedia ? (
|
||||||
|
// 视频显示
|
||||||
|
<VideoPlayer
|
||||||
|
source={{ uri: currentTemplate.previewUrl }}
|
||||||
|
poster={currentTemplate.coverImageUrl}
|
||||||
|
style={styles.video}
|
||||||
|
resizeMode={ResizeMode.COVER}
|
||||||
|
shouldPlay={true}
|
||||||
|
isLooping={true}
|
||||||
|
isMuted={false}
|
||||||
|
useNativeControls={false}
|
||||||
|
showPoster={false}
|
||||||
|
autoPlay={true}
|
||||||
|
fullscreenMode={true}
|
||||||
|
onPress={handleMediaPress}
|
||||||
|
onReady={handleMediaLoad}
|
||||||
|
onWebLoadedData={handleWebVideoLoad}
|
||||||
|
onError={handleMediaError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// 图片显示(包括视频无法加载时的降级显示)
|
||||||
|
<Image
|
||||||
|
source={{ uri: currentTemplate.coverImageUrl || '' }}
|
||||||
|
style={styles.image}
|
||||||
|
contentFit="contain"
|
||||||
|
onLoad={handleMediaLoad}
|
||||||
|
onError={handleMediaError}
|
||||||
|
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载指示器 */}
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 左右导航指示器 */}
|
||||||
|
{currentIndex > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.leftIndicator}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePrevious();
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.indicatorIcon}>‹</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{currentIndex < templates.length - 1 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.rightIndicator}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleNext();
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.indicatorIcon}>›</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
mediaContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
mediaWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenHeight,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenHeight,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
leftIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 20,
|
||||||
|
top: '50%',
|
||||||
|
marginTop: -20,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
rightIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 20,
|
||||||
|
top: '50%',
|
||||||
|
marginTop: -20,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
indicatorIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FullscreenMediaModal;
|
||||||
@@ -1,27 +1,40 @@
|
|||||||
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
||||||
import { Image } from 'expo-image';
|
|
||||||
import { Video, ResizeMode } from 'expo-av';
|
|
||||||
import { VideoPlayer } from '@/components/video/video-player';
|
import { VideoPlayer } from '@/components/video/video-player';
|
||||||
|
import { FullscreenMediaModal } from '@/components/media/fullscreen-media-modal';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { Template } from '@/lib/types/template';
|
||||||
|
import { ResizeMode } from 'expo-av';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||||
import { ThemedText } from '../themed-text';
|
import { ThemedText } from '../themed-text';
|
||||||
import { ThemedView } from '../themed-view';
|
import { ThemedView } from '../themed-view';
|
||||||
import { Template } from '@/lib/types/template';
|
import { isVideoTemplate, canLoadMedia } from '@/utils/media-utils';
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
|
|
||||||
interface TemplateCardProps {
|
interface TemplateCardProps {
|
||||||
template: Template;
|
template: Template;
|
||||||
onPress?: (template: Template) => void;
|
onPress?: (template: Template) => void;
|
||||||
|
allTemplates?: Template[];
|
||||||
|
currentIndex?: number;
|
||||||
|
onVideoChange?: (template: Template, index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateCard({ template, onPress }: TemplateCardProps) {
|
export function TemplateCard({
|
||||||
|
template,
|
||||||
|
onPress,
|
||||||
|
allTemplates = [],
|
||||||
|
currentIndex = 0,
|
||||||
|
onVideoChange
|
||||||
|
}: TemplateCardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const cardColor = useThemeColor({}, 'card');
|
const cardColor = useThemeColor({}, 'card');
|
||||||
const imagePlaceholderColor = useThemeColor({}, 'imagePlaceholder');
|
const imagePlaceholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||||
|
const [isMediaFullscreenVisible, setIsMediaFullscreenVisible] = useState(false);
|
||||||
|
const [currentMediaIndex, setCurrentMediaIndex] = useState(currentIndex);
|
||||||
|
|
||||||
const isVideo = useMemo(() => {
|
const isVideo = useMemo(() => {
|
||||||
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.previewUrl || '');
|
return isVideoTemplate(template);
|
||||||
}, [template.previewUrl]);
|
}, [template]);
|
||||||
|
|
||||||
const getImageHeight = () => {
|
const getImageHeight = () => {
|
||||||
if (template.aspectRatio) {
|
if (template.aspectRatio) {
|
||||||
@@ -39,23 +52,36 @@ export function TemplateCard({ template, onPress }: TemplateCardProps) {
|
|||||||
router.push(`/template/${template.id}/run`);
|
router.push(`/template/${template.id}/run`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCardPress = () => {
|
const handleFullscreenMedia = () => {
|
||||||
if (onPress) {
|
setIsMediaFullscreenVisible(true);
|
||||||
onPress(template);
|
|
||||||
} else {
|
|
||||||
// 默认行为:跳转到运行页面
|
|
||||||
handleUseTemplate();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMediaIndexChanged = (newIndex: number) => {
|
||||||
|
setCurrentMediaIndex(newIndex);
|
||||||
|
const newTemplate = allTemplates[newIndex];
|
||||||
|
onVideoChange?.(newTemplate, newIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCardPress = () => {
|
||||||
|
// 统一处理:打开媒体全屏预览
|
||||||
|
handleFullscreenMedia();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前媒体模板
|
||||||
|
const getCurrentMediaTemplate = () => {
|
||||||
|
return allTemplates[currentMediaIndex] || template;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTemplate = getCurrentMediaTemplate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.card, { backgroundColor: cardColor }]}>
|
<View style={[styles.card, { backgroundColor: cardColor }]}>
|
||||||
<View style={styles.mediaContainer}>
|
<View style={styles.mediaContainer}>
|
||||||
<ThemedView style={[styles.mediaContent, { height: mediaHeight }]}>
|
<ThemedView style={[styles.mediaContent, { height: mediaHeight }]}>
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
source={{ uri: template.previewUrl }}
|
source={{ uri: currentTemplate.previewUrl }}
|
||||||
poster={template.coverImageUrl}
|
poster={currentTemplate.coverImageUrl}
|
||||||
style={[styles.videoPlayer, styles.videoCover]}
|
style={[styles.videoPlayer, styles.videoCover]}
|
||||||
resizeMode={ResizeMode.COVER}
|
resizeMode={ResizeMode.COVER}
|
||||||
shouldPlay={true}
|
shouldPlay={true}
|
||||||
@@ -120,6 +146,15 @@ export function TemplateCard({ template, onPress }: TemplateCardProps) {
|
|||||||
<ThemedText style={styles.useButtonText}>立即使用</ThemedText>
|
<ThemedText style={styles.useButtonText}>立即使用</ThemedText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 统一全屏媒体模态框 */}
|
||||||
|
<FullscreenMediaModal
|
||||||
|
visible={isMediaFullscreenVisible}
|
||||||
|
onClose={() => setIsMediaFullscreenVisible(false)}
|
||||||
|
currentIndex={currentMediaIndex}
|
||||||
|
templates={allTemplates}
|
||||||
|
onIndexChanged={handleMediaIndexChanged}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,6 +193,11 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
// React Native中object-fit对应contentFit属性,但在VideoPlayer中已经处理了
|
// React Native中object-fit对应contentFit属性,但在VideoPlayer中已经处理了
|
||||||
|
...Platform.select({
|
||||||
|
web: {
|
||||||
|
objectFit: 'cover',
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
imageContainer: {
|
imageContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -181,6 +221,7 @@ const styles = StyleSheet.create({
|
|||||||
color: 'rgba(255, 255, 255, 0.85)',
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
|
lineHeight: 6
|
||||||
},
|
},
|
||||||
tagOverlay: {
|
tagOverlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -205,6 +246,7 @@ const styles = StyleSheet.create({
|
|||||||
color: '#fff',
|
color: '#fff',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
lineHeight: 4
|
||||||
},
|
},
|
||||||
actionArea: {
|
actionArea: {
|
||||||
padding: 12,
|
padding: 12,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface TemplateListProps {
|
|||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onLoadMore?: () => void;
|
onLoadMore?: () => void;
|
||||||
onTemplatePress?: (template: Template) => void;
|
onTemplatePress?: (template: Template) => void;
|
||||||
|
onVideoChange?: (template: Template, index: number) => void;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ export function TemplateList({
|
|||||||
onRefresh,
|
onRefresh,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onTemplatePress,
|
onTemplatePress,
|
||||||
|
onVideoChange,
|
||||||
error,
|
error,
|
||||||
}: TemplateListProps) {
|
}: TemplateListProps) {
|
||||||
const tintColor = useThemeColor({}, 'tint');
|
const tintColor = useThemeColor({}, 'tint');
|
||||||
@@ -89,8 +91,14 @@ export function TemplateList({
|
|||||||
<FlatList
|
<FlatList
|
||||||
data={templates}
|
data={templates}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item, index }) => (
|
||||||
<TemplateCard template={item} onPress={onTemplatePress} />
|
<TemplateCard
|
||||||
|
template={item}
|
||||||
|
onPress={onTemplatePress}
|
||||||
|
allTemplates={templates}
|
||||||
|
currentIndex={index}
|
||||||
|
onVideoChange={onVideoChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
numColumns={2}
|
numColumns={2}
|
||||||
columnWrapperStyle={styles.row}
|
columnWrapperStyle={styles.row}
|
||||||
|
|||||||
132
components/user/balance-card.tsx
Normal file
132
components/user/balance-card.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
|
|
||||||
|
interface BalanceCardProps {
|
||||||
|
balance: number;
|
||||||
|
onRecharge: () => void;
|
||||||
|
onHistory?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BalanceCard({ balance, onRecharge, onHistory }: BalanceCardProps) {
|
||||||
|
const tintColor = useThemeColor({}, 'tint');
|
||||||
|
const cardColor = useThemeColor({}, 'card');
|
||||||
|
const borderColor = useThemeColor({}, 'cardBorder');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.titleContainer}>
|
||||||
|
<IconSymbol name="dollarsign.circle" size={24} color={tintColor} />
|
||||||
|
<ThemedText style={styles.title}>账户余额</ThemedText>
|
||||||
|
</View>
|
||||||
|
{onHistory && (
|
||||||
|
<TouchableOpacity onPress={onHistory} style={styles.historyButton}>
|
||||||
|
<ThemedText style={[styles.historyText, { color: tintColor }]}>
|
||||||
|
消费记录
|
||||||
|
</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.balanceContainer}>
|
||||||
|
<ThemedText style={styles.currency}>¥</ThemedText>
|
||||||
|
<ThemedText style={styles.balance}>{balance.toFixed(2)}</ThemedText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.rechargeButton, { backgroundColor: tintColor }]}
|
||||||
|
onPress={onRecharge}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<IconSymbol name="plus.circle.fill" size={20} color="white" />
|
||||||
|
<ThemedText style={styles.rechargeText}>充值</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.tips}>
|
||||||
|
<IconSymbol name="info.circle" size={14} color="#999" />
|
||||||
|
<ThemedText style={styles.tipsText}>
|
||||||
|
余额用于支付生成内容的消费
|
||||||
|
</ThemedText>
|
||||||
|
</View>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
historyButton: {
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 6,
|
||||||
|
},
|
||||||
|
historyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
balanceContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginVertical: 24,
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
balance: {
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 48,
|
||||||
|
},
|
||||||
|
rechargeButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
borderRadius: 24,
|
||||||
|
gap: 8,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
rechargeText: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
tips: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#f0f0f0',
|
||||||
|
},
|
||||||
|
tipsText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
319
components/user/generation-records.tsx
Normal file
319
components/user/generation-records.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { StyleSheet, ScrollView, TouchableOpacity, RefreshControl, Alert, ActivityIndicator } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
|
import { GenerationRecord } from '@/lib/auth/types';
|
||||||
|
|
||||||
|
interface GenerationRecordsProps {
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRecords: GenerationRecord[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: 'user1',
|
||||||
|
type: 'image',
|
||||||
|
title: '夏日风景插画',
|
||||||
|
description: '生成了一张充满夏日气息的风景插画',
|
||||||
|
thumbnail: undefined,
|
||||||
|
mediaUrl: undefined,
|
||||||
|
status: 'completed',
|
||||||
|
cost: 0.5,
|
||||||
|
createdAt: new Date('2024-01-15T10:30:00'),
|
||||||
|
updatedAt: new Date('2024-01-15T10:35:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
userId: 'user1',
|
||||||
|
type: 'video',
|
||||||
|
title: '产品介绍视频',
|
||||||
|
description: '为新产品制作了一个介绍视频',
|
||||||
|
thumbnail: undefined,
|
||||||
|
mediaUrl: undefined,
|
||||||
|
status: 'pending',
|
||||||
|
cost: 2.0,
|
||||||
|
createdAt: new Date('2024-01-14T15:20:00'),
|
||||||
|
updatedAt: new Date('2024-01-14T15:20:00'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GenerationRecords({ userId }: GenerationRecordsProps) {
|
||||||
|
const [records, setRecords] = useState<GenerationRecord[]>(mockRecords);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const tintColor = useThemeColor({}, 'tint');
|
||||||
|
const textColor = useThemeColor({}, 'text');
|
||||||
|
|
||||||
|
const getTypeIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'image':
|
||||||
|
return 'photo';
|
||||||
|
case 'video':
|
||||||
|
return 'video';
|
||||||
|
case 'text':
|
||||||
|
return 'doc.text';
|
||||||
|
default:
|
||||||
|
return 'doc';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '#34C759';
|
||||||
|
case 'pending':
|
||||||
|
return '#FF9500';
|
||||||
|
case 'failed':
|
||||||
|
return '#FF3B30';
|
||||||
|
default:
|
||||||
|
return '#999';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return '已完成';
|
||||||
|
case 'pending':
|
||||||
|
return '生成中';
|
||||||
|
case 'failed':
|
||||||
|
return '失败';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
// TODO: 实现刷新逻辑
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新失败:', error);
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (recordId: string) => {
|
||||||
|
Alert.alert(
|
||||||
|
'删除记录',
|
||||||
|
'确定要删除这条生成记录吗?此操作无法撤销。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '删除',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
setRecords(prev => prev.filter(record => record.id !== recordId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePress = (record: GenerationRecord) => {
|
||||||
|
if (record.status === 'completed') {
|
||||||
|
// TODO: 跳转到详情页面
|
||||||
|
console.log('查看记录详情:', record.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (records.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedView style={styles.header}>
|
||||||
|
<IconSymbol name="clock" size={20} color={tintColor} />
|
||||||
|
<ThemedText style={styles.title}>生成记录</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
<ThemedView style={styles.emptyContainer}>
|
||||||
|
<IconSymbol name="doc.text" size={48} color="#ccc" />
|
||||||
|
<ThemedText style={styles.emptyText}>暂无生成记录</ThemedText>
|
||||||
|
<ThemedText style={styles.emptySubText}>开始创建你的第一个作品吧</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedView style={styles.header}>
|
||||||
|
<IconSymbol name="clock" size={20} color={tintColor} />
|
||||||
|
<ThemedText style={styles.title}>生成记录</ThemedText>
|
||||||
|
<ThemedText style={styles.count}>({records.length})</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{records.map((record) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={record.id}
|
||||||
|
style={styles.recordItem}
|
||||||
|
onPress={() => handlePress(record)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedView style={styles.recordLeft}>
|
||||||
|
<ThemedView style={styles.typeIcon}>
|
||||||
|
<IconSymbol
|
||||||
|
name={getTypeIcon(record.type)}
|
||||||
|
size={20}
|
||||||
|
color={tintColor}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
<ThemedView style={styles.recordContent}>
|
||||||
|
<ThemedText style={styles.recordTitle}>{record.title}</ThemedText>
|
||||||
|
{record.description && (
|
||||||
|
<ThemedText style={styles.recordDescription} numberOfLines={2}>
|
||||||
|
{record.description}
|
||||||
|
</ThemedText>
|
||||||
|
)}
|
||||||
|
<ThemedView style={styles.recordMeta}>
|
||||||
|
<ThemedText style={styles.recordTime}>
|
||||||
|
{new Date(record.createdAt).toLocaleDateString()}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={styles.recordCost}>¥{record.cost}</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</ThemedView>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
<ThemedView style={styles.recordRight}>
|
||||||
|
<ThemedView
|
||||||
|
style={[
|
||||||
|
styles.statusBadge,
|
||||||
|
{ backgroundColor: getStatusColor(record.status) + '20' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.statusText,
|
||||||
|
{ color: getStatusColor(record.status) }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{getStatusText(record.status)}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteButton}
|
||||||
|
onPress={() => handleDelete(record.id)}
|
||||||
|
>
|
||||||
|
<IconSymbol name="trash" size={16} color="#FF3B30" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ThemedView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
recordItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#f0f0f0',
|
||||||
|
},
|
||||||
|
recordLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
typeIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
recordContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
recordTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
recordDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
recordMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
recordTime: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
recordCost: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
recordRight: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
statusBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 40,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
});
|
||||||
165
components/user/settings-list.tsx
Normal file
165
components/user/settings-list.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
|
|
||||||
|
interface SettingsListProps {
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
destructive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsList({ onLogout }: SettingsListProps) {
|
||||||
|
const tintColor = useThemeColor({}, 'tint');
|
||||||
|
const textColor = useThemeColor({}, 'text');
|
||||||
|
const destructiveColor = '#FF3B30';
|
||||||
|
|
||||||
|
const settings: SettingItem[] = [
|
||||||
|
{
|
||||||
|
id: 'account',
|
||||||
|
title: '账户设置',
|
||||||
|
icon: 'person.circle',
|
||||||
|
onPress: () => router.push('/settings/account'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notification',
|
||||||
|
title: '通知设置',
|
||||||
|
icon: 'bell',
|
||||||
|
onPress: () => router.push('/settings/notification'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'privacy',
|
||||||
|
title: '隐私设置',
|
||||||
|
icon: 'lock',
|
||||||
|
onPress: () => router.push('/settings/privacy'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: '关于我们',
|
||||||
|
icon: 'info.circle',
|
||||||
|
onPress: () => router.push('/settings/about'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'feedback',
|
||||||
|
title: '意见反馈',
|
||||||
|
icon: 'exclamationmark.bubble',
|
||||||
|
onPress: () => router.push('/settings/feedback'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'logout',
|
||||||
|
title: '退出登录',
|
||||||
|
icon: 'arrow.right.square',
|
||||||
|
onPress: onLogout,
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSettingPress = (item: SettingItem) => {
|
||||||
|
if (item.onPress) {
|
||||||
|
if (item.destructive) {
|
||||||
|
Alert.alert(
|
||||||
|
'退出登录',
|
||||||
|
'确定要退出登录吗?',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '确定',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: item.onPress,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
item.onPress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedView style={styles.header}>
|
||||||
|
<IconSymbol name="gearshape" size={20} color={tintColor} />
|
||||||
|
<ThemedText style={styles.title}>设置</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
<ThemedView style={styles.settingsList}>
|
||||||
|
{settings.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id}
|
||||||
|
style={styles.settingItem}
|
||||||
|
onPress={() => handleSettingPress(item)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedView style={styles.settingLeft}>
|
||||||
|
<IconSymbol
|
||||||
|
name={item.icon}
|
||||||
|
size={20}
|
||||||
|
color={item.destructive ? destructiveColor : tintColor}
|
||||||
|
/>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.settingTitle,
|
||||||
|
{ color: item.destructive ? destructiveColor : textColor }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
<IconSymbol
|
||||||
|
name="chevron.right"
|
||||||
|
size={16}
|
||||||
|
color="#ccc"
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ThemedView>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
settingsList: {
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
settingItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#f0f0f0',
|
||||||
|
},
|
||||||
|
settingLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
110
components/user/user-avatar.tsx
Normal file
110
components/user/user-avatar.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Image, StyleSheet, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
image?: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
showBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserAvatar({
|
||||||
|
image,
|
||||||
|
name,
|
||||||
|
size = 40,
|
||||||
|
showBorder = true
|
||||||
|
}: UserAvatarProps) {
|
||||||
|
const borderColor = useThemeColor({}, 'cardBorder');
|
||||||
|
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||||
|
const textColor = useThemeColor({}, 'text');
|
||||||
|
|
||||||
|
const avatarSize = size;
|
||||||
|
const borderRadius = avatarSize / 2;
|
||||||
|
const fontSize = avatarSize / 3;
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
width: avatarSize,
|
||||||
|
height: avatarSize,
|
||||||
|
borderRadius,
|
||||||
|
borderColor: showBorder ? borderColor : 'transparent',
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{image ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: image }}
|
||||||
|
style={[
|
||||||
|
styles.image,
|
||||||
|
{
|
||||||
|
width: avatarSize - 4,
|
||||||
|
height: avatarSize - 4,
|
||||||
|
borderRadius: (avatarSize - 4) / 2,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ThemedView
|
||||||
|
style={[
|
||||||
|
styles.placeholder,
|
||||||
|
{
|
||||||
|
width: avatarSize - 4,
|
||||||
|
height: avatarSize - 4,
|
||||||
|
borderRadius: (avatarSize - 4) / 2,
|
||||||
|
backgroundColor: placeholderColor,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ThemedText
|
||||||
|
style={[
|
||||||
|
styles.initials,
|
||||||
|
{
|
||||||
|
fontSize,
|
||||||
|
color: textColor,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{getInitials(name)}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
borderWidth: 2,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
initials: {
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
294
components/video/fullscreen-video-modal.tsx
Normal file
294
components/video/fullscreen-video-modal.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { AVPlaybackStatus, ResizeMode, Video } from 'expo-av';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
Modal,
|
||||||
|
PanResponder,
|
||||||
|
Platform,
|
||||||
|
StatusBar,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export interface FullscreenVideoModalProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
videoUrl: string;
|
||||||
|
poster?: string;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
isLooping?: boolean;
|
||||||
|
isMuted?: boolean;
|
||||||
|
onPrevious?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
hasNext?: boolean;
|
||||||
|
hasPrevious?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FullscreenVideoModal({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
videoUrl,
|
||||||
|
poster,
|
||||||
|
autoPlay = true,
|
||||||
|
isLooping = true,
|
||||||
|
isMuted = false,
|
||||||
|
onPrevious,
|
||||||
|
onNext,
|
||||||
|
hasNext = false,
|
||||||
|
hasPrevious = false,
|
||||||
|
}: FullscreenVideoModalProps) {
|
||||||
|
const videoRef = useRef<Video>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(autoPlay);
|
||||||
|
const [showControls, setShowControls] = useState(true);
|
||||||
|
|
||||||
|
// 重置状态当模态框打开/关闭时
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsPlaying(autoPlay);
|
||||||
|
setShowControls(true);
|
||||||
|
} else {
|
||||||
|
// 停止播放当关闭时
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.stopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [visible, autoPlay]);
|
||||||
|
|
||||||
|
// 处理视频加载完成
|
||||||
|
const handleVideoReady = (status: AVPlaybackStatus) => {
|
||||||
|
if (status.isLoaded) {
|
||||||
|
setIsLoading(false);
|
||||||
|
if (autoPlay) {
|
||||||
|
videoRef.current?.playAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理视频播放状态变化
|
||||||
|
const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
|
||||||
|
if (status.isLoaded) {
|
||||||
|
setIsPlaying(status.isPlaying);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理视频错误
|
||||||
|
const handleVideoError = (error: any) => {
|
||||||
|
console.error('视频播放错误:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视频点击处理(直接关闭)
|
||||||
|
const handleVideoPress = () => {
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建手势处理器
|
||||||
|
const panResponder = useRef(
|
||||||
|
PanResponder.create({
|
||||||
|
onMoveShouldSetPanResponder: (_, gestureState) => {
|
||||||
|
// 只有水平移动时才响应
|
||||||
|
return Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.dx) > 10;
|
||||||
|
},
|
||||||
|
onPanResponderRelease: (_, gestureState) => {
|
||||||
|
const threshold = screenWidth / 4; // 滑动屏幕宽度的1/4触发切换
|
||||||
|
|
||||||
|
if (gestureState.dx > threshold && hasPrevious) {
|
||||||
|
// 向右滑动,显示上一个
|
||||||
|
onPrevious?.();
|
||||||
|
} else if (gestureState.dx < -threshold && hasNext) {
|
||||||
|
// 向左滑动,显示下一个
|
||||||
|
onNext?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).current;
|
||||||
|
|
||||||
|
// 处理返回键(Android)
|
||||||
|
useEffect(() => {
|
||||||
|
const backHandler = () => {
|
||||||
|
if (visible) {
|
||||||
|
handleClose();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在实际应用中,这里需要添加返回键监听
|
||||||
|
// 对于演示,我们只做概念性实现
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 清理返回键监听
|
||||||
|
};
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={false}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
statusBarTranslucent={true}
|
||||||
|
>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 状态栏处理 */}
|
||||||
|
{Platform.OS === 'android' && <StatusBar hidden />}
|
||||||
|
|
||||||
|
{/* 视频容器 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.videoContainer}
|
||||||
|
onPress={handleVideoPress}
|
||||||
|
activeOpacity={1}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={styles.videoWrapper}
|
||||||
|
{...panResponder.panHandlers}
|
||||||
|
>
|
||||||
|
{/* 视频播放器 */}
|
||||||
|
{Platform.OS === 'web' ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef as any}
|
||||||
|
src={videoUrl}
|
||||||
|
style={styles.video}
|
||||||
|
autoPlay={autoPlay}
|
||||||
|
loop={isLooping}
|
||||||
|
muted={isMuted}
|
||||||
|
playsInline
|
||||||
|
onLoadedData={() => setIsLoading(false)}
|
||||||
|
onError={handleVideoError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={{ uri: videoUrl }}
|
||||||
|
style={styles.video}
|
||||||
|
resizeMode={ResizeMode.CONTAIN}
|
||||||
|
shouldPlay={autoPlay}
|
||||||
|
isLooping={isLooping}
|
||||||
|
isMuted={isMuted}
|
||||||
|
useNativeControls={false}
|
||||||
|
onReadyForDisplay={handleVideoReady}
|
||||||
|
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||||
|
onError={handleVideoError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载指示器 */}
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 左右导航指示器 */}
|
||||||
|
{hasPrevious && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.leftIndicator}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPrevious?.();
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.indicatorIcon}>‹</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{hasNext && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.rightIndicator}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onNext?.();
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<ThemedText style={styles.indicatorIcon}>›</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
videoContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
videoWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenHeight,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
objectFit: 'cover'
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
leftIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 20,
|
||||||
|
top: '50%',
|
||||||
|
marginTop: -20,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
rightIndicator: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 20,
|
||||||
|
top: '50%',
|
||||||
|
marginTop: -20,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
indicatorIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FullscreenVideoModal;
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
|
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
@@ -33,8 +34,10 @@ export interface VideoPlayerProps {
|
|||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
onReady?: (status: AVPlaybackStatus) => void;
|
onReady?: (status: AVPlaybackStatus) => void;
|
||||||
|
onWebLoadedData?: () => void;
|
||||||
onError?: (error: any) => void;
|
onError?: (error: any) => void;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
fullscreenMode?: boolean; // 新增:全屏模式标志
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoPlayer({
|
export function VideoPlayer({
|
||||||
@@ -51,14 +54,18 @@ export function VideoPlayer({
|
|||||||
maxHeight,
|
maxHeight,
|
||||||
aspectRatio,
|
aspectRatio,
|
||||||
onReady,
|
onReady,
|
||||||
|
onWebLoadedData,
|
||||||
onError,
|
onError,
|
||||||
onPress,
|
onPress,
|
||||||
|
fullscreenMode = false, // 新增:默认非全屏模式
|
||||||
}: VideoPlayerProps) {
|
}: VideoPlayerProps) {
|
||||||
const videoRef = useRef<Video>(null);
|
const videoRef = useRef<Video>(null);
|
||||||
const [videoStatus, setVideoStatus] = useState<AVPlaybackStatus>();
|
const [videoStatus, setVideoStatus] = useState<AVPlaybackStatus>();
|
||||||
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [showPosterOverlay, setShowPosterOverlay] = useState(showPoster);
|
const [showPosterOverlay, setShowPosterOverlay] = useState(showPoster);
|
||||||
|
const [hasVideoError, setHasVideoError] = useState(false);
|
||||||
|
const [shouldShowAsImage, setShouldShowAsImage] = useState(false);
|
||||||
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
const backgroundColor = useThemeColor({}, 'background');
|
||||||
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
|
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||||
@@ -67,6 +74,16 @@ export function VideoPlayer({
|
|||||||
const isValidUrl = isValidVideoUrl(source.uri);
|
const isValidUrl = isValidVideoUrl(source.uri);
|
||||||
const videoFileType = getVideoFileType(source.uri);
|
const videoFileType = getVideoFileType(source.uri);
|
||||||
|
|
||||||
|
// 如果URL看起来不是视频,显示为图片
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isValidUrl || !videoFileType) {
|
||||||
|
console.log('URL不是有效的视频格式,将作为图片显示:', source.uri);
|
||||||
|
setShouldShowAsImage(true);
|
||||||
|
setHasVideoError(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isValidUrl, videoFileType, source.uri]);
|
||||||
|
|
||||||
// 计算视频容器的最佳尺寸
|
// 计算视频容器的最佳尺寸
|
||||||
const calculateVideoDimensions = () => {
|
const calculateVideoDimensions = () => {
|
||||||
const containerWidth = screenWidth - 40; // 减去padding
|
const containerWidth = screenWidth - 40; // 减去padding
|
||||||
@@ -132,7 +149,16 @@ export function VideoPlayer({
|
|||||||
// 处理视频加载错误
|
// 处理视频加载错误
|
||||||
const handleVideoError = (error: any) => {
|
const handleVideoError = (error: any) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setHasVideoError(true);
|
||||||
console.error('视频加载失败:', error);
|
console.error('视频加载失败:', error);
|
||||||
|
|
||||||
|
// 尝试作为图片显示
|
||||||
|
const isImageUrl = source.uri.match(/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i);
|
||||||
|
if (isImageUrl) {
|
||||||
|
console.log('视频加载失败,尝试作为图片显示:', source.uri);
|
||||||
|
setShouldShowAsImage(true);
|
||||||
|
}
|
||||||
|
|
||||||
onError?.(error);
|
onError?.(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -153,6 +179,11 @@ export function VideoPlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全屏模式下不处理视频播放逻辑,只传递点击事件
|
||||||
|
if (fullscreenMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果视频已加载但未播放,开始播放
|
// 如果视频已加载但未播放,开始播放
|
||||||
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
|
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
|
||||||
videoRef.current?.playAsync();
|
videoRef.current?.playAsync();
|
||||||
@@ -166,19 +197,50 @@ export function VideoPlayer({
|
|||||||
}
|
}
|
||||||
}, [autoPlay, videoStatus]);
|
}, [autoPlay, videoStatus]);
|
||||||
|
|
||||||
const isVideoLoaded = videoStatus?.isLoaded;
|
const isVideoLoaded = Platform.OS === 'web' ? !isLoading : videoStatus?.isLoaded;
|
||||||
const isVideoPlaying = videoStatus?.isLoaded && videoStatus.isPlaying;
|
const isVideoPlaying = Platform.OS === 'web' ? true : (videoStatus?.isLoaded && videoStatus.isPlaying);
|
||||||
|
|
||||||
|
// 如果应该显示为图片,显示图片而不是视频
|
||||||
|
if (shouldShowAsImage) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.container, { height: videoSize.height }, style]}
|
||||||
|
onPress={handleContainerPress}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: source.uri }}
|
||||||
|
style={[
|
||||||
|
styles.poster,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: placeholderColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
contentFit="cover"
|
||||||
|
onLoad={() => setIsLoading(false)}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('图片加载失败:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.container, { height: videoSize.height }, style]}
|
style={[styles.container, { height: videoSize.height }, style]}
|
||||||
onPress={handleContainerPress}
|
onPress={handleContainerPress}
|
||||||
activeOpacity={0.9}
|
activeOpacity={fullscreenMode ? 0.8 : 0.9}
|
||||||
disabled={!onPress && useNativeControls}
|
disabled={fullscreenMode ? false : (!onPress && useNativeControls)}
|
||||||
>
|
>
|
||||||
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
|
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
|
||||||
{/* 封面图 */}
|
{/* 封面图 - 只在非 Web 平台显示 */}
|
||||||
{poster && showPosterOverlay && (
|
{poster && showPosterOverlay && Platform.OS !== 'web' && !hasVideoError && (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: poster }}
|
source={{ uri: poster }}
|
||||||
style={[
|
style={[
|
||||||
@@ -194,35 +256,87 @@ export function VideoPlayer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 视频播放器 */}
|
{/* 视频播放器 */}
|
||||||
<Video
|
{!hasVideoError && (
|
||||||
ref={videoRef}
|
<>
|
||||||
source={source}
|
{Platform.OS === 'web' ? (
|
||||||
style={[
|
<video
|
||||||
styles.video,
|
ref={videoRef}
|
||||||
{
|
src={source.uri}
|
||||||
width: '100%',
|
style={{
|
||||||
height: '100%',
|
position: 'absolute',
|
||||||
},
|
top: 0,
|
||||||
]}
|
left: 0,
|
||||||
resizeMode={resizeMode}
|
width: '100%',
|
||||||
shouldPlay={shouldPlay}
|
height: '100%',
|
||||||
isLooping={isLooping}
|
objectFit: 'cover',
|
||||||
isMuted={isMuted}
|
overflow: 'hidden',
|
||||||
useNativeControls={useNativeControls}
|
cursor: fullscreenMode ? 'pointer' : 'default',
|
||||||
onReadyForDisplay={handleVideoReady}
|
}}
|
||||||
onError={handleVideoError}
|
autoPlay={autoPlay}
|
||||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
loop={isLooping}
|
||||||
/>
|
muted={isMuted}
|
||||||
|
playsInline
|
||||||
|
onClick={(e) => {
|
||||||
|
// 全屏模式下,点击视频直接触发关闭
|
||||||
|
if (fullscreenMode && onPress) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onPress();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onLoadedData={() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
onWebLoadedData?.(); // 通知父组件Web平台视频已加载
|
||||||
|
}}
|
||||||
|
onError={handleVideoError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Video
|
||||||
|
ref={videoRef}
|
||||||
|
source={source}
|
||||||
|
style={styles.video}
|
||||||
|
resizeMode={resizeMode}
|
||||||
|
shouldPlay={shouldPlay}
|
||||||
|
isLooping={isLooping}
|
||||||
|
isMuted={isMuted}
|
||||||
|
useNativeControls={useNativeControls}
|
||||||
|
onReadyForDisplay={handleVideoReady}
|
||||||
|
onError={handleVideoError}
|
||||||
|
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 加载指示器 */}
|
{/* 错误状态下显示为图片 */}
|
||||||
{isLoading && (
|
{hasVideoError && !shouldShowAsImage && (
|
||||||
|
<Image
|
||||||
|
source={{ uri: source.uri }}
|
||||||
|
style={[
|
||||||
|
styles.poster,
|
||||||
|
{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: placeholderColor,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
contentFit="cover"
|
||||||
|
onLoad={() => setIsLoading(false)}
|
||||||
|
onError={(error) => {
|
||||||
|
console.error('错误回退:图片也加载失败:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载指示器 - 只在非 Web 平台显示 */}
|
||||||
|
{isLoading && Platform.OS !== 'web' && !hasVideoError && (
|
||||||
<View style={styles.loadingOverlay}>
|
<View style={styles.loadingOverlay}>
|
||||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) */}
|
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) - 全屏模式下不显示 */}
|
||||||
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && (
|
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && !hasVideoError && !fullscreenMode && (
|
||||||
<View style={styles.playButtonOverlay}>
|
<View style={styles.playButtonOverlay}>
|
||||||
<View style={styles.playButton}>
|
<View style={styles.playButton}>
|
||||||
<ThemedView style={styles.playIcon}>
|
<ThemedView style={styles.playIcon}>
|
||||||
@@ -232,8 +346,8 @@ export function VideoPlayer({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 错误状态 */}
|
{/* 错误状态 - 只在非 Web 平台显示 */}
|
||||||
{!isLoading && !isVideoLoaded && (
|
{!isLoading && !isVideoLoaded && hasVideoError && Platform.OS !== 'web' && !shouldShowAsImage && (
|
||||||
<View style={styles.errorOverlay}>
|
<View style={styles.errorOverlay}>
|
||||||
<ThemedView style={styles.errorMessage}>
|
<ThemedView style={styles.errorMessage}>
|
||||||
{'❌'}
|
{'❌'}
|
||||||
@@ -265,8 +379,10 @@ const styles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
poster: {
|
poster: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
30
eas.json
Normal file
30
eas.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": "16.22.0",
|
||||||
|
"appVersionSource": "local"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
},
|
||||||
|
"autoIncrement": true
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,5 +23,4 @@ export const authClient = createAuthClient({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export const { signIn, signUp, signOut, useSession, $Infer, subscription } = authClient;
|
export const { signIn, signUp, signOut, useSession, $Infer, subscription } = authClient;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface User {
|
|||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
|
balance?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -18,6 +19,30 @@ export interface Session {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenerationRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: 'image' | 'video' | 'text';
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
thumbnail?: string;
|
||||||
|
mediaUrl?: string;
|
||||||
|
status: 'pending' | 'completed' | 'failed';
|
||||||
|
cost: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BalanceTransaction {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: 'recharge' | 'consumption' | 'refund';
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
relatedRecordId?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthError {
|
export interface AuthError {
|
||||||
message: string;
|
message: string;
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|||||||
3434
package-lock.json
generated
3434
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,16 +13,18 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/expo": "^1.3.27",
|
"@better-auth/expo": "^1.3.27",
|
||||||
"@better-auth/stripe": "^1.3.27",
|
"@better-auth/stripe": "^1.3.27",
|
||||||
"@bowong/better-auth-stripe": "1.3.27-a",
|
"@bowong/better-auth-stripe": "1.3.27-g",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"better-auth": "1.3.27",
|
"better-auth": "1.3.27",
|
||||||
|
"eas-cli": "^16.22.0",
|
||||||
"expo": "~54.0.13",
|
"expo": "~54.0.13",
|
||||||
"expo-av": "~16.0.7",
|
"expo-av": "~16.0.7",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
|
"expo-dev-client": "~6.0.15",
|
||||||
"expo-file-system": "~19.0.17",
|
"expo-file-system": "~19.0.17",
|
||||||
"expo-font": "~14.0.9",
|
"expo-font": "~14.0.9",
|
||||||
"expo-haptics": "~15.0.7",
|
"expo-haptics": "~15.0.7",
|
||||||
@@ -50,9 +52,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"typescript": "~5.9.2",
|
|
||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0"
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
}
|
}
|
||||||
|
|||||||
113
utils/media-utils.ts
Normal file
113
utils/media-utils.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Template } from '@/lib/types/template';
|
||||||
|
|
||||||
|
export const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv', 'flv'];
|
||||||
|
export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证媒体URL是否有效
|
||||||
|
*/
|
||||||
|
export const isValidMediaUrl = (url: string): boolean => {
|
||||||
|
if (!url || url.trim() === '') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// 如果不是有效的URL格式,检查是否是相对路径
|
||||||
|
return url.startsWith('/') || url.startsWith('./') || url.startsWith('../');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取媒体类型:video 或 image
|
||||||
|
*/
|
||||||
|
export const getMediaType = (template: Template): 'video' | 'image' => {
|
||||||
|
// 1. 优先检查 previewUrl 的文件扩展名
|
||||||
|
if (template.previewUrl && isValidMediaUrl(template.previewUrl)) {
|
||||||
|
const previewUrl = template.previewUrl.toLowerCase();
|
||||||
|
|
||||||
|
// 检查是否为视频文件
|
||||||
|
const videoMatch = new RegExp(`\\.(${VIDEO_EXTENSIONS.join('|')})$`).test(previewUrl);
|
||||||
|
if (videoMatch) {
|
||||||
|
console.log(`"${template.title}" 通过previewUrl扩展名识别为视频: ${template.previewUrl}`);
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为图片文件
|
||||||
|
const imageMatch = new RegExp(`\\.(${IMAGE_EXTENSIONS.join('|')})$`).test(previewUrl);
|
||||||
|
if (imageMatch) {
|
||||||
|
console.log(`"${template.title}" 通过previewUrl扩展名识别为图片: ${template.previewUrl}`);
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检查 coverImageUrl 的文件扩展名
|
||||||
|
if (template.coverImageUrl && isValidMediaUrl(template.coverImageUrl)) {
|
||||||
|
const coverImageUrl = template.coverImageUrl.toLowerCase();
|
||||||
|
const imageMatch = new RegExp(`\\.(${IMAGE_EXTENSIONS.join('|')})$`).test(coverImageUrl);
|
||||||
|
if (imageMatch) {
|
||||||
|
console.log(`"${template.title}" 通过coverImageUrl扩展名识别为图片: ${template.coverImageUrl}`);
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 根据 previewUrl 是否存在来推断类型
|
||||||
|
if (template.previewUrl && template.previewUrl.trim() !== '') {
|
||||||
|
console.log(`"${template.title}" 通过previewUrl存在性推断为视频: ${template.previewUrl}`);
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 如果有coverImageUrl,默认为图片
|
||||||
|
if (template.coverImageUrl && template.coverImageUrl.trim() !== '') {
|
||||||
|
console.log(`"${template.title}" 通过coverImageUrl存在性推断为图片: ${template.coverImageUrl}`);
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 默认为图片
|
||||||
|
console.log(`"${template.title}" 无法确定类型,默认为图片`);
|
||||||
|
return 'image';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断模板是否为视频类型(带日志)
|
||||||
|
*/
|
||||||
|
export const isVideoTemplate = (template: Template): boolean => {
|
||||||
|
const mediaType = getMediaType(template);
|
||||||
|
return mediaType === 'video';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断模板是否为图片类型(带日志)
|
||||||
|
*/
|
||||||
|
export const isImageTemplate = (template: Template): boolean => {
|
||||||
|
const mediaType = getMediaType(template);
|
||||||
|
return mediaType === 'image';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取有效的媒体URL
|
||||||
|
*/
|
||||||
|
export const getEffectiveMediaUrl = (template: Template): string => {
|
||||||
|
const mediaType = getMediaType(template);
|
||||||
|
|
||||||
|
if (mediaType === 'video') {
|
||||||
|
return template.previewUrl || '';
|
||||||
|
} else {
|
||||||
|
return template.coverImageUrl || '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查媒体URL是否有效且可以加载
|
||||||
|
*/
|
||||||
|
export const canLoadMedia = (template: Template): boolean => {
|
||||||
|
const mediaType = getMediaType(template);
|
||||||
|
const url = getEffectiveMediaUrl(template);
|
||||||
|
|
||||||
|
if (!url || !isValidMediaUrl(url)) {
|
||||||
|
console.warn(`模板 "${template.title}" 的${mediaType}URL无效:`, url);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user