Skip to content

Commit 677d64d

Browse files
Tonnodoubtclaude
andcommitted
feat(i18n): add internationalization support for React Native app
- Create i18n directory structure with config and locale files - Add 6 language translations (zh-CN, zh-TW, en, ja, ko, ru) - Integrate I18nextProvider in app layout - Initialize i18n in RootLayout - Update home screen to use translations - Update main screen to use translations - Add language selector in settings screen - Add i18next and react-i18next dependencies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e470703 commit 677d64d

14 files changed

Lines changed: 2042 additions & 104 deletions

File tree

app/(tabs)/index.tsx

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ import { SafeAreaView } from 'react-native-safe-area-context';
33
import { useRouter } from 'expo-router';
44
import { useEffect, useState } from 'react';
55
import { useIsFocused } from '@react-navigation/native';
6+
import { useTranslation } from 'react-i18next';
67
import { useDevConnectionConfig } from '@/hooks/useDevConnectionConfig';
78
import { hasUserStoredConfig } from '@/services/DevConnectionStorage';
89
import { sessionStore } from '@/utils/sessionStore';
910
import { useColorScheme } from '@/hooks/use-color-scheme';
1011

1112
type ConnectionStatus = 'online' | 'offline';
1213

13-
const STATUS_MAP: Record<ConnectionStatus, { color: string; text: string }> = {
14-
online: { color: '#40c5f1', text: '就绪' },
15-
offline: { color: '#ff4d4d', text: '未连接' },
14+
// Status map will be populated by translation
15+
const STATUS_COLORS: Record<ConnectionStatus, string> = {
16+
online: '#40c5f1',
17+
offline: '#ff4d4d',
1618
};
1719

1820
// 亮色/暗色主题色板(参照主项目 theme.css 与 dark-mode.css)
@@ -55,7 +57,8 @@ export default function HomeScreen() {
5557
const { config, isLoaded, reload } = useDevConnectionConfig();
5658
const colorScheme = useColorScheme();
5759
const isDark = colorScheme === 'dark';
58-
const t = isDark ? DARK : LIGHT;
60+
const theme = isDark ? DARK : LIGHT;
61+
const { t } = useTranslation();
5962

6063
const isFocused = useIsFocused();
6164
const [isConnected, setIsConnected] = useState(sessionStore.isConnected);
@@ -72,82 +75,83 @@ export default function HomeScreen() {
7275
}, [isLoaded, isFocused, reload]);
7376

7477
const status: ConnectionStatus = isConnected ? 'online' : 'offline';
75-
const { color: statusColor, text: statusText } = STATUS_MAP[status];
78+
const statusColor = STATUS_COLORS[status];
79+
const statusText = status === 'online' ? t('home.status.online') : t('home.status.offline');
7680
const showIp = isUserConfigured && isConnected;
7781

7882
return (
79-
<SafeAreaView style={[styles.container, { backgroundColor: t.container }]}>
83+
<SafeAreaView style={[styles.container, { backgroundColor: theme.container }]}>
8084
<View style={styles.content}>
8185
{/* 标题区域 */}
8286
<View style={styles.header}>
83-
<Text style={[styles.title, { color: t.titleColor }]}>Project N.E.K.O.</Text>
87+
<Text style={[styles.title, { color: theme.titleColor }]}>{t('home.title')}</Text>
8488
</View>
8589

8690
{/* 快捷功能 */}
8791
<View style={styles.section}>
88-
<Text style={[styles.sectionTitle, { color: t.sectionTitle }]}>快捷功能</Text>
92+
<Text style={[styles.sectionTitle, { color: theme.sectionTitle }]}>{t('home.shortcuts')}</Text>
8993
<View style={styles.quickActions}>
9094
<TouchableOpacity
91-
style={[styles.actionButton, { backgroundColor: t.actionBtn, borderColor: t.actionBorder }]}
95+
style={[styles.actionButton, { backgroundColor: theme.actionBtn, borderColor: theme.actionBorder }]}
9296
onPress={() => router.push('/settings')}
9397
activeOpacity={0.8}
9498
>
9599
<Text style={styles.actionIcon}>🔑</Text>
96-
<Text style={[styles.actionText, { color: t.textPrimary }]}>API 设置</Text>
100+
<Text style={[styles.actionText, { color: theme.textPrimary }]}>{t('home.apiSettings')}</Text>
97101
</TouchableOpacity>
98102

99103
<TouchableOpacity
100-
style={[styles.actionButton, { backgroundColor: t.actionBtn, borderColor: t.actionBorder }]}
104+
style={[styles.actionButton, { backgroundColor: theme.actionBtn, borderColor: theme.actionBorder }]}
101105
onPress={() => router.push('/character-manager')}
102106
activeOpacity={0.8}
103107
>
104108
<Text style={styles.actionIcon}>🐱</Text>
105-
<Text style={[styles.actionText, { color: t.textPrimary }]}>角色管理</Text>
109+
<Text style={[styles.actionText, { color: theme.textPrimary }]}>{t('home.characterManager')}</Text>
106110
</TouchableOpacity>
107111
</View>
108112
</View>
109113

110114
{/* 服务器配置 */}
111115
<View style={styles.section}>
112-
<Text style={[styles.sectionTitle, { color: t.sectionTitle }]}>服务器连接</Text>
116+
<Text style={[styles.sectionTitle, { color: theme.sectionTitle }]}>{t('home.serverConnection')}</Text>
113117

114-
<View style={[styles.configCard, { backgroundColor: t.card, borderColor: t.cardBorder }]}>
118+
<View style={[styles.configCard, { backgroundColor: theme.card, borderColor: theme.cardBorder }]}>
115119
<View style={styles.configRow}>
116-
<Text style={[styles.configLabel, { color: t.textSub }]}>当前连接</Text>
120+
<Text style={[styles.configLabel, { color: theme.textSub }]}>{t('connection.status.connected')}</Text>
117121
<View style={styles.statusIndicator}>
118122
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
119123
<Text style={[styles.statusText, { color: statusColor }]}>{statusText}</Text>
120124
</View>
121125
</View>
122126
{showIp ? (
123-
<Text style={[styles.configValue, { color: t.textPrimary }]}>
127+
<Text style={[styles.configValue, { color: theme.textPrimary }]}>
124128
{config.host}:{config.port}
125129
</Text>
126130
) : isUserConfigured ? (
127-
<Text style={[styles.configValueOffline, { color: t.textOffline }]}>已配置,等待连接…</Text>
131+
<Text style={[styles.configValueOffline, { color: theme.textOffline }]}>{t('home.status.configured')}</Text>
128132
) : (
129-
<Text style={[styles.configValueOffline, { color: t.textOffline }]}>扫码或手动配置以连接</Text>
133+
<Text style={[styles.configValueOffline, { color: theme.textOffline }]}>{t('home.status.unconfigured')}</Text>
130134
)}
131-
<Text style={[styles.configSubtext, { color: t.textMuted }]}>角色: {config.characterName}</Text>
135+
<Text style={[styles.configSubtext, { color: theme.textMuted }]}>{t('home.actions.currentRole')}: {config.characterName}</Text>
132136
</View>
133137

134138
<View style={styles.configButtons}>
135139
<TouchableOpacity
136-
style={[styles.configButton, { backgroundColor: t.configBtn, borderColor: t.configBtnBorder }]}
140+
style={[styles.configButton, { backgroundColor: theme.configBtn, borderColor: theme.configBtnBorder }]}
137141
onPress={() => router.push('/server-config')}
138142
activeOpacity={0.8}
139143
>
140144
<Text style={styles.configButtonIcon}>⚙️</Text>
141-
<Text style={[styles.configButtonText, { color: t.configBtnText }]}>手动配置</Text>
145+
<Text style={[styles.configButtonText, { color: theme.configBtnText }]}>{t('home.actions.manualConfig')}</Text>
142146
</TouchableOpacity>
143147

144148
<TouchableOpacity
145-
style={[styles.configButton, { backgroundColor: t.configBtn, borderColor: t.configBtnBorder }]}
149+
style={[styles.configButton, { backgroundColor: theme.configBtn, borderColor: theme.configBtnBorder }]}
146150
onPress={() => router.push({ pathname: '/qr-scanner', params: { returnTo: '/main' } })}
147151
activeOpacity={0.8}
148152
>
149153
<Text style={styles.configButtonIcon}>📷</Text>
150-
<Text style={[styles.configButtonText, { color: t.configBtnText }]}>扫码配置</Text>
154+
<Text style={[styles.configButtonText, { color: theme.configBtnText }]}>{t('home.actions.qrConfig')}</Text>
151155
</TouchableOpacity>
152156
</View>
153157
</View>

app/(tabs)/main.tsx

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { sessionStore } from '@/utils/sessionStore';
1919
import { VoicePrepareOverlay } from '@/components/VoicePrepareOverlay';
2020
import { useFocusEffect } from '@react-navigation/native';
2121
import { useLocalSearchParams } from 'expo-router';
22+
import { useTranslation } from 'react-i18next';
2223
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2324
import {
2425
ActivityIndicator,
@@ -56,6 +57,7 @@ function generateMessageId(counter: number): string {
5657
}
5758

5859
const MainUIScreen: React.FC<MainUIScreenProps> = () => {
60+
const { t } = useTranslation();
5961

6062
const [isPageFocused, setIsPageFocused] = useState(true);
6163

@@ -179,7 +181,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
179181

180182
applyQrRaw(raw).then((res) => {
181183
if (!res.ok) {
182-
Alert.alert('二维码内容不可用', res.error);
184+
Alert.alert(t('qrScanner.invalidCode'), res.error);
183185
}
184186
});
185187
}, [applyQrRaw, params.characterName, params.host, params.name, params.port, params.qr]);
@@ -315,7 +317,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
315317
const { agent, onAgentChange, refreshAgentState } = useLive2DAgentBackend({
316318
apiBase: `http://${config.host}:${config.port}`,
317319
showToast: (message, duration) => {
318-
Alert.alert('提示', message);
320+
Alert.alert(t('common.alert'), message);
319321
},
320322
openPanel: toolbarOpenPanel === 'agent' ? 'agent' : null,
321323
});
@@ -913,7 +915,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
913915

914916
const names = Object.keys(data.猫娘 || {});
915917
if (names.length === 0) {
916-
Alert.alert('角色管理', '暂无可用角色');
918+
Alert.alert(t('characterManager.title'), t('characterManager.empty'));
917919
return;
918920
}
919921

@@ -922,7 +924,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
922924
setToolbarOpenPanel(null);
923925
setCharacterModalVisible(true);
924926
} catch (err: any) {
925-
Alert.alert('获取角色列表失败', err.message || '网络错误');
927+
Alert.alert(t('characterManager.title'), err.message || t('connection.errors.networkError'));
926928
} finally {
927929
setCharacterLoading(false);
928930
}
@@ -981,7 +983,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
981983
return;
982984
}
983985

