This commit is contained in:
imeepos
2025-11-12 13:19:06 +08:00
parent a7d922b535
commit 3ebcc24e9b
14 changed files with 483 additions and 265 deletions

2
.gitignore vendored
View File

@@ -2,7 +2,7 @@
# dependencies # dependencies
node_modules/ node_modules/
docs/
# Expo # Expo
.expo/ .expo/
dist/ dist/

View File

@@ -7,6 +7,11 @@ color: cyan
You are a Chinese Code Artisan (代码艺术家), a master craftsman who views code not as mere instructions, but as timeless works of art and cultural heritage for the digital age. Every line you write carries profound purpose; every word is carefully chosen. You don't simply code—you create masterpieces meant to endure. You are a Chinese Code Artisan (代码艺术家), a master craftsman who views code not as mere instructions, but as timeless works of art and cultural heritage for the digital age. Every line you write carries profound purpose; every word is carefully chosen. You don't simply code—you create masterpieces meant to endure.
注意:不要过度设计!
注意:如无必要,不要写无用的总结文档,代码即文档
注意:用中文回答
## Core Philosophy ## Core Philosophy
**存在即合理 (Existence Implies Necessity)** **存在即合理 (Existence Implies Necessity)**
@@ -15,6 +20,7 @@ You are a Chinese Code Artisan (代码艺术家), a master craftsman who views c
- Ruthlessly eliminate any meaningless or redundant code - Ruthlessly eliminate any meaningless or redundant code
- Before adding anything, ask: "Is this absolutely necessary? Does it serve an irreplaceable purpose?" - Before adding anything, ask: "Is this absolutely necessary? Does it serve an irreplaceable purpose?"
- If something can be removed without loss of functionality or clarity, it must be removed - If something can be removed without loss of functionality or clarity, it must be removed
- 注意:不要过度设计!
**优雅即简约 (Elegance is Simplicity)** **优雅即简约 (Elegance is Simplicity)**
- Never write meaningless comments—the code itself tells its story - Never write meaningless comments—the code itself tells its story
@@ -23,12 +29,14 @@ You are a Chinese Code Artisan (代码艺术家), a master craftsman who views c
- Variable and function names are poetry: `useSession` is not just an identifier, it's the beginning of a narrative - Variable and function names are poetry: `useSession` is not just an identifier, it's the beginning of a narrative
- Names should reveal intent, tell stories, and guide readers through the code's journey - Names should reveal intent, tell stories, and guide readers through the code's journey
- Favor clarity and expressiveness over brevity when naming - Favor clarity and expressiveness over brevity when naming
- 注意:不要过度设计!
**性能即艺术 (Performance is Art)** **性能即艺术 (Performance is Art)**
- Optimize not just for speed, but for elegance in execution - Optimize not just for speed, but for elegance in execution
- Performance improvements should enhance, not compromise, code beauty - Performance improvements should enhance, not compromise, code beauty
- Seek algorithmic elegance—the most efficient solution is often the most beautiful - Seek algorithmic elegance—the most efficient solution is often the most beautiful
- Balance performance with maintainability and clarity - Balance performance with maintainability and clarity
- 注意:不要过度设计!
**错误处理如为人处世的哲学 (Error Handling as Life Philosophy)** **错误处理如为人处世的哲学 (Error Handling as Life Philosophy)**
- Every error is an opportunity for refinement and growth - Every error is an opportunity for refinement and growth
@@ -36,12 +44,14 @@ You are a Chinese Code Artisan (代码艺术家), a master craftsman who views c
- Error messages should guide and educate, not merely report - Error messages should guide and educate, not merely report
- Use errors as signals for architectural improvement - Use errors as signals for architectural improvement
- Design error handling that makes the system more resilient and elegant - Design error handling that makes the system more resilient and elegant
- 注意:不要过度设计!
**日志是思想的表达 (Logs Express Thought)** **日志是思想的表达 (Logs Express Thought)**
- Logs should narrate the system's story, not clutter it - Logs should narrate the system's story, not clutter it
- Each log entry serves a purpose: debugging, monitoring, or understanding system behavior - Each log entry serves a purpose: debugging, monitoring, or understanding system behavior
- Log messages should be meaningful, contextual, and actionable - Log messages should be meaningful, contextual, and actionable
- Avoid verbose logging—only capture what matters - Avoid verbose logging—only capture what matters
- 注意:不要过度设计!
## Your Approach ## Your Approach
@@ -53,6 +63,7 @@ When writing code:
5. Eliminate every unnecessary element 5. Eliminate every unnecessary element
6. Ensure every abstraction earns its place 6. Ensure every abstraction earns its place
7. Optimize for both human understanding and machine performance 7. Optimize for both human understanding and machine performance
8. 注意:不要过度设计!
When reviewing code: When reviewing code:
1. Identify redundancies and unnecessary complexity 1. Identify redundancies and unnecessary complexity
@@ -62,6 +73,7 @@ When reviewing code:
5. Assess error handling: Is it philosophical and purposeful? 5. Assess error handling: Is it philosophical and purposeful?
6. Review logs: Do they express meaningful thoughts? 6. Review logs: Do they express meaningful thoughts?
7. Provide refactoring suggestions that elevate code to art 7. Provide refactoring suggestions that elevate code to art
8. 注意:不要过度设计!
## Quality Standards ## Quality Standards
@@ -70,5 +82,6 @@ When reviewing code:
- **Elegance**: Is this the simplest, most beautiful solution? - **Elegance**: Is this the simplest, most beautiful solution?
- **Performance**: Is this efficient without sacrificing clarity? - **Performance**: Is this efficient without sacrificing clarity?
- **Purpose**: Does every element serve an irreplaceable function? - **Purpose**: Does every element serve an irreplaceable function?
- 注意:不要过度设计!
Remember: 你写的不是代码,是数字时代的文化遗产,是艺术品 (You don't write code—you create cultural heritage for the digital age, you create art). Every keystroke is a brushstroke on the canvas of software. Make it worthy of preservation. Remember: 你写的不是代码,是数字时代的文化遗产,是艺术品 (You don't write code—you create cultural heritage for the digital age, you create art). Every keystroke is a brushstroke on the canvas of software. Make it worthy of preservation.

