✨ feat: 性能优化与余额扣费系统集成
主要更新: - 🚀 模板详情页性能优化:添加缓存机制、图片预加载、请求中断控制 - 💰 集成余额检查与扣费功能到表单提交流程 - 🔄 添加错误重试机制,提升用户体验 - 🎨 重构动态表单字段为独立组件 (DynamicFormField) - 🔧 修复类型错误,优化样式结构 技术细节: - 实现5分钟缓存策略减少API调用 - 使用 AbortController 管理请求生命周期 - React.memo 优化列表项渲染性能 - 图片预缓存提升加载体验 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { getTagByName, type TagTemplate } from '@/lib/api/tags';
|
||||
import { getTemplateById } from '@/lib/api/templates';
|
||||
import Feather from '@expo/vector-icons/Feather';
|
||||
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
@@ -55,6 +55,31 @@ const quickActions = [
|
||||
{ id: 'download', label: 'Download' },
|
||||
];
|
||||
|
||||
const PreviewFrame = React.memo<{
|
||||
frame: TemplateFrame;
|
||||
isActive: boolean;
|
||||
onPress: () => void;
|
||||
}>(({ frame, isActive, onPress }) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
styles.previewFrame,
|
||||
{ borderColor: isActive ? frame.accent : 'rgba(255,255,255,0.08)' },
|
||||
isActive && styles.previewFrameActive,
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: frame.uri }} style={styles.previewImage} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
PreviewFrame.displayName = 'PreviewFrame';
|
||||
|
||||
const CACHE_DURATION = 5 * 60 * 1000;
|
||||
const cache = new Map<string, { data: TemplateFrame[]; timestamp: number }>();
|
||||
|
||||
export default function TemplateDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const [looks, setLooks] = useState<TemplateFrame[]>([]);
|
||||
@@ -62,19 +87,55 @@ export default function TemplateDetailScreen() {
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [loadingLooks, setLoadingLooks] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const imageCache = useRef(new Set<string>());
|
||||
|
||||
const getCachedData = useCallback((key: string) => {
|
||||
const cached = cache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const setCachedData = useCallback((key: string, data: TemplateFrame[]) => {
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
}, []);
|
||||
|
||||
const preloadImage = useCallback((uri: string) => {
|
||||
if (imageCache.current.has(uri)) {
|
||||
return;
|
||||
}
|
||||
Image.prefetch(uri).then(() => {
|
||||
imageCache.current.add(uri);
|
||||
}).catch(() => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const loadByTagName = async (tagName?: string | null) => {
|
||||
const loadByTagName = async (tagName?: string | null): Promise<TemplateFrame[]> => {
|
||||
if (!tagName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cacheKey = `tag:${tagName}`;
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getTagByName(tagName);
|
||||
if (response.success && response.data?.templates?.length) {
|
||||
return mapTagTemplates(response.data.templates);
|
||||
const templates = mapTagTemplates(response.data.templates);
|
||||
setCachedData(cacheKey, templates);
|
||||
return templates;
|
||||
}
|
||||
} catch (tagError) {
|
||||
console.warn('Failed to load tag templates:', tagError);
|
||||
@@ -95,70 +156,109 @@ export default function TemplateDetailScreen() {
|
||||
setLoadingLooks(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
let frames = await loadByTagName(id);
|
||||
let fallbackTemplateId: string | null = id;
|
||||
try {
|
||||
const templateResponse = await getTemplateById(id);
|
||||
if (!signal.aborted && templateResponse.success && templateResponse.data) {
|
||||
const template = templateResponse.data;
|
||||
const primaryTagName = template.tags?.[0]?.nameEn || template.tags?.[0]?.name;
|
||||
|
||||
if (frames.length === 0) {
|
||||
try {
|
||||
const templateResponse = await getTemplateById(id);
|
||||
if (templateResponse.success && templateResponse.data) {
|
||||
const template = templateResponse.data;
|
||||
fallbackTemplateId = template.id;
|
||||
const primaryTagName = template.tags?.[0]?.nameEn || template.tags?.[0]?.name;
|
||||
const relatedFrames = await loadByTagName(primaryTagName);
|
||||
frames = relatedFrames.length > 0 ? relatedFrames : [createFrame(template, 0)];
|
||||
const [relatedFrames, mainFrame] = await Promise.allSettled([
|
||||
loadByTagName(primaryTagName),
|
||||
Promise.resolve(createFrame(template, 0))
|
||||
]);
|
||||
|
||||
const frames: TemplateFrame[] = [];
|
||||
|
||||
if (relatedFrames.status === 'fulfilled' && relatedFrames.value.length > 0) {
|
||||
frames.push(...relatedFrames.value);
|
||||
} else {
|
||||
frames.push(mainFrame.status === 'fulfilled' ? mainFrame.value : createFrame(template, 0));
|
||||
}
|
||||
} catch (templateError) {
|
||||
console.warn('Failed to fetch template detail:', templateError);
|
||||
|
||||
if (!isMounted || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLooks(frames);
|
||||
setCachedData(`template:${id}`, frames);
|
||||
|
||||
const preferredId =
|
||||
frames.find(frame => frame.id === id)?.id ??
|
||||
frames[0]?.id ??
|
||||
template.id;
|
||||
|
||||
setSelectedId(preferredId);
|
||||
|
||||
frames.slice(0, 3).forEach(frame => preloadImage(frame.uri));
|
||||
} else {
|
||||
if (!isMounted || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage('Template not found');
|
||||
setLooks([]);
|
||||
setSelectedId(null);
|
||||
}
|
||||
} catch (templateError) {
|
||||
if (!isMounted || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.warn('Failed to fetch template detail:', templateError);
|
||||
setErrorMessage('Failed to load template');
|
||||
setLooks([]);
|
||||
setSelectedId(null);
|
||||
} finally {
|
||||
if (isMounted && !signal.aborted) {
|
||||
setLoadingLooks(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frames.length === 0) {
|
||||
setErrorMessage('No templates available');
|
||||
}
|
||||
|
||||
setLooks(frames);
|
||||
|
||||
const preferredId =
|
||||
frames.find(frame => frame.id === id)?.id ??
|
||||
frames[0]?.id ??
|
||||
fallbackTemplateId ??
|
||||
null;
|
||||
|
||||
setSelectedId(preferredId);
|
||||
setLoadingLooks(false);
|
||||
};
|
||||
|
||||
hydrateLooks();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [id]);
|
||||
}, [id, getCachedData, setCachedData, preloadImage]);
|
||||
|
||||
const current = useMemo(
|
||||
() => {
|
||||
if (looks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const current = useMemo(() => {
|
||||
if (looks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!selectedId) {
|
||||
return looks[0];
|
||||
}
|
||||
if (!selectedId) {
|
||||
return looks[0];
|
||||
}
|
||||
|
||||
return looks.find(look => look.id === selectedId) ?? looks[0];
|
||||
},
|
||||
[looks, selectedId],
|
||||
);
|
||||
return looks.find(look => look.id === selectedId) ?? looks[0];
|
||||
}, [looks, selectedId]);
|
||||
|
||||
const resolvedTemplateId = current?.id ?? (typeof id === 'string' ? id : '');
|
||||
const heroImageUri = current?.uri ?? FALLBACK_PREVIEW;
|
||||
const infoTitle = current?.title ?? 'Loading template';
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
cache.delete(`template:${id}`);
|
||||
cache.delete(`tag:${current?.title}`);
|
||||
setErrorMessage(null);
|
||||
setLoadingLooks(true);
|
||||
|
||||
const event = new Event('retry');
|
||||
window.dispatchEvent(event);
|
||||
}, [id, current?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleRetryEvent = () => {
|
||||
const newController = new AbortController();
|
||||
abortControllerRef.current = newController;
|
||||
};
|
||||
|
||||
window.addEventListener('retry', handleRetryEvent);
|
||||
return () => {
|
||||
window.removeEventListener('retry', handleRetryEvent);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (!resolvedTemplateId) {
|
||||
return;
|
||||
@@ -198,6 +298,9 @@ export default function TemplateDetailScreen() {
|
||||
{!loadingLooks && errorMessage && (
|
||||
<View style={styles.errorBanner}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={handleRetry}>
|
||||
<Text style={styles.retryLabel}>Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -208,23 +311,14 @@ export default function TemplateDetailScreen() {
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
>
|
||||
{looks.map(look => {
|
||||
const isActive = look.id === selectedId;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={look.id}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setSelectedId(look.id)}
|
||||
style={[
|
||||
styles.previewFrame,
|
||||
{ borderColor: isActive ? look.accent : 'rgba(255,255,255,0.08)' },
|
||||
isActive && styles.previewFrameActive,
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: look.uri }} style={styles.previewImage} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
{looks.map(look => (
|
||||
<PreviewFrame
|
||||
key={look.id}
|
||||
frame={look}
|
||||
isActive={look.id === selectedId}
|
||||
onPress={() => setSelectedId(look.id)}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : (
|
||||
!loadingLooks && (
|
||||
@@ -334,6 +428,19 @@ const styles = StyleSheet.create({
|
||||
color: '#FF8A8A',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: 'rgba(255, 94, 94, 0.2)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 12,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
retryLabel: {
|
||||
color: '#FF8A8A',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerBar: {
|
||||
flexDirection: 'row',
|
||||
|
||||
Reference in New Issue
Block a user