Initial commit: expo-popcore project

This commit is contained in:
imeepos
2025-12-25 16:31:17 +08:00
commit 0cbb74e03a
188 changed files with 20796 additions and 0 deletions

64
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { Image } from 'expo-image';
import { HapticTab } from '@/components/haptic-tab';
import { useAuth } from '@/hooks/use-auth';
export default function TabLayout() {
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) {
return null;
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#FFFFFF',
headerShown: false,
tabBarButton: HapticTab,
tabBarStyle: {
backgroundColor: '#050505',
borderTopColor: '#1a1a1a',
},
}}>
<Tabs.Screen
name="home"
options={{
title: 'Home',
tabBarIcon: ({ focused }) => (
<Image
source={require('@/assets/images/home.png')}
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
/>
),
}}
/>
<Tabs.Screen
name="history"
options={{
title: 'Content',
tabBarIcon: ({ focused }) => (
<Image
source={require('@/assets/images/content.png')}
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
/>
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ focused }) => (
<Image
source={require('@/assets/images/user.png')}
style={{ width: 28, height: 28, opacity: focused ? 1 : 0.5 }}
/>
),
}}
/>
</Tabs>
);
}

354
app/(tabs)/history.tsx Normal file
View File

@@ -0,0 +1,354 @@
import { ErrorView } from '@/components/ErrorView';
import { PageLayout } from '@/components/bestai/layout';
import VideoPlayer from '@/components/sker/video-player/video-player';
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
import { groupByDate } from '@/lib/utils/date';
import { distributeToColumns } from '@/lib/utils/media';
import { FlashList } from '@shopify/flash-list';
import { router } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
const LAYOUT_CONFIG = {
VIDEO_HEIGHT: 280,
IMAGE_HEIGHT: 240,
DEFAULT_HEIGHT: 200,
COLUMN_GAP: 16,
PAGE_SIZE: 20,
} as const;
const ColumnRenderer = React.memo(({ items }: { items: TemplateGeneration[] }) => (
<>
{items.map(item => {
const itemHeight = item.type === 'VIDEO' ? LAYOUT_CONFIG.VIDEO_HEIGHT : LAYOUT_CONFIG.IMAGE_HEIGHT;
return (
<TouchableOpacity
key={item.id}
style={[styles.frame, { height: itemHeight }]}
onPress={() => router.push(`/result?generationId=${item.id}`)}
activeOpacity={0.9}
>
{item.resultUrl.map(uri => {
return <VideoPlayer key={uri} source={{ uri, useCaching: true }} style={{ borderRadius: 8 }}/>
})}
</TouchableOpacity>
);
})}
</>
));
ColumnRenderer.displayName = 'ColumnRenderer';
interface DateGroupItem {
date: string;
items: TemplateGeneration[];
}
export default function HistoryScreen() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
const [refreshing, setRefreshing] = useState(false);
const groupedData = useMemo(() => groupByDate(allGenerations), [allGenerations]);
const flatData = useMemo<DateGroupItem[]>(() =>
Object.entries(groupedData).map(([date, items]) => ({ date, items })),
[groupedData]
);
const fetchData = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else if (pageNum === 1) {
setLoading(true);
}
const response = await getTemplateGenerations({
page: String(pageNum),
limit: String(LAYOUT_CONFIG.PAGE_SIZE)
});
if (response?.success && response.data) {
const newGenerations = response.data.generations as any[];
if (isRefresh || pageNum === 1) {
setAllGenerations(newGenerations);
setPage(1);
} else {
setAllGenerations(prev => [...prev, ...newGenerations]);
}
setHasMore(newGenerations.length === LAYOUT_CONFIG.PAGE_SIZE);
setError(null);
} else {
setError('获取数据失败');
}
} catch (err) {
console.error('Failed to fetch data:', err);
const errorMessage = err instanceof Error ? err.message : '获取数据失败';
if (!errorMessage.includes('401')) {
setError(errorMessage);
}
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
fetchData(1);
}, [fetchData]);
const onRefresh = useCallback(() => {
fetchData(1, true);
}, [fetchData]);
const loadMore = useCallback(() => {
if (hasMore && !loading && !refreshing) {
const nextPage = page + 1;
setPage(nextPage);
fetchData(nextPage, false);
}
}, [hasMore, page, loading, refreshing, fetchData]);
const renderItem = useCallback(({ item }: { item: DateGroupItem }) => {
const { leftColumn, rightColumn } = distributeToColumns(
item.items,
LAYOUT_CONFIG.VIDEO_HEIGHT,
LAYOUT_CONFIG.IMAGE_HEIGHT,
LAYOUT_CONFIG.COLUMN_GAP
);
return (
<View style={styles.dateGroup}>
<Text style={styles.dateline}>{item.date}</Text>
<View style={styles.gallery}>
<View style={styles.leadingLane}>
<ColumnRenderer items={leftColumn} />
</View>
<View style={styles.trailingLane}>
<ColumnRenderer items={rightColumn} />
</View>
</View>
</View>
);
}, []);
const ListHeaderComponent = useCallback(() => (
<Text style={styles.heading}>Content Generation</Text>
), []);
const ListFooterComponent = useCallback(() => {
if (hasMore) {
return (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#FFFFFF" />
<Text style={styles.loadingMoreText}>...</Text>
</View>
);
}
if (allGenerations.length > 0) {
return (
<View style={styles.noMoreContainer}>
<Text style={styles.noMoreText}></Text>
</View>
);
}
return null;
}, [hasMore, allGenerations.length]);
const ListEmptyComponent = useCallback(() => (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}></Text>
</View>
), []);
if (loading) {
return (
<PageLayout backgroundColor="#050505">
<StatusBar style="light" />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" />
<Text style={styles.loadingText}>...</Text>
</View>
</PageLayout>
);
}
if (error) {
return (
<PageLayout backgroundColor="#050505">
<StatusBar style="light" />
<ErrorView message={error} onRetry={() => fetchData(1)} />
</PageLayout>
);
}
return (
<PageLayout backgroundColor="#050505">
<StatusBar style="light" />
<FlashList
data={flatData}
renderItem={renderItem}
keyExtractor={(item) => item.date}
onEndReached={loadMore}
onEndReachedThreshold={0.5}
refreshing={refreshing}
onRefresh={onRefresh}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={ListEmptyComponent}
contentContainerStyle={styles.flashListContent}
/>
</PageLayout>
);
}
const styles = StyleSheet.create({
flashListContent: {
paddingTop: 14,
paddingBottom: 14,
},
heading: {
fontSize: 24,
fontWeight: '600',
color: '#FFFFFF',
textAlign: 'center',
letterSpacing: 0.4,
marginBottom: 16,
},
dateline: {
marginTop: 16,
marginBottom: 16,
fontSize: 16,
fontWeight: '500',
color: '#EDEDED',
},
gallery: {
flexDirection: 'row',
marginBottom: 24,
},
leadingLane: {
flex: 1,
paddingRight: 12,
},
trailingLane: {
flex: 1,
paddingLeft: 12,
},
frame: {
width: '100%',
borderRadius: 16,
marginBottom: 16,
overflow: 'hidden',
backgroundColor: '#1A1A1A',
},
image: {
width: '100%',
},
placeholderImage: {
backgroundColor: '#2A2A2A',
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: {
fontSize: 48,
},
videoPlaceholder: {
backgroundColor: '#1A1A1A',
justifyContent: 'center',
alignItems: 'center',
},
videoIcon: {
fontSize: 48,
marginBottom: 8,
},
videoText: {
fontSize: 14,
color: '#9E9E9E',
},
overlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 12,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
statusText: {
fontSize: 12,
color: '#FFFFFF',
fontWeight: '500',
},
templateName: {
fontSize: 13,
color: '#FFFFFF',
fontWeight: '600',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#FFFFFF',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 48,
},
emptyText: {
fontSize: 16,
color: '#9E9E9E',
textAlign: 'center',
},
dateGroup: {
marginBottom: 24,
},
loadingMoreContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 20,
gap: 12,
},
loadingMoreText: {
fontSize: 14,
color: '#9E9E9E',
},
noMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
},
noMoreText: {
fontSize: 14,
color: '#666666',
},
});

