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:
imeepos
2025-11-11 18:11:05 +08:00
parent eee280d9b3
commit d22ca2a85c
11 changed files with 822 additions and 582 deletions

View File

@@ -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',