feat(ai报告): 新增AI健康报告画廊功能,支持报告生成、保存与分享
This commit is contained in:
@@ -14,17 +14,19 @@ import { WorkoutSummaryCard } from '@/components/WorkoutSummaryCard';
|
|||||||
import { Colors } from '@/constants/Colors';
|
import { Colors } from '@/constants/Colors';
|
||||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||||
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
|
|
||||||
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
import { syncHealthKitToServer } from '@/services/healthKitSync';
|
||||||
import { setHealthData } from '@/store/healthSlice';
|
import { setHealthData } from '@/store/healthSlice';
|
||||||
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
import { fetchDailyMoodCheckins, selectLatestMoodRecordByDate } from '@/store/moodSlice';
|
||||||
import { updateUserProfile } from '@/store/userSlice';
|
import { updateUserProfile } from '@/store/userSlice';
|
||||||
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
import { fetchTodayWaterStats } from '@/store/waterSlice';
|
||||||
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
import { getMonthDaysZh, getTodayIndexInMonth } from '@/utils/date';
|
||||||
import { fetchHealthDataForDate, testHRVDataFetch } from '@/utils/health';
|
import { fetchHealthDataForDate } from '@/utils/health';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -63,8 +65,8 @@ export default function ExploreScreen() {
|
|||||||
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
const stepGoal = useAppSelector((s) => s.user.profile?.dailyStepsGoal) ?? 2000;
|
||||||
const userProfile = useAppSelector((s) => s.user.profile);
|
const userProfile = useAppSelector((s) => s.user.profile);
|
||||||
|
|
||||||
const { pushIfAuthedElseLogin, isLoggedIn } = useAuthGuard();
|
const { pushIfAuthedElseLogin, isLoggedIn, ensureLoggedIn } = useAuthGuard();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 使用 dayjs:当月日期与默认选中"今天"
|
// 使用 dayjs:当月日期与默认选中"今天"
|
||||||
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
const [selectedIndex, setSelectedIndex] = useState(getTodayIndexInMonth());
|
||||||
@@ -80,7 +82,11 @@ export default function ExploreScreen() {
|
|||||||
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
return dayjs(currentSelectedDate).format('YYYY-MM-DD');
|
||||||
}, [currentSelectedDate]);
|
}, [currentSelectedDate]);
|
||||||
|
|
||||||
|
const handleOpenGallery = React.useCallback(async () => {
|
||||||
|
const ok = await ensureLoggedIn();
|
||||||
|
if (!ok) return;
|
||||||
|
router.push('/gallery');
|
||||||
|
}, [ensureLoggedIn, router]);
|
||||||
|
|
||||||
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
// 用于触发动画重置的 token(当日期或数据变化时更新)
|
||||||
const [animToken, setAnimToken] = useState(0);
|
const [animToken, setAnimToken] = useState(0);
|
||||||
@@ -384,7 +390,7 @@ export default function ExploreScreen() {
|
|||||||
{/* 顶部信息栏 */}
|
{/* 顶部信息栏 */}
|
||||||
<View style={styles.headerContainer}>
|
<View style={styles.headerContainer}>
|
||||||
<View style={styles.headerContent}>
|
<View style={styles.headerContent}>
|
||||||
{/* 左边logo */}
|
<View style={styles.headerLeft}>
|
||||||
<Image
|
<Image
|
||||||
source={require('@/assets/machine.png')}
|
source={require('@/assets/machine.png')}
|
||||||
style={styles.logoImage}
|
style={styles.logoImage}
|
||||||
@@ -395,31 +401,30 @@ export default function ExploreScreen() {
|
|||||||
<View style={styles.headerTextContainer}>
|
<View style={styles.headerTextContainer}>
|
||||||
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
<Text style={styles.headerTitle}>{t('statistics.title')}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* 开发环境调试按钮 */}
|
<View style={styles.headerActions}>
|
||||||
{__DEV__ && (
|
|
||||||
<View style={styles.debugButtonsContainer}>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.debugButton}
|
activeOpacity={0.85}
|
||||||
onPress={async () => {
|
onPress={handleOpenGallery}
|
||||||
console.log('🔧 Manual background task test...');
|
|
||||||
await BackgroundTaskManager.getInstance().triggerTaskForTesting();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.debugButtonText}>🔧</Text>
|
{isLiquidGlassAvailable() ? (
|
||||||
</TouchableOpacity>
|
<GlassView
|
||||||
|
style={styles.liquidGlassButton}
|
||||||
<TouchableOpacity
|
glassEffectStyle="regular"
|
||||||
style={[styles.debugButton, styles.hrvTestButton]}
|
tintColor="rgba(255, 255, 255, 0.3)"
|
||||||
onPress={async () => {
|
isInteractive={true}
|
||||||
console.log('🫀 Testing HRV data fetch...');
|
|
||||||
await testHRVDataFetch();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.debugButtonText}>🫀</Text>
|
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||||
</TouchableOpacity>
|
</GlassView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.liquidGlassButton, styles.liquidGlassFallback]}>
|
||||||
|
<Ionicons name="sparkles-outline" size={18} color="#0F172A" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -537,6 +542,7 @@ export default function ExploreScreen() {
|
|||||||
{/* 围度数据卡片 - 占满底部一行 */}
|
{/* 围度数据卡片 - 占满底部一行 */}
|
||||||
<CircumferenceCard style={styles.circumferenceCard} />
|
<CircumferenceCard style={styles.circumferenceCard} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -585,6 +591,13 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
logoImage: {
|
logoImage: {
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -921,6 +934,53 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontFamily: 'AliBold',
|
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 = {
|
export const statistics = {
|
||||||
title: 'Out Live',
|
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: {
|
sections: {
|
||||||
bodyMetrics: 'Body Metrics',
|
bodyMetrics: 'Body Metrics',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -52,6 +52,33 @@ export const healthPermissions = {
|
|||||||
|
|
||||||
export const statistics = {
|
export const statistics = {
|
||||||
title: 'Out Live',
|
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: {
|
sections: {
|
||||||
bodyMetrics: '身体指标',
|
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