feat: 前端 UI 国际化,支持中/英/日三语切换
之前语言设置只影响后端 AI 行为,前端 UI 文字始终为中文。 新增轻量 i18n 模块(React Context + 翻译字典),切换语言后所有 UI 文字即时刷新。 - 新增 i18n 核心模块和 zh-CN/en-US/ja-JP 翻译文件(约 72 个 key) - App.tsx 拆分为 App(Provider)+ AppContent(业务),确保 Hook 可访问 Context - 所有组件通过 useI18n().t() 获取翻译文本 - errors.ts 改为接受翻译函数参数 - storage.ts saveConfig 时派发事件通知 locale 变化
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface CameraManagerHandle {
|
||||
/** 获取当前视频轨道 */
|
||||
@@ -13,6 +14,7 @@ export interface CameraManagerHandle {
|
||||
}
|
||||
|
||||
export function useCamera() {
|
||||
const { t } = useI18n();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -29,7 +31,7 @@ export function useCamera() {
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "无法访问摄像头";
|
||||
const message = err instanceof Error ? err.message : t("error.cameraAccess");
|
||||
setError(message);
|
||||
console.error("[Camera] 获取摄像头失败:", err);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { ChatMessage } from "../../types";
|
||||
import type { ConnectionStatus } from "../../lib/websocket";
|
||||
|
||||
@@ -19,6 +20,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScroll = useRef(true);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const { t } = useI18n();
|
||||
|
||||
const isConnected = connectionStatus === "connected";
|
||||
|
||||
@@ -56,8 +58,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
return (
|
||||
<div className="chat-panel chat-panel--empty">
|
||||
<span className="chat-panel--empty-icon">💬</span>
|
||||
<p>点击下方按钮开始对话</p>
|
||||
<span className="chat-panel--empty-hint">连接后可打字或语音与 AI 交互</span>
|
||||
<p>{t("chat.empty.prompt")}</p>
|
||||
<span className="chat-panel--empty-hint">{t("chat.empty.hint")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,15 +71,15 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
{messages.length === 0 && !currentReply && (
|
||||
<div className="chat-panel__welcome">
|
||||
<span className="chat-panel__welcome-icon">💬</span>
|
||||
<p>在下方输入文字开始对话</p>
|
||||
<span className="chat-panel__welcome-hint">也可以开启麦克风用语音对话</span>
|
||||
<p>{t("chat.welcome.prompt")}</p>
|
||||
<span className="chat-panel__welcome-hint">{t("chat.welcome.hint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => (
|
||||
<div key={index} className={`chat-message chat-message--${msg.role}`}>
|
||||
<div className="chat-message__role">
|
||||
{msg.role === "user" ? "你" : "AI"}
|
||||
{msg.role === "user" ? t("chat.userLabel") : "AI"}
|
||||
</div>
|
||||
<div className="chat-message__content">{msg.content}</div>
|
||||
{msg.role === "assistant" && msg.tokensUsed !== undefined && (
|
||||
@@ -110,7 +112,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
<input
|
||||
type="text"
|
||||
className="chat-input__field"
|
||||
placeholder="输入文字对话..."
|
||||
placeholder={t("chat.input.placeholder")}
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
/>
|
||||
@@ -118,7 +120,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
type="submit"
|
||||
className="chat-input__send"
|
||||
disabled={!inputText.trim()}
|
||||
title="发送"
|
||||
title={t("chat.send")}
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 职责:主题切换、TTS 开关、detail level 切换、语言选择
|
||||
// ============================================================
|
||||
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { SessionConfig, Theme } from "../../types";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
@@ -14,40 +15,42 @@ interface ConfigPanelProps {
|
||||
}
|
||||
|
||||
export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="drawer-overlay" onClick={onClose}>
|
||||
<div className="drawer" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="drawer__header">
|
||||
<span className="drawer__title">设置</span>
|
||||
<span className="drawer__title">{t("settings.title")}</span>
|
||||
<button className="drawer__close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer__body">
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">外观</div>
|
||||
<div className="config-group__title">{t("settings.appearance")}</div>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">主题</span>
|
||||
<span className="config-row__desc">切换明暗主题</span>
|
||||
<span className="config-row__label">{t("settings.theme")}</span>
|
||||
<span className="config-row__desc">{t("settings.theme.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => onThemeChange(e.target.value as Theme)}
|
||||
>
|
||||
<option value="dark">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark">{t("settings.theme.dark")}</option>
|
||||
<option value="light">{t("settings.theme.light")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="config-group">
|
||||
<div className="config-group__title">会话</div>
|
||||
<div className="config-group__title">{t("settings.session")}</div>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">语音回答</span>
|
||||
<span className="config-row__desc">AI 回答时同步播放语音</span>
|
||||
<span className="config-row__label">{t("settings.tts")}</span>
|
||||
<span className="config-row__desc">{t("settings.tts.desc")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -58,22 +61,22 @@ export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }:
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">图像精度</span>
|
||||
<span className="config-row__desc">高精度适合识别文字,低精度省流量</span>
|
||||
<span className="config-row__label">{t("settings.detail")}</span>
|
||||
<span className="config-row__desc">{t("settings.detail.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={config.detailLevel}
|
||||
onChange={(e) => onUpdate({ detailLevel: e.target.value as "low" | "high" })}
|
||||
>
|
||||
<option value="low">低</option>
|
||||
<option value="high">高</option>
|
||||
<option value="low">{t("settings.detail.low")}</option>
|
||||
<option value="high">{t("settings.detail.high")}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="config-row">
|
||||
<div className="config-row__info">
|
||||
<span className="config-row__label">语言</span>
|
||||
<span className="config-row__desc">交互语言偏好</span>
|
||||
<span className="config-row__label">{t("settings.language")}</span>
|
||||
<span className="config-row__desc">{t("settings.language.desc")}</span>
|
||||
</div>
|
||||
<select
|
||||
value={config.language}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { MicVAD } from "@ricky0123/vad-web";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export interface VADOptions {
|
||||
/** 语音结束回调,携带录音 Float32Array(16kHz) */
|
||||
@@ -21,6 +22,7 @@ export interface VADOptions {
|
||||
* 基于 @ricky0123/vad-web 的 MicVAD,检测用户说话并回调
|
||||
*/
|
||||
export function useVAD(options?: VADOptions) {
|
||||
const { t } = useI18n();
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -79,7 +81,7 @@ export function useVAD(options?: VADOptions) {
|
||||
setIsReady(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "VAD 初始化失败";
|
||||
const message = err instanceof Error ? err.message : t("error.vadInit");
|
||||
setError(message);
|
||||
console.error("[VAD] 初始化失败:", err);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
// ============================================================
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export function useMicrophone() {
|
||||
const { t } = useI18n();
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
@@ -25,7 +27,7 @@ export function useMicrophone() {
|
||||
setError(null);
|
||||
return mediaStream;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "无法访问麦克风";
|
||||
const message = err instanceof Error ? err.message : t("error.micAccess");
|
||||
setError(message);
|
||||
console.error("[Mic] 获取麦克风失败:", err);
|
||||
return null;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { forwardRef } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
interface VideoPreviewProps {
|
||||
isStreaming: boolean;
|
||||
@@ -11,6 +12,8 @@ interface VideoPreviewProps {
|
||||
|
||||
export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
|
||||
function VideoPreview({ isStreaming }, ref) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="video-preview">
|
||||
<video
|
||||
@@ -22,7 +25,7 @@ export const VideoPreview = forwardRef<HTMLVideoElement, VideoPreviewProps>(
|
||||
/>
|
||||
{!isStreaming && (
|
||||
<div className="video-preview__placeholder">
|
||||
摄像头未开启
|
||||
{t("video.cameraOff")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user