feat(i18n): 实现应用国际化支持,添加中英文翻译

- 为所有UI组件添加国际化支持,替换硬编码文本
- 新增useI18n钩子函数统一管理翻译
- 完善中英文翻译资源,覆盖统计、用药、通知设置等模块
- 优化Tab布局使用翻译键值替代静态文本
- 更新药品管理、个人资料编辑等页面的多语言支持
This commit is contained in:
richarjiang
2025-11-13 11:09:55 +08:00
parent 416d144387
commit 2dca3253e6
21 changed files with 1669 additions and 366 deletions

View File

@@ -3,12 +3,13 @@ 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_LABELS, FORM_OPTIONS } from '@/constants/Medication';
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 { useI18n } from '@/hooks/useI18n';
import { useVipService } from '@/hooks/useVipService';
import { medicationNotificationService } from '@/services/medicationNotifications';
import {
@@ -59,6 +60,7 @@ type RecordsSummary = {
};
export default function MedicationDetailScreen() {
const { t } = useI18n();
const params = useLocalSearchParams<{ medicationId?: string }>();
const medicationId = Array.isArray(params.medicationId)
? params.medicationId[0]
@@ -198,7 +200,7 @@ export default function MedicationDetailScreen() {
console.error('加载药品详情失败', err);
console.log('[MEDICATION_DETAIL] API call failed for medication', medicationId, err);
if (isMounted) {
setError('暂时无法获取该药品的信息,请稍后重试。');
setError(t('medications.detail.error.title'));
}
})
.finally(() => {
@@ -295,7 +297,7 @@ export default function MedicationDetailScreen() {
console.log('[MEDICATION_DETAIL] voice error', error);
setDictationActive(false);
setDictationLoading(false);
Alert.alert('语音识别不可用', '无法使用语音输入,请检查权限设置后重试');
Alert.alert(t('medications.add.note.voiceError'), t('medications.add.note.voiceErrorMessage'));
};
return () => {
@@ -354,7 +356,7 @@ export default function MedicationDetailScreen() {
} catch (error) {
console.log('[MEDICATION_DETAIL] unable to start dictation', error);
setDictationLoading(false);
Alert.alert('无法启动语音输入', '请检查麦克风与语音识别权限后重试');
Alert.alert(t('medications.add.note.voiceStartError'), t('medications.add.note.voiceStartErrorMessage'));
}
}, [dictationActive, dictationLoading, isDictationSupported]);
@@ -400,7 +402,7 @@ export default function MedicationDetailScreen() {
}
} catch (err) {
console.error('切换药品状态失败', err);
Alert.alert('操作失败', '切换提醒状态时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.toggleError.title'), t('medications.detail.toggleError.message'));
} finally {
setUpdatePending(false);
}
@@ -430,13 +432,13 @@ export default function MedicationDetailScreen() {
}
} catch (error) {
console.error('停用药物失败', error);
Alert.alert('操作失败', '停用药物时发生问题,请稍后重试。');
Alert.alert(t('medications.detail.deactivate.error.title'), t('medications.detail.deactivate.error.message'));
} finally {
setDeactivateLoading(false);
}
}, [dispatch, medication, deactivateLoading]);
const formLabel = medication ? FORM_LABELS[medication.form] : '';
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日')
@@ -454,24 +456,24 @@ export default function MedicationDetailScreen() {
return `${startDate} - ${endDate}`;
} else {
// 没有结束日期,显示长期
return `${startDate} - 长期`;
return `${startDate} - ${t('medications.detail.plan.longTerm')}`;
}
}, [medication]);
}, [medication, t]);
const reminderTimes = medication?.medicationTimes?.length
? medication.medicationTimes.join('、')
: '尚未设置';
: t('medications.manage.reminderNotSet');
const frequencyLabel = useMemo(() => {
if (!medication) return '--';
switch (medication.repeatPattern) {
case 'daily':
return `每日 ${medication.timesPerDay}`;
return `${t('medications.manage.frequency.daily')} ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
case 'weekly':
return `每周 ${medication.timesPerDay}`;
return `${t('medications.manage.frequency.weekly')} ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
default:
return `自定义 · ${medication.timesPerDay} 次/日`;
return `${t('medications.manage.frequency.custom')} · ${medication.timesPerDay} ${t('medications.add.frequency.value', { count: medication.timesPerDay }).split(' ')[1]}`;
}
}, [medication]);
}, [medication, t]);
const handleOpenNoteModal = useCallback(() => {
setNoteDraft(medication?.note ?? '');
@@ -493,20 +495,20 @@ export default function MedicationDetailScreen() {
closeNoteModal();
} catch (err) {
console.error('保存备注失败', err);
Alert.alert('保存失败', '提交备注时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.note.saveError.title'), t('medications.detail.note.saveError.message'));
} finally {
setNoteSaving(false);
}
}, [closeNoteModal, dispatch, medication, noteDraft]);
const statusLabel = medication?.isActive ? '提醒已开启' : '提醒已关闭';
const noteText = medication?.note?.trim() ? medication.note : '暂无备注信息';
const statusLabel = medication?.isActive ? t('medications.detail.status.enabled') : t('medications.detail.status.disabled');
const noteText = medication?.note?.trim() ? medication.note : t('medications.detail.note.noNote');
const dayStreakText =
typeof summary.startedDays === 'number'
? `已坚持 ${summary.startedDays}`
? t('medications.detail.overview.startedDays', { days: summary.startedDays })
: medication
? `开始于 ${dayjs(medication.startDate).format('YYYY年M月D日')}`
: '暂无开始日期';
? 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) {
@@ -534,7 +536,7 @@ export default function MedicationDetailScreen() {
router.back();
} catch (err) {
console.error('删除药品失败', err);
Alert.alert('删除失败', '移除该药品时出现问题,请稍后再试。');
Alert.alert(t('medications.detail.delete.error.title'), t('medications.detail.delete.error.message'));
} finally {
setDeleteLoading(false);
}
@@ -550,21 +552,26 @@ export default function MedicationDetailScreen() {
if (!medication) return;
const startDate = dayjs(medication.startDate).format('YYYY年M月D日');
let message = `开始服药日期:${startDate}`;
let message;
if (medication.endDate) {
const endDate = dayjs(medication.endDate).format('YYYY年M月D日');
message += `\n结束服药日期${endDate}`;
message = t('medications.detail.plan.periodMessage', {
startDate,
endDateInfo: t('medications.detail.plan.periodMessage', { endDate })
});
} else {
message += `\n服药计划长期服药`;
message = t('medications.detail.plan.periodMessage', {
startDate,
endDateInfo: t('medications.detail.plan.longTermPlan')
});
}
Alert.alert('服药周期', message);
}, [medication]);
Alert.alert(t('medications.detail.sections.plan'), message);
}, [medication, t]);
const handleTimePress = useCallback(() => {
Alert.alert('服药时间', `设置的时间:${reminderTimes}`);
}, [reminderTimes]);
Alert.alert(t('medications.detail.plan.time'), t('medications.detail.plan.timeMessage', { times: reminderTimes }));
}, [reminderTimes, t]);
const handleDosagePress = useCallback(() => {
if (!medication) return;
@@ -621,7 +628,7 @@ export default function MedicationDetailScreen() {
}
} catch (err) {
console.error('更新剂量失败', err);
Alert.alert('更新失败', '更新剂量时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.updateErrors.dosage'), t('medications.detail.updateErrors.dosageMessage'));
} finally {
setUpdatePending(false);
}
@@ -656,7 +663,7 @@ export default function MedicationDetailScreen() {
}
} catch (err) {
console.error('更新剂型失败', err);
Alert.alert('更新失败', '更新剂型时出现问题,请稍后重试。');
Alert.alert(t('medications.detail.updateErrors.form'), t('medications.detail.updateErrors.formMessage'));
} finally {
setUpdatePending(false);
}
@@ -723,27 +730,27 @@ export default function MedicationDetailScreen() {
onError: (error: any) => {
console.error('[MEDICATION] AI 分析失败:', error);
let errorMessage = 'AI 分析失败,请稍后重试';
let errorMessage = t('medications.detail.aiAnalysis.error.message');
// 解析服务端返回的错误信息
if (error?.message) {
if (error.message.includes('[ERROR]')) {
errorMessage = error.message.replace('[ERROR]', '').trim();
} else if (error.message.includes('无权访问')) {
errorMessage = '无权访问此药物';
errorMessage = t('medications.detail.aiAnalysis.error.forbidden');
} else if (error.message.includes('不存在')) {
errorMessage = '药物不存在';
errorMessage = t('medications.detail.aiAnalysis.error.notFound');
}
} else if (error?.status === 401) {
errorMessage = '请先登录';
errorMessage = t('medications.detail.aiAnalysis.error.unauthorized');
} else if (error?.status === 403) {
errorMessage = '无权访问此药物';
errorMessage = t('medications.detail.aiAnalysis.error.forbidden');
} else if (error?.status === 404) {
errorMessage = '药物不存在';
errorMessage = t('medications.detail.aiAnalysis.error.notFound');
}
// 使用 Alert 弹窗显示错误
Alert.alert('分析失败', errorMessage);
Alert.alert(t('medications.detail.aiAnalysis.error.title'), errorMessage);
// 清空内容和加载状态
setAiAnalysisContent('');
@@ -756,7 +763,7 @@ export default function MedicationDetailScreen() {
console.error('[MEDICATION] AI 分析异常:', error);
// 使用 Alert 弹窗显示错误
Alert.alert('分析失败', '发起分析请求失败,请检查网络连接');
Alert.alert(t('medications.detail.aiAnalysis.error.title'), t('medications.detail.aiAnalysis.error.networkError'));
// 清空内容和加载状态
setAiAnalysisContent('');
@@ -789,10 +796,10 @@ export default function MedicationDetailScreen() {
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar title="药品详情" variant="minimal" transparent />
<HeaderBar title={t('medications.detail.title')} variant="minimal" transparent />
<View style={[styles.centered, { paddingTop: insets.top + 72, paddingHorizontal: 24 }]}>
<ThemedText style={styles.emptyTitle}></ThemedText>
<ThemedText style={styles.emptySubtitle}></ThemedText>
<ThemedText style={styles.emptyTitle}>{t('medications.detail.notFound.title')}</ThemedText>
<ThemedText style={styles.emptySubtitle}>{t('medications.detail.notFound.subtitle')}</ThemedText>
</View>
</View>
);
@@ -815,17 +822,17 @@ export default function MedicationDetailScreen() {
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
<HeaderBar title="药品详情" variant="minimal" transparent />
<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 }]}>...</Text>
<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 ? (
@@ -878,10 +885,10 @@ export default function MedicationDetailScreen() {
</View>
</View>
<Section title="服药计划" color={colors.text}>
<Section title={t('medications.detail.sections.plan')} color={colors.text}>
<View style={styles.row}>
<InfoCard
label="服药周期"
label={t('medications.detail.plan.period')}
value={medicationPeriodLabel}
icon="calendar-outline"
colors={colors}
@@ -889,7 +896,7 @@ export default function MedicationDetailScreen() {
onPress={handleStartDatePress}
/>
<InfoCard
label="用药时间"
label={t('medications.detail.plan.time')}
value={reminderTimes}
icon="time-outline"
colors={colors}
@@ -904,7 +911,7 @@ export default function MedicationDetailScreen() {
>
<View style={styles.fullCardLeading}>
<Ionicons name="repeat-outline" size={18} color={colors.primary} />
<Text style={[styles.fullCardLabel, { color: colors.text }]}></Text>
<Text style={[styles.fullCardLabel, { color: colors.text }]}>{t('medications.detail.plan.frequency')}</Text>
</View>
<View style={styles.fullCardTrailing}>
<Text style={[styles.fullCardValue, { color: colors.text }]}>{frequencyLabel}</Text>
@@ -913,10 +920,10 @@ export default function MedicationDetailScreen() {
</TouchableOpacity>
</Section>
<Section title="剂量与形式" color={colors.text}>
<Section title={t('medications.detail.sections.dosage')} color={colors.text}>
<View style={styles.row}>
<InfoCard
label="每次剂量"
label={t('medications.detail.dosage.label')}
value={dosageLabel}
icon="medkit-outline"
colors={colors}
@@ -924,7 +931,7 @@ export default function MedicationDetailScreen() {
onPress={handleDosagePress}
/>
<InfoCard
label="剂型"
label={t('medications.detail.dosage.form')}
value={formLabel}
icon="cube-outline"
colors={colors}
@@ -934,7 +941,7 @@ export default function MedicationDetailScreen() {
</View>
</Section>
<Section title="备注" color={colors.text}>
<Section title={t('medications.detail.sections.note')} color={colors.text}>
<TouchableOpacity
style={[styles.noteCard, { backgroundColor: colors.surface }]}
activeOpacity={0.92}
@@ -942,7 +949,7 @@ export default function MedicationDetailScreen() {
>
<Ionicons name="document-text-outline" size={20} color={colors.primary} />
<View style={styles.noteBody}>
<Text style={[styles.noteLabel, { color: colors.text }]}></Text>
<Text style={[styles.noteLabel, { color: colors.text }]}>{t('medications.detail.note.label')}</Text>
<Text
style={[
styles.noteValue,
@@ -956,17 +963,17 @@ export default function MedicationDetailScreen() {
</TouchableOpacity>
</Section>
<Section title="服药概览" color={colors.text}>
<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 ? '统计中...' : `累计服药 ${summary.takenCount}`}
{summaryLoading ? t('medications.detail.overview.calculating') : t('medications.detail.overview.takenCount', { count: summary.takenCount })}
</Text>
<Text style={[styles.summaryMeta, { color: colors.textSecondary }]}>
{summaryLoading ? '正在计算坚持天数' : dayStreakText}
{summaryLoading ? t('medications.detail.overview.calculatingDays') : dayStreakText}
</Text>
</View>
</View>
@@ -974,13 +981,13 @@ export default function MedicationDetailScreen() {
{/* AI 分析结果展示 - 移动到底部 */}
{(aiAnalysisContent || aiAnalysisLoading) && (
<Section title="AI 用药分析" color={colors.text}>
<Section title={t('medications.detail.sections.aiAnalysis')} color={colors.text}>
<View style={[styles.aiAnalysisCard, { backgroundColor: colors.surface }]}>
{aiAnalysisLoading && !aiAnalysisContent && (
<View style={styles.aiAnalysisLoading}>
<ActivityIndicator color={colors.primary} size="small" />
<Text style={[styles.aiAnalysisLoadingText, { color: colors.textSecondary }]}>
...
{t('medications.detail.aiAnalysis.analyzing')}
</Text>
</View>
)}
@@ -1102,7 +1109,7 @@ export default function MedicationDetailScreen() {
<Ionicons name="sparkles-outline" size={18} color="#fff" />
)}
<Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? '分析中...' : 'AI 分析'}
{aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')}
</Text>
</GlassView>
) : (
@@ -1113,7 +1120,7 @@ export default function MedicationDetailScreen() {
<Ionicons name="sparkles-outline" size={18} color="#fff" />
)}
<Text style={styles.aiAnalysisButtonText}>
{aiAnalysisLoading ? '分析中...' : 'AI 分析'}
{aiAnalysisLoading ? t('medications.detail.aiAnalysis.analyzingButton') : t('medications.detail.aiAnalysis.button')}
</Text>
</View>
)}
@@ -1161,7 +1168,7 @@ export default function MedicationDetailScreen() {
<View style={[styles.modalCard, { backgroundColor: colors.surface }]}>
<View style={styles.modalHandle} />
<View style={styles.modalHeader}>
<Text style={[styles.modalTitle, { color: colors.text }]}></Text>
<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>
@@ -1181,7 +1188,7 @@ export default function MedicationDetailScreen() {
numberOfLines={6}
value={noteDraft}
onChangeText={setNoteDraft}
placeholder="记录注意事项、医生叮嘱或自定义提醒"
placeholder={t('medications.detail.note.placeholder')}
placeholderTextColor={colors.textMuted}
style={[styles.noteEditorInput, { color: colors.text }]}
textAlignVertical="center"
@@ -1213,7 +1220,7 @@ export default function MedicationDetailScreen() {
</View>
{!isDictationSupported && (
<Text style={[styles.voiceHint, { color: colors.textMuted }]}>
{t('medications.detail.note.voiceNotSupported')}
</Text>
)}
<View style={styles.modalActionContainer}>
@@ -1232,7 +1239,7 @@ export default function MedicationDetailScreen() {
{noteSaving ? (
<ActivityIndicator color={colors.onPrimary} size="small" />
) : (
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}></Text>
<Text style={[styles.modalActionPrimaryText, { color: colors.onPrimary }]}>{t('medications.detail.note.save')}</Text>
)}
</TouchableOpacity>
</View>
@@ -1253,12 +1260,12 @@ export default function MedicationDetailScreen() {
/>
<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}
@@ -1277,7 +1284,7 @@ export default function MedicationDetailScreen() {
</View>
<View style={styles.pickerColumn}>
<ThemedText style={[styles.pickerLabel, { color: colors.textSecondary }]}>
{t('medications.detail.dosage.unit')}
</ThemedText>
<Picker
selectedValue={dosageUnitPicker}
@@ -1301,7 +1308,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
@@ -1309,7 +1316,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
@@ -1328,7 +1335,7 @@ export default function MedicationDetailScreen() {
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.pickerTitle, { color: colors.text }]}>
{t('medications.detail.dosage.selectForm')}
</ThemedText>
<Picker
selectedValue={formPicker}
@@ -1350,7 +1357,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, { borderColor: colors.border }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.textSecondary }]}>
{t('medications.detail.pickers.cancel')}
</ThemedText>
</Pressable>
<Pressable
@@ -1358,7 +1365,7 @@ export default function MedicationDetailScreen() {
style={[styles.pickerBtn, styles.pickerBtnPrimary, { backgroundColor: colors.primary }]}
>
<ThemedText style={[styles.pickerBtnText, { color: colors.onPrimary }]}>
{t('medications.detail.pickers.confirm')}
</ThemedText>
</Pressable>
</View>
@@ -1370,10 +1377,10 @@ export default function MedicationDetailScreen() {
visible={deleteSheetVisible}
onClose={() => setDeleteSheetVisible(false)}
onConfirm={handleDeleteMedication}
title={`删除 ${medication.name}`}
description="删除后将清除与该药品相关的提醒与历史记录,且无法恢复。"
confirmText="删除"
cancelText="取消"
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}
/>
@@ -1384,10 +1391,10 @@ export default function MedicationDetailScreen() {
visible={deactivateSheetVisible}
onClose={() => setDeactivateSheetVisible(false)}
onConfirm={handleDeactivateMedication}
title={`停用 ${medication.name}`}
description="停用后,当天已生成的用药计划会一并删除,且无法恢复。"
confirmText="确认停用"
cancelText="取消"
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}
/>
@@ -1415,7 +1422,7 @@ export default function MedicationDetailScreen() {
style={styles.imageViewerFooterButton}
onPress={() => setShowImagePreview(false)}
>
<Text style={styles.imageViewerFooterButtonText}></Text>
<Text style={styles.imageViewerFooterButtonText}>{t('medications.detail.imageViewer.close')}</Text>
</TouchableOpacity>
</View>
)}