- 新增AI药品识别流程,支持多角度拍摄和实时进度显示 - 添加药品有效期字段,支持在添加和编辑药品时设置有效期 - 新增MedicationAddOptionsSheet选择录入方式(AI识别/手动录入) - 新增ai-camera和ai-progress两个独立页面处理AI识别流程 - 新增ExpiryDatePickerModal和MedicationPhotoGuideModal组件 - 移除本地通知系统,迁移到服务端推送通知 - 添加medicationNotificationCleanup服务清理旧的本地通知 - 更新药品详情页支持AI草稿模式和有效期显示 - 优化药品表单,支持有效期选择和AI识别结果确认 - 更新i18n资源,添加有效期相关翻译 BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
627 lines
18 KiB
TypeScript
627 lines
18 KiB
TypeScript
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',
|
||
},
|
||
});
|