- 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
515 lines
15 KiB
TypeScript
515 lines
15 KiB
TypeScript
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||
import { Colors } from '@/constants/Colors';
|
||
import { getMedicationRecognitionStatus } from '@/services/medications';
|
||
import { MedicationRecognitionTask } from '@/types/medication';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
|
||
import { Image } from 'expo-image';
|
||
import { LinearGradient } from 'expo-linear-gradient';
|
||
import { router, useLocalSearchParams } from 'expo-router';
|
||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { ActivityIndicator, Animated, Dimensions, Modal, SafeAreaView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||
|
||
const STATUS_STEPS: { key: MedicationRecognitionTask['status']; label: string }[] = [
|
||
{ key: 'analyzing_product', label: '正在进行产品分析...' },
|
||
{ key: 'analyzing_suitability', label: '正在检测适宜人群...' },
|
||
{ key: 'analyzing_ingredients', label: '正在评估成分信息...' },
|
||
{ key: 'analyzing_effects', label: '正在生成安全建议...' },
|
||
];
|
||
|
||
export default function MedicationAiProgressScreen() {
|
||
const { taskId, cover } = useLocalSearchParams<{ taskId?: string; cover?: string }>();
|
||
const insets = useSafeAreaInsets();
|
||
const [task, setTask] = useState<MedicationRecognitionTask | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showErrorModal, setShowErrorModal] = useState(false);
|
||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||
const navigatingRef = useRef(false);
|
||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
// 动画值:上下浮动和透明度
|
||
const floatAnim = useRef(new Animated.Value(0)).current;
|
||
const opacityAnim = useRef(new Animated.Value(0.3)).current;
|
||
|
||
const currentStepIndex = useMemo(() => {
|
||
if (!task) return 0;
|
||
const idx = STATUS_STEPS.findIndex((step) => step.key === task.status);
|
||
if (idx >= 0) return idx;
|
||
if (task.status === 'completed') return STATUS_STEPS.length;
|
||
return 0;
|
||
}, [task]);
|
||
|
||
const fetchStatus = async () => {
|
||
if (!taskId || navigatingRef.current) return;
|
||
try {
|
||
const data = await getMedicationRecognitionStatus(taskId as string);
|
||
setTask(data);
|
||
setError(null);
|
||
|
||
// 识别成功,跳转到详情页
|
||
if (data.status === 'completed' && data.result && !navigatingRef.current) {
|
||
navigatingRef.current = true;
|
||
// 清除轮询
|
||
if (pollingTimerRef.current) {
|
||
clearInterval(pollingTimerRef.current);
|
||
pollingTimerRef.current = null;
|
||
}
|
||
router.replace({
|
||
pathname: '/medications/[medicationId]',
|
||
params: {
|
||
medicationId: 'ai-draft',
|
||
aiTaskId: data.taskId,
|
||
cover: (cover as string) || data.result.photoUrl || '',
|
||
},
|
||
});
|
||
}
|
||
|
||
// 识别失败,停止轮询并显示错误弹窗
|
||
if (data.status === 'failed' && !navigatingRef.current) {
|
||
navigatingRef.current = true;
|
||
// 清除轮询
|
||
if (pollingTimerRef.current) {
|
||
clearInterval(pollingTimerRef.current);
|
||
pollingTimerRef.current = null;
|
||
}
|
||
// 显示错误提示弹窗
|
||
setErrorMessage(data.errorMessage || '识别失败,请重新拍摄');
|
||
setShowErrorModal(true);
|
||
}
|
||
} catch (err: any) {
|
||
console.error('[MEDICATION_AI] status failed', err);
|
||
setError(err?.message || '查询失败,请稍后再试');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 处理重新拍摄
|
||
const handleRetry = () => {
|
||
setShowErrorModal(false);
|
||
router.back();
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchStatus();
|
||
pollingTimerRef.current = setInterval(fetchStatus, 2400);
|
||
return () => {
|
||
if (pollingTimerRef.current) {
|
||
clearInterval(pollingTimerRef.current);
|
||
pollingTimerRef.current = null;
|
||
}
|
||
};
|
||
}, [taskId]);
|
||
|
||
// 启动浮动和闪烁动画 - 更快的动画速度
|
||
useEffect(() => {
|
||
// 上下浮动动画 - 加快速度
|
||
const floatAnimation = Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(floatAnim, {
|
||
toValue: -10,
|
||
duration: 1000,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(floatAnim, {
|
||
toValue: 0,
|
||
duration: 1000,
|
||
useNativeDriver: true,
|
||
}),
|
||
])
|
||
);
|
||
|
||
// 透明度闪烁动画 - 加快速度,增加对比度
|
||
const opacityAnimation = Animated.loop(
|
||
Animated.sequence([
|
||
Animated.timing(opacityAnim, {
|
||
toValue: 1,
|
||
duration: 800,
|
||
useNativeDriver: true,
|
||
}),
|
||
Animated.timing(opacityAnim, {
|
||
toValue: 0.4,
|
||
duration: 800,
|
||
useNativeDriver: true,
|
||
}),
|
||
])
|
||
);
|
||
|
||
floatAnimation.start();
|
||
opacityAnimation.start();
|
||
|
||
return () => {
|
||
floatAnimation.stop();
|
||
opacityAnimation.stop();
|
||
};
|
||
}, []);
|
||
|
||
const progress = task?.progress ?? Math.min(100, (currentStepIndex / STATUS_STEPS.length) * 100 + 10);
|
||
|
||
return (
|
||
<SafeAreaView style={styles.container}>
|
||
<LinearGradient colors={['#fdfdfd', '#f3f6fb']} style={StyleSheet.absoluteFill} />
|
||
<HeaderBar title="识别中" onBack={() => router.back()} transparent />
|
||
<View style={{ height: insets.top }} />
|
||
|
||
<View style={styles.heroCard}>
|
||
<View style={styles.heroImageWrapper}>
|
||
{cover ? (
|
||
<Image source={{ uri: cover }} style={styles.heroImage} contentFit="cover" />
|
||
) : (
|
||
<View style={styles.heroPlaceholder} />
|
||
)}
|
||
|
||
{/* 识别中的点阵网格动画效果 - 带深色蒙版 */}
|
||
{task?.status !== 'completed' && task?.status !== 'failed' && (
|
||
<>
|
||
{/* 深色半透明蒙版层,让点阵更清晰 */}
|
||
<View style={styles.overlayMask} />
|
||
|
||
{/* 渐变蒙版边框,增加视觉层次 */}
|
||
<LinearGradient
|
||
colors={['rgba(14, 165, 233, 0.3)', 'rgba(6, 182, 212, 0.2)', 'transparent']}
|
||
style={styles.gradientBorder}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 1 }}
|
||
/>
|
||
|
||
{/* 点阵网格动画 */}
|
||
<Animated.View
|
||
style={[
|
||
styles.dottedGrid,
|
||
{
|
||
transform: [{ translateY: floatAnim }],
|
||
opacity: opacityAnim,
|
||
}
|
||
]}
|
||
>
|
||
{Array.from({ length: 11 }).map((_, idx) => (
|
||
<View key={idx} style={styles.dotRow}>
|
||
{Array.from({ length: 11 }).map((__, jdx) => (
|
||
<View key={`${idx}-${jdx}`} style={styles.dot} />
|
||
))}
|
||
</View>
|
||
))}
|
||
</Animated.View>
|
||
</>
|
||
)}
|
||
</View>
|
||
<View style={styles.progressRow}>
|
||
<View style={[styles.progressBar, { width: `${progress}%` }]} />
|
||
</View>
|
||
<Text style={styles.progressText}>{Math.round(progress)}%</Text>
|
||
</View>
|
||
|
||
<View style={styles.stepList}>
|
||
{STATUS_STEPS.map((step, index) => {
|
||
const active = index === currentStepIndex;
|
||
const done = index < currentStepIndex;
|
||
return (
|
||
<View key={step.key} style={styles.stepRow}>
|
||
<View style={[styles.bullet, done && styles.bulletDone, active && styles.bulletActive]} />
|
||
<Text style={[styles.stepLabel, active && styles.stepLabelActive, done && styles.stepLabelDone]}>
|
||
{step.label}
|
||
</Text>
|
||
</View>
|
||
);
|
||
})}
|
||
{task?.status === 'completed' && (
|
||
<View style={styles.stepRow}>
|
||
<View style={[styles.bullet, styles.bulletDone]} />
|
||
<Text style={[styles.stepLabel, styles.stepLabelDone]}>识别完成,正在载入详情...</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.loadingBox}>
|
||
{loading ? <ActivityIndicator color={Colors.light.primary} /> : null}
|
||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
||
</View>
|
||
|
||
{/* 识别提示弹窗 */}
|
||
<Modal
|
||
visible={showErrorModal}
|
||
transparent={true}
|
||
animationType="fade"
|
||
onRequestClose={handleRetry}
|
||
>
|
||
<TouchableOpacity
|
||
style={styles.modalOverlay}
|
||
activeOpacity={1}
|
||
onPress={handleRetry}
|
||
>
|
||
<TouchableOpacity
|
||
activeOpacity={1}
|
||
onPress={(e) => e.stopPropagation()}
|
||
style={styles.errorModalContainer}
|
||
>
|
||
<View style={styles.errorModalContent}>
|
||
|
||
{/* 标题 */}
|
||
<Text style={styles.errorModalTitle}>需要重新拍摄</Text>
|
||
|
||
{/* 提示信息 */}
|
||
<View style={styles.errorMessageBox}>
|
||
<Text style={styles.errorMessageText}>{errorMessage}</Text>
|
||
</View>
|
||
|
||
{/* 重新拍摄按钮 */}
|
||
<TouchableOpacity
|
||
onPress={handleRetry}
|
||
activeOpacity={0.8}
|
||
style={{ width: '100%' }}
|
||
>
|
||
{isLiquidGlassAvailable() ? (
|
||
<GlassView
|
||
style={styles.retryButton}
|
||
glassEffectStyle="regular"
|
||
tintColor="rgba(14, 165, 233, 0.9)"
|
||
isInteractive={true}
|
||
>
|
||
<LinearGradient
|
||
colors={['rgba(14, 165, 233, 0.95)', 'rgba(6, 182, 212, 0.95)']}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 0 }}
|
||
style={styles.retryButtonGradient}
|
||
>
|
||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||
</LinearGradient>
|
||
</GlassView>
|
||
) : (
|
||
<View style={styles.retryButton}>
|
||
<LinearGradient
|
||
colors={['#0ea5e9', '#06b6d4']}
|
||
start={{ x: 0, y: 0 }}
|
||
end={{ x: 1, y: 0 }}
|
||
style={styles.retryButtonGradient}
|
||
>
|
||
<Ionicons name="camera" size={20} color="#FFFFFF" style={{ marginRight: 8 }} />
|
||
<Text style={styles.retryButtonText}>重新拍摄</Text>
|
||
</LinearGradient>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
</View>
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
flex: 1,
|
||
},
|
||
heroCard: {
|
||
marginHorizontal: 20,
|
||
marginTop: 24,
|
||
borderRadius: 24,
|
||
backgroundColor: '#fff',
|
||
padding: 16,
|
||
shadowColor: '#0f172a',
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 18,
|
||
shadowOffset: { width: 0, height: 10 },
|
||
},
|
||
heroImageWrapper: {
|
||
height: 230,
|
||
borderRadius: 18,
|
||
overflow: 'hidden',
|
||
backgroundColor: '#e2e8f0',
|
||
},
|
||
heroImage: {
|
||
width: '100%',
|
||
height: '100%',
|
||
},
|
||
heroPlaceholder: {
|
||
flex: 1,
|
||
backgroundColor: '#e2e8f0',
|
||
},
|
||
// 深色蒙版层,让点阵更清晰可见
|
||
overlayMask: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
backgroundColor: 'rgba(15, 23, 42, 0.35)',
|
||
},
|
||
// 渐变边框效果
|
||
gradientBorder: {
|
||
position: 'absolute',
|
||
left: 0,
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
borderRadius: 18,
|
||
},
|
||
// 点阵网格容器
|
||
dottedGrid: {
|
||
position: 'absolute',
|
||
left: 16,
|
||
right: 16,
|
||
top: 16,
|
||
bottom: 16,
|
||
justifyContent: 'space-between',
|
||
},
|
||
dotRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
},
|
||
// 单个点样式 - 更明亮和更大的发光效果
|
||
dot: {
|
||
width: 5,
|
||
height: 5,
|
||
borderRadius: 2.5,
|
||
backgroundColor: '#FFFFFF',
|
||
shadowColor: '#0ea5e9',
|
||
shadowOpacity: 0.9,
|
||
shadowRadius: 6,
|
||
shadowOffset: { width: 0, height: 0 },
|
||
},
|
||
progressRow: {
|
||
height: 8,
|
||
backgroundColor: '#f1f5f9',
|
||
borderRadius: 10,
|
||
marginTop: 14,
|
||
overflow: 'hidden',
|
||
},
|
||
progressBar: {
|
||
height: '100%',
|
||
borderRadius: 10,
|
||
backgroundColor: '#0ea5e9',
|
||
},
|
||
progressText: {
|
||
marginTop: 8,
|
||
fontSize: 14,
|
||
fontWeight: '700',
|
||
color: '#0f172a',
|
||
textAlign: 'right',
|
||
},
|
||
stepList: {
|
||
marginTop: 24,
|
||
marginHorizontal: 24,
|
||
gap: 14,
|
||
},
|
||
stepRow: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 10,
|
||
},
|
||
bullet: {
|
||
width: 14,
|
||
height: 14,
|
||
borderRadius: 7,
|
||
backgroundColor: '#e2e8f0',
|
||
},
|
||
bulletActive: {
|
||
backgroundColor: '#0ea5e9',
|
||
},
|
||
bulletDone: {
|
||
backgroundColor: '#22c55e',
|
||
},
|
||
stepLabel: {
|
||
fontSize: 15,
|
||
color: '#94a3b8',
|
||
},
|
||
stepLabelActive: {
|
||
color: '#0f172a',
|
||
fontWeight: '700',
|
||
},
|
||
stepLabelDone: {
|
||
color: '#16a34a',
|
||
fontWeight: '700',
|
||
},
|
||
loadingBox: {
|
||
marginTop: 30,
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
},
|
||
errorText: {
|
||
color: '#ef4444',
|
||
fontSize: 14,
|
||
},
|
||
// Modal 样式
|
||
modalOverlay: {
|
||
flex: 1,
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
backgroundColor: 'rgba(15, 23, 42, 0.4)',
|
||
},
|
||
errorModalContainer: {
|
||
width: SCREEN_WIDTH - 48,
|
||
backgroundColor: '#FFFFFF',
|
||
borderRadius: 28,
|
||
overflow: 'hidden',
|
||
shadowColor: '#0ea5e9',
|
||
shadowOpacity: 0.15,
|
||
shadowRadius: 24,
|
||
shadowOffset: { width: 0, height: 8 },
|
||
elevation: 8,
|
||
},
|
||
errorModalContent: {
|
||
padding: 32,
|
||
alignItems: 'center',
|
||
},
|
||
errorIconContainer: {
|
||
marginBottom: 24,
|
||
},
|
||
errorIconCircle: {
|
||
width: 96,
|
||
height: 96,
|
||
borderRadius: 48,
|
||
backgroundColor: 'rgba(14, 165, 233, 0.08)',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
errorModalTitle: {
|
||
fontSize: 22,
|
||
fontWeight: '700',
|
||
color: '#0f172a',
|
||
marginBottom: 16,
|
||
textAlign: 'center',
|
||
},
|
||
errorMessageBox: {
|
||
backgroundColor: '#f0f9ff',
|
||
borderRadius: 16,
|
||
padding: 20,
|
||
marginBottom: 28,
|
||
width: '100%',
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(14, 165, 233, 0.2)',
|
||
},
|
||
errorMessageText: {
|
||
fontSize: 15,
|
||
lineHeight: 24,
|
||
color: '#475569',
|
||
textAlign: 'center',
|
||
},
|
||
retryButton: {
|
||
borderRadius: 16,
|
||
overflow: 'hidden',
|
||
shadowColor: '#0ea5e9',
|
||
shadowOpacity: 0.25,
|
||
shadowRadius: 12,
|
||
shadowOffset: { width: 0, height: 6 },
|
||
elevation: 6,
|
||
},
|
||
retryButtonGradient: {
|
||
paddingVertical: 16,
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
},
|
||
retryButtonText: {
|
||
fontSize: 18,
|
||
fontWeight: '700',
|
||
color: '#FFFFFF',
|
||
},
|
||
});
|