feat(i18n): 全面实现应用核心功能模块的国际化支持

- 新增 i18n 翻译资源,覆盖睡眠、饮水、体重、锻炼、用药 AI 识别、步数、健身圆环、基础代谢及设置等核心模块
- 重构相关页面及组件(如 SleepDetail, WaterDetail, WorkoutHistory 等)使用 `useI18n` 钩子替换硬编码文本
- 升级 `utils/date` 工具库与 `DateSelector` 组件,支持基于语言环境的日期格式化与显示
- 完善登录页、注销流程及权限申请弹窗的双语提示信息
- 优化部分页面的 UI 细节与字体样式以适配多语言显示
This commit is contained in:
richarjiang
2025-11-27 17:54:36 +08:00
parent 08adf0f20d
commit fbe0c92f0f
26 changed files with 2508 additions and 1622 deletions

View File

@@ -4,6 +4,7 @@ import { Colors } from '@/constants/Colors';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { createMedicationRecognitionTask } from '@/services/medications';
import { getItem, setItem } from '@/utils/kvStore';
import { Ionicons } from '@expo/vector-icons';
@@ -39,9 +40,9 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
const MEDICATION_GUIDE_SEEN_KEY = 'medication_ai_camera_guide_seen';
const captureSteps = [
{ key: 'front', title: '正面', subtitle: '保证药品名称清晰可见', mandatory: true },
{ key: 'side', title: '背面', subtitle: '包含规格、成分等信息', mandatory: true },
{ key: 'aux', title: '侧面', subtitle: '补充更多细节提升准确率', mandatory: false },
{ key: 'front', mandatory: true },
{ key: 'side', mandatory: true },
{ key: 'aux', mandatory: false },
] as const;
type CaptureKey = (typeof captureSteps)[number]['key'];
@@ -51,6 +52,7 @@ type Shot = {
};
export default function MedicationAiCameraScreen() {
const { t } = useI18n();
const insets = useSafeAreaInsets();
const scheme = (useColorScheme() ?? 'light') as keyof typeof Colors;
const colors = Colors[scheme];
@@ -113,7 +115,14 @@ export default function MedicationAiCameraScreen() {
}
}, [allRequiredCaptured]);
const stepTitle = useMemo(() => `步骤 ${currentStepIndex + 1} / ${captureSteps.length}`, [currentStepIndex]);
const stepTitle = useMemo(
() =>
t('medications.aiCamera.steps.stepProgress', {
current: currentStepIndex + 1,
total: captureSteps.length,
}),
[currentStepIndex, t]
);
// 计算固定的相机高度,不受按钮状态影响,避免布局跳动
const cameraHeight = useMemo(() => {
@@ -149,7 +158,7 @@ export default function MedicationAiCameraScreen() {
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(() => {
@@ -159,7 +168,10 @@ export default function MedicationAiCameraScreen() {
}
} catch (error) {
console.error('[MEDICATION_AI] pick image failed', error);
Alert.alert('选择失败', '请重试或更换图片');
Alert.alert(
t('medications.aiCamera.alerts.pickFailed.title'),
t('medications.aiCamera.alerts.pickFailed.message')
);
}
};
@@ -169,7 +181,7 @@ export default function MedicationAiCameraScreen() {
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(() => {
@@ -179,7 +191,10 @@ export default function MedicationAiCameraScreen() {
}
} catch (error) {
console.error('[MEDICATION_AI] take picture failed', error);
Alert.alert('拍摄失败', '请重试');
Alert.alert(
t('medications.aiCamera.alerts.captureFailed.title'),
t('medications.aiCamera.alerts.captureFailed.message')
);
}
};
@@ -192,7 +207,10 @@ export default function MedicationAiCameraScreen() {
const handleStartRecognition = async () => {
// 检查必需照片是否完成
if (!allRequiredCaptured) {
Alert.alert('照片不足', '请至少完成正面和背面拍摄');
Alert.alert(
t('medications.aiCamera.alerts.insufficientPhotos.title'),
t('medications.aiCamera.alerts.insufficientPhotos.message')
);
return;
}
@@ -209,7 +227,9 @@ export default function MedicationAiCameraScreen() {
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),
shots.aux
? upload({ uri: shots.aux.uri, name: `aux-${Date.now()}.jpg`, type: 'image/jpeg' })
: Promise.resolve(null),
]);
const task = await createMedicationRecognitionTask({
@@ -227,7 +247,10 @@ export default function MedicationAiCameraScreen() {
});
} catch (error: any) {
console.error('[MEDICATION_AI] recognize failed', error);
Alert.alert('创建任务失败', error?.message || '请检查网络后重试');
Alert.alert(
t('medications.aiCamera.alerts.taskFailed.title'),
error?.message || t('medications.aiCamera.alerts.taskFailed.defaultMessage')
);
} finally {
setCreatingTask(false);
}
@@ -278,12 +301,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true}
>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="camera-reverse-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.flip')}
</Text>
</View>
)}
</TouchableOpacity>
@@ -440,12 +467,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true}
>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</GlassView>
) : (
<View style={[styles.splitButton, styles.fallbackSplitButton]}>
<Ionicons name="camera" size={20} color="#0ea5e9" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.capture')}
</Text>
</View>
)}
</TouchableOpacity>
@@ -470,7 +501,9 @@ export default function MedicationAiCameraScreen() {
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</>
)}
</GlassView>
@@ -481,7 +514,9 @@ export default function MedicationAiCameraScreen() {
) : (
<>
<Ionicons name="checkmark-circle" size={20} color="#10b981" />
<Text style={styles.splitButtonLabel}></Text>
<Text style={styles.splitButtonLabel}>
{t('medications.aiCamera.buttons.complete')}
</Text>
</>
)}
</View>
@@ -501,12 +536,25 @@ export default function MedicationAiCameraScreen() {
if (!permission.granted) {
return (
<View style={[styles.container, { backgroundColor: '#f8fafc' }]}>
<HeaderBar title="AI 用药识别" onBack={() => router.back()} transparent />
<HeaderBar
title={t('medications.aiCamera.title')}
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>
<Text style={styles.permissionTitle}>
{t('medications.aiCamera.permission.title')}
</Text>
<Text style={styles.permissionTip}>
{t('medications.aiCamera.permission.description')}
</Text>
<TouchableOpacity
style={[styles.permissionBtn, { backgroundColor: colors.primary }]}
onPress={requestPermission}
>
<Text style={styles.permissionBtnText}>
{t('medications.aiCamera.permission.button')}
</Text>
</TouchableOpacity>
</View>
</View>
@@ -524,14 +572,14 @@ export default function MedicationAiCameraScreen() {
<View style={styles.container}>
<LinearGradient colors={['#fefefe', '#f4f7fb']} style={StyleSheet.absoluteFill} />
<HeaderBar
title="AI 用药识别"
title={t('medications.aiCamera.title')}
onBack={() => router.back()}
transparent
right={
<TouchableOpacity
onPress={() => setShowGuideModal(true)}
activeOpacity={0.7}
accessibilityLabel="查看拍摄说明"
accessibilityLabel={t('medications.aiCamera.guideModal.title')}
>
{isLiquidGlassAvailable() ? (
<GlassView
@@ -556,8 +604,12 @@ export default function MedicationAiCameraScreen() {
<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>
<Text style={styles.metaTitle}>
{t(`medications.aiCamera.steps.${currentStep.key}.title`)}
</Text>
<Text style={styles.metaSubtitle}>
{t(`medications.aiCamera.steps.${currentStep.key}.subtitle`)}
</Text>
</View>
<View style={styles.cameraCard}>
@@ -587,14 +639,22 @@ export default function MedicationAiCameraScreen() {
style={[styles.shotCard, active && styles.shotCardActive]}
>
<Text style={[styles.shotLabel, active && styles.shotLabelActive]}>
{step.title}
{!step.mandatory ? '(可选)' : ''}
{t(`medications.aiCamera.steps.${step.key}.title`)}
{!step.mandatory
? ` ${t('medications.aiCamera.steps.optional')}`
: ''}
</Text>
{shot ? (
<Image source={{ uri: shot.uri }} style={styles.shotThumb} contentFit="cover" />
<Image
source={{ uri: shot.uri }}
style={styles.shotThumb}
contentFit="cover"
/>
) : (
<View style={styles.shotPlaceholder}>
<Text style={styles.shotPlaceholderText}></Text>
<Text style={styles.shotPlaceholderText}>
{t('medications.aiCamera.steps.notTaken')}
</Text>
</View>
)}
</TouchableOpacity>
@@ -617,12 +677,16 @@ export default function MedicationAiCameraScreen() {
isInteractive={true}
>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</GlassView>
) : (
<View style={[styles.secondaryBtn, styles.fallbackSecondaryBtn]}>
<Ionicons name="images-outline" size={20} color="#0f172a" />
<Text style={styles.secondaryBtnText}></Text>
<Text style={styles.secondaryBtnText}>
{t('medications.aiCamera.buttons.album')}
</Text>
</View>
)}
</TouchableOpacity>