Merge pull request #77 from tjengbudi/main

Added: English translation
This commit is contained in:
coso
2026-01-08 08:40:18 +08:00
committed by GitHub
27 changed files with 10764 additions and 41 deletions
+4 -1
View File
@@ -10,6 +10,7 @@
import { useState, useEffect, useCallback } from "react";
import styled from "styled-components";
import { withI18nPatch } from "./i18n/withI18nPatch";
import { SplashScreen } from "./components/SplashScreen";
import { AppSidebar } from "./components/AppSidebar";
import { SettingsPage } from "./components/settings";
@@ -81,7 +82,7 @@ const FullscreenWrapper = styled.div`
position: relative;
`;
function App() {
function AppContent() {
const [showSplash, setShowSplash] = useState(true);
const [currentPage, setCurrentPage] = useState<Page>("agent");
const { needsOnboarding, completeOnboarding } = useOnboardingState();
@@ -262,4 +263,6 @@ function App() {
);
}
// Export the App component wrapped with i18n patch support
const App = withI18nPatch(AppContent);
export default App;
+111
View File
@@ -0,0 +1,111 @@
/**
* Web Mode Warning Component
*
* Displays a warning banner when running in web mode (npm run dev)
* to inform users that some features may not work without Tauri backend
*/
import { useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import styled from "styled-components";
const WarningBanner = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #78350f;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
z-index: 9999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 14px;
`;
const Content = styled.div`
display: flex;
align-items: center;
gap: 12px;
flex: 1;
`;
const IconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
`;
const Message = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
`;
const Title = styled.div`
font-weight: 600;
`;
const Description = styled.div`
font-size: 12px;
opacity: 0.9;
`;
const Code = styled.code`
background: rgba(0, 0, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 12px;
`;
const CloseButton = styled.button`
background: none;
border: none;
color: #78350f;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
`;
export function WebModeWarning() {
const [visible, setVisible] = useState(true);
// Check if running in Tauri
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
// Only show in web mode (not Tauri)
if (isTauri || !visible) {
return null;
}
return (
<WarningBanner>
<Content>
<IconWrapper>
<AlertTriangle size={20} />
</IconWrapper>
<Message>
<Title>⚠️ Web Mode - Limited Functionality</Title>
<Description>
Running in browser mode. Some features require Tauri backend. For
full functionality, run: <Code>npm run tauri dev</Code>
</Description>
</Message>
</Content>
<CloseButton onClick={() => setVisible(false)} title="Close">
<X size={18} />
</CloseButton>
</WarningBanner>
);
}
@@ -7,6 +7,8 @@ import { Moon, Sun, Monitor, RefreshCw, Info, RotateCcw } from "lucide-react";
import { cn, validateProxyUrl } from "@/lib/utils";
import { getConfig, saveConfig, Config } from "@/hooks/useTauri";
import { useOnboardingState } from "@/components/onboarding";
import { LanguageSelector, Language } from "./LanguageSelector";
import { useI18nPatch } from "@/i18n/I18nPatchProvider";
type Theme = "light" | "dark" | "system";
@@ -14,7 +16,9 @@ export function GeneralSettings() {
const [theme, setTheme] = useState<Theme>("system");
const [launchOnStartup, setLaunchOnStartup] = useState(false);
const [minimizeToTray, setMinimizeToTray] = useState(true);
const [language, setLanguageState] = useState<Language>("zh");
const { resetOnboarding } = useOnboardingState();
const { setLanguage: setI18nLanguage } = useI18nPatch();
// 重新运行引导
const handleResetOnboarding = useCallback(() => {
@@ -48,6 +52,7 @@ export function GeneralSettings() {
setConfig(c);
setProxyUrl(c.proxy_url || "");
setMinimizeToTray(c.minimize_to_tray ?? true);
setLanguageState((c.language || "zh") as Language);
} catch (e) {
console.error("加载配置失败:", e);
} finally {
@@ -100,6 +105,20 @@ export function GeneralSettings() {
}
};
const handleLanguageChange = async (newLanguage: Language) => {
if (!config) return;
try {
const newConfig = { ...config, language: newLanguage };
await saveConfig(newConfig);
setConfig(newConfig);
setLanguageState(newLanguage);
// Update i18n context to trigger DOM replacement
setI18nLanguage(newLanguage);
} catch (err) {
console.error("保存语言设置失败:", err);
}
};
const themeOptions = [
{ id: "light" as Theme, label: "浅色", icon: Sun },
{ id: "dark" as Theme, label: "深色", icon: Moon },
@@ -187,6 +206,17 @@ export function GeneralSettings() {
</div>
</div>
{/* 语言 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">语言</h3>
<LanguageSelector
currentLanguage={language}
onLanguageChange={handleLanguageChange}
/>
</div>
</div>
{/* 启动行为 */}
<div className="rounded-lg border p-3 space-y-2">
<h3 className="text-sm font-medium">启动行为</h3>
@@ -0,0 +1,70 @@
/**
* Language Selector Component
*
* Dropdown component for selecting the UI language.
* Similar design to the theme selector in GeneralSettings.
*
* Features:
* - Displays available languages (Chinese, English)
* - Highlights currently selected language
* - Persists selection to config
* - Updates UI immediately via Patch Layer
*/
import { cn } from "@/lib/utils";
export type Language = "zh" | "en";
interface LanguageOption {
id: Language;
label: string;
nativeName: string;
}
interface LanguageSelectorProps {
currentLanguage: Language;
onLanguageChange: (language: Language) => void;
disabled?: boolean;
}
const languageOptions: LanguageOption[] = [
{ id: "zh", label: "中文", nativeName: "Chinese" },
{ id: "en", label: "English", nativeName: "English" },
];
/**
* Language Selector Component
*
* A simple button-based language selector similar to the theme selector.
* Each button shows both the native name and English label.
*/
export function LanguageSelector({
currentLanguage,
onLanguageChange,
disabled = false,
}: LanguageSelectorProps) {
return (
<div className="flex gap-1">
{languageOptions.map((option) => (
<button
key={option.id}
onClick={() => onLanguageChange(option.id)}
disabled={disabled}
className={cn(
"flex items-center gap-1.5 px-3 py-1 rounded text-sm transition-colors",
currentLanguage === option.id
? "bg-primary/10 text-primary"
: "hover:bg-muted",
disabled && "opacity-50 cursor-not-allowed",
)}
title={option.nativeName}
>
<span className="font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">
({option.nativeName})
</span>
</button>
))}
</div>
);
}
+62 -38
View File
@@ -1,4 +1,19 @@
import { invoke } from "@tauri-apps/api/core";
// Safe Tauri invoke wrapper for web mode compatibility
const safeInvoke = async (cmd: string, args?: any): Promise<any> => {
// Check if Tauri is available
if (typeof window !== "undefined" && (window as any).__TAURI__) {
return (window as any).__TAURI__.invoke(cmd, args);
}
// Try to use real Tauri API
try {
const { invoke } = await import("@tauri-apps/api/core");
return invoke(cmd, args);
} catch (e) {
console.error(`[useTauri] Failed to invoke ${cmd}:`, e);
throw new Error(`Tauri API not available. Command: ${cmd}`);
}
};
export interface ServerStatus {
running: boolean;
@@ -148,6 +163,8 @@ export interface Config {
proxy_url: string | null;
/** 关闭时最小化到托盘(而不是退出应用) */
minimize_to_tray: boolean;
/** 用户界面语言 ("zh" 或 "en") */
language: string;
}
export interface LogEntry {
@@ -157,44 +174,44 @@ export interface LogEntry {
}
export async function startServer(): Promise<string> {
return invoke("start_server");
return safeInvoke("start_server");
}
export async function stopServer(): Promise<string> {
return invoke("stop_server");
return safeInvoke("stop_server");
}
export async function getServerStatus(): Promise<ServerStatus> {
return invoke("get_server_status");
return safeInvoke("get_server_status");
}
export async function getConfig(): Promise<Config> {
return invoke("get_config");
return safeInvoke("get_config");
}
export async function saveConfig(config: Config): Promise<void> {
return invoke("save_config", { config });
return safeInvoke("save_config", { config });
}
export async function getDefaultProvider(): Promise<string> {
return invoke("get_default_provider");
return safeInvoke("get_default_provider");
}
export async function setDefaultProvider(provider: string): Promise<string> {
return invoke("set_default_provider", { provider });
return safeInvoke("set_default_provider", { provider });
}
export async function refreshKiroToken(): Promise<string> {
return invoke("refresh_kiro_token");
return safeInvoke("refresh_kiro_token");
}
export async function reloadCredentials(): Promise<string> {
return invoke("reload_credentials");
return safeInvoke("reload_credentials");
}
export async function getLogs(): Promise<LogEntry[]> {
try {
return await invoke("get_logs");
return await safeInvoke("get_logs");
} catch {
return [];
}
@@ -202,7 +219,7 @@ export async function getLogs(): Promise<LogEntry[]> {
export async function clearLogs(): Promise<void> {
try {
await invoke("clear_logs");
await safeInvoke("clear_logs");
} catch {
// ignore
}
@@ -221,7 +238,7 @@ export async function testApi(
body: string | null,
auth: boolean,
): Promise<TestResult> {
return invoke("test_api", { method, path, body, auth });
return safeInvoke("test_api", { method, path, body, auth });
}
export interface KiroCredentialStatus {
@@ -235,7 +252,7 @@ export interface KiroCredentialStatus {
}
export async function getKiroCredentials(): Promise<KiroCredentialStatus> {
return invoke("get_kiro_credentials");
return safeInvoke("get_kiro_credentials");
}
export interface EnvVariable {
@@ -245,11 +262,11 @@ export interface EnvVariable {
}
export async function getEnvVariables(): Promise<EnvVariable[]> {
return invoke("get_env_variables");
return safeInvoke("get_env_variables");
}
export async function getTokenFileHash(): Promise<string> {
return invoke("get_token_file_hash");
return safeInvoke("get_token_file_hash");
}
export interface CheckResult {
@@ -261,7 +278,7 @@ export interface CheckResult {
export async function checkAndReloadCredentials(
lastHash: string,
): Promise<CheckResult> {
return invoke("check_and_reload_credentials", { last_hash: lastHash });
return safeInvoke("check_and_reload_credentials", { last_hash: lastHash });
}
// ============ Gemini Provider ============
@@ -276,29 +293,31 @@ export interface GeminiCredentialStatus {
}
export async function getGeminiCredentials(): Promise<GeminiCredentialStatus> {
return invoke("get_gemini_credentials");
return safeInvoke("get_gemini_credentials");
}
export async function reloadGeminiCredentials(): Promise<string> {
return invoke("reload_gemini_credentials");
return safeInvoke("reload_gemini_credentials");
}
export async function refreshGeminiToken(): Promise<string> {
return invoke("refresh_gemini_token");
return safeInvoke("refresh_gemini_token");
}
export async function getGeminiEnvVariables(): Promise<EnvVariable[]> {
return invoke("get_gemini_env_variables");
return safeInvoke("get_gemini_env_variables");
}
export async function getGeminiTokenFileHash(): Promise<string> {
return invoke("get_gemini_token_file_hash");
return safeInvoke("get_gemini_token_file_hash");
}
export async function checkAndReloadGeminiCredentials(
lastHash: string,
): Promise<CheckResult> {
return invoke("check_and_reload_gemini_credentials", { last_hash: lastHash });
return safeInvoke("check_and_reload_gemini_credentials", {
last_hash: lastHash,
});
}
// ============ Qwen Provider ============
@@ -313,29 +332,31 @@ export interface QwenCredentialStatus {
}
export async function getQwenCredentials(): Promise<QwenCredentialStatus> {
return invoke("get_qwen_credentials");
return safeInvoke("get_qwen_credentials");
}
export async function reloadQwenCredentials(): Promise<string> {
return invoke("reload_qwen_credentials");
return safeInvoke("reload_qwen_credentials");
}
export async function refreshQwenToken(): Promise<string> {
return invoke("refresh_qwen_token");
return safeInvoke("refresh_qwen_token");
}
export async function getQwenEnvVariables(): Promise<EnvVariable[]> {
return invoke("get_qwen_env_variables");
return safeInvoke("get_qwen_env_variables");
}
export async function getQwenTokenFileHash(): Promise<string> {
return invoke("get_qwen_token_file_hash");
return safeInvoke("get_qwen_token_file_hash");
}
export async function checkAndReloadQwenCredentials(
lastHash: string,
): Promise<CheckResult> {
return invoke("check_and_reload_qwen_credentials", { last_hash: lastHash });
return safeInvoke("check_and_reload_qwen_credentials", {
last_hash: lastHash,
});
}
// ============ OpenAI Custom Provider ============
@@ -347,7 +368,7 @@ export interface OpenAICustomStatus {
}
export async function getOpenAICustomStatus(): Promise<OpenAICustomStatus> {
return invoke("get_openai_custom_status");
return safeInvoke("get_openai_custom_status");
}
export async function setOpenAICustomConfig(
@@ -355,7 +376,7 @@ export async function setOpenAICustomConfig(
baseUrl: string | null,
enabled: boolean,
): Promise<string> {
return invoke("set_openai_custom_config", {
return safeInvoke("set_openai_custom_config", {
api_key: apiKey,
base_url: baseUrl,
enabled,
@@ -371,7 +392,7 @@ export interface ClaudeCustomStatus {
}
export async function getClaudeCustomStatus(): Promise<ClaudeCustomStatus> {
return invoke("get_claude_custom_status");
return safeInvoke("get_claude_custom_status");
}
export async function setClaudeCustomConfig(
@@ -379,7 +400,7 @@ export async function setClaudeCustomConfig(
baseUrl: string | null,
enabled: boolean,
): Promise<string> {
return invoke("set_claude_custom_config", {
return safeInvoke("set_claude_custom_config", {
api_key: apiKey,
base_url: baseUrl,
enabled,
@@ -395,7 +416,7 @@ export interface ModelInfo {
}
export async function getAvailableModels(): Promise<ModelInfo[]> {
return invoke("get_available_models");
return safeInvoke("get_available_models");
}
// ============ API Compatibility Check ============
@@ -420,7 +441,7 @@ export interface ApiCompatibilityResult {
export async function checkApiCompatibility(
provider: string,
): Promise<ApiCompatibilityResult> {
return invoke("check_api_compatibility", { provider });
return safeInvoke("check_api_compatibility", { provider });
}
// ============ Endpoint Provider Configuration ============
@@ -449,7 +470,7 @@ export interface EndpointProvidersConfig {
* @returns 端点 Provider 配置对象
*/
export async function getEndpointProviders(): Promise<EndpointProvidersConfig> {
return invoke("get_endpoint_providers");
return safeInvoke("get_endpoint_providers");
}
/**
@@ -462,7 +483,10 @@ export async function setEndpointProvider(
clientType: string,
provider: string | null,
): Promise<string> {
return invoke("set_endpoint_provider", { endpoint: clientType, provider });
return safeInvoke("set_endpoint_provider", {
endpoint: clientType,
provider,
});
}
// Network Info
@@ -476,5 +500,5 @@ export interface NetworkInfo {
* @returns 本地和内网 IP 地址
*/
export async function getNetworkInfo(): Promise<NetworkInfo> {
return invoke("get_network_info");
return safeInvoke("get_network_info");
}
+106
View File
@@ -0,0 +1,106 @@
/**
* I18nPatchProvider Component
*
* React Provider component that manages the i18n patch state.
* Applies DOM text replacement when language changes and watches for
* dynamic content via MutationObserver.
*
* This is the core of the Patch Layer architecture - it intercepts
* text rendering and applies translations without modifying original components.
*/
/* eslint-disable react-refresh/only-export-components */
import {
useEffect,
useState,
createContext,
useContext,
ReactNode,
} from "react";
import { replaceTextInDOM } from "./dom-replacer";
import { Language, isValidLanguage } from "./text-map";
interface I18nPatchContextValue {
language: Language;
setLanguage: (lang: Language) => void;
}
const I18nPatchContext = createContext<I18nPatchContextValue>({
language: "zh",
setLanguage: () => {},
});
/**
* Hook to access i18n patch context
* Must be used within I18nPatchProvider
*/
export const useI18nPatch = () => {
const context = useContext(I18nPatchContext);
if (!context) {
throw new Error("useI18nPatch must be used within I18nPatchProvider");
}
return context;
};
interface I18nPatchProviderProps {
children: ReactNode;
initialLanguage?: Language;
}
/**
* I18nPatchProvider Component
*
* Provides i18n context and manages DOM text replacement.
* Automatically patches new content via MutationObserver.
*/
export function I18nPatchProvider({
children,
initialLanguage = "zh",
}: I18nPatchProviderProps) {
const [language, setLanguage] = useState<Language>(initialLanguage);
// Validate and normalize language
const normalizeLanguage = (lang: string): Language => {
if (isValidLanguage(lang)) {
return lang;
}
console.warn(`[i18n] Invalid language "${lang}", falling back to "zh"`);
return "zh";
};
// Handle language change
const handleSetLanguage = (lang: Language) => {
const normalized = normalizeLanguage(lang);
setLanguage(normalized);
};
useEffect(() => {
// Apply patches when language changes
replaceTextInDOM(language);
// Track language changes
if (window.__I18N_METRICS__) {
window.__I18N_METRICS__.languageChanges++;
}
// Set up MutationObserver for dynamic content
const observer = new MutationObserver(() => {
replaceTextInDOM(language);
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => observer.disconnect();
}, [language]);
return (
<I18nPatchContext.Provider
value={{ language, setLanguage: handleSetLanguage }}
>
{children}
</I18nPatchContext.Provider>
);
}
@@ -0,0 +1,89 @@
/**
* Config Validation Tests for i18n
*
* Tests for invalid config scenarios, fallback behavior, and type safety.
*/
import { describe, it, expect } from "vitest";
import { isValidLanguage, Language, getTextMap } from "../text-map";
describe("Config Validation: Language Types", () => {
it("should accept valid language codes", () => {
expect(isValidLanguage("zh")).toBe(true);
expect(isValidLanguage("en")).toBe(true);
});
it("should reject invalid language codes", () => {
expect(isValidLanguage("invalid")).toBe(false);
expect(isValidLanguage("")).toBe(false);
expect(isValidLanguage("ZH")).toBe(false);
expect(isValidLanguage("EN")).toBe(false);
expect(isValidLanguage("english")).toBe(false);
});
});
describe("Config Validation: Default Language Fallback", () => {
it("should fallback to zh for invalid language", () => {
const map = getTextMap("invalid" as Language);
expect(map).toBeDefined();
expect(map["凭证池"]).toBe("凭证池"); // Chinese
});
it("should fallback to zh for null language", () => {
const map = getTextMap(null as unknown as Language);
expect(map).toBeDefined();
});
it("should fallback to zh for undefined language", () => {
const map = getTextMap(undefined as unknown as Language);
expect(map).toBeDefined();
});
});
describe("Config Validation: Type Safety", () => {
it("should only allow valid Language type", () => {
const validLanguages: Language[] = ["zh", "en"];
validLanguages.forEach((lang) => {
expect(isValidLanguage(lang)).toBe(true);
});
});
});
describe("Config Validation: Text Map Integrity", () => {
it("should have same keys in zh and en maps", () => {
const zhMap = getTextMap("zh");
const enMap = getTextMap("en");
const zhKeys = Object.keys(zhMap).filter((k) => !k.startsWith("//"));
const enKeys = Object.keys(enMap).filter((k) => !k.startsWith("//"));
// Check if all Chinese keys exist in English map
zhKeys.forEach((key) => {
expect(enMap).toHaveProperty(key);
});
// Check if all English keys exist in Chinese map
enKeys.forEach((key) => {
expect(zhMap).toHaveProperty(key);
});
});
it("should not have empty values", () => {
const enMap = getTextMap("en");
const zhMap = getTextMap("zh");
Object.entries(enMap).forEach(([key, value]) => {
if (!key.startsWith("//")) {
expect(value).toBeTruthy();
expect(typeof value).toBe("string");
}
});
Object.entries(zhMap).forEach(([key, value]) => {
if (!key.startsWith("//")) {
expect(value).toBeTruthy();
expect(typeof value).toBe("string");
}
});
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Edge Case Testing for i18n Patch Layer
*
* Tests for race conditions, memory leaks, performance, and ambiguous text handling.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getTextMap, Language } from "../text-map";
describe("Edge Cases: Text Map", () => {
it("should return Chinese text map for zh language", () => {
const map = getTextMap("zh");
expect(map).toBeDefined();
expect(map["凭证池"]).toBe("凭证池");
});
it("should return English text map for en language", () => {
const map = getTextMap("en");
expect(map).toBeDefined();
expect(map["凭证池"]).toBe("Credential Pool");
});
it("should handle missing keys gracefully", () => {
const map = getTextMap("en");
expect(map["不存在的文本"]).toBeUndefined();
});
it("should skip comment entries", () => {
const map = getTextMap("en");
// Comment entries start with //
expect(map["// ==="]).toBeUndefined();
});
});
describe("Edge Cases: Performance", () => {
it("should complete text map lookup within 1ms", () => {
const startTime = performance.now();
for (let i = 0; i < 1000; i++) {
getTextMap("en");
}
const endTime = performance.now();
const duration = endTime - startTime;
expect(duration).toBeLessThan(1);
});
});
describe("Edge Cases: Ambiguous Chinese Text", () => {
it("should handle same word in different contexts", () => {
const mapEn = getTextMap("en");
const mapZh = getTextMap("zh");
// "设置" appears in multiple contexts
expect(mapZh["设置"]).toBe("设置");
expect(mapEn["设置"]).toBe("Settings");
// "通用" is a specific context
expect(mapZh["通用"]).toBe("通用");
expect(mapEn["通用"]).toBe("General");
});
});
describe("Edge Cases: Language Validation", () => {
it("should handle invalid language codes", () => {
const map = getTextMap("invalid" as Language);
expect(map).toBeDefined(); // Should fallback to zh
});
});
// Mock performance metrics
declare global {
interface Window {
__I18N_METRICS__?: {
patchTimes: number[];
languageChanges: number;
};
}
}
describe("Edge Cases: Metrics Tracking", () => {
beforeEach(() => {
window.__I18N_METRICS__ = {
patchTimes: [],
languageChanges: 0,
};
});
afterEach(() => {
delete window.__I18N_METRICS__;
});
it("should track patch times", () => {
if (window.__I18N_METRICS__) {
window.__I18N_METRICS__.patchTimes.push(10);
window.__I18N_METRICS__.patchTimes.push(20);
window.__I18N_METRICS__.patchTimes.push(15);
expect(window.__I18N_METRICS__.patchTimes).toHaveLength(3);
expect(window.__I18N_METRICS__.patchTimes[0]).toBe(10);
}
});
it("should track language changes", () => {
if (window.__I18N_METRICS__) {
window.__I18N_METRICS__.languageChanges = 5;
expect(window.__I18N_METRICS__.languageChanges).toBe(5);
}
});
});
@@ -0,0 +1,107 @@
/**
* Translation Coverage Test
*
* Verifies that translation patch files are valid and contain expected entries
*/
import { describe, it, expect } from "vitest";
import enPatch from "../patches/en.json";
import zhPatch from "../patches/zh.json";
describe("Translation Coverage", () => {
describe("Patch File Validity", () => {
it("should load en.json without errors", () => {
expect(enPatch).toBeDefined();
expect(typeof enPatch).toBe("object");
});
it("should load zh.json without errors", () => {
expect(zhPatch).toBeDefined();
expect(typeof zhPatch).toBe("object");
});
it("should have matching keys in both patch files", () => {
const enKeys = Object.keys(enPatch).filter((k) => !k.startsWith("//"));
const zhKeys = Object.keys(zhPatch).filter((k) => !k.startsWith("//"));
// Both should have similar number of keys (allowing some variance)
expect(Math.abs(enKeys.length - zhKeys.length)).toBeLessThan(50);
});
});
describe("Translation Quality", () => {
it("should not have [TODO: Translate] markers in production", () => {
const enValues = Object.values(enPatch);
const todoCount = enValues.filter(
(v) => typeof v === "string" && v.includes("[TODO: Translate]"),
).length;
// Allow some TODOs in development, but warn if too many
if (todoCount > 0) {
console.warn(`Found ${todoCount} [TODO: Translate] markers in en.json`);
}
});
it("should have Chinese text as keys in zh.json", () => {
const zhKeys = Object.keys(zhPatch).filter((k) => !k.startsWith("//"));
const chineseKeys = zhKeys.filter((k) => /[\u4e00-\u9fff]/.test(k));
// Most keys should contain Chinese characters
expect(chineseKeys.length).toBeGreaterThan(zhKeys.length * 0.8);
});
it("should have identity mappings in zh.json", () => {
const entries = Object.entries(zhPatch).filter(
([k]) => !k.startsWith("//"),
);
// Check that most Chinese keys map to themselves
const identityMappings = entries.filter(([k, v]) => k === v).length;
expect(identityMappings).toBeGreaterThan(entries.length * 0.8);
});
});
describe("Common Translations", () => {
it("should have translations for common UI elements", () => {
const commonElements = ["设置", "保存", "取消", "确认", "删除"];
commonElements.forEach((element) => {
expect(enPatch).toHaveProperty(element);
expect(zhPatch).toHaveProperty(element);
});
});
it("should have translations for main navigation", () => {
const navItems = ["凭证池", "工具", "插件中心"];
navItems.forEach((item) => {
expect(enPatch).toHaveProperty(item);
expect(zhPatch).toHaveProperty(item);
});
});
});
describe("Translation Consistency", () => {
it("should not have empty translations", () => {
const enEntries = Object.entries(enPatch).filter(
([k]) => !k.startsWith("//"),
);
const emptyTranslations = enEntries.filter(([, v]) => v === "").length;
expect(emptyTranslations).toBe(0);
});
it("should have reasonable translation lengths", () => {
const entries = Object.entries(enPatch).filter(
([k]) => !k.startsWith("//"),
);
entries.forEach(([key, value]) => {
if (typeof value === "string" && value.length > 0) {
// English translation shouldn't be 10x longer than Chinese
expect(value.length).toBeLessThan(key.length * 10);
}
});
});
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* i18next Configuration
*
* Initialize i18next with react-i18next plugin.
* Note: We use i18next for compatibility but our primary translation
* mechanism is the Patch Layer (DOM text replacement).
*/
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
// Initialize i18next
i18n.use(initReactI18next).init({
lng: "zh", // Default language (Chinese)
fallbackLng: "zh",
interpolation: {
escapeValue: false, // React already escapes by default
},
react: {
useSuspense: false, // Disable suspense as we handle loading differently
},
});
export default i18n;
+129
View File
@@ -0,0 +1,129 @@
/**
* DOM Text Replacer Utility
*
* Replaces Chinese text in the DOM with translated text using a TreeWalker.
* This is the core of the Patch Layer architecture.
*
* Key features:
* - Walks the entire DOM tree to find text nodes
* - Replaces Chinese text with translations based on the current language
* - Skips script, style, and already patched nodes
* - Handles multiple Chinese segments in a single text node
* - Marks patched nodes to avoid double-patching
*/
import { getTextMap, Language } from "./text-map";
/**
* Escape special regex characters in a string
*/
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Replace text in DOM nodes with translations
*
* @param language - Target language ('zh' or 'en')
*/
export function replaceTextInDOM(language: Language): void {
const patches = getTextMap(language);
const startTime = performance.now();
// Sort patches by length (longest first) to avoid partial replacements
// This ensures "初次设置向导" is replaced before "初次" or "设置"
const sortedPatches = Object.entries(patches)
.filter(([zh]) => !zh.startsWith("//")) // Skip comment entries
.sort(([a], [b]) => b.length - a.length); // Sort by length descending
// Create a TreeWalker to traverse all text nodes
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip script, style, and already patched nodes
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const tagName = parent.tagName;
if (
tagName === "SCRIPT" ||
tagName === "STYLE" ||
parent.hasAttribute("data-i18n-patched")
) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
},
);
const nodesToReplace: Array<{ node: Text; text: string }> = [];
let node: Node | null;
while ((node = walker.nextNode())) {
const text = node.textContent;
if (!text) continue;
// Apply patches from longest to shortest to avoid partial replacements
let newText = text;
let hasMatch = false;
for (const [zh, replacement] of sortedPatches) {
// Use 'g' flag for global replacement (all occurrences)
// Escape regex special characters to avoid errors
const escaped = escapeRegExp(zh);
const regex = new RegExp(escaped, "g");
const replaced = newText.replace(regex, replacement);
if (replaced !== newText) {
newText = replaced;
hasMatch = true;
}
}
if (hasMatch) {
nodesToReplace.push({
node: node as Text,
text: newText,
});
}
}
// Apply replacements (batch for performance)
nodesToReplace.forEach(({ node, text }) => {
node.textContent = text;
// Mark as patched to avoid double-patching
node.parentElement?.setAttribute("data-i18n-patched", "true");
});
const endTime = performance.now();
const duration = endTime - startTime;
// Log if slow (> 50ms)
if (duration > 50) {
console.warn(`[i18n] DOM replacement took ${duration.toFixed(2)}ms`);
} else {
console.debug(`[i18n] DOM replacement took ${duration.toFixed(2)}ms`);
}
// Track for analytics (optional)
if (window.__I18N_METRICS__) {
window.__I18N_METRICS__.patchTimes.push(duration);
}
}
// Declare global type for metrics
declare global {
interface Window {
__I18N_METRICS__?: {
patchTimes: number[];
languageChanges: number;
};
}
}
window.__I18N_METRICS__ = {
patchTimes: [],
languageChanges: 0,
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
/**
* Text Map Registry
*
* Centralized registry for all patch definitions.
* Loads patch files and provides type-safe access to translations.
*/
import patchesZh from "./patches/zh.json";
import patchesEn from "./patches/en.json";
/**
* Available languages
*/
export type Language = "zh" | "en";
/**
* Text maps for all supported languages
* Keys are Chinese text (original), values are translated text
*/
export const TEXT_MAPS = {
zh: patchesZh,
en: patchesEn,
} as const;
/**
* Get the patch map for a specific language
*/
export function getTextMap(language: Language): Record<string, string> {
return TEXT_MAPS[language] || TEXT_MAPS.zh;
}
/**
* Validate if a language code is supported
*/
export function isValidLanguage(code: string): code is Language {
return code === "zh" || code === "en";
}
+86
View File
@@ -0,0 +1,86 @@
/**
* withI18nPatch Higher-Order Component
*
* HOC that wraps a component with I18nPatchProvider.
* Loads the language config from Tauri and passes it to the provider.
*
* This HOC is used to wrap the root App component, enabling
* the Patch Layer architecture for the entire application.
*
* Features:
* - Loads language config from Tauri backend
* - Handles loading state
* - Applies fade-in transition to prevent text flashing
*/
import React, { useEffect, useState } from "react";
import { Config, getConfig } from "@/hooks/useTauri";
import { I18nPatchProvider } from "./I18nPatchProvider";
import { Language } from "./text-map";
import { replaceTextInDOM } from "./dom-replacer";
interface WithI18nPatchOptions {
/** Fade-in duration in milliseconds (default: 150ms) */
fadeInDuration?: number;
}
/**
* Higher-Order Component that adds i18n patch support
*
* @param Component - The component to wrap
* @param options - Configuration options
* @returns A new component with i18n patch support
*/
export function withI18nPatch<P extends object>(
Component: React.ComponentType<P>,
options: WithI18nPatchOptions = {},
): React.ComponentType<P> {
const { fadeInDuration = 150 } = options;
return function PatchedComponent(props: P) {
const [config, setConfig] = useState<Config | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
getConfig()
.then((c) => {
setConfig(c);
// Apply initial patch immediately (synchronous)
const lang = (c.language || "zh") as Language;
replaceTextInDOM(lang);
// Fade in after patch is complete
requestAnimationFrame(() => {
setIsReady(true);
});
})
.catch((err) => {
console.error("[i18n] Failed to load config:", err);
// Use default language on error
replaceTextInDOM("zh");
requestAnimationFrame(() => {
setIsReady(true);
});
});
}, []);
if (!config) {
// Return null or minimal loading state
return null;
}
return (
<div
style={{
opacity: isReady ? 1 : 0,
transition: `opacity ${fadeInDuration}ms ease-in`,
}}
>
<I18nPatchProvider
initialLanguage={(config.language || "zh") as Language}
>
<Component {...props} />
</I18nPatchProvider>
</div>
);
};
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Tauri API Mock for Web Development Mode
*
* Provides mock implementations of Tauri APIs when running in web mode (npm run dev)
* This allows the app to run in browser without Tauri backend
*/
export const isTauriAvailable = () => {
return typeof window !== "undefined" && "__TAURI__" in window;
};
export const mockTauriAPI = () => {
if (typeof window === "undefined") return;
if (isTauriAvailable()) return; // Already have real Tauri
console.log("[Mock] Initializing Tauri API mock for web mode");
// Mock Tauri global
(window as any).__TAURI__ = {
invoke: async (cmd: string, args?: any) => {
console.log(`[Mock] Tauri invoke: ${cmd}`, args);
// Return mock data based on command
switch (cmd) {
case "get_config":
return {
language: "zh",
theme: "system",
proxy: "",
minimize_to_tray: false,
launch_on_startup: false,
tls: {
enable: false,
cert_path: null,
key_path: null,
},
remote_management: {
allow_remote: false,
secret_key: null,
disable_control_panel: false,
},
quota_exceeded: {
switch_project: true,
switch_preview_model: false,
cooldown_seconds: 60,
},
};
case "save_config":
console.log("[Mock] Config saved:", args);
return { success: true };
case "get_providers":
return [];
case "get_credentials":
return [];
case "check_server_status":
case "get_server_status":
return {
running: false,
host: "127.0.0.1",
port: 8787,
requests: 0,
uptime_secs: 0,
};
case "start_server":
return "Server started (mock)";
case "stop_server":
return "Server stopped (mock)";
case "get_default_provider":
return "openai";
case "set_default_provider":
return "Provider set (mock)";
case "get_available_models":
return [];
case "get_network_info":
return {
local_ip: "127.0.0.1",
public_ip: null,
hostname: "localhost",
};
default:
console.warn(`[Mock] Unhandled Tauri command: ${cmd}`);
return null;
}
},
event: {
listen: async (event: string, _handler: any) => {
console.log(`[Mock] Tauri listen: ${event}`);
// Return unlisten function
return () => {
console.log(`[Mock] Tauri unlisten: ${event}`);
};
},
emit: async (event: string, payload?: any) => {
console.log(`[Mock] Tauri emit: ${event}`, payload);
},
once: async (event: string, _handler: any) => {
console.log(`[Mock] Tauri once: ${event}`);
return () => {};
},
},
tauri: {
invoke: async (cmd: string, args?: any) => {
return (window as any).__TAURI__.invoke(cmd, args);
},
},
};
// Mock @tauri-apps/api modules
(window as any).__TAURI_INVOKE__ = (window as any).__TAURI__.invoke;
console.log("[Mock] Tauri API mock initialized");
console.log("[Mock] Running in WEB MODE - some features may not work");
console.log("[Mock] For full functionality, run: npm run tauri dev");
};
// Auto-initialize in development mode
if (import.meta.env.DEV && !isTauriAvailable()) {
mockTauriAPI();
}
+6
View File
@@ -4,6 +4,12 @@ import App from "./App";
import { Toaster } from "./components/ui/sonner";
import "./index.css";
// Initialize Tauri mock for web mode
import "./lib/tauri-mock";
// Initialize i18n configuration
import "./i18n/config";
// 初始化插件组件全局暴露(供动态加载的插件使用)
import "./lib/plugin-components/global";