fix: bug
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
docs/
|
||||||
# Expo
|
# Expo
|
||||||
.expo/
|
.expo/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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.
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
5
app/(tabs)/todo.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/plan 分析并解决下面的问题
|
||||||
|
|
||||||
|
组件:components\bestai\community-grid.tsx
|
||||||
|
|
||||||
|
瀑布流渲染时 并没有考虑目标图片的真实的宽高比,应该是真实的宽高比,才能保证图片、视频不变形。
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
22
theme/profile.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user