🐛 修复视频播放器无限循环渲染问题

## 主要修复
- 修复 FullscreenMediaModal 和 FullscreenVideoModal 中的 useEffect 循环依赖
- 重构 VideoPlayer 组件的视频属性管理逻辑
- 优化 useVideoPlayer 的初始化回调机制

## 新增功能
- 新增标签 API 支持 (lib/api/tags.ts)
- 新增内容骨架屏组件 (components/profile/content-skeleton.tsx)
- 新增返回按钮组件 (components/ui/back-button.tsx)

## 改进优化
- 优化视频播放器的性能,避免重复初始化
- 修复 useEffect 依赖项导致的无限循环更新
- 完善类型定义和 API 接口

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
imeepos
2025-11-10 11:58:56 +08:00
parent af64049c69
commit eee280d9b3
26 changed files with 1425 additions and 885 deletions

View File

@@ -1,73 +1,171 @@
import { ThemedView } from '@/components/themed-view';
import { StatusBar } from 'expo-status-bar';
import React from 'react';
import { Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import React, { useState, useEffect } from 'react';
import { ActivityIndicator, Image, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations';
type GalleryLane = 'leading' | 'trailing';
interface GalleryTile {
id: string;
lane: GalleryLane;
uri: string;
height: number;
interface GroupedData {
[date: string]: TemplateGeneration[];
}
const curatedGallery: GalleryTile[] = [
{
id: 'ember-circle',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1616004655122-818af0f3efc6?auto=format&fit=crop&w=1200&q=80',
height: 232,
},
{
id: 'ocean-horizon',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=1200&q=80',
height: 152,
},
{
id: 'studio-poise',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1582096897407-9b6c86e1a8d3?auto=format&fit=crop&w=1200&q=80',
height: 232,
},
{
id: 'duo-portrait',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1601040122900-86ad461b8234?auto=format&fit=crop&w=1200&q=80',
height: 148,
},
{
id: 'sage-armor',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=1200&q=80',
height: 224,
},
{
id: 'velvet-repose',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1515378791036-0648a3ef77b2?auto=format&fit=crop&w=1200&q=80',
height: 188,
},
{
id: 'nocturne-lounge',
lane: 'trailing',
uri: 'https://images.unsplash.com/photo-1573497491208-6b1acb260507?auto=format&fit=crop&w=1200&q=80',
height: 184,
},
{
id: 'sunlit-duet',
lane: 'leading',
uri: 'https://images.unsplash.com/photo-1531257240576-a4a0ef4af1c8?auto=format&fit=crop&w=1200&q=80',
height: 188,
},
];
const groupByDate = (data: TemplateGeneration[]): GroupedData => {
const groups: GroupedData = {};
const leadingNarratives = curatedGallery.filter(tile => tile.lane === 'leading');
const trailingNarratives = curatedGallery.filter(tile => tile.lane === 'trailing');
data.forEach(item => {
const date = new Date(item.createdAt);
const dateKey = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
weekday: 'short'
});
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(item);
});
return groups;
};
const formatDateLabel = (dateStr: string): string => {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dateOnly = new Date(date.toDateString());
const todayOnly = new Date(today.toDateString());
const yesterdayOnly = new Date(yesterday.toDateString());
if (dateOnly.getTime() === todayOnly.getTime()) {
return 'Today';
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
return 'Yesterday';
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
weekday: 'short'
});
}
};
const distributeToColumns = (items: TemplateGeneration[]) => {
const leftColumn: TemplateGeneration[] = [];
const rightColumn: TemplateGeneration[] = [];
let leftHeight = 0;
let rightHeight = 0;
items.forEach(item => {
const height = item.type === 'VIDEO' ? 280 : 240;
if (leftHeight <= rightHeight) {
leftColumn.push(item);
leftHeight += height + 16;
} else {
rightColumn.push(item);
rightHeight += height + 16;
}
});
return { leftColumn, rightColumn };
};
const getImageHeight = (type: string): number => {
switch (type) {
case 'VIDEO':
return 280;
case 'IMAGE':
return 240;
default:
return 200;
}
};
const getStatusColor = (status: string): string => {
switch (status) {
case 'completed':
return '#4CAF50';
case 'processing':
case 'pending':
return '#FFA726';
case 'failed':
return '#EF5350';
default:
return '#9E9E9E';
}
};
const getStatusText = (status: string): string => {
switch (status) {
case 'completed':
return '已完成';
case 'processing':
case 'pending':
return '处理中';
case 'failed':
return '失败';
default:
return status;
}
};
export default function HistoryScreen() {
const [groupedData, setGroupedData] = useState<GroupedData>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await getTemplateGenerations({ limit: 100 });
if (response.success && response.data) {
const grouped = groupByDate(response.data.generations);
setGroupedData(grouped);
} else {
setError('获取数据失败');
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<StatusBar style="light" />
<SafeAreaView style={styles.safeArea}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" />
<Text style={styles.loadingText}>...</Text>
</View>
</SafeAreaView>
</ThemedView>
);
}
if (error) {
return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<StatusBar style="light" />
<SafeAreaView style={styles.safeArea}>
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
</SafeAreaView>
</ThemedView>
);
}
return (
<ThemedView style={styles.canvas} lightColor="#050505" darkColor="#050505">
<StatusBar style="light" />
@@ -77,27 +175,77 @@ export default function HistoryScreen() {
contentContainerStyle={styles.scrollContent}
>
<Text style={styles.heading}>Content Generation</Text>
<Text style={styles.dateline}>10.19 Wednesday</Text>
<View style={styles.gallery}>
<View style={styles.leadingLane}>
{leadingNarratives.map(tile => (
<Image
key={tile.id}
source={{ uri: tile.uri }}
style={[styles.frame, { height: tile.height }]}
/>
))}
{Object.keys(groupedData).length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}></Text>
</View>
<View style={styles.trailingLane}>
{trailingNarratives.map(tile => (
<Image
key={tile.id}
source={{ uri: tile.uri }}
style={[styles.frame, { height: tile.height }]}
/>
))}
</View>
</View>
) : (
Object.entries(groupedData).map(([date, items]) => {
const { leftColumn, rightColumn } = distributeToColumns(items);
return (
<View key={date} style={styles.dateGroup}>
<Text style={styles.dateline}>{date}</Text>
<View style={styles.gallery}>
<View style={styles.leadingLane}>
{leftColumn.map(item => (
<View key={item.id} style={styles.frame}>
{item.resultUrl && item.resultUrl.length > 0 ? (
<Image
source={{ uri: item.resultUrl[0] }}
style={[styles.image, { height: getImageHeight(item.type) }]}
/>
) : (
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.placeholderImage]}>
<Text style={styles.placeholderText}>
{item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'}
</Text>
</View>
)}
<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>
</View>
))}
</View>
<View style={styles.trailingLane}>
{rightColumn.map(item => (
<View key={item.id} style={styles.frame}>
{item.resultUrl && item.resultUrl.length > 0 ? (
<Image
source={{ uri: item.resultUrl[0] }}
style={[styles.image, { height: getImageHeight(item.type) }]}
/>
) : (
<View style={[styles.image, { height: getImageHeight(item.type) }, styles.placeholderImage]}>
<Text style={styles.placeholderText}>
{item.type === 'VIDEO' ? '🎬' : item.type === 'IMAGE' ? '🖼️' : '📝'}
</Text>
</View>
)}
<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>
</View>
))}
</View>
</View>
</View>
);
})
)}
</ScrollView>
</SafeAreaView>
</ThemedView>
@@ -125,13 +273,14 @@ const styles = StyleSheet.create({
},
dateline: {
marginTop: 16,
marginBottom: 16,
fontSize: 16,
fontWeight: '500',
color: '#EDEDED',
},
gallery: {
flexDirection: 'row',
marginTop: 24,
marginBottom: 24,
},
leadingLane: {
flex: 1,
@@ -145,5 +294,84 @@ const styles = StyleSheet.create({
width: '100%',
borderRadius: 28,
marginBottom: 16,
overflow: 'hidden',
},
image: {
width: '100%',
borderRadius: 28,
},
placeholderImage: {
backgroundColor: '#2A2A2A',
justifyContent: 'center',
alignItems: 'center',
},
placeholderText: {
fontSize: 48,
},
overlay: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 12,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderBottomLeftRadius: 28,
borderBottomRightRadius: 28,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
statusText: {
fontSize: 12,
color: '#FFFFFF',
fontWeight: '500',
},
templateName: {
fontSize: 13,
color: '#FFFFFF',
fontWeight: '600',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#FFFFFF',
},
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
errorText: {
fontSize: 16,
color: '#EF5350',
textAlign: 'center',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 48,
},
emptyText: {
fontSize: 16,
color: '#9E9E9E',
textAlign: 'center',
},
dateGroup: {
marginBottom: 24,
},
});