Files
digital-pilates/app/medications/[medicationId].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

2942 lines
91 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 { ExpiryDatePickerModal } from '@/components/medications/ExpiryDatePickerModal';
import { ThemedText } from '@/components/ThemedText';
import { ConfirmationSheet } from '@/components/ui/ConfirmationSheet';
import { HeaderBar } from '@/components/ui/HeaderBar';
import InfoCard from '@/components/ui/InfoCard';
import { Colors } from '@/constants/Colors';
import { DOSAGE_UNITS, DOSAGE_VALUES, FORM_OPTIONS } from '@/constants/Medication';
import { ROUTES } from '@/constants/Routes';
import { useMembershipModal } from '@/contexts/MembershipModalContext';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { useVipService } from '@/hooks/useVipService';
import {
analyzeMedicationV2,
confirmMedicationRecognition,
getMedicationById,
getMedicationRecognitionStatus,
getMedicationRecords,
} from '@/services/medications';
import {
deleteMedicationAction,
fetchMedications,
selectMedications,
updateMedicationAction,
} from '@/store/medicationsSlice';
import type {
Medication,
MedicationAiAnalysisV2,
MedicationAiRecognitionResult,
MedicationForm,
} from '@/types/medication';
import { Ionicons } from '@expo/vector-icons';
import { Picker } from '@react-native-picker/picker';
import Voice from '@react-native-voice/voice';
import dayjs from 'dayjs';
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 { useLocalSearchParams, useRouter } from 'expo-router';
import LottieView from 'lottie-react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Alert,
Keyboard,
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
type RecordsSummary = {
takenCount: number;
startedDays: number | null;
};
export default function MedicationDetailScreen() {
const { t } = useI18n();
const params = useLocalSearchParams<{ medicationId?: string; aiTaskId?: string; cover?: string }>();
const medicationId = Array.isArray(params.medicationId)
? params.medicationId[0]
: params.medicationId;
const aiTaskId = Array.isArray(params.aiTaskId) ? params.aiTaskId[0] : params.aiTaskId;
const coverFromParams = Array.isArray(params.cover) ? params.cover[0] : params.cover;
const dispatch = useAppDispatch();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
const insets = useSafeAreaInsets();
const router = useRouter();
const { ensureLoggedIn } = useAuthGuard();
const { openMembershipModal } = useMembershipModal();
const { checkServiceAccess } = useVipService();
const { upload, uploading } = useCosUpload({ prefix: 'images/medications' });
const medications = useAppSelector(selectMedications);
const medicationFromStore = medications.find((item) => item.id === medicationId);
const isAiDraft = Boolean(aiTaskId);
const [medication, setMedication] = useState<Medication | null>(medicationFromStore ?? null);
const [loading, setLoading] = useState(isAiDraft ? true : !medicationFromStore);
const [summary, setSummary] = useState<RecordsSummary>({
takenCount: 0,
startedDays: null,
});
const [summaryLoading, setSummaryLoading] = useState(true);
const [updatePending, setUpdatePending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [noteModalVisible, setNoteModalVisible] = useState(false);
const [noteDraft, setNoteDraft] = useState(medication?.note ?? '');
const [noteSaving, setNoteSaving] = useState(false);
const [nameModalVisible, setNameModalVisible] = useState(false);
const [nameDraft, setNameDraft] = useState(medicationFromStore?.name ?? '');
const [nameSaving, setNameSaving] = useState(false);
const [dictationActive, setDictationActive] = useState(false);
const [dictationLoading, setDictationLoading] = useState(false);
const isDictationSupported = Platform.OS === 'ios';
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [deleteSheetVisible, setDeleteSheetVisible] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deactivateSheetVisible, setDeactivateSheetVisible] = useState(false);
const [deactivateLoading, setDeactivateLoading] = useState(false);
const [showImagePreview, setShowImagePreview] = useState(false);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const buildAiDraftMedication = useCallback(
(result: MedicationAiRecognitionResult): Medication => {
const timeList =
result.medicationTimes && result.medicationTimes.length
? result.medicationTimes
: Array.from({ length: result.timesPerDay ?? 1 }, (_, idx) => {
const base = ['08:00', '12:30', '18:30', '22:00'];
return base[idx] ?? base[0];
});
return {
id: 'ai-draft',
userId: '',
name: result.name || 'AI 识别药物',
photoUrl: result.photoUrl || coverFromParams || undefined,
form: result.form || 'other',
dosageValue: result.dosageValue ?? 1,
dosageUnit: result.dosageUnit || '次',
timesPerDay: result.timesPerDay ?? Math.max(timeList.length, 1),
medicationTimes: timeList,
startDate: result.startDate || new Date().toISOString(),
endDate: result.endDate ?? null,
repeatPattern: 'daily',
note: result.note || '',
aiAnalysis: result ? JSON.stringify(result) : undefined,
isActive: true,
deleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
},
[coverFromParams]
);
// AI 分析相关状态
const [aiAnalysisLoading, setAiAnalysisLoading] = useState(false);
const [aiAnalysisResult, setAiAnalysisResult] = useState<MedicationAiAnalysisV2 | null>(null);
const [aiAnalysisError, setAiAnalysisError] = useState<string | null>(null);
const [aiAnalysisLocked, setAiAnalysisLocked] = useState(false);
const [aiDraftSaving, setAiDraftSaving] = useState(false);
// 剂量选择相关状态
const [dosagePickerVisible, setDosagePickerVisible] = useState(false);
const [dosageValuePicker, setDosageValuePicker] = useState(
medicationFromStore?.dosageValue ?? 1
);
const [dosageUnitPicker, setDosageUnitPicker] = useState(
medicationFromStore?.dosageUnit ?? DOSAGE_UNITS[0]
);
// 剂型选择相关状态
const [formPickerVisible, setFormPickerVisible] = useState(false);
const [formPicker, setFormPicker] = useState<MedicationForm>(
medicationFromStore?.form ?? 'capsule'
);
// 有效期选择相关状态
const [expiryDatePickerVisible, setExpiryDatePickerVisible] = useState(false);
const [expiryDatePickerValue, setExpiryDatePickerValue] = useState<Date>(new Date());
// ScrollView 引用,用于滚动到底部
const scrollViewRef = React.useRef<ScrollView>(null);
useEffect(() => {
if (!medicationFromStore) {
dispatch(fetchMedications());
}
}, [dispatch, medicationFromStore]);
useEffect(() => {
setAiAnalysisError(null);
}, [medicationId]);
useEffect(() => {
if (medicationFromStore) {
setMedication(medicationFromStore);
setLoading(false);
setAiAnalysisResult(null);
// 如果服务端返回了 AI 分析结果,自动展示
if (medicationFromStore.aiAnalysis) {
try {
const parsed = JSON.parse(medicationFromStore.aiAnalysis);
if (parsed && typeof parsed === 'object') {
setAiAnalysisResult(parsed as MedicationAiAnalysisV2);
}
} catch {
// ignore legacy markdown
}
}
}
}, [medicationFromStore]);
useEffect(() => {
// 同步剂量选择器和剂型选择器的默认值
if (medication) {
setDosageValuePicker(medication.dosageValue);
setDosageUnitPicker(medication.dosageUnit);
setFormPicker(medication.form);
}
}, [medication?.dosageValue, medication?.dosageUnit, medication?.form]);
useEffect(() => {
setNoteDraft(medication?.note ?? '');
}, [medication?.note]);
useEffect(() => {
setNameDraft(medication?.name ?? '');
}, [medication?.name]);
useEffect(() => {
let isMounted = true;
let abortController = new AbortController();
console.log('[MEDICATION_DETAIL] useEffect triggered', {
medicationId,
hasMedicationFromStore: !!medicationFromStore,
deleteLoading
});
if (isAiDraft) {
return () => {
isMounted = false;
abortController.abort();
};
}
// 如果正在删除操作中,不执行任何操作
if (deleteLoading) {
console.log('[MEDICATION_DETAIL] Delete operation in progress, skipping useEffect');
return () => {
isMounted = false;
abortController.abort();
};
}
if (!medicationId || medicationFromStore) {
console.log('[MEDICATION_DETAIL] Early return from useEffect', {
hasMedicationId: !!medicationId,
hasMedicationFromStore: !!medicationFromStore
});
return () => {
isMounted = false;
abortController.abort();
};
}
console.log('[MEDICATION_DETAIL] Starting API call for medication', medicationId);
setLoading(true);
getMedicationById(medicationId)
.then((data) => {
if (!isMounted || abortController.signal.aborted) return;
console.log('[MEDICATION_DETAIL] API call successful', data);
setMedication(data);
setError(null);
// 如果服务端返回了 AI 分析结果,自动展示
setAiAnalysisResult(null);
if (data.aiAnalysis) {
try {
const parsed = JSON.parse(data.aiAnalysis);
if (parsed && typeof parsed === 'object') {
setAiAnalysisResult(parsed as MedicationAiAnalysisV2);
}
} catch {
// ignore legacy markdown
}
}
})
.catch((err) => {
if (abortController.signal.aborted) return;
console.error('加载药品详情失败', err);
console.log('[MEDICATION_DETAIL] API call failed for medication', medicationId, err);
if (isMounted) {
setError(t('medications.detail.error.title'));
}
})
.finally(() => {
if (isMounted && !abortController.signal.aborted) {
setLoading(false);
}
});
return () => {
isMounted = false;
abortController.abort();
};
}, [medicationId, medicationFromStore, deleteLoading]);
useEffect(() => {
let cancelled = false;
if (!aiTaskId) return;
const hydrateFromAi = async () => {
try {
setLoading(true);
const data = await getMedicationRecognitionStatus(aiTaskId);
if (cancelled) return;
if (data.status !== 'completed' || !data.result) {
setError('AI 识别结果暂不可用');
return;
}
const draft = buildAiDraftMedication(data.result);
setMedication(draft);
setAiAnalysisResult({
suitableFor: data.result.suitableFor ?? [],
unsuitableFor: data.result.unsuitableFor ?? [],
mainIngredients: data.result.mainIngredients ?? [],
mainUsage: data.result.mainUsage ?? '',
sideEffects: data.result.sideEffects ?? [],
storageAdvice: data.result.storageAdvice ?? [],
healthAdvice: data.result.healthAdvice ?? [],
});
setSummary({ takenCount: 0, startedDays: null });
setSummaryLoading(false);
setError(null);
setAiAnalysisLocked(false);
} catch (err) {
if (cancelled) return;
console.error('[MEDICATION_DETAIL] 加载 AI 草稿失败', err);
setError('识别结果加载失败,请返回重试');
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
hydrateFromAi();
return () => {
cancelled = true;
};
}, [aiTaskId, buildAiDraftMedication]);
useEffect(() => {
let isMounted = true;
if (!medicationId || isAiDraft) {
return () => {
isMounted = false;
};
}
setSummaryLoading(true);
getMedicationRecords({ medicationId })
.then((records) => {
if (!isMounted) return;
const takenCount = records.filter((record) => record.status === 'taken').length;
const earliestRecord = records.reduce<Date | null>((earliest, record) => {
const current = new Date(record.scheduledTime);
if (!earliest || current < earliest) {
return current;
}
return earliest;
}, null);
const startedDaysRaw = earliestRecord
? dayjs().diff(dayjs(earliestRecord), 'day')
: medication
? dayjs().diff(dayjs(medication.startDate), 'day')
: null;
const startedDays = typeof startedDaysRaw === 'number'
? Math.max(startedDaysRaw, 0)
: null;
setSummary({
takenCount,
startedDays: startedDays ?? null,
});
})
.catch((err) => {
console.error('加载服药记录失败', err);
})
.finally(() => {
if (isMounted) {
setSummaryLoading(false);
}
});
return () => {
isMounted = false;
};
}, [medicationId, medication]);
const appendDictationResult = useCallback((text: string) => {
const clean = text.trim();
if (!clean) return;
setNoteDraft((prev) => {
if (!prev) return clean;
return `${prev}${prev.endsWith('\n') ? '' : '\n'}${clean}`;
});
}, []);
useEffect(() => {
if (!isDictationSupported || !noteModalVisible) {
return;
}
Voice.onSpeechStart = () => {
setDictationActive(true);
setDictationLoading(false);
};
Voice.onSpeechEnd = () => {
setDictationActive(false);
setDictationLoading(false);
};
Voice.onSpeechResults = (event: any) => {
const recognized = event?.value?.[0];
if (recognized) {
appendDictationResult(recognized);
}
};
Voice.onSpeechError = (error: any) => {
console.log('[MEDICATION_DETAIL] voice error', error);
setDictationActive(false);
setDictationLoading(false);
Alert.alert(t('medications.add.note.voiceError'), t('medications.add.note.voiceErrorMessage'));
};
return () => {
Voice.destroy()
.then(() => {
Voice.removeAllListeners();
})
.catch(() => {});
};
}, [appendDictationResult, isDictationSupported, noteModalVisible]);
useEffect(() => {
// 统一处理名字编辑弹窗和备注弹窗的键盘监听
if (!noteModalVisible && !nameModalVisible) {
setKeyboardHeight(0);
return;
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const handleShow = (event: any) => {
const height = event?.endCoordinates?.height ?? 0;
setKeyboardHeight(height);
};
const handleHide = () => setKeyboardHeight(0);
const showSub = Keyboard.addListener(showEvent, handleShow);
const hideSub = Keyboard.addListener(hideEvent, handleHide);
return () => {
showSub.remove();
hideSub.remove();
};
}, [noteModalVisible, nameModalVisible]);
const handleDictationPress = useCallback(async () => {
if (!isDictationSupported || dictationLoading) {
return;
}
try {
if (dictationActive) {
setDictationLoading(true);
await Voice.stop();
setDictationLoading(false);
return;
}
setDictationLoading(true);
try {
await Voice.stop();
} catch {
// ignore if not recording
}
await Voice.start('zh-CN');
} catch (error) {
console.log('[MEDICATION_DETAIL] unable to start dictation', error);
setDictationLoading(false);
Alert.alert(t('medications.add.note.voiceStartError'), t('medications.add.note.voiceStartErrorMessage'));
}
}, [dictationActive, dictationLoading, isDictationSupported]);
const closeNoteModal = useCallback(() => {
setNoteModalVisible(false);
if (dictationActive) {
Voice.stop().catch(() => {});
}
setDictationActive(false);
setDictationLoading(false);
setKeyboardHeight(0);
}, [dictationActive]);
const handleToggleMedication = async (nextValue: boolean) => {
if (!medication || updatePending || isAiDraft) return;
// 如果是关闭激活状态,显示确认弹窗
if (!nextValue) {
setDeactivateSheetVisible(true);
return;
}
// 如果是开启激活状态,直接执行
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
isActive: nextValue,
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('切换药品状态失败', err);
Alert.alert(t('medications.detail.toggleError.title'), t('medications.detail.toggleError.message'));
} finally {
setUpdatePending(false);
}
};
const handleDeactivateMedication = useCallback(async () => {
if (!medication || deactivateLoading || isAiDraft) return;
try {
setDeactivateLoading(true);
setDeactivateSheetVisible(false); // 立即关闭确认对话框
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
isActive: false,
})
).unwrap();
setMedication(updated);
} catch (error) {
console.error('停用药物失败', error);
Alert.alert(t('medications.detail.deactivate.error.title'), t('medications.detail.deactivate.error.message'));
} finally {
setDeactivateLoading(false);
}
}, [dispatch, medication, deactivateLoading]);
const formLabel = medication ? t(`medications.manage.formLabels.${medication.form}`) : '';
const dosageLabel = medication ? `${medication.dosageValue} ${medication.dosageUnit}` : '--';
const startDateLabel = medication
? dayjs(medication.startDate).format('YYYY年M月D日')
: '--';
// 计算服药周期显示
const medicationPeriodLabel = useMemo(() => {
if (!medication) return '--';
const startDate = dayjs(medication.startDate).format('YYYY年M月D日');
if (medication.endDate) {
// 有结束日期,显示开始日期到结束日期
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
return `${startDate} - ${endDate}`;
} else {
// 没有结束日期,显示长期
return `${startDate} - ${t('medications.detail.plan.longTerm')}`;
}
}, [medication, t]);
// 计算有效期显示
const expiryDateLabel = useMemo(() => {
if (!medication?.expiryDate) return '未设置';
const expiry = dayjs(medication.expiryDate);
const today = dayjs();
const daysUntilExpiry = expiry.diff(today, 'day');
if (daysUntilExpiry < 0) {
return `${expiry.format('YYYY年M月D日')} (已过期)`;
} else if (daysUntilExpiry === 0) {
return `${expiry.format('YYYY年M月D日')} (今天到期)`;
} else if (daysUntilExpiry <= 30) {
return `${expiry.format('YYYY年M月D日')} (${daysUntilExpiry}天后到期)`;
} else {
return expiry.format('YYYY年M月D日');
}
}, [medication?.expiryDate]);
const reminderTimes = medication?.medicationTimes?.length
? medication.medicationTimes.join('、')
: t('medications.manage.reminderNotSet');
const frequencyLabel = useMemo(() => {
if (!medication) return '--';
switch (medication.repeatPattern) {
case 'daily':
return `${t('medications.manage.frequency.daily')} ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
case 'weekly':
return `${t('medications.manage.frequency.weekly')} ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
default:
return `${t('medications.manage.frequency.custom')} · ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
}
}, [medication, t]);
const serviceInfo = useMemo(() => checkServiceAccess(), [checkServiceAccess]);
const hasAiAnalysis = Boolean(aiAnalysisResult);
const aiActionLabel = aiAnalysisLoading
? t('medications.detail.aiAnalysis.analyzingButton')
: hasAiAnalysis
? '重新分析'
: '获取 AI 分析';
const handleOpenNoteModal = useCallback(() => {
setNoteDraft(medication?.note ?? '');
setNoteModalVisible(true);
}, [medication?.note]);
const handleOpenNameModal = useCallback(() => {
setNameDraft(medication?.name ?? '');
setNameModalVisible(true);
}, [medication?.name]);
const handleCloseNameModal = useCallback(() => {
setNameModalVisible(false);
}, []);
const handleNameChange = useCallback((value: string) => {
const sanitized = value.replace(/\n/g, '');
const normalized = Array.from(sanitized).slice(0, 10).join('');
setNameDraft(normalized);
}, []);
const handleSaveName = useCallback(async () => {
if (!medication || nameSaving) return;
const trimmed = nameDraft.trim();
if (!trimmed) {
Alert.alert(
'提示',
'药物名称不能为空'
);
return;
}
if (Array.from(trimmed).length > 10) {
Alert.alert(
'提示',
'药物名称不能超过10个字'
);
return;
}
if (isAiDraft) {
setMedication((prev) => (prev ? { ...prev, name: trimmed } : prev));
setNameModalVisible(false);
return;
}
setNameSaving(true);
try {
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
name: trimmed,
})
).unwrap();
setMedication(updated);
setNameModalVisible(false);
} catch (err) {
console.error('更新药物名称失败', err);
Alert.alert(
'提示',
'名称更新失败,请稍后再试'
);
} finally {
setNameSaving(false);
}
}, [dispatch, isAiDraft, medication, nameDraft, nameSaving, t]);
const handleSaveNote = useCallback(async () => {
if (!medication) return;
const trimmed = noteDraft.trim();
if (isAiDraft) {
setMedication((prev) => (prev ? { ...prev, note: trimmed } : prev));
closeNoteModal();
return;
}
setNoteSaving(true);
try {
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
note: trimmed || undefined,
})
).unwrap();
setMedication(updated);
closeNoteModal();
} catch (err) {
console.error('保存备注失败', err);
Alert.alert(t('medications.detail.note.saveError.title'), t('medications.detail.note.saveError.message'));
} finally {
setNoteSaving(false);
}
}, [closeNoteModal, dispatch, isAiDraft, medication, noteDraft]);
useEffect(() => {
if (serviceInfo.canUseService) {
setAiAnalysisLocked(false);
}
}, [serviceInfo.canUseService]);
const noteText = medication?.note?.trim() ? medication.note : t('medications.detail.note.noNote');
const dayStreakText =
typeof summary.startedDays === 'number'
? t('medications.detail.overview.startedDays', { days: summary.startedDays })
: medication
? t('medications.detail.overview.startDate', { date: dayjs(medication.startDate).format('YYYY年M月D日') })
: t('medications.detail.overview.noStartDate');
const handleDeleteMedication = useCallback(async () => {
if (!medication || deleteLoading) {
console.log('[MEDICATION_DETAIL] Delete aborted', {
hasMedication: !!medication,
deleteLoading
});
return;
}
console.log('[MEDICATION_DETAIL] Starting delete operation for medication', medication.id);
try {
setDeleteLoading(true);
setDeleteSheetVisible(false); // 立即关闭确认对话框
await dispatch(deleteMedicationAction(medication.id)).unwrap();
console.log('[MEDICATION_DETAIL] Delete operation successful, navigating back');
router.back();
} catch (err) {
console.error('删除药品失败', err);
Alert.alert(t('medications.detail.delete.error.title'), t('medications.detail.delete.error.message'));
} finally {
setDeleteLoading(false);
}
}, [deleteLoading, dispatch, medication, router]);
const handleImagePreview = useCallback(() => {
if (medication?.photoUrl) {
setShowImagePreview(true);
}
}, [medication?.photoUrl]);
// 处理图片选择(拍照或相册)
const handleSelectPhoto = useCallback(async () => {
if (!medication || uploading) return;
Alert.alert(
t('medications.add.photo.selectTitle'),
t('medications.add.photo.selectMessage'),
[
{
text: t('medications.add.photo.camera'),
onPress: async () => {
try {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert(
t('medications.detail.photo.permissionDenied', { defaultValue: '权限不足' }),
t('medications.detail.photo.cameraPermissionMessage', { defaultValue: '需要相机权限以拍摄药品照片' })
);
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.3,
aspect: [9, 16],
});
if (result.canceled || !result.assets?.length) {
return;
}
const asset = result.assets[0];
setPhotoPreview(asset.uri);
try {
const { url } = await upload(
{
uri: asset.uri,
name: asset.fileName ?? `medication-${Date.now()}.jpg`,
type: asset.mimeType ?? 'image/jpeg',
},
{ prefix: 'images/medications' }
);
if (isAiDraft) {
setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev));
setPhotoPreview(null);
return;
}
// 上传成功后更新药物信息
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
photoUrl: url,
})
).unwrap();
setMedication(updated);
setPhotoPreview(null);
} catch (uploadError) {
console.error('[MEDICATION] 图片上传失败', uploadError);
Alert.alert(
t('medications.detail.photo.uploadFailed', { defaultValue: '上传失败' }),
t('medications.detail.photo.uploadFailedMessage', { defaultValue: '图片上传失败,请稍后重试' })
);
setPhotoPreview(null);
}
} catch (error) {
console.error('[MEDICATION] 拍照失败', error);
Alert.alert(
t('medications.detail.photo.cameraFailed', { defaultValue: '拍照失败' }),
t('medications.detail.photo.cameraFailedMessage', { defaultValue: '无法打开相机,请稍后再试' })
);
}
},
},
{
text: t('medications.add.photo.album'),
onPress: async () => {
try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') {
Alert.alert(
t('medications.detail.photo.permissionDenied', { defaultValue: '权限不足' }),
t('medications.detail.photo.libraryPermissionMessage', { defaultValue: '需要相册权限以选择药品照片' })
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.9,
});
if (result.canceled || !result.assets?.length) {
return;
}
const asset = result.assets[0];
setPhotoPreview(asset.uri);
try {
const { url } = await upload(
{
uri: asset.uri,
name: asset.fileName ?? `medication-${Date.now()}.jpg`,
type: asset.mimeType ?? 'image/jpeg',
},
{ prefix: 'images/medications' }
);
if (isAiDraft) {
setMedication((prev) => (prev ? { ...prev, photoUrl: url } : prev));
setPhotoPreview(null);
return;
}
// 上传成功后更新药物信息
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
photoUrl: url,
})
).unwrap();
setMedication(updated);
setPhotoPreview(null);
} catch (uploadError) {
console.error('[MEDICATION] 图片上传失败', uploadError);
Alert.alert(
t('medications.detail.photo.uploadFailed', { defaultValue: '上传失败' }),
t('medications.detail.photo.uploadFailedMessage', { defaultValue: '图片上传失败,请稍后重试' })
);
setPhotoPreview(null);
}
} catch (error) {
console.error('[MEDICATION] 从相册选择失败', error);
Alert.alert(
t('medications.detail.photo.libraryFailed', { defaultValue: '选择失败' }),
t('medications.detail.photo.libraryFailedMessage', { defaultValue: '无法打开相册,请稍后再试' })
);
}
},
},
{
text: t('medications.add.photo.cancel'),
style: 'cancel',
},
],
{ cancelable: true }
);
}, [dispatch, isAiDraft, medication, t, upload, uploading]);
const handleStartDatePress = useCallback(() => {
if (!medication) return;
const startDate = dayjs(medication.startDate).format('YYYY年M月D日');
let message;
if (medication.endDate) {
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
message = `${startDate}${endDate}`;
} else {
message = `${startDate} 至长期`;
}
Alert.alert('服药周期', message);
}, [medication, t]);
const handleTimePress = useCallback(() => {
Alert.alert('服药时间', `每日提醒时间:${reminderTimes}`);
}, [reminderTimes, t]);
const handleDosagePress = useCallback(() => {
if (!medication) return;
setDosagePickerVisible(true);
}, [medication]);
const handleFormPress = useCallback(() => {
if (!medication) return;
setFormPickerVisible(true);
}, [medication]);
const handleFrequencyPress = useCallback(() => {
if (!medication) return;
// AI 草稿模式:显示提示,暂不支持编辑频率
if (isAiDraft) {
Alert.alert(
'提示',
'请先保存药物信息后,再编辑服药频率',
[{ text: '知道了', style: 'default' }]
);
return;
}
// 正常模式:跳转到独立的频率编辑页面
router.push({
pathname: ROUTES.MEDICATION_EDIT_FREQUENCY,
params: {
medicationId: medication.id,
medicationName: medication.name,
repeatPattern: medication.repeatPattern,
timesPerDay: medication.timesPerDay.toString(),
medicationTimes: medication.medicationTimes.join(','),
},
});
}, [medication, router, isAiDraft]);
const handleExpiryDatePress = useCallback(() => {
if (!medication) return;
setExpiryDatePickerValue(medication.expiryDate ? new Date(medication.expiryDate) : new Date());
setExpiryDatePickerVisible(true);
}, [medication]);
const handleExpiryDateConfirm = useCallback(async (date: Date) => {
if (!medication || updatePending) return;
if (isAiDraft) {
setMedication((prev) =>
prev
? { ...prev, expiryDate: dayjs(date).endOf('day').toISOString() }
: prev
);
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
expiryDate: dayjs(date).endOf('day').toISOString(),
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('更新有效期失败', err);
Alert.alert('更新失败', '有效期更新失败,请稍后重试');
} finally {
setUpdatePending(false);
}
}, [dispatch, isAiDraft, medication, updatePending]);
const renderAdviceCard = useCallback(
(
title: string,
items: string[] | undefined,
icon: keyof typeof Ionicons.glyphMap,
accentColor: string,
backgroundColor: string
) => {
if (!items?.length) return null;
return (
<View style={[styles.aiListCard, { backgroundColor, borderColor: backgroundColor }]}>
<View style={styles.aiListHeader}>
<View style={[styles.aiListIcon, { backgroundColor: `${accentColor}14` }]}>
<Ionicons name={icon} size={16} color={accentColor} />
</View>
<Text style={[styles.aiListTitle, { color: colors.text }]}>{title}</Text>
<View style={[styles.aiListCountBadge, { borderColor: `${accentColor}40` }]}>
<Text style={[styles.aiListCount, { color: accentColor }]}>{items.length}</Text>
</View>
</View>
<View style={styles.aiListContent}>
{items.map((item, index) => (
<View key={`${title}-${index}`} style={styles.aiBulletRow}>
<View style={[styles.aiBulletDot, { backgroundColor: accentColor }]} />
<Text style={[styles.aiBulletText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
</View>
);
},
[colors.text]
);
const confirmDosagePicker = useCallback(async () => {
if (!medication || updatePending) return;
setDosagePickerVisible(false);
// 如果值没有变化,不需要更新
if (dosageValuePicker === medication.dosageValue && dosageUnitPicker === medication.dosageUnit) {
return;
}
if (isAiDraft) {
setMedication((prev) =>
prev
? { ...prev, dosageValue: dosageValuePicker, dosageUnit: dosageUnitPicker }
: prev
);
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
dosageValue: dosageValuePicker,
dosageUnit: dosageUnitPicker,
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('更新剂量失败', err);
Alert.alert(t('medications.detail.updateErrors.dosage'), t('medications.detail.updateErrors.dosageMessage'));
} finally {
setUpdatePending(false);
}
}, [dispatch, dosageUnitPicker, dosageValuePicker, isAiDraft, medication, updatePending]);
const confirmFormPicker = useCallback(async () => {
if (!medication || updatePending) return;
setFormPickerVisible(false);
// 如果值没有变化,不需要更新
if (formPicker === medication.form) {
return;
}
if (isAiDraft) {
setMedication((prev) => (prev ? { ...prev, form: formPicker } : prev));
return;
}
try {
setUpdatePending(true);
const updated = await dispatch(
updateMedicationAction({
id: medication.id,
form: formPicker,
})
).unwrap();
setMedication(updated);
} catch (err) {
console.error('更新剂型失败', err);
Alert.alert(t('medications.detail.updateErrors.form'), t('medications.detail.updateErrors.formMessage'));
} finally {
setUpdatePending(false);
}
}, [dispatch, formPicker, isAiDraft, medication, updatePending]);
// AI 分析处理函数
const handleAiAnalysis = useCallback(async () => {
if (!medication || aiAnalysisLoading || isAiDraft) return;
// 1. 先验证用户是否登录
const isLoggedIn = await ensureLoggedIn();
if (!isLoggedIn) {
return; // 如果未登录ensureLoggedIn 会自动跳转到登录页
}
// 2. 检查用户是否是 VIP 或有剩余免费次数
const serviceAccess = checkServiceAccess();
if (!serviceAccess.canUseService) {
setAiAnalysisLocked(true);
openMembershipModal({
onPurchaseSuccess: () => {
handleAiAnalysis();
},
});
return;
}
// 3. 通过验证,执行 AI 分析
setAiAnalysisLoading(true);
setAiAnalysisError(null);
setAiAnalysisLocked(false);
// 滚动到底部,让用户看到分析内容
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
try {
const result = await analyzeMedicationV2(medication.id);
setAiAnalysisResult(result);
// 本地保存一份,便于下次打开快速展示
setMedication((prev) => (prev ? { ...prev, aiAnalysis: JSON.stringify(result) } : prev));
} catch (error: any) {
console.error('[MEDICATION] AI 分析失败:', error);
const status = error?.status;
const message =
status === 403
? '免费使用次数已用完,请开通会员获取更多分析次数'
: status === 404
? '药品未找到'
: t('medications.detail.aiAnalysis.error.message');
setAiAnalysisError(message);
if (status === 403) {
setAiAnalysisLocked(true);
openMembershipModal({
onPurchaseSuccess: () => {
handleAiAnalysis();
},
});
} else {
Alert.alert(t('medications.detail.aiAnalysis.error.title'), message);
}
} finally {
setAiAnalysisLoading(false);
}
}, [aiAnalysisLoading, checkServiceAccess, ensureLoggedIn, isAiDraft, medication, openMembershipModal, t]);
const handleAiDraftSave = useCallback(async () => {
if (!aiTaskId || !medication || aiDraftSaving) return;
try {
setAiDraftSaving(true);
const created = await confirmMedicationRecognition(aiTaskId, {
name: medication.name,
timesPerDay: medication.timesPerDay,
medicationTimes: medication.medicationTimes,
startDate: medication.startDate,
endDate: medication.endDate ?? undefined,
note: medication.note,
});
await dispatch(fetchMedications());
router.replace({
pathname: '/medications/[medicationId]',
params: { medicationId: created.id },
});
} catch (err: any) {
console.error('[MEDICATION_DETAIL] AI 草稿保存失败', err);
Alert.alert('保存失败', err?.message || '请稍后再试');
} finally {
setAiDraftSaving(false);
}
}, [aiDraftSaving, aiTaskId, dispatch, medication, router]);
if (!medicationId) {
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar title={t('medications.detail.title')} variant="minimal" transparent />
<View style={[styles.centered, { paddingTop: insets.top + 72, paddingHorizontal: 24 }]}>
<ThemedText style={styles.emptyTitle}>{t('medications.detail.notFound.title')}</ThemedText>
<ThemedText style={styles.emptySubtitle}>{t('medications.detail.notFound.subtitle')}</ThemedText>
</View>
</View>
);
}
const isLoadingState = loading && !medication;
const contentBottomPadding = Math.max(insets.bottom, 16) + 140;
return (
<View style={styles.container}>
{/* 背景渐变 */}
<LinearGradient
colors={['#f5e5fbff', '#edf4f4ff', '#f7f8f8ff']}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar title={t('medications.detail.title')} variant="minimal" transparent />
{isLoadingState ? (
<View style={[styles.centered, { paddingTop: insets.top + 48 }]}>
<ActivityIndicator color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>{t('medications.detail.loading')}</Text>
</View>
) : error ? (
<View style={[styles.centered, { paddingHorizontal: 24, paddingTop: insets.top + 72 }]}>
<ThemedText style={styles.emptyTitle}>{error}</ThemedText>
<ThemedText style={[styles.emptySubtitle, { color: colors.textMuted }]}>
{t('medications.detail.error.subtitle')}
</ThemedText>
</View>
) : medication ? (
<ScrollView
ref={scrollViewRef}
contentContainerStyle={[
styles.content,
{
paddingTop: insets.top + 48,
paddingBottom: contentBottomPadding,
},
]}
showsVerticalScrollIndicator={false}
>
<View style={[styles.heroCard, { backgroundColor: colors.surface }]}>
<View style={styles.heroInfo}>
<View style={styles.heroImageWrapper}>
{/* 点击图片区域 - 触发上传 */}
<TouchableOpacity
style={styles.heroImageTouchable}
onPress={handleSelectPhoto}
activeOpacity={0.8}
disabled={uploading}
>
<Image
source={
photoPreview
? { uri: photoPreview }
: medication.photoUrl
? { uri: medication.photoUrl }
: DEFAULT_IMAGE
}
style={styles.heroImage}
contentFit="cover"
/>
</TouchableOpacity>
{/* 点击预览图标 - 触发预览(只在有照片且不在上传中时显示) */}
{medication.photoUrl && !uploading && (
<TouchableOpacity
style={styles.imagePreviewHint}
onPress={handleImagePreview}
activeOpacity={0.7}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
<Ionicons name="expand-outline" size={14} color="#FFF" />
</TouchableOpacity>
)}
{/* 上传中提示 */}
{uploading && (
<View style={styles.photoUploadingIndicator}>
<ActivityIndicator color={colors.primary} size="small" />
<Text style={[styles.uploadingText, { color: '#FFF' }]}>
...
</Text>
</View>
)}
</View>
<View style={styles.heroTitleWrapper}>
<View style={styles.heroTitleRow}>
<Text style={[styles.heroTitle, { color: colors.text }]} numberOfLines={1}>
{medication.name}
</Text>
<TouchableOpacity
style={styles.heroEditButton}
onPress={handleOpenNameModal}
activeOpacity={0.7}
hitSlop={8}
>
<Ionicons name="create-outline" size={18} color={colors.primary} />
</TouchableOpacity>
</View>
<Text style={[styles.heroMeta, { color: colors.textSecondary }]}>
{dosageLabel} · {formLabel}
</Text>
</View>
</View>
<View style={styles.heroToggle}>
<Switch
value={medication.isActive}
onValueChange={handleToggleMedication}
disabled={updatePending}
trackColor={{ false: '#D9D9D9', true: colors.primary }}
thumbColor="#fff"
ios_backgroundColor="#D9D9D9"
/>
</View>
</View>
<Section title={t('medications.detail.sections.plan')} color={colors.text}>
<View style={styles.row}>
<InfoCard
label={t('medications.detail.plan.period')}
value={medicationPeriodLabel}
icon="calendar-outline"
colors={colors}
clickable={false}
onPress={isAiDraft ? undefined : handleStartDatePress}
/>
<InfoCard
label={t('medications.detail.plan.time')}
value={reminderTimes}
icon="time-outline"
colors={colors}
clickable={false}
onPress={isAiDraft ? undefined : handleTimePress}
/>
</View>
<View style={styles.row}>
<InfoCard
label={t('medications.detail.plan.expiryDate')}
value={expiryDateLabel}
icon="hourglass-outline"
colors={colors}
clickable
onPress={handleExpiryDatePress}
/>
<InfoCard
label={t('medications.detail.plan.frequency')}
value={frequencyLabel}
icon="repeat-outline"
colors={colors}
clickable={!isAiDraft}
onPress={handleFrequencyPress}
/>
</View>
</Section>
<Section title={t('medications.detail.sections.dosage')} color={colors.text}>
<View style={styles.row}>
<InfoCard
label={t('medications.detail.dosage.label')}
value={dosageLabel}
icon="medkit-outline"
colors={colors}
clickable={!isAiDraft}
onPress={handleDosagePress}
/>
<InfoCard
label={t('medications.detail.dosage.form')}
value={formLabel}
icon="cube-outline"
colors={colors}
clickable={!isAiDraft}
onPress={handleFormPress}
/>
</View>
</Section>
<Section title={t('medications.detail.sections.note')} color={colors.text}>
<TouchableOpacity
style={[styles.noteCard, { backgroundColor: colors.surface }]}
activeOpacity={0.92}
onPress={handleOpenNoteModal}
>
<Ionicons name="document-text-outline" size={20} color={colors.primary} />
<View style={styles.noteBody}>
<Text style={[styles.noteLabel, { color: colors.text }]}>{t('medications.detail.note.label')}</Text>
<Text
style={[
styles.noteValue,
{ color: medication.note ? colors.text : colors.textMuted },
]}
>
{noteText}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</TouchableOpacity>
</Section>
<Section title="AI 分析" color={colors.text}>
<View style={[styles.aiCardContainer, { backgroundColor: colors.surface }]}>
<View style={styles.aiHeaderRow}>
<View style={styles.aiHeaderLeft}>
<Ionicons name="sparkles-outline" size={18} color={colors.primary} />
<Text style={[styles.aiHeaderTitle, { color: colors.text }]}></Text>
</View>
<View
style={[
styles.aiStatusPill,
{ backgroundColor: hasAiAnalysis ? '#E0F2FE' : aiAnalysisLocked ? '#FEF2F2' : '#EEF2FF' },
]}
>
<Ionicons
name={hasAiAnalysis ? 'checkmark-circle-outline' : aiAnalysisLocked ? 'lock-closed-outline' : 'flash-outline'}
size={15}
color={hasAiAnalysis ? '#22c55e' : aiAnalysisLocked ? '#ef4444' : colors.primary}
/>
<Text
style={[
styles.aiStatusText,
{ color: hasAiAnalysis ? '#16a34a' : aiAnalysisLocked ? '#ef4444' : colors.primary },
]}
>
{hasAiAnalysis ? '已生成' : aiAnalysisLocked ? '会员专享' : '待生成'}
</Text>
</View>
</View>
<View style={styles.aiHeroRow}>
<View style={styles.aiHeroImageWrapper}>
<View style={styles.aiHeroImageShadow}>
<Image
source={
photoPreview
? { uri: photoPreview }
: medication.photoUrl
? { uri: medication.photoUrl }
: DEFAULT_IMAGE
}
style={styles.aiHeroImage}
contentFit="cover"
/>
</View>
{hasAiAnalysis && (
<LinearGradient
colors={['#34d399', '#22c55e']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.aiScoreBadge}
>
<Ionicons name="thumbs-up-outline" size={14} color="#fff" />
<Text style={styles.aiScoreBadgeText}>AI </Text>
</LinearGradient>
)}
</View>
<View style={styles.aiHeroText}>
<Text style={[styles.aiHeroTitle, { color: colors.text }]} numberOfLines={2}>
{medication.name}
</Text>
<Text style={[styles.aiHeroSubtitle, { color: colors.textSecondary }]} numberOfLines={3}>
{aiAnalysisResult?.mainUsage || '获取 AI 分析,快速了解适用人群、成分安全与使用建议。'}
</Text>
<View style={styles.aiChipRow}>
<View style={styles.aiChip}>
<Ionicons name="medkit-outline" size={14} color={colors.primary} />
<Text style={[styles.aiChipText, { color: colors.text }]}>{dosageLabel}</Text>
</View>
<View style={styles.aiChip}>
<Ionicons name="cube-outline" size={14} color={colors.primary} />
<Text style={[styles.aiChipText, { color: colors.text }]}>{formLabel}</Text>
</View>
</View>
</View>
</View>
{aiAnalysisLoading && (
<View style={styles.aiLoadingContainer}>
<LottieView
source={require('@/assets/lottie/loading-blue.json')}
autoPlay
loop
style={styles.aiLoadingAnimation}
/>
<Text style={[styles.aiLoadingText, { color: colors.textSecondary }]}>
{t('medications.detail.aiAnalysis.analyzing')}
</Text>
</View>
)}
{aiAnalysisResult ? (
<>
{!!aiAnalysisResult.mainIngredients?.length && (
<View style={styles.aiTagRow}>
{aiAnalysisResult.mainIngredients.map((item) => (
<View key={item} style={styles.aiTag}>
<Text style={[styles.aiTagText, { color: colors.text }]} numberOfLines={1}>
{item}
</Text>
</View>
))}
</View>
)}
<View style={styles.aiUsageCard}>
<Ionicons name="leaf-outline" size={18} color="#22c55e" />
<Text style={[styles.aiUsageText, { color: colors.text }]}>{aiAnalysisResult.mainUsage}</Text>
</View>
<View style={styles.aiColumns}>
<View style={[styles.aiBubbleCard, { backgroundColor: '#ECFEFF', borderColor: '#BAF2F4' }]}>
<View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#0284c7' }]}></Text>
<Ionicons name="checkmark-circle" size={16} color="#0ea5e9" />
</View>
{aiAnalysisResult.suitableFor.map((item, idx) => (
<View key={`s-${idx}`} style={styles.aiBulletRow}>
<View style={[styles.aiBulletDot, { backgroundColor: '#0ea5e9' }]} />
<Text style={[styles.aiBulletText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
<View style={[styles.aiBubbleCard, { backgroundColor: '#FEF2F2', borderColor: '#FEE2E2' }]}>
<View style={styles.aiBubbleHeader}>
<Text style={[styles.aiBubbleTitle, { color: '#ef4444' }]}></Text>
<Ionicons name="alert-circle" size={16} color="#ef4444" />
</View>
{aiAnalysisResult.unsuitableFor.map((item, idx) => (
<View key={`u-${idx}`} style={styles.aiBulletRow}>
<View style={[styles.aiBulletDot, { backgroundColor: '#ef4444' }]} />
<Text style={[styles.aiBulletText, { color: colors.text }]}>{item}</Text>
</View>
))}
</View>
</View>
{renderAdviceCard('可能的副作用', aiAnalysisResult.sideEffects, 'warning-outline', '#f59e0b', '#FFFBEB')}
{renderAdviceCard('储存建议', aiAnalysisResult.storageAdvice, 'cube-outline', '#10b981', '#ECFDF3')}
{renderAdviceCard('健康/使用建议', aiAnalysisResult.healthAdvice, 'sparkles-outline', '#6366f1', '#EEF2FF')}
</>
) : null}
{aiAnalysisError && (
<View style={styles.aiErrorBox}>
<Ionicons name="alert-circle-outline" size={18} color="#ef4444" />
<Text style={[styles.aiErrorText, { color: colors.text }]}>{aiAnalysisError}</Text>
</View>
)}
{(aiAnalysisLocked || !serviceInfo.canUseService) && (
<TouchableOpacity
activeOpacity={0.9}
onPress={() =>
openMembershipModal({
onPurchaseSuccess: () => {
handleAiAnalysis();
},
})
}
style={styles.aiMembershipCard}
>
<View style={styles.aiMembershipLeft}>
<Ionicons name="diamond-outline" size={18} color="#f59e0b" />
<View>
<Text style={styles.aiMembershipTitle}> AI </Text>
<Text style={styles.aiMembershipSub}>使</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={18} color="#f59e0b" />
</TouchableOpacity>
)}
</View>
</Section>
<Section title={t('medications.detail.sections.overview')} color={colors.text}>
<View style={[styles.summaryCard, { backgroundColor: colors.surface }]}>
<View style={styles.summaryIcon}>
<Ionicons name="tablet-portrait-outline" size={22} color={colors.primary} />
</View>
<View style={styles.summaryBody}>
<Text style={[styles.summaryHighlight, { color: colors.text }]}>
{summaryLoading ? t('medications.detail.overview.calculating') : t('medications.detail.overview.takenCount', { count: summary.takenCount })}
</Text>
<Text style={[styles.summaryMeta, { color: colors.textSecondary }]}>
{summaryLoading ? t('medications.detail.overview.calculatingDays') : dayStreakText}
</Text>
</View>
</View>
</Section>
</ScrollView>
) : null}
{medication ? (
<View
style={[
styles.footerBar,
{
paddingBottom: Math.max(insets.bottom, 18),
},
]}
>
{isAiDraft ? (
<View style={styles.footerButtonContainer}>
<TouchableOpacity
style={styles.secondaryFooterBtn}
activeOpacity={0.9}
onPress={() => router.replace('/medications/ai-camera')}
>
<Text style={styles.secondaryFooterText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.primaryFooterBtn, { backgroundColor: colors.primary }]}
activeOpacity={0.9}
onPress={handleAiDraftSave}
disabled={aiDraftSaving}
>
{aiDraftSaving ? (
<ActivityIndicator color={colors.onPrimary} />
) : (
<Text style={[styles.primaryFooterText, { color: colors.onPrimary }]}></Text>
)}
</TouchableOpacity>
</View>
) : (
<View style={styles.footerButtonContainer}>
{/* AI 分析按钮 */}
{!hasAiAnalysis && (
<TouchableOpacity
style={styles.aiAnalysisButtonWrapper}
activeOpacity={0.9}
onPress={handleAiAnalysis}
disabled={aiAnalysisLoading}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.aiAnalysisButton}
glassEffectStyle="regular"
tintColor="rgba(59, 130, 246, 0.8)"
isInteractive={true}
>
{aiAnalysisLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="sparkles-outline" size={18} color="#fff" />
)}
<Text style={styles.aiAnalysisButtonText}>
{aiActionLabel}
</Text>
</GlassView>
) : (
<View style={[styles.aiAnalysisButton, styles.fallbackAiButton]}>
{aiAnalysisLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="sparkles-outline" size={18} color="#fff" />
)}
<Text style={styles.aiAnalysisButtonText}>
{aiActionLabel}
</Text>
</View>
)}
</TouchableOpacity>
)}
{/* 删除按钮 */}
<TouchableOpacity
style={styles.deleteButtonWrapper}
activeOpacity={0.9}
onPress={() => setDeleteSheetVisible(true)}
>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.deleteButton}
glassEffectStyle="regular"
tintColor="rgba(239, 68, 68, 0.8)"
isInteractive={true}
>
<Ionicons name="trash-outline" size={24} color="#fff" />
</GlassView>
) : (
<View style={[styles.deleteButton, styles.fallbackDeleteButton]}>
<Ionicons name="trash-outline" size={24} color="#fff" />
</View>
)}
</TouchableOpacity>
</View>
)}
</View>
) : null}
<Modal
transparent
animationType="fade"
visible={nameModalVisible}
onRequestClose={handleCloseNameModal}
>
<View style={styles.modalOverlay}>
<TouchableOpacity style={styles.modalBackdrop} activeOpacity={1} onPress={handleCloseNameModal} />
<View
style={[
styles.modalContainer,
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 },
]}
>
<View style={[styles.modalCard, { backgroundColor: colors.surface }]}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>
</Text>
<TouchableOpacity onPress={handleCloseNameModal} hitSlop={12}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<View
style={[
styles.nameInputWrapper,
{
backgroundColor: scheme === 'light' ? '#F2F2F7' : 'rgba(255, 255, 255, 0.08)',
},
]}
>
<TextInput
value={nameDraft}
onChangeText={handleNameChange}
placeholder="请输入药物名称"
placeholderTextColor={colors.textMuted}
style={[styles.nameInput, { color: colors.text }]}
autoFocus
returnKeyType="done"
onSubmitEditing={handleSaveName}
selectionColor={colors.primary}
clearButtonMode="while-editing"
/>
</View>
<View style={styles.nameInputCounterWrapper}>
<Text style={[styles.nameInputCounter, { color: colors.textMuted }]}>
{`${Array.from(nameDraft).length}/10`}
</Text>
</View>
<View style={styles.modalActionContainer}>
<TouchableOpacity
style={[
styles.modalActionPrimary,
{
backgroundColor: colors.primary,
shadowColor: colors.primary,
},
]}
onPress={handleSaveName}
activeOpacity={0.9}
disabled={nameSaving}
>
{nameSaving ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>
</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</View>
</Modal>
<Modal
transparent
animationType="fade"
visible={noteModalVisible}
onRequestClose={closeNoteModal}
>
<View style={styles.modalOverlay}>
<TouchableOpacity style={styles.modalBackdrop} activeOpacity={1} onPress={closeNoteModal} />
<View
style={[
styles.modalContainer,
{ paddingBottom: Math.max(keyboardHeight, insets.bottom) + 12 },
]}
>
<View style={[styles.modalCard, { backgroundColor: colors.surface }]}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('medications.detail.note.edit')}</Text>
<TouchableOpacity onPress={closeNoteModal} hitSlop={12}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<View
style={[
styles.noteEditorWrapper,
{
borderColor: dictationActive ? colors.primary : `${colors.border}80`,
backgroundColor: colors.surface,
},
]}
>
<TextInput
multiline
numberOfLines={6}
value={noteDraft}
onChangeText={setNoteDraft}
placeholder={t('medications.detail.note.placeholder')}
placeholderTextColor={colors.textMuted}
style={[styles.noteEditorInput, { color: colors.text }]}
textAlignVertical="center"
/>
{isDictationSupported && (
<TouchableOpacity
style={[
styles.voiceButton,
{
backgroundColor: dictationActive ? colors.primary : 'transparent',
borderColor: dictationActive ? colors.primary : `${colors.border}80`,
},
]}
onPress={handleDictationPress}
activeOpacity={0.85}
disabled={dictationLoading}
>
{dictationLoading ? (
<ActivityIndicator size="small" color={dictationActive ? colors.onPrimary : colors.primary} />
) : (
<Ionicons
name={dictationActive ? 'mic' : 'mic-outline'}
size={18}
color={dictationActive ? colors.onPrimary : colors.textSecondary}
/>
)}
</TouchableOpacity>
)}
</View>
{!isDictationSupported && (
<Text style={[styles.voiceHint, { color: colors.textMuted }]}>
{t('medications.detail.note.voiceNotSupported')}
</Text>
)}
<View style={styles.modalActionContainer}>
<TouchableOpacity
style={[
styles.modalActionPrimary,
{
backgroundColor: colors.primary,
shadowColor: colors.primary,
},
]}
onPress={handleSaveNote}
activeOpacity={0.9}
disabled={noteSaving}
>
{noteSaving ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>{t('medications.detail.note.save')}</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</View>
</Modal>
<Modal
visible={dosagePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setDosagePickerVisible(false)}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => setDosagePickerVisible(false)}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
{t('medications.detail.dosage.selectDosage')}
</ThemedText>
<View style={styles.pickerRow}>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
{t('medications.detail.dosage.dosageValue')}
</ThemedText>
<Picker
selectedValue={dosageValuePicker}
onValueChange={(value) => setDosageValuePicker(Number(value))}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{DOSAGE_VALUES.map((value) => (
<Picker.Item
key={value}
label={String(value)}
value={value}
/>
))}
</Picker>
</View>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
{t('medications.detail.dosage.unit')}
</ThemedText>
<Picker
selectedValue={dosageUnitPicker}
onValueChange={(value) => setDosageUnitPicker(String(value))}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{DOSAGE_UNITS.map((unit) => (
<Picker.Item
key={unit}
label={unit}
value={unit}
/>
))}
</Picker>
</View>
</View>
<View style={styles.pickerActions}>
<Pressable
onPress={() => setDosagePickerVisible(false)}
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
onPress={confirmDosagePicker}
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
</View>
</Modal>
<Modal
visible={formPickerVisible}
transparent
animationType="fade"
onRequestClose={() => setFormPickerVisible(false)}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => setFormPickerVisible(false)}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
{t('medications.detail.dosage.selectForm')}
</ThemedText>
<Picker
selectedValue={formPicker}
onValueChange={(value) => setFormPicker(value as MedicationForm)}
itemStyle={styles.pickerItem}
style={styles.picker}
>
{FORM_OPTIONS.map((option) => (
<Picker.Item
key={option.id}
label={option.label}
value={option.id}
/>
))}
</Picker>
<View style={styles.pickerActions}>
<Pressable
onPress={() => setFormPickerVisible(false)}
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
onPress={confirmFormPicker}
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
</View>
</Modal>
{/* 有效期选择器 */}
<ExpiryDatePickerModal
visible={expiryDatePickerVisible}
currentDate={medication?.expiryDate ? new Date(medication.expiryDate) : null}
onClose={() => setExpiryDatePickerVisible(false)}
onConfirm={handleExpiryDateConfirm}
isAiDraft={isAiDraft}
/>
{medication && !isAiDraft ? (
<ConfirmationSheet
visible={deleteSheetVisible}
onClose={() => setDeleteSheetVisible(false)}
onConfirm={handleDeleteMedication}
title={t('medications.detail.delete.title', { name: medication.name })}
description={t('medications.detail.delete.description')}
confirmText={t('medications.detail.delete.confirm')}
cancelText={t('medications.detail.delete.cancel')}
destructive
loading={deleteLoading}
/>
) : null}
{medication && !isAiDraft ? (
<ConfirmationSheet
visible={deactivateSheetVisible}
onClose={() => setDeactivateSheetVisible(false)}
onConfirm={handleDeactivateMedication}
title={t('medications.detail.deactivate.title', { name: medication.name })}
description={t('medications.detail.deactivate.description')}
confirmText={t('medications.detail.deactivate.confirm')}
cancelText={t('medications.detail.deactivate.cancel')}
destructive
loading={deactivateLoading}
/>
) : null}
{/* 图片预览 */}
{medication?.photoUrl && (
<ImageViewing
images={[{ uri: medication.photoUrl }]}
imageIndex={0}
visible={showImagePreview}
onRequestClose={() => setShowImagePreview(false)}
swipeToCloseEnabled={true}
doubleTapToZoomEnabled={true}
HeaderComponent={() => (
<View style={styles.imageViewerHeader}>
<Text style={styles.imageViewerHeaderText}>
{medication.name}
</Text>
</View>
)}
FooterComponent={() => (
<View style={styles.imageViewerFooter}>
<TouchableOpacity
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}>{t('medications.detail.imageViewer.close')}</Text>
</TouchableOpacity>
</View>
)}
/>
)}
</View>
);
}
const Section = ({
title,
children,
color,
}: {
title: string;
children: React.ReactNode;
color: string;
}) => {
return (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color }]}>{title}</Text>
{children}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
position: 'relative',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
decorativeCircle1: {
position: 'absolute',
top: 40,
right: 20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#0EA5E9',
opacity: 0.1,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#0EA5E9',
opacity: 0.05,
},
content: {
paddingHorizontal: 20,
gap: 24,
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
loadingText: {
marginTop: 8,
fontSize: 14,
},
emptyTitle: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
},
emptySubtitle: {
fontSize: 14,
textAlign: 'center',
},
heroCard: {
borderRadius: 28,
padding: 20,
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#FFFFFF',
alignItems: 'center',
gap: 12,
},
heroInfo: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
flex: 1,
},
heroImageWrapper: {
width: 64,
height: 64,
borderRadius: 20,
backgroundColor: '#F2F2F2',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
overflow: 'hidden',
},
heroImageTouchable: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
},
heroImage: {
width: '60%',
height: '60%',
borderRadius: '20%'
},
heroTitleWrapper: {
flex: 1,
minWidth: 0,
},
heroTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
heroTitle: {
fontSize: 20,
fontWeight: '700',
},
heroEditButton: {
padding: 4,
borderRadius: 12,
},
heroMeta: {
marginTop: 4,
fontSize: 13,
fontWeight: '500',
},
heroToggle: {
alignItems: 'flex-end',
gap: 6,
},
section: {
gap: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
},
row: {
flexDirection: 'row',
gap: 12,
},
fullCard: {
borderRadius: 22,
padding: 18,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
fullCardLeading: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
fullCardLabel: {
fontSize: 15,
fontWeight: '600',
},
fullCardTrailing: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
fullCardValue: {
fontSize: 16,
fontWeight: '600',
},
noteCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 14,
borderRadius: 24,
paddingHorizontal: 18,
paddingVertical: 16
},
noteBody: {
flex: 1,
gap: 4,
},
noteLabel: {
fontSize: 14,
fontWeight: '600',
},
noteValue: {
fontSize: 14,
lineHeight: 20,
},
summaryCard: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 24,
padding: 18,
gap: 16,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 3 },
elevation: 3,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.04)',
},
summaryIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#EEF1FF',
alignItems: 'center',
justifyContent: 'center',
},
summaryBody: {
flex: 1,
gap: 4,
},
summaryHighlight: {
fontSize: 16,
fontWeight: '700',
},
summaryMeta: {
fontSize: 14,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'flex-end',
},
modalBackdrop: {
flex: 1,
},
modalContainer: {
width: '100%',
paddingHorizontal: 20,
},
modalCard: {
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingHorizontal: 20,
paddingTop: 20,
paddingBottom: 32,
gap: 16,
},
modalHandle: {
width: 60,
height: 5,
borderRadius: 2.5,
backgroundColor: 'rgba(0,0,0,0.12)',
alignSelf: 'center',
marginBottom: 12,
},
modalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
},
nameInputWrapper: {
borderRadius: 18,
paddingHorizontal: 18,
paddingVertical: 14,
},
nameInput: {
fontSize: 18,
fontWeight: '600',
},
nameInputCounterWrapper: {
alignItems: 'flex-end',
},
nameInputCounter: {
fontSize: 12,
fontWeight: '500',
},
noteEditorWrapper: {
borderWidth: 1,
borderRadius: 24,
paddingHorizontal: 18,
paddingVertical: 24,
minHeight: 120,
paddingRight: 70,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 3,
},
noteEditorInput: {
minHeight: 50,
fontSize: 15,
lineHeight: 22,
},
voiceButton: {
position: 'absolute',
right: 16,
top: 16,
width: 40,
height: 40,
borderRadius: 20,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
voiceHint: {
fontSize: 12,
},
modalActionGhostText: {
fontSize: 16,
fontWeight: '600',
},
modalActionPrimary: {
borderRadius: 22,
height: 52,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 8,
shadowOpacity: 0.25,
shadowRadius: 12,
shadowOffset: { width: 0, height: 8 },
elevation: 4,
},
modalActionPrimaryText: {
fontSize: 17,
fontWeight: '700',
},
modalActionContainer: {
marginTop: 8,
},
footerBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 20,
paddingTop: 16,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(15,23,42,0.06)',
},
deleteButton: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden', // 保证玻璃边界圆角效果
},
fallbackDeleteButton: {
backgroundColor: '#EF4444',
shadowColor: 'rgba(239,68,68,0.4)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1,
shadowRadius: 20,
elevation: 6,
},
// 底部按钮容器样式
footerButtonContainer: {
flexDirection: 'row',
gap: 12,
justifyContent: 'flex-end',
},
aiAnalysisButtonWrapper: {
flex: 1,
},
deleteButtonWrapper: {
// auto width
},
aiAnalysisButton: {
height: 56,
borderRadius: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
overflow: 'hidden',
},
fallbackAiButton: {
backgroundColor: '#3B82F6',
shadowColor: 'rgba(59,130,246,0.4)',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 1,
shadowRadius: 20,
elevation: 6,
},
aiAnalysisButtonText: {
fontSize: 17,
fontWeight: '700',
color: '#fff',
},
primaryFooterBtn: {
flex: 1,
height: 56,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#0f172a',
shadowOpacity: 0.15,
shadowRadius: 10,
shadowOffset: { width: 0, height: 8 },
},
primaryFooterText: {
fontSize: 17,
fontWeight: '700',
},
secondaryFooterBtn: {
height: 56,
paddingHorizontal: 18,
borderRadius: 16,
borderWidth: 1,
borderColor: '#E2E8F0',
alignItems: 'center',
justifyContent: 'center',
},
secondaryFooterText: {
fontSize: 16,
fontWeight: '700',
color: '#0f172a',
},
// AI 分析卡片样式
aiCardContainer: {
borderRadius: 26,
padding: 20,
gap: 14,
shadowColor: '#000',
shadowOpacity: 0.06,
shadowRadius: 12,
shadowOffset: { width: 0, height: 3 },
elevation: 3,
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.04)',
},
aiHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
aiHeaderLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
aiHeaderTitle: {
fontSize: 17,
fontWeight: '700',
},
aiStatusPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
},
aiStatusText: {
fontSize: 12,
fontWeight: '700',
},
aiLoadingContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 24,
gap: 12,
},
aiLoadingAnimation: {
width: 120,
height: 120,
},
aiLoadingText: {
fontSize: 14,
fontWeight: '500',
},
aiHeroRow: {
flexDirection: 'row',
gap: 14,
alignItems: 'center',
},
aiHeroImageWrapper: {
width: 110,
height: 110,
borderRadius: 28,
backgroundColor: '#F8FAFC',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
aiHeroImageShadow: {
width: 90,
height: 90,
borderRadius: 22,
overflow: 'hidden',
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.08,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 3,
},
aiHeroImage: {
width: '100%',
height: '100%',
borderRadius: 22,
},
aiScoreBadge: {
position: 'absolute',
bottom: -6,
right: -4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 14,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
shadowColor: '#22c55e',
shadowOpacity: 0.25,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 4,
},
aiScoreBadgeText: {
fontSize: 12,
fontWeight: '700',
color: '#fff',
},
aiHeroText: {
flex: 1,
gap: 8,
},
aiHeroTitle: {
fontSize: 20,
fontWeight: '800',
},
aiHeroSubtitle: {
fontSize: 14,
lineHeight: 20,
},
aiChipRow: {
flexDirection: 'row',
gap: 8,
flexWrap: 'wrap',
},
aiChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor: '#F3F4F6',
borderRadius: 12,
},
aiChipText: {
fontSize: 13,
fontWeight: '600',
},
aiTagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
aiTag: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 14,
backgroundColor: '#F2FCE2',
},
aiTagText: {
fontSize: 13,
fontWeight: '600',
},
aiUsageCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 14,
borderRadius: 18,
backgroundColor: '#F8FAFC',
borderWidth: 1,
borderColor: 'rgba(15,23,42,0.05)',
},
aiUsageText: {
fontSize: 15,
lineHeight: 22,
flex: 1,
},
aiColumns: {
flexDirection: 'row',
gap: 12,
flexWrap: 'wrap',
},
aiBubbleCard: {
flex: 1,
borderWidth: 1,
borderRadius: 18,
padding: 14,
gap: 8,
},
aiBubbleHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
aiBubbleTitle: {
fontSize: 15,
fontWeight: '700',
},
aiListCard: {
borderWidth: 1,
borderRadius: 18,
padding: 14,
gap: 12,
},
aiListHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
aiListIcon: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
aiListTitle: {
fontSize: 15,
fontWeight: '700',
flex: 1,
},
aiListCountBadge: {
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 8,
paddingVertical: 2,
},
aiListCount: {
fontSize: 12,
fontWeight: '700',
},
aiListContent: {
gap: 8,
},
aiBulletRow: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
},
aiBulletDot: {
width: 8,
height: 8,
borderRadius: 4,
marginTop: 6,
},
aiBulletText: {
fontSize: 14,
lineHeight: 22,
flex: 1,
},
aiEmptyBox: {
borderRadius: 18,
padding: 16,
backgroundColor: '#F8FAFF',
borderWidth: 1,
borderColor: '#E0EAFF',
gap: 8,
alignItems: 'center',
},
aiEmptyTitle: {
fontSize: 16,
fontWeight: '700',
},
aiEmptyDesc: {
fontSize: 14,
lineHeight: 22,
textAlign: 'center',
},
aiFreeBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#EEF2FF',
borderRadius: 12,
},
aiFreeBadgeText: {
fontSize: 12,
fontWeight: '700',
},
aiErrorBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
borderRadius: 14,
backgroundColor: '#FEF2F2',
borderWidth: 1,
borderColor: '#FEE2E2',
},
aiErrorText: {
fontSize: 14,
lineHeight: 20,
flex: 1,
},
aiMembershipCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 14,
borderRadius: 18,
backgroundColor: '#FFFBEB',
borderWidth: 1,
borderColor: '#FDE68A',
},
aiMembershipLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
flex: 1,
},
aiMembershipTitle: {
fontSize: 15,
fontWeight: '700',
color: '#92400e',
},
aiMembershipSub: {
fontSize: 13,
color: '#b45309',
marginTop: 2,
},
// Picker 相关样式
pickerBackdrop: {
flex: 1,
backgroundColor: 'rgba(15, 23, 42, 0.4)',
},
pickerSheet: {
position: 'absolute',
left: 20,
right: 20,
bottom: 40,
borderRadius: 24,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
},
pickerTitle: {
fontSize: 20,
fontWeight: '700',
marginBottom: 20,
textAlign: 'center',
},
pickerRow: {
flexDirection: 'row',
gap: 16,
marginBottom: 20,
},
pickerColumn: {
flex: 1,
gap: 8,
},
pickerLabel: {
fontSize: 14,
fontWeight: '600',
textAlign: 'center',
},
picker: {
width: '100%',
height: 150,
},
pickerItem: {
fontSize: 18,
height: 150,
},
pickerActions: {
flexDirection: 'row',
gap: 12,
marginTop: 16,
},
pickerBtn: {
flex: 1,
paddingVertical: 14,
borderRadius: 16,
alignItems: 'center',
borderWidth: 1,
},
pickerBtnPrimary: {
borderWidth: 0,
},
pickerBtnText: {
fontSize: 16,
fontWeight: '600',
},
imagePreviewHint: {
position: 'absolute',
top: 4,
right: 4,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 10,
padding: 4,
zIndex: 10,
},
photoUploadingIndicator: {
position: 'absolute',
bottom: 8,
left: 8,
right: 8,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 8,
paddingVertical: 6,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: 12,
},
uploadingText: {
fontSize: 11,
fontWeight: '600',
color: '#FFF',
},
// ImageViewing 组件样式
imageViewerHeader: {
position: 'absolute',
top: 60,
left: 20,
right: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
zIndex: 1,
},
imageViewerHeaderText: {
color: '#FFF',
fontSize: 14,
fontWeight: '500',
textAlign: 'center',
},
imageViewerFooter: {
position: 'absolute',
bottom: 60,
left: 20,
right: 20,
alignItems: 'center',
zIndex: 1,
},
imageViewerFooterButton: {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 20,
},
imageViewerFooterButtonText: {
color: '#FFF',
fontSize: 16,
fontWeight: '500',
},
});