mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-24 12:04:22 +08:00
+4
-1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user