376
app/(tabs)/home.tsx Normal file
View File

@@ -0,0 +1,376 @@
import { router } from 'expo-router';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { StyleSheet, TouchableOpacity, View, AppState } from 'react-native';
import { Image } from 'expo-image';
import { FlashList } from '@shopify/flash-list';
import VideoPlayer from '@/components/sker/video-player/video-player'
import {
CategoryTabs,
FeatureCarousel,
Header,
PageLayout,
SectionHeader,
StatusBarSpacer,
type FeatureItem
} from '@/components/bestai';
import type { FeatureItem as FeatureItemType } from '@/components/bestai/feature-carousel';
import { useAuth } from '@/hooks/use-auth';
import { useAuthGuard } from '@/hooks/use-auth-guard';
import { getActivities, type Activity } from '@/lib/api/activities';
import { categoriesWithChildren } from '@/lib/api/categories-with-children';
import type { CategoryWithChildren } from '@/lib/types/template';
type ListItem =
| { type: 'feature'; data: FeatureItem[] }
| { type: 'section'; categoryId: string; title: string; tagName: string }
| { type: 'template-row'; categoryId: string; items: Array<{ id: string; template: any; tagName: string }>; itemWidth: number };
type TemplateItemProps = {
template: any;
tagName: string;
itemWidth: number;
marginRight: number;
onPress: (tagName: string) => void;
};
const TemplateItem = memo(({ template, tagName, itemWidth, marginRight, onPress }: TemplateItemProps) => {
const aspectRatio = template.aspectRatio || '3:4';
const [w, h] = aspectRatio.split(':');
const height = itemWidth * parseInt(h) / parseInt(w);
const handlePress = useCallback(() => {
onPress(tagName);
}, [tagName, onPress]);
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={handlePress}
style={[
styles.templateItem,
{
width: itemWidth,
height,
marginRight,
}
]}
>
<VideoPlayer
source={{ uri: template.previewUrl, useCaching: true, metadata: { title: template.title } }}
style={styles.videoPlayer}
dwellTime={1000}
threshold={0.3}
/>
</TouchableOpacity>
);
});
TemplateItem.displayName = 'TemplateItem';
type TemplateRowProps = {
items: Array<{ id: string; template: any; tagName: string }>;
itemWidth: number;
onPress: (tagName: string) => void;
};
const TemplateRow = memo(({ items, itemWidth, onPress }: TemplateRowProps) => (
<View style={styles.row}>
{items.map((item, index) => {
if (!item.template) return null;
return (
<TemplateItem
key={item.id}
template={item.template}
tagName={item.tagName}
itemWidth={itemWidth}
marginRight={index < items.length - 1 ? 8 : 0}
onPress={onPress}
/>
);
})}
</View>
));
TemplateRow.displayName = 'TemplateRow';
function coalesceText(...values: Array<string | null | undefined>): string {
for (const value of values) {
const trimmed = (value ?? '').trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return '';
}
function translateActivity(activity: Activity): FeatureItem | null {
const image = (activity.coverUrl || '').trim();
const title = (activity.titleEn || '').trim() || (activity.title || '').trim();
if (!image || !title) {
return null;
}
const subtitle = (activity.descEn || '').trim() || (activity.desc || '').trim();
return {
id: activity.id,
title,
subtitle,
image,
};
}
export default function ExploreScreen() {
const { isAuthenticated } = useAuth();
const { requireAuth } = useAuthGuard();
const [activeCategory, setActiveCategory] = useState<string>('');
const [activityFeatures, setActivityFeatures] = useState<FeatureItem[]>([]);
const [categoryCollection, setCategoryCollection] = useState<CategoryWithChildren[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [containerWidth, setContainerWidth] = useState(0);
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'background' || nextAppState === 'inactive') {
Image.clearMemoryCache();
if (__DEV__) {
console.log('[MemoryManager] Cleared image cache on app background');
}
}
});
return () => {
subscription.remove();
};
}, []);
useEffect(() => {
const hydrateFeatureCarousel = async () => {
try {
const activities = await getActivities({ isActive: 'true' });
const curatedFeatures = activities
.map(translateActivity)
.filter((feature: any): feature is FeatureItem => feature !== null);
if (curatedFeatures.length > 0) {
setActivityFeatures(curatedFeatures);
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
console.warn('FeatureCarousel activities feed unavailable:', detail);
}
};
hydrateFeatureCarousel();
}, []);
useEffect(() => {
let isActive = true;
const hydrateCategories = async () => {
try {
const response = await categoriesWithChildren();
if (!isActive) {
return;
}
const payload = response?.success && Array.isArray(response?.data) ? response.data : [];
const curated = payload.filter((category: any) => category.id && (category.isActive ?? true));
setCategoryCollection(curated.length > 0 ? curated : payload);
} catch (error) {
if (isActive) {
const detail = error instanceof Error ? error.message : String(error);
console.warn('Categories feed unavailable:', detail);
}
}
};
hydrateCategories();
return () => {
isActive = false;
};
}, [isAuthenticated]);
useEffect(() => {
if (categoryCollection.length === 0) {
return;
}
const hasActive = categoryCollection.some((category) => category.id === activeCategory);
if (!hasActive) {
setActiveCategory(categoryCollection[0].id);
}
}, [categoryCollection, activeCategory]);
const categoryOptions = useMemo(() => {
if (categoryCollection.length === 0) {
return [];
}
const options = categoryCollection
.map((category) => {
const label = coalesceText(category.nameEn, category.name);
return label
? {
id: category.id,
label,
}
: null;
})
.filter((category): category is { id: string; label: string } => category !== null);
return options.length > 0 ? options : [];
}, [categoryCollection]);
const flattenedData = useMemo(() => {
const result: ListItem[] = [];
const itemWidth = containerWidth > 0 ? (containerWidth - 8) / 2 : 0;
if (activityFeatures.length > 0) {
result.push({ type: 'feature', data: activityFeatures });
}
categoryCollection.forEach((category) => {
result.push({
type: 'section',
categoryId: category.id,
title: category.nameEn || category.name || '',
tagName: category.tags[0]?.name || ''
});
const tags = category.tags || [];
for (let i = 0; i < tags.length; i += 2) {
const rowItems = tags.slice(i, i + 2)
.map(tag => ({
id: tag.templates[0]?.id || tag.id,
template: tag.templates[0],
tagName: tag.nameEn || tag.name || ''
}))
.filter(item => item.template);
if (rowItems.length > 0) {
result.push({
type: 'template-row',
categoryId: category.id,
items: rowItems,
itemWidth
});
}
}
});
return result;
}, [categoryCollection, activityFeatures, containerWidth]);
const handleCategoryPress = useCallback((tagName: string) => {
router.push(`/category/${tagName}` as any);
}, []);
const handleFeaturePress = useCallback((item: FeatureItemType) => {
requireAuth(() => {
// TODO: 实现功能逻辑
});
}, [requireAuth]);
const handleTemplatePress = useCallback((templateId: string) => {
router.push(`/templates/${templateId}` as any);
}, []);
const renderItem = useCallback(({ item }: { item: ListItem }) => {
if (item.type === 'feature') {
return (
<FeatureCarousel
items={item.data}
onPress={handleFeaturePress}
/>
);
}
if (item.type === 'section') {
return (
<SectionHeader
title={item.title}
onPress={() => handleCategoryPress(item.tagName)}
/>
);
}
if (item.type === 'template-row') {
return (
<TemplateRow
items={item.items}
itemWidth={item.itemWidth}
onPress={handleTemplatePress}
/>
);
}
return null;
}, [handleFeaturePress, handleCategoryPress, handleTemplatePress]);
return (
<PageLayout backgroundColor="#050505" topInset="none">
<StatusBarSpacer />
<Header onSearchChange={setSearchQuery} />
<CategoryTabs
categories={categoryOptions}
activeId={activeCategory}
onChange={setActiveCategory}
/>
<View
style={styles.container}
onLayout={(event) => {
setContainerWidth(event.nativeEvent.layout.width);
}}
>
{containerWidth > 0 && (
<FlashList
data={flattenedData}
renderItem={renderItem}
keyExtractor={(item, index) => {
if (item.type === 'feature') return 'feature-carousel';
if (item.type === 'section') return `section-${item.categoryId}`;
if (item.type === 'template-row') return `row-${item.categoryId}-${index}`;
return `item-${index}`;
}}
/>
)}
</View>
</PageLayout>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
row: {
flexDirection: 'row',
paddingHorizontal: 0,
marginBottom: 8,
},
templateItem: {
backgroundColor: '#2E3031',
borderRadius: 12,
overflow: 'hidden',
},
videoPlayer: {
borderRadius: 8
}
});
/**
<Waterfall
items={waterfallItems}
column={column}
itemPadding={8}
containerWidth={containerWidth}
renderItem={(item: CommunityItem, width: number, height: number) => (
<CommunityCard
item={{ ...item, width, height }}
onPressCard={handleCardPress}
onPressAction={handleGeneratePress}
/>
)}
/>
*/

3
app/(tabs)/profile.tsx Normal file
View File

@@ -0,0 +1,3 @@
import { ProfileScreen } from '@/components/profile/profile-screen';
export default ProfileScreen

6
app/(tabs)/todo.md Normal file
View File

@@ -0,0 +1,6 @@
/plan 分析并解决下面的问题
移动端apps\expo\app\(tabs)\history.tsx
pc端apps\frontend\src\routes\personal.tsx
请对比pc端实现 进行优化移动端