View File

@@ -1,6 +1,6 @@
import { ThemedView } from '@/components/themed-view'; import { PageLayout } from '@/components/bestai/layout';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Image, Image,
@@ -12,10 +12,19 @@ import {
Platform, Platform,
TouchableOpacity TouchableOpacity
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations'; import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
import { router } from 'expo-router'; import { router } from 'expo-router';
const LAYOUT_CONFIG = {
VIDEO_HEIGHT: 280,
IMAGE_HEIGHT: 240,
DEFAULT_HEIGHT: 200,
COLUMN_GAP: 16,
PAGE_SIZE: 20,
LOAD_MORE_THRESHOLD: 200,
SCROLL_THROTTLE: 800,
} as const;
interface GroupedData { interface GroupedData {
[date: string]: TemplateGeneration[]; [date: string]: TemplateGeneration[];
} }
@@ -86,14 +95,14 @@ const distributeToColumns = (items: TemplateGeneration[]) => {
let rightHeight = 0; let rightHeight = 0;
items.forEach(item => { items.forEach(item => {
const height = item.type === 'VIDEO' ? 280 : 240; const height = item.type === 'VIDEO' ? LAYOUT_CONFIG.VIDEO_HEIGHT : LAYOUT_CONFIG.IMAGE_HEIGHT;
if (leftHeight <= rightHeight) { if (leftHeight <= rightHeight) {
leftColumn.push(item); leftColumn.push(item);
leftHeight += height + 16; leftHeight += height + LAYOUT_CONFIG.COLUMN_GAP;
} else { } else {
rightColumn.push(item); rightColumn.push(item);
rightHeight += height + 16; rightHeight += height + LAYOUT_CONFIG.COLUMN_GAP;
} }
}); });
@@ -103,11 +112,11 @@ const distributeToColumns = (items: TemplateGeneration[]) => {
const getImageHeight = (type: string): number => { const getImageHeight = (type: string): number => {
switch (type) { switch (type) {
case 'VIDEO': case 'VIDEO':
return 280; return LAYOUT_CONFIG.VIDEO_HEIGHT;
case 'IMAGE': case 'IMAGE':
return 240; return LAYOUT_CONFIG.IMAGE_HEIGHT;
default: default:
return 200; return LAYOUT_CONFIG.DEFAULT_HEIGHT;
} }
}; };
@@ -139,82 +148,7 @@ const getStatusText = (status: string): string => {
} }
}; };
export default function HistoryScreen() { const MediaItem = React.memo(({ item }: { item: TemplateGeneration }) => {
const [groupedData, setGroupedData] = useState<GroupedData>({});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
const fetchData = async (pageNum: number = 1, isRefresh: boolean = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else if (pageNum === 1) {
setLoading(true);
} else {
setLoadingMore(true);
}
const response = await getTemplateGenerations({ page: pageNum, limit: 20 });
if (response.success && response.data) {
const newGenerations = response.data.generations;
if (isRefresh || pageNum === 1) {
setAllGenerations(newGenerations);
setPage(1);
} else {
setAllGenerations(prev => [...prev, ...newGenerations]);
}
setHasMore(newGenerations.length === 20);
const grouped = groupByDate(isRefresh || pageNum === 1 ? newGenerations : [...allGenerations, ...newGenerations]);
setGroupedData(grouped);
setError(null);
} else {
setError('获取数据失败');
}
} catch (err) {
console.error('Failed to fetch data:', err);
setError(err instanceof Error ? err.message : '获取数据失败');
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
};
useEffect(() => {
fetchData(1);
}, []);
const onRefresh = useCallback(() => {
fetchData(1, true);
}, []);
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
const nextPage = page + 1;
setPage(nextPage);
fetchData(nextPage, false);
}
}, [loadingMore, hasMore, page, loading]);
const handleScroll = (event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 20;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;
if (isCloseToBottom && !loadingMore && hasMore) {
loadMore();
}
};
const renderMediaItem = (item: TemplateGeneration) => {
const url = item.resultUrl && item.resultUrl.length > 0 ? item.resultUrl[0] : null; const url = item.resultUrl && item.resultUrl.length > 0 ? item.resultUrl[0] : null;
if (!url) { if (!url) {
@@ -261,39 +195,143 @@ export default function HistoryScreen() {
resizeMode="cover" resizeMode="cover"
/> />
); );
}; });
MediaItem.displayName = 'MediaItem';
const MediaOverlay = React.memo(({ item }: { item: TemplateGeneration }) => (
<View style={styles.overlay}>
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.status) }]} />
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
</View>
));
MediaOverlay.displayName = 'MediaOverlay';
const ColumnRenderer = React.memo(({ items }: { items: TemplateGeneration[] }) => (
<>
{items.map(item => (
<TouchableOpacity
key={item.id}
style={styles.frame}
onPress={() => router.push(`/result?generationId=${item.id}`)}
activeOpacity={0.9}
>
<MediaItem item={item} />
<MediaOverlay item={item} />
</TouchableOpacity>
))}
</>
));
ColumnRenderer.displayName = 'ColumnRenderer';
export default function HistoryScreen() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [allGenerations, setAllGenerations] = useState<TemplateGeneration[]>([]);
const groupedData = useMemo(() => groupByDate(allGenerations), [allGenerations]);
const fetchData = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else if (pageNum === 1) {
setLoading(true);
} else {
setLoadingMore(true);
}
const response = await getTemplateGenerations({ page: pageNum, limit: LAYOUT_CONFIG.PAGE_SIZE });
if (response.success && response.data) {
const newGenerations = response.data.generations;
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 : '获取数据失败';
// 401 错误由 AuthProvider 处理,不显示错误页面
if (!errorMessage.includes('401')) {
setError(errorMessage);
}
} finally {
setLoading(false);
setRefreshing(false);
setLoadingMore(false);
}
}, []);
useEffect(() => {
fetchData(1);
}, [fetchData]);
const onRefresh = useCallback(() => {
fetchData(1, true);
}, [fetchData]);
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
const nextPage = page + 1;
setPage(nextPage);
fetchData(nextPage, false);
}
}, [loadingMore, hasMore, page, loading, fetchData]);
const handleScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - LAYOUT_CONFIG.LOAD_MORE_THRESHOLD;
if (isCloseToBottom && !loadingMore && hasMore) {
loadMore();
}
}, [loadingMore, hasMore, loadMore]);
if (loading) { if (loading) {
return ( return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505"> <PageLayout backgroundColor="#050505">
<StatusBar style="light" /> <StatusBar style="light" />
<SafeAreaView style={styles.safeArea}>
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" /> <ActivityIndicator size="large" color="#FFFFFF" />
<Text style={styles.loadingText}>...</Text> <Text style={styles.loadingText}>...</Text>
</View> </View>
</SafeAreaView> </PageLayout>
</ThemedView>
); );
} }
if (error) { if (error) {
return ( return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505"> <PageLayout backgroundColor="#050505">
<StatusBar style="light" /> <StatusBar style="light" />
<SafeAreaView style={styles.safeArea}>
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{error}</Text>
</View> </View>
</SafeAreaView> </PageLayout>
</ThemedView>
); );
} }
return ( return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505"> <PageLayout backgroundColor="#050505">
<StatusBar style="light" /> <StatusBar style="light" />
<SafeAreaView style={styles.safeArea} edges={['top', 'left', 'right']}>
<ScrollView <ScrollView
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
@@ -306,7 +344,7 @@ export default function HistoryScreen() {
/> />
} }
onScroll={handleScroll} onScroll={handleScroll}
scrollEventThrottle={400} scrollEventThrottle={LAYOUT_CONFIG.SCROLL_THROTTLE}
> >
<Text style={styles.heading}>Content Generation</Text> <Text style={styles.heading}>Content Generation</Text>
@@ -323,45 +361,11 @@ export default function HistoryScreen() {
<Text style={styles.dateline}>{date}</Text> <Text style={styles.dateline}>{date}</Text>
<View style={styles.gallery}> <View style={styles.gallery}>
<View style={styles.leadingLane}> <View style={styles.leadingLane}>
{leftColumn.map(item => ( <ColumnRenderer items={leftColumn} />
<TouchableOpacity
key={item.id}
style={styles.frame}
onPress={() => router.push(`/result?generationId=${item.id}`)}
activeOpacity={0.9}
>
{renderMediaItem(item)}
<View style={styles.overlay}>
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.status) }]} />
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
</View>
</TouchableOpacity>
))}
</View> </View>
<View style={styles.trailingLane}> <View style={styles.trailingLane}>
{rightColumn.map(item => ( <ColumnRenderer items={rightColumn} />
<TouchableOpacity
key={item.id}
style={styles.frame}
onPress={() => router.push(`/result?generationId=${item.id}`)}
activeOpacity={0.9}
>
{renderMediaItem(item)}
<View style={styles.overlay}>
<View style={styles.statusBadge}>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(item.status) }]} />
<Text style={styles.statusText}>{getStatusText(item.status)}</Text>
</View>
<Text style={styles.templateName}>{item.template?.title || '未知模板'}</Text>
</View>
</TouchableOpacity>
))}
</View> </View>
</View> </View>
</View> </View>
@@ -382,20 +386,12 @@ export default function HistoryScreen() {
</View> </View>
)} )}
</ScrollView> </ScrollView>
</SafeAreaView> </PageLayout>
</ThemedView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
canvas: {
flex: 1,
},
safeArea: {
flex: 1,
},
scrollContent: { scrollContent: {
paddingHorizontal: 24,
paddingTop: 24, paddingTop: 24,
paddingBottom: 48, paddingBottom: 48,
}, },

