feat: 添加测试框架和优化项目结构
- 添加 Jest 配置和测试设置 - 添加 use-categories hook 的单元测试 - 更新 CLAUDE.md 添加包管理工具说明 - 优化首页、视频页和频道页的组件结构 - 添加 .claude 命令配置文件 - 移除 bun.lock 和 package-lock.json,统一使用 bun - 更新 package.json 依赖 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@ import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||
import { memo as ReactMemo, useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -19,59 +18,54 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { DownArrowIcon, PointsIcon, SearchIcon, WhiteStarIcon } from '@/components/icon';
|
||||
import { DownArrowIcon, PointsIcon, SearchIcon } from '@/components/icon';
|
||||
import { HomeSkeleton } from '@/components/skeleton/HomeSkeleton';
|
||||
import { useActivates } from '@/hooks/use-activates';
|
||||
import { useCategories } from '@/hooks/use-categories';
|
||||
import { CategoryTemplate } from '@repo/sdk';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
||||
// 卡片组件 - 使用 useCallback 缓存以优化 FlashList 性能
|
||||
const Card = ReactMemo(({ card, cardWidth, t, onPress }: {
|
||||
card: any
|
||||
card: CategoryTemplate & { webpPreviewUrl?: string }
|
||||
cardWidth: number
|
||||
t: any
|
||||
onPress: (id: string) => void
|
||||
}) => {
|
||||
// 解析 aspectRatio 字符串为数字(如 "128:128" -> 1)
|
||||
const aspectRatio = card.aspectRatio?.includes(':')
|
||||
? (() => {
|
||||
const [w, h] = card.aspectRatio.split(':').map(Number)
|
||||
return w / h
|
||||
})()
|
||||
: card.aspectRatio
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.card, { width: cardWidth }]}
|
||||
onPress={() => onPress(card.id)}
|
||||
onPress={() => onPress(card.id!)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.cardImageContainer,
|
||||
{ height: card.height || cardWidth * 1.2 },
|
||||
{ aspectRatio },
|
||||
]}
|
||||
>
|
||||
{card.isVideo ? (
|
||||
<VideoPreview source={card.image.uri} style={styles.cardImage} />
|
||||
) : (
|
||||
<Image
|
||||
source={card.image}
|
||||
style={styles.cardImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
source={{ uri: card.webpPreviewUrl }}
|
||||
style={[
|
||||
styles.cardImage,
|
||||
{ aspectRatio },
|
||||
]}
|
||||
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>
|
||||
@@ -80,23 +74,6 @@ const Card = ReactMemo(({ card, cardWidth, t, onPress }: {
|
||||
)
|
||||
})
|
||||
|
||||
// 视频预览组件
|
||||
function VideoPreview({ source, style }: { source: string; style: any }) {
|
||||
const player = useVideoPlayer(source, (player) => {
|
||||
player.loop = true
|
||||
player.muted = true
|
||||
player.play()
|
||||
})
|
||||
|
||||
return (
|
||||
<VideoView
|
||||
style={style}
|
||||
player={player}
|
||||
allowsFullscreen
|
||||
allowsPictureInPicture
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation()
|
||||
@@ -115,11 +92,6 @@ export default function HomeScreen() {
|
||||
}, [])
|
||||
|
||||
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)
|
||||
@@ -158,22 +130,13 @@ export default function HomeScreen() {
|
||||
|
||||
// 将 CategoryTemplate 数据转换为卡片数据格式
|
||||
const displayCardData = categoryTemplates.length > 0
|
||||
? categoryTemplates.map((template, idx) => {
|
||||
const previewUrl = (template as any).webpPreviewUrl || template.previewUrl || template.coverImageUrl || ``
|
||||
const isVideo = previewUrl.includes('.mp4') || previewUrl.includes('.mov') || previewUrl.includes('.webm')
|
||||
return {
|
||||
id: template.id || `template-${idx}`,
|
||||
title: template.title,
|
||||
image: { uri: previewUrl },
|
||||
isHot: false,
|
||||
users: 0,
|
||||
height: undefined,
|
||||
previewUrl: template.previewUrl,
|
||||
aspectRatio: template.aspectRatio,
|
||||
tag: template.tag,
|
||||
isVideo,
|
||||
}
|
||||
})
|
||||
? categoryTemplates
|
||||
.filter((template) => {
|
||||
// 过滤掉视频类型的模板,只显示图片
|
||||
const previewUrl = (template as any).webpPreviewUrl || template.previewUrl || template.coverImageUrl || ``
|
||||
const isVideo = previewUrl.includes('.mp4') || previewUrl.includes('.mov') || previewUrl.includes('.webm')
|
||||
return !isVideo
|
||||
})
|
||||
: []
|
||||
const [gridWidth, setGridWidth] = useState(screenWidth)
|
||||
const [showTabArrow, setShowTabArrow] = useState(false)
|
||||
@@ -184,7 +147,8 @@ export default function HomeScreen() {
|
||||
|
||||
const horizontalPadding = 8 * 2 // gridContainer 的左右 padding
|
||||
const cardGap = 5 // 两个卡片之间的间距
|
||||
const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2
|
||||
const numColumns = 3 // 每行显示3个卡片
|
||||
const cardWidth = (gridWidth - horizontalPadding - cardGap * (numColumns - 1)) / numColumns
|
||||
|
||||
// 判断是否显示加载状态
|
||||
const showLoading = categoriesLoading
|
||||
@@ -404,6 +368,7 @@ export default function HomeScreen() {
|
||||
card={item}
|
||||
cardWidth={cardWidth}
|
||||
t={t}
|
||||
key={item.id}
|
||||
onPress={(id) => {
|
||||
router.push({
|
||||
pathname: '/templateDetail' as any,
|
||||
@@ -412,8 +377,8 @@ export default function HomeScreen() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
keyExtractor={(item) => item.id!}
|
||||
numColumns={numColumns}
|
||||
showsVerticalScrollIndicator={false}
|
||||
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,12 @@ import { useTemplates, type TemplateDetail } from '@/hooks'
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
|
||||
const TAB_BAR_HEIGHT = 83
|
||||
|
||||
// 检查 URL 是否为视频格式
|
||||
const isVideoUrl = (url: string): boolean => {
|
||||
const videoExtensions = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m3u8']
|
||||
return videoExtensions.some(ext => url.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
// 统一的布局组件
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
@@ -165,6 +171,12 @@ export default function VideoScreen() {
|
||||
index,
|
||||
}), [videoHeight])
|
||||
|
||||
// 过滤掉视频类型的 item
|
||||
const filteredTemplates = templates.filter((item: TemplateDetail) => {
|
||||
const displayUrl = item.webpPreviewUrl || item.previewUrl
|
||||
return !(displayUrl && isVideoUrl(displayUrl))
|
||||
})
|
||||
|
||||
if (loading && templates.length === 0) return <LoadingState />
|
||||
if (error && templates.length === 0) return <ErrorState />
|
||||
|
||||
@@ -172,7 +184,7 @@ export default function VideoScreen() {
|
||||
<Layout>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={templates}
|
||||
data={filteredTemplates}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
getItemLayout={getItemLayout}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -30,11 +30,6 @@ export default function ChannelsScreen() {
|
||||
// 从路由参数获取当前选中的分类 ID
|
||||
const currentCategoryId = params.categoryId as string | undefined
|
||||
|
||||
useEffect(() => {
|
||||
// 加载分类数据
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// 使用接口返回的分类数据
|
||||
const categories = categoriesData?.categories || []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user