This commit is contained in:
imeepos
2025-10-21 10:21:45 +08:00
parent 7e3f94bae3
commit 3a99ff96d5
28 changed files with 6981 additions and 176 deletions

View File

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

@@ -0,0 +1,11 @@
{
"android": {
"keystore": {
"alias": "default",
"keyAlias": "default",
"keystorePath": "bw-expo-app-v3.keystore",
"keyPassword": "defaultpassword",
"storePassword": "defaultpassword"
}
}
}

View File

@@ -44,6 +44,12 @@
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
},
"extra": {
"eas": {
"projectId": "191f0396-3e0c-40ec-bd06-b15f58278b8f"
}
},
"owner": "imeepos"
}
}

View File

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

View File

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

View 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,
},
});

View File

@@ -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 {
// 跳转到订阅
}
};

946
bun.lock

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View File

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

View File

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

View 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,
},
});

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

View 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,
},
});

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

View 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;

View File

@@ -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
View 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": {}
}
}

View File

@@ -23,5 +23,4 @@ export const authClient = createAuthClient({
],
});
export const { signIn, signUp, signOut, useSession, $Infer, subscription } = authClient;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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;
};