feat(medications): 添加AI智能识别药品功能和有效期管理

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

BREAKING CHANGE: 药品通知系统从本地通知迁移到服务端推送,旧版本的本地通知将被清理
This commit is contained in:
richarjiang
2025-11-21 17:32:44 +08:00
parent 29942feee9
commit bcb910140e
18 changed files with 2735 additions and 407 deletions

View File

@@ -0,0 +1,626 @@
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',
},
});