fix: 修复所有 TypeScript 类型错误
- 修复 webpPreviewUrl -> previewUrl 字段名错误 - 修复 video.tsx 中的 useTemplates 参数类型问题 - 修复 video.tsx 中 position 重复定义问题 - 修复 searchResults.tsx 中的 SearchResultItem 类型不匹配 - 修复 SearchResultsGrid.tsx 中不存在的 scrollContent 样式 - 修复 use-templates.ts 中 page 可能是 undefined 的类型问题 - 添加 tsconfig.json 的 exclude 配置 - 修复 use-template-actions.ts 中 null 不能赋值给 ApiError 的问题 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import { StatusBar } from 'expo-status-bar'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Platform,
|
||||
Pressable,
|
||||
@@ -13,114 +12,95 @@ import {
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
|
||||
import { DownArrowIcon, PointsIcon, SearchIcon, WhiteStarIcon } from '@/components/icon'
|
||||
import { HomeSkeleton } from '@/components/skeleton/HomeSkeleton'
|
||||
import { useActivates } from '@/hooks/use-activates'
|
||||
import { useCategories } from '@/hooks/use-categories'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
||||
// 卡片数据 - 根据 Figma 设计更新
|
||||
const cardData = [
|
||||
{
|
||||
id: 1,
|
||||
title: '宠物写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
isHot: true,
|
||||
users: 6349,
|
||||
height: 214,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '我和小猫的人生合照',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
users: 6349,
|
||||
height: 236,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '猫:晚安~人',
|
||||
image: require('@/assets/images/favicon.png'),
|
||||
users: 6349,
|
||||
height: 214,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '穿越时空的相聚',
|
||||
image: require('@/assets/images/icon.png'),
|
||||
users: 6349,
|
||||
height: 100,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '睡衣版猫咪',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
users: 6349,
|
||||
height: 120,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: '猫咪写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
users: 6349,
|
||||
height: 214,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: '站姐视角写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
users: 6349,
|
||||
height: 214,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: '猫咪写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
users: 6349,
|
||||
height: 200,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation()
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
|
||||
// 标签数据 - 根据 Figma 设计更新
|
||||
const tabs = [
|
||||
t('home.tabs.featured'),
|
||||
t('home.tabs.christmas'),
|
||||
t('home.tabs.pets'),
|
||||
t('home.tabs.avatar'),
|
||||
t('home.tabs.theater1'),
|
||||
t('home.tabs.theater2'),
|
||||
]
|
||||
const [gridWidth, setGridWidth] = useState(screenWidth)
|
||||
const [showTabArrow, setShowTabArrow] = useState(false)
|
||||
const [tabsSticky, setTabsSticky] = useState(false)
|
||||
const [tabsHeight, setTabsHeight] = useState(0)
|
||||
const tabsPositionRef = useRef(0)
|
||||
const titleBarHeightRef = useRef(0)
|
||||
const scrollY = useRef(new Animated.Value(0)).current
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
||||
|
||||
const { load: loadCategories, data: categoriesData, loading: categoriesLoading, error: categoriesError } = useCategories()
|
||||
const { load, data: activatesData, error } = useActivates()
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
loadCategories()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ activatesData, error })
|
||||
}, [activatesData, error])
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ categoriesData, categoriesError })
|
||||
// 当分类数据加载完成后,默认选中第一个分类
|
||||
if (categoriesData?.categories && categoriesData.categories.length > 0 && !selectedCategoryId) {
|
||||
setSelectedCategoryId(categoriesData.categories[0].id)
|
||||
}
|
||||
}, [categoriesData, categoriesError])
|
||||
|
||||
// 使用接口返回的分类数据,如果没有则使用默认翻译
|
||||
const categories = categoriesData?.categories || []
|
||||
const tabs = categories.length > 0
|
||||
? categories.map(cat => cat.name)
|
||||
: [
|
||||
t('home.tabs.featured'),
|
||||
t('home.tabs.christmas'),
|
||||
t('home.tabs.pets'),
|
||||
t('home.tabs.avatar'),
|
||||
t('home.tabs.theater1'),
|
||||
t('home.tabs.theater2'),
|
||||
]
|
||||
|
||||
// 获取当前选中分类的模板数据
|
||||
const currentCategory = categories.find(cat => cat.id === selectedCategoryId)
|
||||
const categoryTemplates = currentCategory?.templates || []
|
||||
|
||||
// 将 CategoryTemplate 数据转换为卡片数据格式
|
||||
const displayCardData = categoryTemplates.length > 0
|
||||
? categoryTemplates.map((template, idx) => ({
|
||||
id: template.id || `template-${idx}`,
|
||||
title: template.title,
|
||||
image: { uri: template.previewUrl || template.coverImageUrl || `` },
|
||||
isHot: false,
|
||||
users: 0,
|
||||
height: undefined,
|
||||
previewUrl: template.previewUrl,
|
||||
aspectRatio: template.aspectRatio,
|
||||
tag: template.tag,
|
||||
}))
|
||||
: []
|
||||
const [gridWidth, setGridWidth] = useState(screenWidth)
|
||||
const [showTabArrow, setShowTabArrow] = useState(false)
|
||||
const [tabsSticky, setTabsSticky] = useState(false)
|
||||
const [tabsHeight, setTabsHeight] = useState(0)
|
||||
const tabsPositionRef = useRef(0)
|
||||
const titleBarHeightRef = useRef(0)
|
||||
|
||||
const horizontalPadding = 8 * 2 // gridContainer 的左右 padding
|
||||
const cardGap = 5 // 两个卡片之间的间距
|
||||
const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2
|
||||
|
||||
// 判断是否显示加载状态
|
||||
const showLoading = categoriesLoading
|
||||
|
||||
// 判断是否显示分类空状态
|
||||
const showEmptyState = !categoriesLoading && categoriesData?.categories && categoriesData.categories.length === 0
|
||||
|
||||
// 判断是否显示模板空状态(当前分类下没有模板)
|
||||
const showEmptyTemplates = !categoriesLoading && !showEmptyState && displayCardData.length === 0
|
||||
|
||||
// 渲染标签导航的函数
|
||||
const renderTabs = (wrapperStyle?: any) => (
|
||||
<View style={[styles.tabsWrapper, wrapperStyle]}>
|
||||
@@ -141,7 +121,15 @@ export default function HomeScreen() {
|
||||
{tabs.map((tab, index) => (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => setActiveTab(index)}
|
||||
onPress={() => {
|
||||
setActiveTab(index)
|
||||
// 设置选中的分类ID
|
||||
if (categories.length > 0 && categories[index]) {
|
||||
setSelectedCategoryId(categories[index].id)
|
||||
} else {
|
||||
setSelectedCategoryId(null)
|
||||
}
|
||||
}}
|
||||
style={styles.tab}
|
||||
>
|
||||
<View style={styles.tabLabelWrapper}>
|
||||
@@ -216,7 +204,7 @@ export default function HomeScreen() {
|
||||
</View>
|
||||
</View>
|
||||
{/* 吸顶的标签导航 - 适配 iOS 和 Android 的安全区域 */}
|
||||
{tabsSticky && (
|
||||
{tabsSticky && !showLoading && !showEmptyState && (
|
||||
<View
|
||||
style={[
|
||||
styles.stickyTabsWrapper,
|
||||
@@ -266,78 +254,105 @@ export default function HomeScreen() {
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 标签导航 */}
|
||||
<View
|
||||
onLayout={(event) => {
|
||||
const { y, height } = event.nativeEvent.layout
|
||||
tabsPositionRef.current = y
|
||||
setTabsHeight(height)
|
||||
}}
|
||||
style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined}
|
||||
>
|
||||
{renderTabs()}
|
||||
</View>
|
||||
{/* 标签导航 - 只要有分类数据就显示标签 */}
|
||||
{!showLoading && !showEmptyState && (
|
||||
<View
|
||||
onLayout={(event) => {
|
||||
const { y, height } = event.nativeEvent.layout
|
||||
tabsPositionRef.current = y
|
||||
setTabsHeight(height)
|
||||
}}
|
||||
style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined}
|
||||
>
|
||||
{renderTabs()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{showLoading && <HomeSkeleton />}
|
||||
|
||||
{/* 空状态 - 分类数据为空 */}
|
||||
{showEmptyState && (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>暂无分类数据</Text>
|
||||
<Pressable style={styles.retryButton} onPress={() => loadCategories()}>
|
||||
<Text style={styles.retryButtonText}>重新加载</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 空状态 - 当前分类下没有模板 */}
|
||||
{showEmptyTemplates && (
|
||||
<View style={styles.emptyState}>
|
||||
<Text style={styles.emptyStateText}>该分类暂无模板</Text>
|
||||
<Pressable style={styles.retryButton} onPress={() => loadCategories()}>
|
||||
<Text style={styles.retryButtonText}>刷新</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 内容网格 */}
|
||||
<View
|
||||
style={styles.gridContainer}
|
||||
onLayout={(event) => {
|
||||
const { width } = event.nativeEvent.layout
|
||||
setGridWidth(width)
|
||||
}}
|
||||
>
|
||||
{cardData.map((card, index) => (
|
||||
<Pressable
|
||||
key={card.id}
|
||||
style={[
|
||||
styles.card,
|
||||
{ width: cardWidth },
|
||||
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
|
||||
]}
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: '/templateDetail' as any,
|
||||
params: { id: card.id.toString() },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<View
|
||||
{!showLoading && !showEmptyState && !showEmptyTemplates && (
|
||||
<View
|
||||
style={styles.gridContainer}
|
||||
onLayout={(event) => {
|
||||
const { width } = event.nativeEvent.layout
|
||||
setGridWidth(width)
|
||||
}}
|
||||
>
|
||||
{displayCardData.map((card, index) => (
|
||||
<Pressable
|
||||
key={card.id}
|
||||
style={[
|
||||
styles.cardImageContainer,
|
||||
{ height: card.height || cardWidth * 1.2 },
|
||||
styles.card,
|
||||
{ width: cardWidth },
|
||||
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
|
||||
]}
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: '/templateDetail' as any,
|
||||
params: { id: card.id.toString() },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={card.image}
|
||||
style={styles.cardImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={styles.cardImageGradient}
|
||||
/>
|
||||
{card.isHot ? (
|
||||
<View style={styles.hotBadge}>
|
||||
<Text style={styles.hotEmoji}>🔥</Text>
|
||||
<Text style={styles.hotText}>{t('home.hotTemplate')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.hotBadge}>
|
||||
<WhiteStarIcon />
|
||||
<Text style={styles.hotText}>
|
||||
{card.users}{t('home.peopleUsed')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
{card.title}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.cardImageContainer,
|
||||
{ height: card.height || cardWidth * 1.2 },
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={card.image}
|
||||
style={styles.cardImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={styles.cardImageGradient}
|
||||
/>
|
||||
{card.isHot ? (
|
||||
<View style={styles.hotBadge}>
|
||||
<Text style={styles.hotEmoji}>🔥</Text>
|
||||
<Text style={styles.hotText}>{t('home.hotTemplate')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.hotBadge}>
|
||||
<WhiteStarIcon />
|
||||
<Text style={styles.hotText}>
|
||||
{card.users}{t('home.peopleUsed')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
{card.title}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -616,4 +631,26 @@ const styles = StyleSheet.create({
|
||||
color: '#F5F5F5',
|
||||
lineHeight: 20,
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
gap: 16,
|
||||
},
|
||||
emptyStateText: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 14,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#FF6699',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -7,176 +7,187 @@ import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
StatusBar as RNStatusBar,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
} from 'react-native'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { Image, ImageLoadEventData } from 'expo-image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SameStyleIcon, VideoIcon, WhiteStarIcon } from '@/components/icon'
|
||||
import { SameStyleIcon, WhiteStarIcon } from '@/components/icon'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useTemplates, type TemplateDetail } from '@/hooks'
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
|
||||
const TAB_BAR_HEIGHT = 83 // 底部导航栏高度
|
||||
const TAB_BAR_HEIGHT = 83
|
||||
|
||||
// 视频数据
|
||||
const videoData = [
|
||||
{
|
||||
id: 1,
|
||||
videoUrl: require('@/assets/images/android-icon-background.png'),
|
||||
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
|
||||
title: '雅琳圣诞写真',
|
||||
duration: '00:03',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
videoUrl: require('@/assets/images/android-icon-background.png'),
|
||||
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
|
||||
title: '宠物写真',
|
||||
duration: '00:05',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
videoUrl: require('@/assets/images/android-icon-background.png'),
|
||||
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
|
||||
title: '我和小猫的人生合照',
|
||||
duration: '00:04',
|
||||
},
|
||||
]
|
||||
// 统一的布局组件
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
{children}
|
||||
</SafeAreaView>
|
||||
)
|
||||
|
||||
interface VideoItemProps {
|
||||
item: typeof videoData[0]
|
||||
index: number
|
||||
const LoadingState = () => (
|
||||
<Layout>
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color="#FFE500" />
|
||||
<Text style={styles.messageText}>加载中...</Text>
|
||||
</View>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const ErrorState = () => (
|
||||
<Layout>
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={styles.messageText}>加载失败,请下拉刷新重试</Text>
|
||||
</View>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const FooterLoading = () => (
|
||||
<View style={styles.footerLoading}>
|
||||
<ActivityIndicator size="small" color="#FFE500" />
|
||||
</View>
|
||||
)
|
||||
|
||||
// 计算图片显示尺寸
|
||||
const calculateImageSize = (
|
||||
imageSize: { width: number; height: number } | null,
|
||||
videoHeight: number
|
||||
) => {
|
||||
if (!imageSize) return { width: screenWidth, height: videoHeight }
|
||||
|
||||
const imageAspectRatio = imageSize.width / imageSize.height
|
||||
const screenAspectRatio = screenWidth / videoHeight
|
||||
|
||||
return imageAspectRatio > screenAspectRatio
|
||||
? { width: screenWidth, height: screenWidth / imageAspectRatio }
|
||||
: { width: videoHeight * imageAspectRatio, height: videoHeight }
|
||||
}
|
||||
|
||||
function VideoItem({ item, videoHeight }: VideoItemProps) {
|
||||
const VideoItem = memo(({ item, videoHeight }: { item: TemplateDetail; videoHeight: number }) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
const handleImageLoad = (event: ImageLoadEventData) => {
|
||||
const handleImageLoad = useCallback((event: ImageLoadEventData) => {
|
||||
const { source } = event
|
||||
if (source?.width && source?.height) {
|
||||
setImageSize({ width: source.width, height: source.height })
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 根据图片比例计算显示尺寸
|
||||
const getImageStyle = () => {
|
||||
if (!imageSize) {
|
||||
return {
|
||||
width: screenWidth,
|
||||
height: videoHeight,
|
||||
}
|
||||
}
|
||||
const handlePress = useCallback(() => {
|
||||
router.push({
|
||||
pathname: '/generateVideo' as any,
|
||||
params: { template: JSON.stringify(item) },
|
||||
})
|
||||
}, [router, item])
|
||||
|
||||
const imageAspectRatio = imageSize.width / imageSize.height
|
||||
const screenAspectRatio = screenWidth / videoHeight
|
||||
|
||||
let displayWidth = screenWidth
|
||||
let displayHeight = videoHeight
|
||||
|
||||
if (imageAspectRatio > screenAspectRatio) {
|
||||
// 图片更宽,以宽度为准
|
||||
displayHeight = screenWidth / imageAspectRatio
|
||||
} else {
|
||||
// 图片更高,以高度为准
|
||||
displayWidth = videoHeight * imageAspectRatio
|
||||
}
|
||||
|
||||
return {
|
||||
width: displayWidth,
|
||||
height: displayHeight,
|
||||
}
|
||||
}
|
||||
const imageStyle = calculateImageSize(imageSize, videoHeight)
|
||||
|
||||
return (
|
||||
<View style={[styles.videoContainer, { height: videoHeight }]}>
|
||||
{/* 主视频区域 */}
|
||||
<View style={styles.videoWrapper}>
|
||||
<Image
|
||||
source={item.videoUrl}
|
||||
style={[getImageStyle()]}
|
||||
source={{ uri: item.previewUrl }}
|
||||
style={imageStyle}
|
||||
contentFit="contain"
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
{/* 播放按钮 */}
|
||||
{!isPlaying && (
|
||||
<Pressable
|
||||
style={styles.playButton}
|
||||
onPress={() => setIsPlaying(true)}
|
||||
>
|
||||
<Pressable style={styles.playButton} onPress={() => setIsPlaying(true)}>
|
||||
<View style={styles.playIcon}>
|
||||
<View style={styles.playTriangle} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* 左下角原图缩略图 */}
|
||||
<View style={styles.thumbnailContainer}>
|
||||
<Image
|
||||
source={item.thumbnailUrl}
|
||||
style={styles.thumbnail}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Image source={{ uri: item.coverImageUrl }} style={styles.thumbnail} contentFit="cover" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 左下角按钮 */}
|
||||
<Pressable style={styles.actionButtonLeft}>
|
||||
<WhiteStarIcon />
|
||||
<Text style={styles.actionButtonTextTitle}>{item.title}</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* 右下角按钮 */}
|
||||
<Pressable
|
||||
style={styles.actionButtonRight}
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: '/generateVideo' as any,
|
||||
params: {
|
||||
template: JSON.stringify(item),
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pressable style={styles.actionButtonRight} onPress={handlePress}>
|
||||
<SameStyleIcon />
|
||||
<Text style={styles.actionButtonText}>{t('video.makeSame')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default function VideoScreen() {
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
const insets = useSafeAreaInsets()
|
||||
const videoHeight = screenHeight - TAB_BAR_HEIGHT
|
||||
const { templates, loading, error, execute, refetch, loadMore, hasMore } = useTemplates({
|
||||
sortBy: 'likeCount',
|
||||
sortOrder: 'desc',
|
||||
})
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
execute()
|
||||
}, [execute])
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await refetch()
|
||||
setRefreshing(false)
|
||||
}, [refetch])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!hasMore || loading) return
|
||||
loadMore()
|
||||
}, [hasMore, loading, loadMore])
|
||||
|
||||
const renderItem = useCallback(({ item }: { item: TemplateDetail }) => (
|
||||
<VideoItem item={item} videoHeight={videoHeight} />
|
||||
), [videoHeight])
|
||||
|
||||
const keyExtractor = useCallback((item: TemplateDetail) => item.id, [])
|
||||
|
||||
const getItemLayout = useCallback((_: any, index: number) => ({
|
||||
length: videoHeight,
|
||||
offset: videoHeight * index,
|
||||
index,
|
||||
}), [videoHeight])
|
||||
|
||||
if (loading && templates.length === 0) return <LoadingState />
|
||||
if (error && templates.length === 0) return <ErrorState />
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
<Layout>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={videoData}
|
||||
renderItem={({ item, index }) => (
|
||||
<VideoItem item={item} index={index} videoHeight={videoHeight} />
|
||||
)}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
data={templates}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
getItemLayout={getItemLayout}
|
||||
pagingEnabled
|
||||
showsVerticalScrollIndicator={false}
|
||||
snapToInterval={videoHeight}
|
||||
snapToAlignment="start"
|
||||
decelerationRate="fast"
|
||||
getItemLayout={(_, index) => ({
|
||||
length: videoHeight,
|
||||
offset: videoHeight * index,
|
||||
index,
|
||||
})}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor="#FFE500"
|
||||
colors={['#FFE500']}
|
||||
/>
|
||||
}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={loading ? <FooterLoading /> : null}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -185,24 +196,33 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
messageText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
footerLoading: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
videoContainer: {
|
||||
width: screenWidth,
|
||||
backgroundColor: '#090A0B',
|
||||
position: 'relative',
|
||||
},
|
||||
videoWrapper: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
backgroundColor: '#090A0B',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playButton: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
@@ -236,7 +256,6 @@ const styles = StyleSheet.create({
|
||||
overflow: 'hidden',
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFFFFF',
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
|
||||
@@ -10,63 +10,38 @@ import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
|
||||
import SearchResultsGrid from '@/components/SearchResultsGrid'
|
||||
import SearchBar from '@/components/SearchBar'
|
||||
import type { TemplateGeneration } from '@/hooks'
|
||||
|
||||
const createMockResult = (id: string, height: number): TemplateGeneration & { height: number; title: string; image: any } => ({
|
||||
id,
|
||||
userId: 'test',
|
||||
templateId: `template-${id}`,
|
||||
type: 'VIDEO',
|
||||
resultUrl: [],
|
||||
status: 'completed',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height,
|
||||
template: {
|
||||
id: `template-${id}`,
|
||||
title: '猫咪圣诞写真',
|
||||
titleEn: 'Cat Christmas',
|
||||
coverImageUrl: '',
|
||||
},
|
||||
})
|
||||
|
||||
// 搜索结果数据
|
||||
const searchResults = [
|
||||
{
|
||||
id: 1,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 236,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 131,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 236,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 236,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 95,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 236,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 228,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 95,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: '猫咪圣诞写真',
|
||||
image: require('@/assets/images/android-icon-background.png'),
|
||||
height: 228,
|
||||
},
|
||||
createMockResult('1', 236),
|
||||
createMockResult('2', 131),
|
||||
createMockResult('3', 236),
|
||||
createMockResult('4', 236),
|
||||
createMockResult('5', 95),
|
||||
createMockResult('6', 236),
|
||||
createMockResult('7', 228),
|
||||
createMockResult('8', 95),
|
||||
createMockResult('9', 228),
|
||||
]
|
||||
|
||||
export default function SearchResultsScreen() {
|
||||
@@ -79,13 +54,10 @@ export default function SearchResultsScreen() {
|
||||
<StatusBar style="light" />
|
||||
<RNStatusBar barStyle="light-content" />
|
||||
|
||||
{/* Top Bar with Search */}
|
||||
<SearchBar
|
||||
searchText={searchText}
|
||||
onSearchTextChange={setSearchText}
|
||||
onSearch={(text) => {
|
||||
// 搜索结果页面的搜索按钮可以保持当前页面或执行搜索
|
||||
}}
|
||||
onSearch={(text) => {}}
|
||||
onBack={() => router.back()}
|
||||
readOnly={true}
|
||||
onInputPress={() => {
|
||||
@@ -103,7 +75,6 @@ export default function SearchResultsScreen() {
|
||||
marginBottom={12}
|
||||
/>
|
||||
|
||||
{/* 搜索结果网格 */}
|
||||
<SearchResultsGrid results={searchResults} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
@@ -115,4 +86,3 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ export default function SearchResultsGrid({ results }: SearchResultsGridProps) {
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
|
||||
35
hooks/use-template-actions.ts
Normal file
35
hooks/use-template-actions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { OWNER_ID } from "@/lib/auth"
|
||||
import { ApiError } from "@/lib/types"
|
||||
import { root } from '@repo/core'
|
||||
import { TemplateController, type RunTemplateInput } from "@repo/sdk"
|
||||
import { useState } from "react"
|
||||
|
||||
export const useTemplateActions = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<ApiError | null>(null)
|
||||
|
||||
const runTemplate = async (params: RunTemplateInput): Promise<{ generationId?: string; error?: ApiError }> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const template = root.get(TemplateController)
|
||||
const result = await template.run({
|
||||
templateId: params.templateId,
|
||||
data: params.data || {},
|
||||
})
|
||||
setLoading(false)
|
||||
return { generationId: result.generationId }
|
||||
} catch (e) {
|
||||
setError(e as ApiError)
|
||||
setLoading(false)
|
||||
return { error: e as ApiError }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
runTemplate,
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { type ApiError } from '@/lib/types'
|
||||
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
import { handleError } from './use-error'
|
||||
|
||||
const OWNER_ID = process.env.EXPO_PUBLIC_OWNER_ID || ''
|
||||
|
||||
type ListTemplatesParams = Omit<ListTemplatesInput, 'ownerId'>
|
||||
|
||||
@@ -30,14 +30,15 @@ export const useTemplates = (initialParams?: ListTemplatesParams) => {
|
||||
currentPageRef.current = params?.page || 1
|
||||
|
||||
const template = root.get(TemplateController)
|
||||
const requestParams: ListTemplatesInput = {
|
||||
...DEFAULT_PARAMS,
|
||||
...initialParams,
|
||||
...params,
|
||||
ownerId: OWNER_ID,
|
||||
}
|
||||
|
||||
const { data, error } = await handleError(
|
||||
async () =>
|
||||
await template.list({
|
||||
...DEFAULT_PARAMS,
|
||||
...initialParams,
|
||||
...params,
|
||||
ownerId: OWNER_ID,
|
||||
}),
|
||||
async () => await template.list(requestParams),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
@@ -60,14 +61,15 @@ export const useTemplates = (initialParams?: ListTemplatesParams) => {
|
||||
const nextPage = currentPageRef.current + 1
|
||||
|
||||
const template = root.get(TemplateController)
|
||||
const requestParams: ListTemplatesInput = {
|
||||
...DEFAULT_PARAMS,
|
||||
...initialParams,
|
||||
page: nextPage,
|
||||
ownerId: OWNER_ID,
|
||||
}
|
||||
|
||||
const { data: newData, error } = await handleError(
|
||||
async () =>
|
||||
await template.list({
|
||||
...DEFAULT_PARAMS,
|
||||
...initialParams,
|
||||
page: nextPage,
|
||||
ownerId: OWNER_ID,
|
||||
}),
|
||||
async () => await template.list(requestParams),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"],
|
||||
"exclude": ["node_modules", "babel.config.js", "metro.config.js", "jest.config.js", "android", "ios"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user