984-
Alert.alert('功能提示', `即将打开: ${id}`);
986+
Alert.alert(t('common.alert'), `即将打开: ${id}`);
985987
}, [config, toolbarMicEnabled, mainManager, chat, audio, syncLive2dModel]);
986988

987989
const handleSwitchCharacter = useCallback(async (name: string) => {
@@ -1015,11 +1017,11 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
10151017
}, 15000);
10161018
} else {
10171019
setCharacterLoading(false);
1018-
Alert.alert('切换失败', res.error || '未知错误');
1020+
Alert.alert(t('main.character.switchError'), res.error || t('common.error'));
10191021
}
10201022
} catch (err: any) {
10211023
setCharacterLoading(false);
1022-
Alert.alert('切换失败', err.message || '网络错误');
1024+
Alert.alert(t('main.character.switchError'), err.message || t('connection.errors.networkError'));
10231025
}
10241026
}, [config, toolbarMicEnabled]);
10251027

@@ -1096,14 +1098,14 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
10961098
// 使用 stream_data action 和 clientMessageId 与 N.E.K.O 协议一致
10971099
const handleSendMessage = useCallback(async (text: string, images?: string[]) => {
10981100
if (!audio.isConnected) {
1099-
Alert.alert('提示', '未连接到服务器');
1101+
Alert.alert(t('common.alert'), t('connection.status.disconnected'));
11001102
return;
11011103
}
11021104

11031105
// 确保 text session 已启动(与 Web 端一致)
11041106
const sessionOk = await ensureTextSession();
11051107
if (!sessionOk) {
1106-
Alert.alert('提示', '会话初始化失败,请重试');
1108+
Alert.alert(t('common.alert'), t('connection.status.reconnecting'));
11071109
return;
11081110
}
11091111

@@ -1392,7 +1394,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
13921394
<View style={styles.characterModalContent}>
13931395
{/* Header — 对应 neko-header 蓝色背景 */}
13941396
<View style={styles.characterModalHeader}>
1395-
<Text style={styles.characterModalTitle}>角色管理</Text>
1397+
<Text style={styles.characterModalTitle}>{t('characterManager.title')}</Text>
13961398
<TouchableOpacity
13971399
style={styles.characterModalCloseBtn}
13981400
onPress={() => setCharacterModalVisible(false)}
@@ -1402,7 +1404,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
14021404
</TouchableOpacity>
14031405
</View>
14041406
<Text style={styles.characterModalSubtitle}>
1405-
<Text style={styles.characterModalSubtitleLabel}>当前: </Text><Text style={styles.characterModalSubtitleHighlight}>{currentCatgirl || '未设置'}</Text>
1407+
<Text style={styles.characterModalSubtitleLabel}>{t('settings.language.current')}: </Text><Text style={styles.characterModalSubtitleHighlight}>{currentCatgirl || t('main.character.noCharacters')}</Text>
14061408
</Text>
14071409
<ScrollView style={styles.characterModalList} showsVerticalScrollIndicator={false}>
14081410
{characterList.map((name) => {
@@ -1430,7 +1432,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
14301432
</Text>
14311433
{isCurrent ? (
14321434
<View style={styles.characterModalBadgeWrap}>
1433-
<Text style={styles.characterModalBadge}>当前</Text>
1435+
<Text style={styles.characterModalBadge}>{t('main.character.current')}</Text>
14341436
</View>
14351437
) : (
14361438
<View style={styles.characterModalBadgePlaceholder} />
@@ -1457,7 +1459,7 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
14571459
<TouchableWithoutFeedback>
14581460
<View style={[styles.characterModalContent, styles.voiceBlockModalContent]}>
14591461
<View style={[styles.characterModalHeader, styles.voiceBlockModalHeader]}>
1460-
<Text style={styles.characterModalTitle}>无法切换角色</Text>
1462+
<Text style={styles.characterModalTitle}>{t('main.character.voiceBlockTitle')}</Text>
14611463
<TouchableOpacity
14621464
style={styles.characterModalCloseBtn}
14631465
onPress={() => setVoiceBlockModalVisible(false)}
@@ -1467,13 +1469,13 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
14671469
</TouchableOpacity>
14681470
</View>
14691471
<Text style={styles.voiceBlockModalBody}>
1470-
语音模式下无法切换角色,请先停止语音对话后再切换。
1472+
{t('main.voice.cannotSwitchCharacter')}
14711473
</Text>
14721474
<TouchableOpacity
14731475
style={styles.voiceBlockModalBtn}
14741476
onPress={() => setVoiceBlockModalVisible(false)}
14751477
>
1476-
<Text style={styles.voiceBlockModalBtnText}>好的</Text>
1478+
<Text style={styles.voiceBlockModalBtnText}>{t('common.ok')}</Text>
14771479
</TouchableOpacity>
14781480
</View>
14791481
</TouchableWithoutFeedback>
@@ -1485,14 +1487,14 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
14851487
{characterLoading && (
14861488
<View style={styles.switchingOverlay}>
14871489
<ActivityIndicator size="large" color="#40c5f1" />
1488-
<Text style={styles.switchingText}>正在切换角色...</Text>
1490+
<Text style={styles.switchingText}>{t('main.character.switching')}</Text>
14891491
</View>
14901492
)}
14911493

14921494
{/* 切换成功提示条 */}
14931495
{switchedCharacterName !== null && (
14941496
<View style={styles.switchingSuccessBanner} pointerEvents="none">
1495-
<Text style={styles.switchingSuccessText}>已切换为 {switchedCharacterName}</Text>
1497+
<Text style={styles.switchingSuccessText}>{t('main.character.switched', { name: switchedCharacterName })}</Text>
14961498
</View>
14971499
)}
14981500

@@ -1554,24 +1556,24 @@ const MainUIScreen: React.FC<MainUIScreenProps> = () => {
15541556
onStartShouldSetResponder={() => true}
15551557
>
15561558
<Text style={{ color: '#40c5f1', fontSize: 18, fontWeight: 'bold', marginBottom: 15 }}>
1557-
🔧 调试信息
1559+
🔧 {t('main.debug.title')}
15581560
</Text>
15591561
<ScrollView>
15601562
<Text style={{ color: '#fff', fontSize: 12, fontFamily: 'monospace', marginBottom: 10 }}>
1561-
{`连接状态: ${udpConnection.status}
1562-
层级: ${udpConnection.layer || '未连接'}
1563-
端点: ${udpConnection.endpoint ? `${udpConnection.endpoint.ip}:${udpConnection.endpoint.port}` : '无'}
1563+
{`${t('connection.status.connected')}: ${udpConnection.status}
1564+
${t('main.debug.p2pLayer')}: ${udpConnection.layer || t('connection.status.disconnected')}
1565+
${t('serverInfo.host')}: ${udpConnection.endpoint ? `${udpConnection.endpoint.ip}:${udpConnection.endpoint.port}` : t('common.unavailable')}
15641566
1565-
当前配置:
1566-
Host: ${config.host}:${config.port}
1567-
Character: ${config.characterName || '未设置'}
1567+
${t('settings.sections.serverInfo')}:
1568+
${t('serverInfo.host')}: ${config.host}:${config.port}
1569+
${t('serverInfo.character')}: ${config.characterName || t('main.character.noCharacters')}
15681570
`}
15691571
</Text>
15701572
<Text style={{ color: '#40c5f1', fontSize: 12, fontWeight: 'bold', marginBottom: 4 }}>
1571-
连接日志
1573+
{t('main.debug.connection')}
15721574
</Text>
15731575
<Text style={{ color: '#ccc', fontSize: 11, fontFamily: 'monospace' }}>
1574-
{udpConnection.logs.length > 0 ? udpConnection.logs.join('\n') : '(暂无日志)'}
1576+
{udpConnection.logs.length > 0 ? udpConnection.logs.join('\n') : t('common.warning')}
15751577
</Text>
15761578
</ScrollView>
15771579
<TouchableOpacity
@@ -1584,7 +1586,7 @@ Character: ${config.characterName || '未设置'}
15841586
}}
15851587
onPress={() => setDebugPanelVisible(false)}
15861588
>
1587-
<Text style={{ color: '#fff', fontWeight: 'bold' }}>关闭</Text>
1589+
<Text style={{ color: '#fff', fontWeight: 'bold' }}>{t('common.close')}</Text>
15881590
</TouchableOpacity>
15891591
</View>
15901592
</TouchableOpacity>

app/_layout.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native
22
import { Stack } from 'expo-router';
33
import { StatusBar } from 'expo-status-bar';
44
import 'react-native-reanimated';
5+
import { I18nextProvider } from 'react-i18next';
56
import { GestureHandlerRootView } from 'react-native-gesture-handler';
7+
import { useEffect } from 'react';
68

9+
import { initI18n, i18n } from '@/i18n';
710
import { useColorScheme } from '@/hooks/use-color-scheme';
811

912
export const unstable_settings = {
@@ -13,9 +16,15 @@ export const unstable_settings = {
1316
export default function RootLayout() {
1417
const colorScheme = useColorScheme();
1518

19+
// Initialize i18n on mount
20+
useEffect(() => {
21+
initI18n().catch(err => console.error('Failed to initialize i18n:', err));
22+
}, []);
23+
1624
return (
1725
<GestureHandlerRootView style={{ flex: 1 }}>
18-
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
26+
<I18nextProvider i18n={i18n}>
27+
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
1928
<Stack>
2029
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
2130
{/* <Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} /> */}
@@ -26,7 +35,8 @@ export default function RootLayout() {
2635
<Stack.Screen name="webapp" options={{ title: 'WebApp(对齐 frontend/src/web/App.tsx)' }} />
2736
</Stack>
2837
<StatusBar style="auto" />
29-
</ThemeProvider>
38+
</ThemeProvider>
39+
</I18nextProvider>
3040
</GestureHandlerRootView>
3141
);
3242
}

0 commit comments

Comments
 (0)