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,6 +3,7 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { useI18n } from '@/hooks/useI18n';
import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding';
import { fetchMyProfile, updateUserProfile } from '@/store/userSlice';
import { fetchMaximumHeartRate } from '@/utils/health';
@@ -46,6 +47,7 @@ interface UserProfile {
const STORAGE_KEY = '@user_profile';
export default function EditProfileScreen() {
const { t } = useI18n();
const safeAreaTop = useSafeAreaTop()
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? 'light'];
@@ -189,7 +191,7 @@ export default function EditProfileScreen() {
const handleSaveWithProfile = async (profileData: UserProfile) => {
try {
if (!userId) {
Alert.alert('未登录', '请先登录后再尝试保存');
Alert.alert(t('editProfile.alerts.notLoggedIn.title'), t('editProfile.alerts.notLoggedIn.message'));
return;
}
const next: UserProfile = { ...profileData };
@@ -215,7 +217,7 @@ export default function EditProfileScreen() {
console.warn('更新用户信息失败', e?.message || e);
}
} catch (e) {
Alert.alert('保存失败', '请稍后重试');
Alert.alert(t('editProfile.alerts.saveFailed.title'), t('editProfile.alerts.saveFailed.message'));
}
};
@@ -249,7 +251,7 @@ export default function EditProfileScreen() {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert('权限不足', '需要相册权限以选择头像');
Alert.alert(t('editProfile.alerts.avatarPermissions.title'), t('editProfile.alerts.avatarPermissions.message'));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
@@ -275,21 +277,21 @@ export default function EditProfileScreen() {
setProfile((p) => ({ ...p, avatarUri: url }));
// 保存更新后的 profile
await handleSaveWithProfile({ ...profile, avatarUri: url });
Alert.alert('成功', '头像更新成功');
Alert.alert(t('editProfile.alerts.avatarSuccess.title'), t('editProfile.alerts.avatarSuccess.message'));
} catch (e) {
console.warn('上传头像失败', e);
Alert.alert('上传失败', '头像上传失败,请重试');
Alert.alert(t('editProfile.alerts.avatarUploadFailed.title'), t('editProfile.alerts.avatarUploadFailed.message'));
}
}
} catch (e) {
Alert.alert('发生错误', '选择头像失败,请重试');
Alert.alert(t('editProfile.alerts.avatarError.title'), t('editProfile.alerts.avatarError.message'));
}
};
return (
<View style={[styles.container, { backgroundColor: '#F5F5F5' }]}>
<HeaderBar
title="编辑资料"
title={t('editProfile.title')}
onBack={() => router.back()}
withSafeTop={false}
transparent={true}
@@ -321,8 +323,8 @@ export default function EditProfileScreen() {
{/* 姓名 */}
<ProfileCard
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-name.png'
title="昵称"
value={profile.name || '今晚要吃肉'}
title={t('editProfile.fields.name')}
value={profile.name || t('editProfile.defaultValues.name')}
onPress={() => {
setTempValue(profile.name || '');
setEditingField('name');
@@ -334,8 +336,8 @@ export default function EditProfileScreen() {
icon="body"
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-sex.png"
iconColor="#FF6B9D"
title="性别"
value={profile.gender === 'male' ? '男' : profile.gender === 'female' ? '女' : '未设置'}
title={t('editProfile.fields.gender')}
value={profile.gender === 'male' ? t('editProfile.gender.male') : profile.gender === 'female' ? t('editProfile.gender.female') : t('editProfile.gender.notSet')}
onPress={() => {
setEditingField('gender');
}}
@@ -344,10 +346,10 @@ export default function EditProfileScreen() {
{/* 身高 */}
<ProfileCard
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-height.png"
title="身高"
value={profile.height ? `${Math.round(profile.height)}厘米` : '170厘米'}
title={t('editProfile.fields.height')}
value={profile.height ? `${Math.round(profile.height)}${t('editProfile.height.unit')}` : t('editProfile.height.placeholder')}
onPress={() => {
setTempValue(profile.height ? String(Math.round(profile.height)) : '170');
setTempValue(profile.height ? String(Math.round(profile.height)) : String(t('editProfile.defaultValues.height')));
setEditingField('height');
}}
/>
@@ -355,10 +357,10 @@ export default function EditProfileScreen() {
{/* 体重 */}
<ProfileCard
iconUri="https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-weight.png"
title="体重"
value={profile.weight ? `${round(profile.weight, 1)}公斤` : '55公斤'}
title={t('editProfile.fields.weight')}
value={profile.weight ? `${round(profile.weight, 1)}${t('editProfile.weight.unit')}` : t('editProfile.weight.placeholder')}
onPress={() => {
setTempValue(profile.weight ? String(round(profile.weight, 1)) : '55');
setTempValue(profile.weight ? String(round(profile.weight, 1)) : String(t('editProfile.defaultValues.weight')));
setEditingField('weight');
}}
/>
@@ -366,14 +368,14 @@ export default function EditProfileScreen() {
{/* 活动水平 */}
<ProfileCard
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-activity.png'
title="活动水平"
title={t('editProfile.fields.activityLevel')}
value={(() => {
switch (profile.activityLevel) {
case 1: return '久坐';
case 2: return '轻度活跃';
case 3: return '中度活跃';
case 4: return '非常活跃';
default: return '久坐';
case 1: return t('editProfile.activityLevels.1');
case 2: return t('editProfile.activityLevels.2');
case 3: return t('editProfile.activityLevels.3');
case 4: return t('editProfile.activityLevels.4');
default: return t('editProfile.activityLevels.1');
}
})()}
onPress={() => {
@@ -384,15 +386,20 @@ export default function EditProfileScreen() {
{/* 出生日期 */}
<ProfileCard
iconUri='https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-edit-birth.png'
title="出生日期"
title={t('editProfile.fields.birthDate')}
value={profile.birthDate ? (() => {
try {
const d = new Date(profile.birthDate);
return `${d.getFullYear()}${d.getMonth() + 1}${d.getDate()}`;
if (t('editProfile.birthDate.format').includes('{{year}}年')) {
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() });
} else {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return t('editProfile.birthDate.format', { year: d.getFullYear(), month: monthNames[d.getMonth()], day: d.getDate() });
}
} catch {
return '1995年1月1日';
return t('editProfile.birthDate.placeholder');
}
})() : '1995年1月1日'}
})() : t('editProfile.birthDate.placeholder')}
onPress={() => {
openDatePicker();
}}
@@ -402,11 +409,11 @@ export default function EditProfileScreen() {
<ProfileCard
icon="heart"
iconColor="#FF6B9D"
title="最大心率"
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}次/分钟` : '未获取'}
title={t('editProfile.fields.maxHeartRate')}
value={profile.maxHeartRate ? `${Math.round(profile.maxHeartRate)}${t('editProfile.maxHeartRate.unit')}` : t('editProfile.maxHeartRate.notAvailable')}
onPress={() => {
// 最大心率不可编辑,只显示
Alert.alert('提示', '最大心率数据从健康应用自动获取');
Alert.alert(t('editProfile.maxHeartRate.alert.title'), t('editProfile.maxHeartRate.alert.message'));
}}
disabled={true}
hideArrow={true}
@@ -432,6 +439,7 @@ export default function EditProfileScreen() {
} else if (field === 'gender') {
updatedProfile.gender = value as 'male' | 'female';
setProfile(p => ({ ...p, gender: value as 'male' | 'female' }));
} else if (field === 'height') {
updatedProfile.height = parseFloat(value) || undefined;
setProfile(p => ({ ...p, height: parseFloat(value) || undefined }));
@@ -455,8 +463,8 @@ export default function EditProfileScreen() {
colors={colors}
textColor={textColor}
placeholderColor={placeholderColor}
t={t}
/>
{/* 出生日期选择器弹窗 */}
<Modal
visible={datePickerVisible}
@@ -488,12 +496,12 @@ export default function EditProfileScreen() {
{Platform.OS === 'ios' && (
<View style={styles.modalActions}>
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
<Text style={styles.modalBtnText}></Text>
<Text style={styles.modalBtnText}>{t('editProfile.modals.cancel')}</Text>
</Pressable>
<Pressable onPress={() => {
onConfirmDate(pickerDate);
}} style={[styles.modalBtn, styles.modalBtnPrimary]}>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}></Text>
<Text style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>{t('editProfile.modals.confirm')}</Text>
</Pressable>
</View>
)}
@@ -536,7 +544,7 @@ function ProfileCard({ icon, iconUri, iconColor, title, value, onPress, disabled
);
}
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor }: {
function EditModal({ visible, field, value, profile, onClose, onSave, colors, textColor, placeholderColor, t }: {
visible: boolean;
field: string | null;
value: string;
@@ -546,6 +554,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
colors: any;
textColor: string;
placeholderColor: string;
t: (key: string) => string;
}) {
const [inputValue, setInputValue] = useState(value);
const [selectedGender, setSelectedGender] = useState(profile.gender || 'female');
@@ -563,10 +572,10 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'name':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.name')}</Text>
<TextInput
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
placeholder="输入昵称"
placeholder={t('editProfile.modals.input.namePlaceholder')}
placeholderTextColor={placeholderColor}
value={inputValue}
onChangeText={setInputValue}
@@ -577,21 +586,21 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'gender':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.gender')}</Text>
<View style={styles.genderSelector}>
<TouchableOpacity
style={[styles.genderOption, selectedGender === 'female' && { backgroundColor: colors.primary + '20' }]}
onPress={() => setSelectedGender('female')}
>
<Text style={[styles.genderEmoji, selectedGender === 'female' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'female' && { color: colors.primary }]}>{t('editProfile.modals.female')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.genderOption, selectedGender === 'male' && { backgroundColor: colors.primary + '20' }]}
onPress={() => setSelectedGender('male')}
>
<Text style={[styles.genderEmoji, selectedGender === 'male' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}></Text>
<Text style={[styles.genderText, selectedGender === 'male' && { color: colors.primary }]}>{t('editProfile.modals.male')}</Text>
</TouchableOpacity>
</View>
</View>
@@ -599,7 +608,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'height':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.height')}</Text>
<View style={styles.pickerContainer}>
<Picker
selectedValue={inputValue}
@@ -607,7 +616,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
style={styles.picker}
>
{Array.from({ length: 101 }, (_, i) => 120 + i).map(height => (
<Picker.Item key={height} label={`${height}厘米`} value={String(height)} />
<Picker.Item key={height} label={`${height}${t('editProfile.height.unit')}`} value={String(height)} />
))}
</Picker>
</View>
@@ -616,29 +625,29 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
case 'weight':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.weight')}</Text>
<TextInput
style={[styles.modalInput, { color: textColor, borderColor: '#E0E0E0' }]}
placeholder="输入体重"
placeholder={t('editProfile.modals.input.weightPlaceholder')}
placeholderTextColor={placeholderColor}
value={inputValue}
onChangeText={setInputValue}
keyboardType="numeric"
autoFocus
/>
<Text style={styles.unitText}> (kg)</Text>
<Text style={styles.unitText}>{t('editProfile.modals.input.weightUnit')}</Text>
</View>
);
case 'activity':
return (
<View>
<Text style={styles.modalTitle}></Text>
<Text style={styles.modalTitle}>{t('editProfile.fields.activityLevel')}</Text>
<View style={styles.activitySelector}>
{[
{ key: 1, label: '久坐', desc: '很少运动' },
{ key: 2, label: '轻度活跃', desc: '每周1-3次运动' },
{ key: 3, label: '中度活跃', desc: '每周3-5次运动' },
{ key: 4, label: '非常活跃', desc: '每周6-7次运动' },
{ key: 1, label: t('editProfile.activityLevels.1'), desc: t('editProfile.activityLevels.descriptions.1') },
{ key: 2, label: t('editProfile.activityLevels.2'), desc: t('editProfile.activityLevels.descriptions.2') },
{ key: 3, label: t('editProfile.activityLevels.3'), desc: t('editProfile.activityLevels.descriptions.3') },
{ key: 4, label: t('editProfile.activityLevels.4'), desc: t('editProfile.activityLevels.descriptions.4') },
].map(item => (
<TouchableOpacity
key={item.key}
@@ -668,7 +677,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
{renderContent()}
<View style={styles.modalButtons}>
<TouchableOpacity onPress={onClose} style={styles.modalCancelBtn}>
<Text style={styles.modalCancelText}></Text>
<Text style={styles.modalCancelText}>{t('editProfile.modals.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
@@ -682,7 +691,7 @@ function EditModal({ visible, field, value, profile, onClose, onSave, colors, te
}}
style={[styles.modalSaveBtn, { backgroundColor: colors.primary }]}
>
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}></Text>
<Text style={[styles.modalSaveText, { color: colors.onPrimary }]}>{t('editProfile.modals.save')}</Text>
</TouchableOpacity>
</View>
</View>