Files
digital-pilates/app/medications/ai-camera.tsx
richarjiang bcb910140e feat(medications): 添加AI智能识别药品功能和有效期管理
- 新增AI药品识别流程,支持多角度拍摄和实时进度显示
- 添加药品有效期字段,支持在添加和编辑药品时设置有效期
- 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入)
- 新增ai-camera和ai-progress两个独立页面处理AI识别流程
- 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件
- 移除本地通知系统,迁移到服务端推送通知
- 添加medicationNotificationCleanup服务清理旧的本地通知
- 更新药品详情页支持AI草稿模式和有效期显示
- 优化药品表单,支持有效期选择和AI识别结果确认
- 更新i18n资源,添加有效期相关翻译

BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
2025-11-21 17:32:44 +08:00

627 lines
18 KiB
TypeScript
Raw 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 { MedicationPhotoGuideModal } from '@/components/medications/MedicationPhotoGuideModal';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { createMedicationRecognitionTask } from '@/services/medications';
import { Ionicons } from '@expo/vector-icons';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const captureSteps = [
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
] as const;
type CaptureKey = (typeof captureSteps)[number]['key'];
type Shot = {
uri: string;
};
export default function MedicationAiCameraScreen() {
const insets = useSafeAreaInsets();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
const { ensureLoggedIn } = useAuthGuard();
const { upload, uploading } = useCosUpload({ prefix: 'images/medications/ai-recognition' });
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
const [facing, setFacing] = useState<'back' | 'front'>('back');
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [shots, setShots] = useState<Record<CaptureKey, Shot | null>>({
front: null,
side: null,
aux: null,
});
const [creatingTask, setCreatingTask] = useState(false);
const [showGuideModal, setShowGuideModal] = useState(false);
// 首次进入时显示引导弹窗
useEffect(() => {
const hasSeenGuide = false; // 每次都显示,如需持久化可使用 AsyncStorage
if (!hasSeenGuide) {
setShowGuideModal(true);
}
}, []);
const currentStep = captureSteps[currentStepIndex];
const coverPreview = shots[currentStep.key]?.uri ?? shots.front?.uri;
const allRequiredCaptured = Boolean(shots.front && shots.side);
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
const handleToggleCamera = () => {
setFacing((prev) => (prev === 'back' ? 'front' : 'back'));
};
const handlePickFromAlbum = async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.9,
});
if (!result.canceled && result.assets?.length) {
const asset = result.assets[0];
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: asset.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => {
goNextStep();
}, 300);
}
}
} catch (error) {
console.error('[MEDICATION_AI] pick image failed', error);
Alert.alert('选择失败', '请重试或更换图片');
}
};
const handleTakePicture = async () => {
if (!cameraRef.current) return;
try {
const photo = await cameraRef.current.takePictureAsync({ quality: 0.85 });
if (photo?.uri) {
setShots((prev) => ({ ...prev, [currentStep.key]: { uri: photo.uri } }));
// 拍摄完成后自动进入下一步(如果还有下一步)
if (currentStepIndex < captureSteps.length - 1) {
setTimeout(() => {
goNextStep();
}, 300);
}
}
} catch (error) {
console.error('[MEDICATION_AI] take picture failed', error);
Alert.alert('拍摄失败', '请重试');
}
};
const goNextStep = () => {
if (currentStepIndex < captureSteps.length - 1) {
setCurrentStepIndex((prev) => prev + 1);
}
};
const handleStartRecognition = async () => {
// 检查必需照片是否完成
if (!allRequiredCaptured) {
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
return;
}
await startRecognition();
};
const startRecognition = async () => {
if (!shots.front || !shots.side) return;
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) return;
try {
setCreatingTask(true);
const [frontUpload, sideUpload, auxUpload] = await Promise.all([
upload({ uri: shots.front.uri, name: `front-${Date.now()}.jpg`, type: 'image/jpeg' }),
upload({ uri: shots.side.uri, name: `side-${Date.now()}.jpg`, type: 'image/jpeg' }),
shots.aux ? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' }) : Promise.resolve(null),
]);
const task = await createMedicationRecognitionTask({
frontImageUrl: frontUpload.url,
sideImageUrl: sideUpload.url,
auxiliaryImageUrl: auxUpload?.url,
});
router.replace({
pathname: '/medications/ai-progress',
params: {
taskId: task.taskId,
cover: frontUpload.url,
},
});
} catch (error: any) {
console.error('[MEDICATION_AI] recognize failed', error);
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
} finally {
setCreatingTask(false);
}
};
if (!permission) {
return null;
}
if (!permission.granted) {
return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
<View style={[styles.permissionCard, { marginTop: insets.top + 60 }]}>
<Text style={styles.permissionTitle}></Text>
<Text style={styles.permissionTip}></Text>
<TouchableOpacity style={[styles.permissionBtn, { backgroundColor: colors.primary }]} onPress={requestPermission}>
<Text style={styles.permissionBtnText}>访</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<>
{/* 引导说明弹窗 - 移到最外层 */}
<MedicationPhotoGuideModal
visible={showGuideModal}
onClose={() => setShowGuideModal(false)}
/>
<View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar
title="AI 用药识别"
onBack={() => router.back()}
transparent
right={
<TouchableOpacity
onPress={() => setShowGuideModal(true)}
activeOpacity={0.7}
accessibilityLabel="查看拍摄说明"
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.infoButton}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="information-circle-outline" size={24} color="#333" />
</GlassView>
) : (
<View style={[styles.infoButton, styles.fallbackInfoButton]}>
<Ionicons name="information-circle-outline" size={24} color="#333" />
</View>
)}
</TouchableOpacity>
}
/>
<View style={{ height: insets.top + 40 }} />
<View style={styles.topMeta}>
<View style={styles.metaBadge}>
<Text style={styles.metaBadgeText}>{stepTitle}</Text>
</View>
<Text style={styles.metaTitle}>{currentStep.title}</Text>
<Text style={styles.metaSubtitle}>{currentStep.subtitle}</Text>
</View>
<View style={styles.cameraCard}>
<View style={styles.cameraFrame}>
<CameraView ref={cameraRef} style={styles.cameraView} facing={facing} />
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.08)']}
style={styles.cameraOverlay}
/>
{coverPreview ? (
<View style={styles.previewBadge}>
<Image source={{ uri: coverPreview }} style={styles.previewImage} contentFit="cover" />
</View>
) : null}
</View>
</View>
<View style={styles.shotsRow}>
{captureSteps.map((step, index) => {
const active = step.key === currentStep.key;
const shot = shots[step.key];
return (
<TouchableOpacity
key={step.key}
onPress={() => setCurrentStepIndex(index)}
activeOpacity={0.7}
style={[styles.shotCard, active && styles.shotCardActive]}
>
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
{step.title}
{!step.mandatory ? '(可选)' : ''}
</Text>
{shot ? (
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
) : (
<View style={styles.shotPlaceholder}>
<Text style={styles.shotPlaceholderText}></Text>
</View>
)}
</TouchableOpacity>
);
})}
</View>
<View style={[styles.bottomBar, { paddingBottom: Math.max(insets.bottom, 20) }]}>
<View style={styles.bottomActions}>
<TouchableOpacity
onPress={handlePickFromAlbum}
disabled={creatingTask || uploading}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleTakePicture}
disabled={creatingTask}
activeOpacity={0.8}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.captureBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.8)"
isInteractive={true}
>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</GlassView>
) : (
<View style={[styles.captureBtn, styles.fallbackCaptureBtn]}>
<View style={styles.captureOuterRing}>
<View style={styles.captureInner} />
</View>
</View>
)}
</TouchableOpacity>
<TouchableOpacity
onPress={handleToggleCamera}
disabled={creatingTask}
activeOpacity={0.7}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.secondaryBtn}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.6)"
isInteractive={true}
>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
</View>
)}
</TouchableOpacity>
</View>
{/* 只要正面和背面都有照片就显示识别按钮 */}
{allRequiredCaptured && (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleStartRecognition}
disabled={creatingTask || uploading}
style={[styles.primaryCta, { backgroundColor: colors.primary }]}
>
{creatingTask || uploading ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<Text style={[styles.primaryText, { color: colors.onPrimary }]}>
</Text>
)}
</TouchableOpacity>
)}
</View>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
topMeta: {
paddingHorizontal: 20,
paddingTop: 12,
gap: 6,
},
metaBadge: {
alignSelf: 'flex-start',
backgroundColor: '#e0f2fe',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
metaBadgeText: {
color: '#0369a1',
fontWeight: '700',
fontSize: 12,
},
metaTitle: {
fontSize: 22,
fontWeight: '700',
color: '#0f172a',
},
metaSubtitle: {
fontSize: 14,
color: '#475569',
},
cameraCard: {
marginHorizontal: 20,
marginTop: 12,
borderRadius: 24,
overflow: 'hidden',
shadowColor: '#0f172a',
shadowOpacity: 0.12,
shadowRadius: 18,
shadowOffset: { width: 0, height: 10 },
},
cameraFrame: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: '#0b172a',
height: 360,
},
cameraView: {
flex: 1,
},
cameraOverlay: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 80,
},
previewBadge: {
position: 'absolute',
right: 12,
bottom: 12,
width: 90,
height: 90,
borderRadius: 12,
overflow: 'hidden',
borderWidth: 2,
borderColor: '#fff',
},
previewImage: {
width: '100%',
height: '100%',
},
shotsRow: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingTop: 12,
gap: 10,
},
shotCard: {
flex: 1,
borderRadius: 14,
backgroundColor: '#f8fafc',
padding: 10,
gap: 8,
borderWidth: 1,
borderColor: '#e2e8f0',
},
shotCardActive: {
borderColor: '#38bdf8',
backgroundColor: '#ecfeff',
},
shotLabel: {
fontSize: 12,
color: '#475569',
fontWeight: '600',
},
shotLabelActive: {
color: '#0ea5e9',
},
shotThumb: {
width: '100%',
height: 70,
borderRadius: 12,
},
shotPlaceholder: {
height: 70,
borderRadius: 12,
backgroundColor: '#e2e8f0',
alignItems: 'center',
justifyContent: 'center',
},
shotPlaceholderText: {
color: '#94a3b8',
fontSize: 12,
},
bottomBar: {
paddingHorizontal: 20,
paddingTop: 12,
gap: 10,
},
bottomActions: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
captureBtn: {
width: 86,
height: 86,
borderRadius: 43,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
shadowColor: '#0ea5e9',
shadowOpacity: 0.25,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
},
fallbackCaptureBtn: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderWidth: 3,
borderColor: 'rgba(14, 165, 233, 0.2)',
},
captureOuterRing: {
width: 76,
height: 76,
borderRadius: 38,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
captureInner: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#fff',
shadowColor: '#0ea5e9',
shadowOpacity: 0.4,
shadowRadius: 8,
shadowOffset: { width: 0, height: 2 },
},
secondaryBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#0f172a',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
},
fallbackSecondaryBtn: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(15, 23, 42, 0.1)',
},
secondaryBtnText: {
color: '#0f172a',
fontWeight: '600',
fontSize: 14,
},
primaryCta: {
marginTop: 6,
borderRadius: 16,
paddingVertical: 14,
alignItems: 'center',
shadowColor: '#0f172a',
shadowOpacity: 0.12,
shadowRadius: 10,
shadowOffset: { width: 0, height: 6 },
},
primaryText: {
fontSize: 16,
fontWeight: '700',
},
skipBtn: {
alignSelf: 'center',
paddingVertical: 6,
paddingHorizontal: 12,
},
skipText: {
color: '#475569',
fontSize: 13,
},
infoButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackInfoButton: {
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
},
permissionCard: {
marginHorizontal: 24,
borderRadius: 18,
padding: 20,
backgroundColor: '#fff',
shadowColor: '#0f172a',
shadowOpacity: 0.08,
shadowRadius: 12,
shadowOffset: { width: 0, height: 10 },
alignItems: 'center',
gap: 10,
},
permissionTitle: {
fontSize: 18,
fontWeight: '700',
color: '#0f172a',
},
permissionTip: {
fontSize: 14,
color: '#475569',
textAlign: 'center',
lineHeight: 20,
},
permissionBtn: {
marginTop: 6,
borderRadius: 14,
paddingHorizontal: 18,
paddingVertical: 12,
},
permissionBtnText: {
color: '#fff',
fontWeight: '700',
},
});