feat(ai报告): 新增AI健康报告画廊功能,支持报告生成、保存与分享
This commit is contained in:
@@ -14,17 +14,19 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
||||
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
||||
import { setHealthData } from '@/store/healthSlice';
|
||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||
import { updateUserProfile } from '@/store/userSlice';
|
||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
||||
import { fetchHealthDataForDate } from '@/utils/health';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { debounce } from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -63,8 +65,8 @@ export default function ExploreScreen() {
|
||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||
const userProfile = useAppSelector((s) => s.user.profile);
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
||||
|
||||
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||
const router = useRouter();
|
||||
|
||||
// 使用 dayjs:当月日期与默认选中"今天"
|
||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||
@@ -80,7 +82,11 @@ export default function ExploreScreen() {
|
||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||
}, [currentSelectedDate]);
|
||||
|
||||
|
||||
const handleOpenGallery = React.useCallback(async () => {
|
||||
const ok = await ensureLoggedIn();
|
||||
if (!ok) return;
|
||||
router.push('/gallery');
|
||||
}, [ensureLoggedIn, router]);
|
||||
|
||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||
const [animToken, setAnimToken] = useState(0);
|
||||
@@ -384,7 +390,7 @@ export default function ExploreScreen() {
|
||||
{/* 顶部信息栏 */}
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.headerContent}>
|
||||
{/* 左边logo */}
|
||||
<View style={styles.headerLeft}>
|
||||
<Image
|
||||
source={require('@/assets/machine.png')}
|
||||
style={styles.logoImage}
|
||||
@@ -395,31 +401,30 @@ export default function ExploreScreen() {
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 开发环境调试按钮 */}
|
||||
{__DEV__ && (
|
||||
<View style={styles.debugButtonsContainer}>
|
||||
<View style={styles.headerActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.debugButton}
|
||||
onPress={async () => {
|
||||
console.log('🔧 Manual background task test...');
|
||||
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
|
||||
}}
|
||||
activeOpacity={0.85}
|
||||
onPress={handleOpenGallery}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🔧</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.debugButton, styles.hrvTestButton]}
|
||||
onPress={async () => {
|
||||
console.log('🫀 Testing HRV data fetch...');
|
||||
await testHRVDataFetch();
|
||||
}}
|
||||
{isLiquidGlassAvailable() ? (
|
||||
<GlassView
|
||||
style={styles.liquidGlassButton}
|
||||
glassEffectStyle="regular"
|
||||
tintColor="rgba(255, 255, 255, 0.3)"
|
||||
isInteractive={true}
|
||||
>
|
||||
<Text style={styles.debugButtonText}>🫀</Text>
|
||||
</TouchableOpacity>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</GlassView>
|
||||
) : (
|
||||
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
|
||||
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -537,6 +542,7 @@ export default function ExploreScreen() {
|
||||
{/* 围度数据卡片 - 占满底部一行 */}
|
||||
<CircumferenceCard style={styles.circumferenceCard} />
|
||||
</ScrollView>
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -585,6 +591,13 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
logoImage: {
|
||||
width: 28,
|
||||
@@ -921,6 +934,53 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'left',
|
||||
fontFamily: 'AliBold',
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
},
|
||||
reportButton: {
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: '#F6F7FB',
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
reportIconWrapper: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reportButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontFamily: 'AliBold',
|
||||
color: '#0F172A',
|
||||
},
|
||||
// Liquid Glass 风格按钮
|
||||
liquidGlassButton: {
|
||||
height: 40,
|
||||
width: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
liquidGlassFallback: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.8)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
|
||||
687
app/gallery/index.tsx
Normal file
687
app/gallery/index.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
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', '正在生成健康报告,预计 10~30 秒…'));
|
||||
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',
|
||||
},
|
||||
});
|
||||
@@ -52,6 +52,32 @@ export const healthPermissions = {
|
||||
|
||||
export const statistics = {
|
||||
title: 'Out Live',
|
||||
aiReport: {
|
||||
button: 'Report',
|
||||
generating: 'Generating your AI health report, this may take 10–30s…',
|
||||
generatingShort: 'Generating',
|
||||
success: 'Report ready',
|
||||
failed: 'Failed to generate report, please try again',
|
||||
missing: 'Report is not ready yet, please try again',
|
||||
permission: 'Media permission is required to save the report',
|
||||
saved: 'Saved to Photos',
|
||||
saveFailed: 'Save failed, please try again',
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
share: 'Share',
|
||||
sharing: 'Sharing…',
|
||||
shareFailed: 'Share failed, please try again',
|
||||
shareTitle: 'AI Health Report',
|
||||
shareMessage: 'Here is my AI health report—take a look!',
|
||||
close: 'Close',
|
||||
galleryTitle: 'AI Report Gallery',
|
||||
gallerySubtitle: 'Browse and keep your immersive reports',
|
||||
bannerDesc: 'Tap generate on the top right, takes about 10–30s',
|
||||
loadFailed: 'Failed to load report history',
|
||||
emptyHistory: 'No reports yet',
|
||||
emptyHistoryHint: 'Tap the top right to generate your first report',
|
||||
generated: 'generated',
|
||||
},
|
||||
sections: {
|
||||
bodyMetrics: 'Body Metrics',
|
||||
},
|
||||
|
||||
@@ -52,6 +52,33 @@ export const healthPermissions = {
|
||||
|
||||
export const statistics = {
|
||||
title: 'Out Live',
|
||||
aiReport: {
|
||||
button: '报告',
|
||||
generating: '正在生成健康报告,预计 10~30 秒…',
|
||||
generatingShort: '生成中',
|
||||
success: '报告已生成',
|
||||
failed: '生成报告失败,请稍后重试',
|
||||
missing: '未获取到报告图片,请稍后重试',
|
||||
permission: '需要相册权限才能保存图片',
|
||||
saved: '已保存到相册',
|
||||
saveFailed: '保存失败,请稍后重试',
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
share: '分享',
|
||||
sharing: '分享中…',
|
||||
shareFailed: '分享失败,请稍后重试',
|
||||
shareTitle: 'AI 健康报告',
|
||||
shareMessage: '这是我的 AI 健康报告,分享给你看看!',
|
||||
close: '收起',
|
||||
galleryTitle: 'AI 报告画廊',
|
||||
gallerySubtitle: '沉浸式浏览你的健康报告',
|
||||
bannerTitle: '今日 AI 健康报告',
|
||||
bannerDesc: '点击右上角生成,约 10~30 秒',
|
||||
loadFailed: '加载报告历史失败',
|
||||
emptyHistory: '暂无报告记录',
|
||||
emptyHistoryHint: '点击右上角生成你的第一份报告',
|
||||
generated: '生成',
|
||||
},
|
||||
sections: {
|
||||
bodyMetrics: '身体指标',
|
||||
},
|
||||
|
||||
68
services/aiReport.ts
Normal file
68
services/aiReport.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export type GenerateAiReportParams = {
|
||||
date?: string;
|
||||
};
|
||||
|
||||
export type GenerateAiReportResponse = {
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 调用后端生成 AI 健康报告图片
|
||||
*/
|
||||
export async function generateAiReport(params: GenerateAiReportParams = {}): Promise<GenerateAiReportResponse> {
|
||||
return await api.post<GenerateAiReportResponse>('/users/ai-report', params, {
|
||||
// 确保请求体使用 JSON
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 报告状态
|
||||
export type AiReportStatus = 'pending' | 'processing' | 'success' | 'failed';
|
||||
|
||||
// 单条报告记录
|
||||
export type AiReportRecord = {
|
||||
id: string;
|
||||
reportDate: string;
|
||||
imageUrl: string;
|
||||
status: AiReportStatus;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// 历史列表请求参数
|
||||
export type GetAiReportHistoryParams = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: AiReportStatus;
|
||||
};
|
||||
|
||||
// 历史列表响应
|
||||
export type GetAiReportHistoryResponse = {
|
||||
records: AiReportRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 AI 健康报告历史列表
|
||||
*/
|
||||
export async function getAiReportHistory(params: GetAiReportHistoryParams = {}): Promise<GetAiReportHistoryResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set('page', String(params.page));
|
||||
if (params.pageSize) searchParams.set('pageSize', String(params.pageSize));
|
||||
if (params.startDate) searchParams.set('startDate', params.startDate);
|
||||
if (params.endDate) searchParams.set('endDate', params.endDate);
|
||||
if (params.status) searchParams.set('status', params.status);
|
||||
|
||||
const query = searchParams.toString();
|
||||
const path = `/users/ai-report/history${query ? `?${query}` : ''}`;
|
||||
|
||||
return await api.get<GetAiReportHistoryResponse>(path);
|
||||
}
|
||||
Reference in New Issue
Block a user