View File

@@ -1,6 +1,6 @@
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native'; import { ScrollView, StyleSheet, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native';
import { import {
CategoryTabs, CategoryTabs,
@@ -69,16 +69,18 @@ function translateActivity(activity: Activity): FeatureItem | null {
export default function ExploreScreen() { export default function ExploreScreen() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { requireAuth } = useAuthGuard(); const { requireAuth } = useAuthGuard();
const [activeCategory, setActiveCategory] = useState<string>(); const [activeCategory, setActiveCategory] = useState<string>('');
const [activityFeatures, setActivityFeatures] = useState<FeatureItem[]>([]); const [activityFeatures, setActivityFeatures] = useState<FeatureItem[]>([]);
const [categoryCollection, setCategoryCollection] = useState<CategoryWithChildren[]>([]); const [categoryCollection, setCategoryCollection] = useState<CategoryWithChildren[]>([]);
const scrollViewRef = useRef<ScrollView>(null);
const categoryPositionsRef = useRef<Map<string, number>>(new Map());
const isScrollingProgrammaticallyRef = useRef(false);
useEffect(() => { useEffect(() => {
const hydrateFeatureCarousel = async () => { const hydrateFeatureCarousel = async () => {
try { try {
const activities = await getActivities({ isActive: true }); const activities = await getActivities({ isActive: true });
console.log({ activities })
const curatedFeatures = activities const curatedFeatures = activities
.map(translateActivity) .map(translateActivity)
.filter((feature): feature is FeatureItem => feature !== null); .filter((feature): feature is FeatureItem => feature !== null);
@@ -92,9 +94,6 @@ export default function ExploreScreen() {
}; };
hydrateFeatureCarousel(); hydrateFeatureCarousel();
return () => {
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -157,65 +156,115 @@ export default function ExploreScreen() {
return options.length > 0 ? options : []; return options.length > 0 ? options : [];
}, [categoryCollection]); }, [categoryCollection]);
const allCategoriesWithTemplates = useMemo(() => {
const activeCategoryRecord = useMemo(() => { return categoryCollection.map((category) => {
return categoryCollection.find((category) => category.id === activeCategory) ?? null; const templates = (category.templates ?? [])
}, [categoryCollection, activeCategory]);
const templateCommunityItems = useMemo(() => {
if (!activeCategoryRecord) {
return [];
}
return (activeCategoryRecord.templates ?? [])
.map(translateTemplateToCommunity) .map(translateTemplateToCommunity)
.filter((item): item is CommunityItem => item !== null); .filter((item): item is CommunityItem => item !== null);
}, [activeCategoryRecord]);
const featureItems = useMemo(() => { return {
return activityFeatures.map((item, index) => ({ id: category.id,
...item, name: coalesceText(category.nameEn, category.name),
id: `${activeCategory}-${item.id || index}`, templates,
})); };
}, [activityFeatures]); });
}, [categoryCollection]);
const communityItems = useMemo(() => { const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const source: CommunityItem[] = if (isScrollingProgrammaticallyRef.current) {
templateCommunityItems.length > 0 ? templateCommunityItems : []; return;
}
return source.map((item, index) => ({ const scrollY = event.nativeEvent.contentOffset.y;
...item, const positions = Array.from(categoryPositionsRef.current.entries());
id: `${activeCategory}-${item.id || index}`,
}));
}, [activeCategory, templateCommunityItems]);
const handleGeneratePress = (item: CommunityItem) => { let newActiveCategory = activeCategory;
for (let i = positions.length - 1; i >= 0; i--) {
const [categoryId, position] = positions[i];
if (scrollY >= position - 100) {
newActiveCategory = categoryId;
break;
}
}
if (newActiveCategory !== activeCategory) {
setActiveCategory(newActiveCategory);
}
}, [activeCategory]);
const handleCategoryChange = useCallback((categoryId: string) => {
const targetPosition = categoryPositionsRef.current.get(categoryId);
if (targetPosition === undefined) {
return;
}
isScrollingProgrammaticallyRef.current = true;
scrollViewRef.current?.scrollTo({
y: targetPosition,
animated: true,
});
setTimeout(() => {
isScrollingProgrammaticallyRef.current = false;
}, 500);
setActiveCategory(categoryId);
}, []);
const handleGeneratePress = useCallback((item: CommunityItem) => {
requireAuth(() => { requireAuth(() => {
const templateId = item.id.split('-').pop() || item.id; const templateId = item.id.split('-').pop() || item.id;
router.push(`/templates/${templateId}`); router.push(`/templates/${templateId}`);
}); });
}; }, [requireAuth]);
const handleFeaturePress = (item: FeatureItemType) => { const handleFeaturePress = useCallback((item: FeatureItemType) => {
requireAuth(() => { requireAuth(() => {
console.log('用户已登录,使用功能:', item.title); // TODO: 实现功能逻辑
}); });
}; }, [requireAuth]);
return ( return (
<PageLayout backgroundColor="#050505"> <PageLayout backgroundColor="#050505" topInset="none">
<StatusBarSpacer />
<Header />
<CategoryTabs
categories={categoryOptions}
activeId={activeCategory}
onChange={handleCategoryChange}
/>
<ScrollView <ScrollView
ref={scrollViewRef}
style={styles.scroll} style={styles.scroll}
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={16}
> >
<StatusBarSpacer /> {activityFeatures.length > 0 && (
<Header /> <FeatureCarousel
<CategoryTabs categories={categoryOptions} activeId={activeCategory || ``} onChange={setActiveCategory} /> items={activityFeatures}
{featureItems.length > 0 && <FeatureCarousel items={featureItems} onPress={handleFeaturePress} />} onPress={handleFeaturePress}
<SectionHeader title={activeCategoryRecord?.name || ``} /> />
<CommunityGrid items={communityItems} onPressAction={handleGeneratePress} /> )}
<View style={styles.bottomSpacer} /> {allCategoriesWithTemplates.map((category, index) => (
<View
key={category.id}
onLayout={(event) => {
const layout = event.nativeEvent.layout;
categoryPositionsRef.current.set(category.id, layout.y);
}}
>
<SectionHeader title={category.name} />
<CommunityGrid
items={category.templates.map((template, idx) => ({
...template,
id: `${category.id}-${template.id || idx}`,
}))}
onPressAction={handleGeneratePress}
/>
</View>
))}
</ScrollView> </ScrollView>
</PageLayout> </PageLayout>
); );
@@ -226,10 +275,6 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
contentContainer: { contentContainer: {
paddingTop: 16, paddingBottom: 16,
paddingBottom: 48,
},
bottomSpacer: {
height: 32,
}, },
}); });

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

@@ -0,0 +1,5 @@
/plan 分析并解决下面的问题
组件components\bestai\community-grid.tsx
瀑布流渲染时 并没有考虑目标图片的真实的宽高比,应该是真实的宽高比,才能保证图片、视频不变形。

View File

@@ -1,4 +1,5 @@
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { authEvents } from '@/lib/api/client';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { LoginModal } from './login-modal'; import { LoginModal } from './login-modal';
@@ -51,6 +52,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
} }
}, [isAuthenticated, showLoginModal, pendingCallback]); }, [isAuthenticated, showLoginModal, pendingCallback]);
// 监听 401 未授权事件
useEffect(() => {
const unsubscribe = authEvents.onUnauthorized(() => {
if (!isLoading && !isAuthenticated) {
setShowLoginModal(true);
}
});
return unsubscribe;
}, [isLoading, isAuthenticated]);
const value = { const value = {
requireAuth, requireAuth,
showLoginModal, showLoginModal,

View File

@@ -1,5 +1,5 @@
import { memo } from 'react'; import { memo, useCallback, useEffect, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
type Category = { type Category = {
id: string; id: string;
@@ -13,18 +13,77 @@ type CategoryTabsProps = {
}; };
export function CategoryTabs({ categories, activeId, onChange }: CategoryTabsProps) { export function CategoryTabs({ categories, activeId, onChange }: CategoryTabsProps) {
const scrollViewRef = useRef<ScrollView>(null);
const tabPositionsRef = useRef<Map<string, { x: number; width: number }>>(new Map());
const containerWidthRef = useRef<number>(0);
const { width: screenWidth } = useWindowDimensions();
const scrollToActiveTab = useCallback(() => {
if (!activeId) {
console.log('[CategoryTabs] No activeId');
return;
}
const position = tabPositionsRef.current.get(activeId);
if (!position || !scrollViewRef.current) {
console.log('[CategoryTabs] Missing position or ref:', { position, hasRef: !!scrollViewRef.current });
return;
}
const centerOffset = screenWidth / 2;
const tabCenter = position.x + position.width / 2;
const scrollX = tabCenter - centerOffset;
const maxScrollX = Math.max(0, containerWidthRef.current - screenWidth);
const targetX = Math.max(0, Math.min(scrollX, maxScrollX));
console.log('[CategoryTabs] Scrolling to:', {
activeId,
position,
screenWidth,
tabCenter,
targetX,
containerWidth: containerWidthRef.current,
});
scrollViewRef.current.scrollTo({
x: targetX,
animated: true,
});
}, [activeId, screenWidth]);
useEffect(() => {
const timer = setTimeout(scrollToActiveTab, 150);
return () => clearTimeout(timer);
}, [scrollToActiveTab]);
const handleContentSizeChange = useCallback((width: number) => {
containerWidthRef.current = width;
console.log('[CategoryTabs] Content size:', width);
}, []);
return ( return (
<ScrollView <ScrollView
ref={scrollViewRef}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container} contentContainerStyle={styles.container}
style={styles.scrollView}
onContentSizeChange={handleContentSizeChange}
> >
{categories.map(category => ( {categories.map((category, index) => (
<CategoryTabItem <CategoryTabItem
key={category.id} key={category.id}
category={category} category={category}
isActive={category.id === activeId} isActive={category.id === activeId}
onPress={onChange} onPress={onChange}
onLayout={(layout) => {
tabPositionsRef.current.set(category.id, {
x: layout.x,
width: layout.width,
});
console.log(`[CategoryTabs] Tab layout [${category.label}]:`, layout);
}}
/> />
))} ))}
</ScrollView> </ScrollView>
@@ -35,11 +94,19 @@ type CategoryTabItemProps = {
category: Category; category: Category;
isActive: boolean; isActive: boolean;
onPress: (id: string) => void; onPress: (id: string) => void;
onLayout: (layout: { x: number; width: number }) => void;
}; };
const CategoryTabItem = memo(({ category, isActive, onPress }: CategoryTabItemProps) => { const CategoryTabItem = memo(({ category, isActive, onPress, onLayout }: CategoryTabItemProps) => {
return ( return (
<Pressable onPress={() => onPress(category.id)} style={styles.tabButton}> <Pressable
onPress={() => onPress(category.id)}
style={styles.tabButton}
onLayout={(event) => {
const { x, width } = event.nativeEvent.layout;
onLayout({ x, width });
}}
>
<Text style={[styles.label, isActive && styles.labelActive]}>{category.label}</Text> <Text style={[styles.label, isActive && styles.labelActive]}>{category.label}</Text>
<View style={[styles.indicator, isActive && styles.indicatorActive]} /> <View style={[styles.indicator, isActive && styles.indicatorActive]} />
</Pressable> </Pressable>
@@ -48,6 +115,10 @@ const CategoryTabItem = memo(({ category, isActive, onPress }: CategoryTabItemPr
CategoryTabItem.displayName = 'CategoryTabItem'; CategoryTabItem.displayName = 'CategoryTabItem';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollView: {
flexGrow: 0,
flexShrink: 0,
},
container: { container: {
paddingVertical: 10, paddingVertical: 10,
paddingHorizontal: 6, paddingHorizontal: 6,

View File

@@ -20,6 +20,8 @@ export type CommunityItem = {
image: string; image: string;
chip: string; chip: string;
actionLabel: string; actionLabel: string;
width?: number;
height?: number;
}; };
type CommunityGridProps = { type CommunityGridProps = {
@@ -83,10 +85,16 @@ type CommunityCardProps = {
}; };
const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => { const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => {
const aspectRatio = item.width && item.height ? item.width / item.height : 9 / 16;
return ( return (
<Pressable onPress={() => onPressCard?.(item)} style={[styles.card, style]}> <Pressable onPress={() => onPressCard?.(item)} style={[styles.card, style]}>
<View style={styles.imageWrapper}> <View style={styles.imageWrapper}>
<ExpoImage source={{ uri: item.image }} style={styles.cardImage} contentFit="cover" /> <ExpoImage
source={{ uri: item.image }}
style={[styles.cardImage, { aspectRatio }]}
contentFit="cover"
/>
<LinearGradient <LinearGradient
colors={['rgba(12, 14, 20, 0)', 'rgba(12, 14, 20, 0.85)']} colors={['rgba(12, 14, 20, 0)', 'rgba(12, 14, 20, 0.85)']}
style={styles.imageGradient} style={styles.imageGradient}
@@ -169,7 +177,6 @@ const styles = StyleSheet.create({
}, },
cardImage: { cardImage: {
width: '100%', width: '100%',
aspectRatio: 9 / 16,
height: undefined, height: undefined,
}, },
imageGradient: { imageGradient: {

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import { StyleSheet, View } from 'react-native'; import { StyleSheet, View } from 'react-native';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
@@ -5,7 +6,7 @@ type HeaderProps = {
title?: string; title?: string;
}; };
export function Header({ title = 'BESTAI' }: HeaderProps) { export const Header = memo(function Header({ title = 'BESTAI' }: HeaderProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Svg width={89} height={13} viewBox="0 0 89 13" style={styles.logo}> <Svg width={89} height={13} viewBox="0 0 89 13" style={styles.logo}>
@@ -13,7 +14,7 @@ export function Header({ title = 'BESTAI' }: HeaderProps) {
</Svg> </Svg>
</View> </View>
); );
} });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {

View File

@@ -1,20 +1,43 @@
import { PropsWithChildren } from 'react'; import { memo, PropsWithChildren } from 'react';
import { StyleSheet, View } from 'react-native'; import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
type PageLayoutProps = PropsWithChildren<{ type PageLayoutProps = PropsWithChildren<{
backgroundColor?: string; backgroundColor?: string;
topInset?: 'auto' | 'none' | number;
horizontalPadding?: number | false;
}>; }>;
export function PageLayout({ children, backgroundColor = '#050505' }: PageLayoutProps) { export function PageLayout({
children,
backgroundColor = '#050505',
topInset = 'auto',
horizontalPadding = 24,
}: PageLayoutProps) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const spacingTop = topInset === 'auto' ? insets.top : topInset === 'none' ? 0 : topInset;
const spacingBottom = Math.max(insets.bottom, 16); const spacingBottom = Math.max(insets.bottom, 16);
const spacingHorizontal = horizontalPadding === false ? 0 : horizontalPadding;
return <View style={[styles.container, { paddingBottom: spacingBottom, backgroundColor }]}>{children}</View>; return (
<View
style={[
styles.container,
{
paddingTop: spacingTop,
paddingBottom: spacingBottom,
paddingHorizontal: spacingHorizontal,
backgroundColor
}
]}
>
{children}
</View>
);
} }
export function StatusBarSpacer() { export const StatusBarSpacer = memo(function StatusBarSpacer() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
if (!insets.top) { if (!insets.top) {
@@ -22,11 +45,10 @@ export function StatusBarSpacer() {
} }
return <View style={{ height: insets.top }} />; return <View style={{ height: insets.top }} />;
} });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
paddingHorizontal: 24,
}, },
}); });

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { View, useWindowDimensions, type ImageSourcePropType, ActivityIndicator } from 'react-native'; import { View, useWindowDimensions, type ImageSourcePropType, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { PageLayout } from '@/components/bestai/layout';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { useProfileData } from '@/hooks/use-profile-data'; import { useProfileData } from '@/hooks/use-profile-data';
import { createStats, deriveAvatarSource, deriveDisplayName, deriveNumericValue } from '@/utils/profile-data'; import { createStats, deriveAvatarSource, deriveDisplayName, deriveNumericValue } from '@/utils/profile-data';
import { StyleSheet } from 'react-native'; import { PROFILE_THEME } from '@/theme/profile';
import { ContentGallery } from './content-gallery'; import { ContentGallery } from './content-gallery';
import { ContentTabs } from './content-tabs'; import { ContentTabs } from './content-tabs';
import { Divider } from './divider'; import { Divider } from './divider';
@@ -23,12 +23,13 @@ type BillingMode = 'monthly' | 'lifetime';
export function ProfileScreen() { export function ProfileScreen() {
const { user } = useAuth(); const { user } = useAuth();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const [billingMode, setBillingMode] = useState<BillingMode>('monthly'); const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
const [activeTab, setActiveTab] = useState<TabKey>('all'); const [activeTab, setActiveTab] = useState<TabKey>('all');
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
const [presentedDisplayName, setPresentedDisplayName] = useState(displayNameFromUser);
const [isEditingIdentity, setIsEditingIdentity] = useState(false); const [isEditingIdentity, setIsEditingIdentity] = useState(false);
const [editedIdentity, setEditedIdentity] = useState<{
name?: string;
avatar?: ImageSourcePropType;
} | null>(null);
const { const {
generations, generations,
@@ -42,24 +43,33 @@ export function ProfileScreen() {
handleLoadMore, handleLoadMore,
} = useProfileData(activeTab); } = useProfileData(activeTab);
const horizontalPadding = width < 400 ? 20 : 24; const isSmallScreen = width < PROFILE_THEME.breakpoints.small;
const avatarSize = width < 400 ? 74 : 82; const horizontalPadding = isSmallScreen
? PROFILE_THEME.spacing.horizontal.small
: PROFILE_THEME.spacing.horizontal.large;
const avatarSize = isSmallScreen
? PROFILE_THEME.avatar.size.small
: PROFILE_THEME.avatar.size.large;
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
const avatarSource = deriveAvatarSource(user); const avatarSource = deriveAvatarSource(user);
const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits); const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits);
const stats = createStats(user); const stats = createStats(user);
const [presentedAvatar, setPresentedAvatar] = useState<ImageSourcePropType | undefined>(avatarSource);
useEffect(() => { const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser;
setPresentedDisplayName(displayNameFromUser); const presentedAvatar = editedIdentity?.avatar ?? avatarSource;
}, [displayNameFromUser]);
useEffect(() => { const headerContainerStyle = useMemo(
setPresentedAvatar(avatarSource); () => ({
}, [avatarSource]); paddingHorizontal: horizontalPadding,
paddingBottom: PROFILE_THEME.spacing.headerBottom,
}),
[horizontalPadding]
);
const headerComponent = useMemo( const headerComponent = useMemo(
() => ( () => (
<View style={{ paddingHorizontal: horizontalPadding, paddingBottom: 16 }}> <View style={headerContainerStyle}>
<ProfileHeader <ProfileHeader
billingMode={billingMode} billingMode={billingMode}
onChangeBilling={setBillingMode} onChangeBilling={setBillingMode}
@@ -86,7 +96,7 @@ export function ProfileScreen() {
</View> </View>
), ),
[ [
horizontalPadding, headerContainerStyle,
billingMode, billingMode,
creditBalance, creditBalance,
presentedDisplayName, presentedDisplayName,
@@ -100,33 +110,29 @@ export function ProfileScreen() {
if (isLoading) { if (isLoading) {
return ( return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}> <PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<ProfileLoadingState /> <ProfileLoadingState />
</View> </PageLayout>
); );
} }
if (error) { if (error) {
return ( return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}> <PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<ProfileErrorState onRetry={handleRefresh} /> <ProfileErrorState onRetry={handleRefresh} />
</View> </PageLayout>
); );
} }
return ( return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}> <PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
{isLoading && generations.length === 0 ? ( {isLoading && generations.length === 0 ? (
<View style={{ flex: 1 }}> <View style={styles.flexContainer}>
{headerComponent} {headerComponent}
<ContentSkeleton /> <ContentSkeleton />
</View> </View>
) : generations.length === 0 && !isTabSwitching ? ( ) : generations.length === 0 && !isTabSwitching ? (
<View style={{ flex: 1 }}> <View style={styles.flexContainer}>
{headerComponent} {headerComponent}
<ProfileEmptyState activeTab={activeTab} /> <ProfileEmptyState activeTab={activeTab} />
</View> </View>
@@ -148,24 +154,16 @@ export function ProfileScreen() {
initialName={presentedDisplayName} initialName={presentedDisplayName}
onClose={() => setIsEditingIdentity(false)} onClose={() => setIsEditingIdentity(false)}
onSave={({ name, avatar }) => { onSave={({ name, avatar }) => {
setPresentedDisplayName(name); setEditedIdentity({ name, avatar });
if (avatar) {
setPresentedAvatar(avatar);
}
setIsEditingIdentity(false); setIsEditingIdentity(false);
}} }}
/> />
</View> </PageLayout>
); );
} }
const stylesVars = {
background: '#050505',
paddingBottom: 96,
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
screen: { flexContainer: {
flex: 1, flex: 1,
}, },
}); });

View File

@@ -53,7 +53,11 @@ export function useProfileData(activeTab: TabKey) {
} }
} catch (err) { } catch (err) {
console.error('Failed to load generations:', err); console.error('Failed to load generations:', err);
setError('Failed to load content'); const errorMessage = err instanceof Error ? err.message : 'Failed to load content';
// 401 错误由 AuthProvider 处理,不显示错误页面
if (!errorMessage.includes('401')) {
setError(errorMessage);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setIsRefreshing(false); setIsRefreshing(false);

View File

@@ -10,6 +10,22 @@ export interface ApiRequestOptions {
requiresAuth?: boolean; requiresAuth?: boolean;
} }
type UnauthorizedListener = () => void;
const unauthorizedListeners: UnauthorizedListener[] = [];
export const authEvents = {
onUnauthorized(listener: UnauthorizedListener) {
unauthorizedListeners.push(listener);
return () => {
const index = unauthorizedListeners.indexOf(listener);
if (index > -1) unauthorizedListeners.splice(index, 1);
};
},
emitUnauthorized() {
unauthorizedListeners.forEach(listener => listener());
}
};
async function getAuthToken(): Promise<string> { async function getAuthToken(): Promise<string> {
return (await storage.getItem(`bestaibest.better-auth.session_token`)) || ''; return (await storage.getItem(`bestaibest.better-auth.session_token`)) || '';
} }
@@ -59,7 +75,13 @@ export async function apiRequest<T = any>(
body: method !== 'GET' ? body : undefined, body: method !== 'GET' ? body : undefined,
}); });
// 检查响应状态 // 检查 401 未授权
if (response.status === 401) {
authEvents.emitUnauthorized();
throw new Error(`HTTP 401: Unauthorized`);
}
// 检查其他错误状态
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`); throw new Error(`HTTP ${response.status}: ${errorText}`);

22
theme/profile.ts Normal file
View File

@@ -0,0 +1,22 @@
export const PROFILE_THEME = {
breakpoints: {
small: 400,
},
spacing: {
horizontal: {
small: 20,
large: 24,
},
headerBottom: 16,
tabBar: 96,
},
avatar: {
size: {
small: 74,
large: 82,
},
},
colors: {
background: '#050505',
},
} as const;