Files
digital-pilates/app/gallery/index.tsx

688 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { HeaderBar } from '@/components/ui/HeaderBar';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useVipService } from '@/hooks/useVipService';
import { AiReportRecord, generateAiReport, getAiReportHistory } from '@/services/aiReport';
import { getAuthToken } from '@/services/api';
import { Toast } from '@/utils/toast.utils';
import { Ionicons } from '@expo/vector-icons';
import dayjs from 'dayjs';
import * as FileSystem from 'expo-file-system/legacy';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image as ExpoImage } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import * as MediaLibrary from 'expo-media-library';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Animated,
Platform,
Pressable,
RefreshControl,
ScrollView,
Share,
StatusBar,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function GalleryScreen() {
const { t, i18n } = useTranslation();
const insets = useSafeAreaInsets();
const { ensureLoggedIn } = useAuthGuard();
const { checkServiceAccess } = useVipService();
const { openMembershipModal } = useMembershipModal();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
// 报告历史列表
const [reports, setReports] = useState<AiReportRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isGeneratingReport, setIsGeneratingReport] = useState(false);
const [reportImageUrl, setReportImageUrl] = useState<string | null>(null);
const [reportLocalUri, setReportLocalUri] = useState<string | null>(null);
const [reportModalVisible, setReportModalVisible] = useState(false);
const [isSavingReport, setIsSavingReport] = useState(false);
const [isSharingReport, setIsSharingReport] = useState(false);
const reportSpinAnim = useRef(new Animated.Value(0)).current;
const reportIconSpin = reportSpinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg']
});
const emptyImageHeight = useMemo(() => screenHeight / 1.5, [screenHeight]);
const todayString = useMemo(() => dayjs().format('YYYY-MM-DD'), []);
const reportImageSize = useMemo(() => {
const maxWidth = Math.min(screenWidth - 40, 440);
const maxHeight = screenHeight - 240;
let width = maxWidth;
let height = (maxWidth * 16) / 9;
if (height > maxHeight) {
height = maxHeight;
width = (maxHeight * 9) / 16;
}
return { width, height };
}, [screenHeight, screenWidth]);
// 加载报告历史
const loadReports = useCallback(async (pageNum: number, refresh = false) => {
try {
const response = await getAiReportHistory({
page: pageNum,
pageSize: 10,
status: 'success',
});
if (refresh) {
setReports(response.records);
} else {
setReports(prev => [...prev, ...response.records]);
}
setHasMore(pageNum < response.totalPages);
setPage(pageNum);
} catch (error: any) {
console.error('load-ai-report-history-failed', error);
if (refresh) {
Toast.error(t('statistics.aiReport.loadFailed', '加载报告历史失败'));
}
}
}, [t]);
// 初始加载
useEffect(() => {
const init = async () => {
setIsLoading(true);
await loadReports(1, true);
setIsLoading(false);
};
init();
}, [loadReports]);
// 下拉刷新
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await loadReports(1, true);
setIsRefreshing(false);
}, [loadReports]);
// 加载更多
const handleLoadMore = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true);
await loadReports(page + 1, false);
setIsLoadingMore(false);
}, [isLoadingMore, hasMore, page, loadReports]);
useEffect(() => {
if (!isGeneratingReport) {
reportSpinAnim.stopAnimation();
return;
}
reportSpinAnim.setValue(0);
const loop = Animated.loop(
Animated.timing(reportSpinAnim, {
toValue: 1,
duration: 1400,
useNativeDriver: true,
})
);
loop.start();
return () => loop.stop();
}, [isGeneratingReport, reportSpinAnim]);
const handleGenerateReport = useCallback(async () => {
const ok = await ensureLoggedIn();
if (!ok || isGeneratingReport) return;
// 检查 VIP 权限
const access = checkServiceAccess();
if (!access.canUseService) {
openMembershipModal({
onPurchaseSuccess: () => {
// 购买成功后自动触发生成
handleGenerateReport();
},
});
return;
}
setIsGeneratingReport(true);
setReportLocalUri(null);
Toast.info(t('statistics.aiReport.generating', '正在生成健康报告,预计 1030 秒…'));
try {
const response = await generateAiReport({ date: todayString });
const imageUrl = (response as any)?.imageUrl ?? (response as any)?.url ?? (response as any)?.image_url;
if (!imageUrl) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
setReportImageUrl(imageUrl);
setReportModalVisible(true);
Toast.success(t('statistics.aiReport.success', '报告已生成'));
// 生成成功后刷新列表
handleRefresh();
} catch (error: any) {
console.error('generate-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.failed', '生成报告失败,请稍后重试'));
} finally {
setIsGeneratingReport(false);
}
}, [ensureLoggedIn, isGeneratingReport, checkServiceAccess, openMembershipModal, t, todayString, handleRefresh]);
const prepareLocalReportImage = useCallback(async () => {
if (!reportImageUrl) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
if (reportLocalUri) {
return reportLocalUri;
}
const fileUri = `${FileSystem.cacheDirectory}ai-report-${Date.now()}.jpg`;
const token = await getAuthToken();
const download = await FileSystem.downloadAsync(
reportImageUrl,
fileUri,
token ? { headers: { Authorization: `Bearer ${token}` } } : undefined,
);
if (!download?.uri) {
throw new Error(t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试'));
}
setReportLocalUri(download.uri);
return download.uri;
}, [reportImageUrl, reportLocalUri, t]);
const handleSaveReport = useCallback(async () => {
if (isSavingReport) return;
try {
setIsSavingReport(true);
const permission = await MediaLibrary.requestPermissionsAsync();
if (permission.status !== 'granted') {
Toast.warning(t('statistics.aiReport.permission', '需要相册权限才能保存图片'));
return;
}
const localUri = await prepareLocalReportImage();
await MediaLibrary.saveToLibraryAsync(localUri);
Toast.success(t('statistics.aiReport.saved', '已保存到相册'));
} catch (error: any) {
console.error('save-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.saveFailed', '保存失败,请稍后重试'));
} finally {
setIsSavingReport(false);
}
}, [isSavingReport, prepareLocalReportImage, t]);
const handleShareReport = useCallback(async () => {
if (isSharingReport) return;
try {
setIsSharingReport(true);
const localUri = await prepareLocalReportImage();
await Share.share({
message: t('statistics.aiReport.shareMessage', '这是我的 AI 健康报告,分享给你看看!'),
url: Platform.OS === 'ios' ? localUri : `file://${localUri}`,
title: t('statistics.aiReport.shareTitle', 'AI 健康报告')
});
} catch (error: any) {
console.error('share-ai-report-failed', error);
Toast.error(error?.message ?? t('statistics.aiReport.shareFailed', '分享失败,请稍后重试'));
} finally {
setIsSharingReport(false);
}
}, [isSharingReport, prepareLocalReportImage, t]);
// 点击卡片查看报告
const handleCardPress = useCallback((report: AiReportRecord) => {
if (!report.imageUrl) return;
setReportImageUrl(report.imageUrl);
setReportLocalUri(null);
setReportModalVisible(true);
}, []);
// 滚动到底部加载更多
const handleScroll = useCallback((event: any) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent;
const paddingToBottom = 100;
if (layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom) {
handleLoadMore();
}
}, [handleLoadMore]);
const headerRight = isLiquidGlassAvailable() ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={handleGenerateReport}
disabled={isGeneratingReport}
>
<GlassView
style={styles.reportButton}
glassEffectStyle="clear"
isInteractive
>
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</Animated.View>
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleGenerateReport}
style={[styles.reportButton, styles.reportButtonFallback]}
disabled={isGeneratingReport}
>
<Animated.View style={[styles.reportIconWrapper, isGeneratingReport && { transform: [{ rotate: reportIconSpin }] }]}>
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
</Animated.View>
</TouchableOpacity>
);
const headerTitle = (
<View style={styles.headerCenter}>
<Text style={styles.headerTitle}>{t('statistics.aiReport.galleryTitle', 'AI 报告画廊')}</Text>
<Text style={styles.headerSubtitle}>{t('statistics.aiReport.gallerySubtitle', '沉浸式浏览你的健康报告')}</Text>
</View>
);
return (
<View style={styles.container}>
<StatusBar barStyle="dark-content" />
<LinearGradient
colors={['#f0f4ff', '#fdf8ff', '#f6f8fa']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
<HeaderBar
title={headerTitle}
right={headerRight}
tone="light"
transparent
/>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{
paddingTop: insets.top + 56,
paddingBottom: 40,
paddingHorizontal: 16,
...(reports.length === 0 && !isLoading ? { flexGrow: 1, justifyContent: 'center' } : {})
}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor="#6B7280"
/>
}
onScroll={handleScroll}
scrollEventThrottle={400}
>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3B82F6" />
</View>
) : reports.length === 0 ? (
<View style={styles.emptyContainer}>
<Pressable
style={styles.emptyImageCard}
onPress={() => {
const imageUrl = i18n.language?.startsWith('en')
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg';
setReportImageUrl(imageUrl);
setReportLocalUri(null);
setReportModalVisible(true);
}}
>
<ExpoImage
source={{
uri: i18n.language?.startsWith('en')
? 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_en.jpg'
: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/gallary/empty_zh.jpg'
}}
style={[styles.emptyImage, { height: emptyImageHeight }]}
contentFit="contain"
transition={300}
/>
<View style={styles.emptyImageOverlay}>
<View style={styles.previewHint}>
<Ionicons name="expand-outline" size={14} color="#fff" />
<Text style={styles.previewHintText}>{t('statistics.aiReport.clickToPreview', '点击预览模板')}</Text>
</View>
</View>
</Pressable>
<View style={styles.emptyContent}>
<Text style={styles.emptyTitle}>{t('statistics.aiReport.emptyHistory', '暂无报告记录')}</Text>
<Text style={styles.emptySubtitle}>{t('statistics.aiReport.emptyHistoryHint', '点击右上方按钮生成你的第一份报告')}</Text>
</View>
</View>
) : (
<View style={styles.galleryGrid}>
{reports.map((report) => (
<Pressable
key={report.id}
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={() => handleCardPress(report)}
>
<ExpoImage
source={{ uri: report.imageUrl }}
style={styles.cardImage}
contentFit="cover"
transition={250}
/>
<View style={styles.cardBody}>
<Text numberOfLines={1} style={styles.cardTitle}>
{dayjs(report.reportDate).format('YYYY年M月D日')}
</Text>
<Text style={styles.cardSubtitle}>
{dayjs(report.createdAt).format('HH:mm')} {t('statistics.aiReport.generated', '生成')}
</Text>
</View>
</Pressable>
))}
{isLoadingMore && (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color="#6B7280" />
</View>
)}
</View>
)}
</ScrollView>
{reportModalVisible && (
<View style={styles.modalOverlay}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setReportModalVisible(false)} />
<View style={styles.modalCard}>
{reportImageUrl ? (
<ExpoImage
source={{ uri: reportImageUrl }}
style={[styles.reportImage, { width: reportImageSize.width, height: reportImageSize.height }]}
contentFit="cover"
/>
) : (
<View style={[styles.reportImageFallback, { width: reportImageSize.width, height: reportImageSize.height }]}>
<Text style={styles.reportFallbackText}>{t('statistics.aiReport.missing', '未获取到报告图片,请稍后重试')}</Text>
</View>
)}
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalButton, isSavingReport && styles.modalButtonDisabled]}
onPress={handleSaveReport}
disabled={isSavingReport}
>
<Ionicons name="download-outline" size={18} color="#0F172A" />
<Text style={styles.modalButtonText}>
{isSavingReport ? t('statistics.aiReport.saving', '保存中…') : t('statistics.aiReport.save', '保存')}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalButton, isSharingReport && styles.modalButtonDisabled]}
onPress={handleShareReport}
disabled={isSharingReport}
>
<Ionicons name="share-social-outline" size={18} color="#0F172A" />
<Text style={styles.modalButtonText}>
{isSharingReport ? t('statistics.aiReport.sharing', '分享中…') : t('statistics.aiReport.share', '分享')}
</Text>
</TouchableOpacity>
</View>
<Pressable style={styles.closeRow} onPress={() => setReportModalVisible(false)}>
<Ionicons name="close" size={18} color="#4B5563" />
<Text style={styles.closeLabel}>{t('statistics.aiReport.close', '收起')}</Text>
</Pressable>
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f7f8fb',
},
headerCenter: {
flex: 1,
minWidth: 0,
},
headerTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: '#0F172A',
textAlign: 'center',
},
headerSubtitle: {
marginTop: 2,
color: '#6B7280',
fontSize: 12,
fontFamily: 'AliRegular',
textAlign: 'center',
},
reportButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 36,
height: 36,
borderRadius: 18,
overflow: 'hidden',
},
reportButtonFallback: {
backgroundColor: 'rgba(255, 255, 255, 0.5)',
borderWidth: 1,
borderColor: '#E5E7EB',
},
reportIconWrapper: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#E0F2FE',
alignItems: 'center',
justifyContent: 'center',
},
loadingContainer: {
flex: 1,
paddingTop: 100,
alignItems: 'center',
justifyContent: 'center',
},
emptyContainer: {
alignItems: 'center',
gap: 24,
},
emptyImageCard: {
width: '100%',
borderRadius: 20,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.1,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
emptyImage: {
width: '100%',
height: 380,
},
emptyImageOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.15)',
borderRadius: 20,
},
previewHint: {
position: 'absolute',
top: 12,
right: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
previewHintText: {
fontSize: 12,
fontFamily: 'AliRegular',
color: '#fff',
},
emptyContent: {
alignItems: 'center',
gap: 12,
paddingHorizontal: 20,
},
emptyTitle: {
fontSize: 18,
fontFamily: 'AliBold',
color: '#1F2937',
textAlign: 'center',
},
emptySubtitle: {
fontSize: 14,
fontFamily: 'AliRegular',
color: '#6B7280',
textAlign: 'center',
lineHeight: 20,
},
emptyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 28,
paddingVertical: 14,
backgroundColor: '#3B82F6',
borderRadius: 28,
marginTop: 8,
shadowColor: '#3B82F6',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 4,
},
emptyButtonText: {
fontSize: 16,
fontFamily: 'AliBold',
color: '#fff',
},
loadingMoreContainer: {
paddingVertical: 20,
alignItems: 'center',
},
galleryGrid: {
gap: 18,
},
card: {
backgroundColor: '#fff',
borderRadius: 22,
overflow: 'hidden',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
elevation: 6,
},
cardPressed: {
transform: [{ scale: 0.99 }],
},
cardImage: {
width: '100%',
height: 360,
},
cardBody: {
paddingHorizontal: 16,
paddingVertical: 14,
gap: 4,
},
cardTitle: {
fontSize: 16,
fontFamily: 'AliBold',
color: '#111827',
},
cardSubtitle: {
fontSize: 12,
fontFamily: 'AliRegular',
color: '#9CA3AF',
},
modalOverlay: {
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(12, 18, 27, 0.78)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
modalCard: {
backgroundColor: '#FDFDFE',
borderRadius: 20,
padding: 14,
alignItems: 'center',
gap: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.28,
shadowRadius: 18,
elevation: 16,
},
reportImage: {
borderRadius: 14,
overflow: 'hidden',
},
reportImageFallback: {
borderRadius: 14,
backgroundColor: '#F3F4F6',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
},
reportFallbackText: {
textAlign: 'center',
color: '#111827',
fontFamily: 'AliRegular',
},
modalActions: {
flexDirection: 'row',
gap: 10,
},
modalButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#E0F2FE',
borderRadius: 12,
borderWidth: 1,
borderColor: '#BAE6FD',
},
modalButtonDisabled: {
opacity: 0.6,
},
modalButtonText: {
fontSize: 14,
color: '#0F172A',
fontFamily: 'AliBold',
},
closeRow: {
marginTop: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 8,
paddingVertical: 6,
},
closeLabel: {
fontSize: 14,
color: '#4B5563',
fontFamily: 'AliRegular',
},
});