fix: bug
This commit is contained in:
@@ -4,7 +4,21 @@
|
||||
"WebFetch(domain:docs.expo.dev)",
|
||||
"WebFetch(domain:docs.expo.dev)",
|
||||
"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": [],
|
||||
"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": {
|
||||
"typedRoutes": 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} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: 'Profile',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="person.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ export default function HomeScreen() {
|
||||
Alert.alert('模板详情', `您选择了: ${template.title}`);
|
||||
};
|
||||
|
||||
const handleVideoChange = (template: Template, index: number) => {
|
||||
console.log('视频切换到:', template.title, '索引:', index);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
@@ -169,6 +173,7 @@ export default function HomeScreen() {
|
||||
onRefresh={handleRefresh}
|
||||
onLoadMore={loadMore}
|
||||
onTemplatePress={handleTemplatePress}
|
||||
onVideoChange={handleVideoChange}
|
||||
error={error}
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const handleConfirm = async () => {
|
||||
// 转换表单数据为 API 期望的格式
|
||||
const transformedData = transformFormData(formData);
|
||||
console.log('转换后的数据:', transformedData);
|
||||
|
||||
const identify = await subscription.meterEvent({
|
||||
meter_id: `mtr_test_61TR3BkyDcen9OrBm412fzUa9k2QB732`,
|
||||
event_name: `token_usage`,
|
||||
payload: {
|
||||
value: `100`
|
||||
}
|
||||
})
|
||||
console.log({ identify })
|
||||
// error
|
||||
const orid = identify.data?.identifier
|
||||
console.log({ orid })
|
||||
if (orid) {
|
||||
try {
|
||||
setCurrentStep('progress');
|
||||
executeTemplate(template!.id, transformedData);
|
||||
} catch (e) {
|
||||
// 退款orid
|
||||
// 这个我后端取 获取月
|
||||
const list = await subscription.list()
|
||||
const listData = list.data;
|
||||
if (listData && listData.length > 0) {
|
||||
const item = listData[0]
|
||||
const subscriptionId = item?.stripeSubscriptionId!;
|
||||
const summary = await subscription.credit.summary({
|
||||
subscriptionId: subscriptionId,
|
||||
filter: {
|
||||
type: `applicability_scope`,
|
||||
applicability_scope: {
|
||||
price_type: `metered`
|
||||
}
|
||||
}
|
||||
});
|
||||
const identify = await subscription.meterEvent({
|
||||
event_name: `token_usage`,
|
||||
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 { 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 { ThemedView } from '../themed-view';
|
||||
import { Template } from '@/lib/types/template';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { isVideoTemplate, canLoadMedia } from '@/utils/media-utils';
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: Template;
|
||||
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 cardColor = useThemeColor({}, 'card');
|
||||
const imagePlaceholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||
const [isMediaFullscreenVisible, setIsMediaFullscreenVisible] = useState(false);
|
||||
const [currentMediaIndex, setCurrentMediaIndex] = useState(currentIndex);
|
||||
|
||||
const isVideo = useMemo(() => {
|
||||
return /\.(mp4|webm|ogg|mov|avi|mkv|flv)$/i.test(template.previewUrl || '');
|
||||
}, [template.previewUrl]);
|
||||
return isVideoTemplate(template);
|
||||
}, [template]);
|
||||
|
||||
const getImageHeight = () => {
|
||||
if (template.aspectRatio) {
|
||||
@@ -39,23 +52,36 @@ export function TemplateCard({ template, onPress }: TemplateCardProps) {
|
||||
router.push(`/template/${template.id}/run`);
|
||||
};
|
||||
|
||||
const handleCardPress = () => {
|
||||
if (onPress) {
|
||||
onPress(template);
|
||||
} else {
|
||||
// 默认行为:跳转到运行页面
|
||||
handleUseTemplate();
|
||||
}
|
||||
const handleFullscreenMedia = () => {
|
||||
setIsMediaFullscreenVisible(true);
|
||||
};
|
||||
|
||||
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 (
|
||||
<View style={[styles.card, { backgroundColor: cardColor }]}>
|
||||
<View style={styles.mediaContainer}>
|
||||
<ThemedView style={[styles.mediaContent, { height: mediaHeight }]}>
|
||||
{isVideo ? (
|
||||
<VideoPlayer
|
||||
source={{ uri: template.previewUrl }}
|
||||
poster={template.coverImageUrl}
|
||||
source={{ uri: currentTemplate.previewUrl }}
|
||||
poster={currentTemplate.coverImageUrl}
|
||||
style={[styles.videoPlayer, styles.videoCover]}
|
||||
resizeMode={ResizeMode.COVER}
|
||||
shouldPlay={true}
|
||||
@@ -120,6 +146,15 @@ export function TemplateCard({ template, onPress }: TemplateCardProps) {
|
||||
<ThemedText style={styles.useButtonText}>立即使用</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 统一全屏媒体模态框 */}
|
||||
<FullscreenMediaModal
|
||||
visible={isMediaFullscreenVisible}
|
||||
onClose={() => setIsMediaFullscreenVisible(false)}
|
||||
currentIndex={currentMediaIndex}
|
||||
templates={allTemplates}
|
||||
onIndexChanged={handleMediaIndexChanged}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +193,11 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// React Native中object-fit对应contentFit属性,但在VideoPlayer中已经处理了
|
||||
...Platform.select({
|
||||
web: {
|
||||
objectFit: 'cover',
|
||||
},
|
||||
}),
|
||||
},
|
||||
imageContainer: {
|
||||
width: '100%',
|
||||
@@ -181,6 +221,7 @@ const styles = StyleSheet.create({
|
||||
color: 'rgba(255, 255, 255, 0.85)',
|
||||
fontSize: 10,
|
||||
fontWeight: '400',
|
||||
lineHeight: 6
|
||||
},
|
||||
tagOverlay: {
|
||||
position: 'absolute',
|
||||
@@ -205,6 +246,7 @@ const styles = StyleSheet.create({
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
lineHeight: 4
|
||||
},
|
||||
actionArea: {
|
||||
padding: 12,
|
||||
|
||||
@@ -14,6 +14,7 @@ interface TemplateListProps {
|
||||
onRefresh?: () => void;
|
||||
onLoadMore?: () => void;
|
||||
onTemplatePress?: (template: Template) => void;
|
||||
onVideoChange?: (template: Template, index: number) => void;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ export function TemplateList({
|
||||
onRefresh,
|
||||
onLoadMore,
|
||||
onTemplatePress,
|
||||
onVideoChange,
|
||||
error,
|
||||
}: TemplateListProps) {
|
||||
const tintColor = useThemeColor({}, 'tint');
|
||||
@@ -89,8 +91,14 @@ export function TemplateList({
|
||||
<FlatList
|
||||
data={templates}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<TemplateCard template={item} onPress={onTemplatePress} />
|
||||
renderItem={({ item, index }) => (
|
||||
<TemplateCard
|
||||
template={item}
|
||||
onPress={onTemplatePress}
|
||||
allTemplates={templates}
|
||||
currentIndex={index}
|
||||
onVideoChange={onVideoChange}
|
||||
/>
|
||||
)}
|
||||
numColumns={2}
|
||||
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,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
|
||||
import { Image } from 'expo-image';
|
||||
@@ -33,8 +34,10 @@ export interface VideoPlayerProps {
|
||||
maxHeight?: number;
|
||||
aspectRatio?: number;
|
||||
onReady?: (status: AVPlaybackStatus) => void;
|
||||
onWebLoadedData?: () => void;
|
||||
onError?: (error: any) => void;
|
||||
onPress?: () => void;
|
||||
fullscreenMode?: boolean; // 新增:全屏模式标志
|
||||
}
|
||||
|
||||
export function VideoPlayer({
|
||||
@@ -51,14 +54,18 @@ export function VideoPlayer({
|
||||
maxHeight,
|
||||
aspectRatio,
|
||||
onReady,
|
||||
onWebLoadedData,
|
||||
onError,
|
||||
onPress,
|
||||
fullscreenMode = false, // 新增:默认非全屏模式
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<Video>(null);
|
||||
const [videoStatus, setVideoStatus] = useState<AVPlaybackStatus>();
|
||||
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showPosterOverlay, setShowPosterOverlay] = useState(showPoster);
|
||||
const [hasVideoError, setHasVideoError] = useState(false);
|
||||
const [shouldShowAsImage, setShouldShowAsImage] = useState(false);
|
||||
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
const placeholderColor = useThemeColor({}, 'imagePlaceholder');
|
||||
@@ -67,6 +74,16 @@ export function VideoPlayer({
|
||||
const isValidUrl = isValidVideoUrl(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 containerWidth = screenWidth - 40; // 减去padding
|
||||
@@ -132,7 +149,16 @@ export function VideoPlayer({
|
||||
// 处理视频加载错误
|
||||
const handleVideoError = (error: any) => {
|
||||
setIsLoading(false);
|
||||
setHasVideoError(true);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -153,6 +179,11 @@ export function VideoPlayer({
|
||||
return;
|
||||
}
|
||||
|
||||
// 全屏模式下不处理视频播放逻辑,只传递点击事件
|
||||
if (fullscreenMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果视频已加载但未播放,开始播放
|
||||
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
|
||||
videoRef.current?.playAsync();
|
||||
@@ -166,19 +197,50 @@ export function VideoPlayer({
|
||||
}
|
||||
}, [autoPlay, videoStatus]);
|
||||
|
||||
const isVideoLoaded = videoStatus?.isLoaded;
|
||||
const isVideoPlaying = videoStatus?.isLoaded && videoStatus.isPlaying;
|
||||
const isVideoLoaded = Platform.OS === 'web' ? !isLoading : videoStatus?.isLoaded;
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, { height: videoSize.height }, style]}
|
||||
onPress={handleContainerPress}
|
||||
activeOpacity={0.9}
|
||||
disabled={!onPress && useNativeControls}
|
||||
activeOpacity={fullscreenMode ? 0.8 : 0.9}
|
||||
disabled={fullscreenMode ? false : (!onPress && useNativeControls)}
|
||||
>
|
||||
<ThemedView style={[styles.videoContainer, { backgroundColor }]}>
|
||||
{/* 封面图 */}
|
||||
{poster && showPosterOverlay && (
|
||||
{/* 封面图 - 只在非 Web 平台显示 */}
|
||||
{poster && showPosterOverlay && Platform.OS !== 'web' && !hasVideoError && (
|
||||
<Image
|
||||
source={{ uri: poster }}
|
||||
style={[
|
||||
@@ -194,35 +256,87 @@ export function VideoPlayer({
|
||||
)}
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={source}
|
||||
style={[
|
||||
styles.video,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]}
|
||||
resizeMode={resizeMode}
|
||||
shouldPlay={shouldPlay}
|
||||
isLooping={isLooping}
|
||||
isMuted={isMuted}
|
||||
useNativeControls={useNativeControls}
|
||||
onReadyForDisplay={handleVideoReady}
|
||||
onError={handleVideoError}
|
||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||
/>
|
||||
{!hasVideoError && (
|
||||
<>
|
||||
{Platform.OS === 'web' ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={source.uri}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
overflow: 'hidden',
|
||||
cursor: fullscreenMode ? 'pointer' : 'default',
|
||||
}}
|
||||
autoPlay={autoPlay}
|
||||
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}>
|
||||
<ActivityIndicator size="large" color="#4ECDC4" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) */}
|
||||
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && (
|
||||
{/* 播放按钮覆盖层(当视频暂停且无原生控件时显示) - 全屏模式下不显示 */}
|
||||
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && !hasVideoError && !fullscreenMode && (
|
||||
<View style={styles.playButtonOverlay}>
|
||||
<View style={styles.playButton}>
|
||||
<ThemedView style={styles.playIcon}>
|
||||
@@ -232,8 +346,8 @@ export function VideoPlayer({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 错误状态 */}
|
||||
{!isLoading && !isVideoLoaded && (
|
||||
{/* 错误状态 - 只在非 Web 平台显示 */}
|
||||
{!isLoading && !isVideoLoaded && hasVideoError && Platform.OS !== 'web' && !shouldShowAsImage && (
|
||||
<View style={styles.errorOverlay}>
|
||||
<ThemedView style={styles.errorMessage}>
|
||||
{'❌'}
|
||||
@@ -265,8 +379,10 @@ const styles = StyleSheet.create({
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
poster: {
|
||||
poster: {
|
||||
position: 'absolute',
|
||||
top: 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;
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface User {
|
||||
email?: string;
|
||||
name?: string;
|
||||
image?: string;
|
||||
balance?: number;
|
||||
createdAt: 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 {
|
||||
message: 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": {
|
||||
"@better-auth/expo": "^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",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"better-auth": "1.3.27",
|
||||
"eas-cli": "^16.22.0",
|
||||
"expo": "~54.0.13",
|
||||
"expo-av": "~16.0.7",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-dev-client": "~6.0.15",
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
@@ -50,9 +52,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0"
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"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