CrossRelay stage 0+1: Go gateway with Anthropic<->Chat Completions relay, Tauri shell, bilingual management UI

- Go gateway (stdlib only): /v1/messages non-stream + SSE, /v1/messages/count_tokens,
  management API /api/* (providers CRUD, apply/restore, settings, backups, logs)
- Protocol conversion: Anthropic Messages <-> Chat Completions with a streaming
  state machine keyed by content block index (text, tool_use, parallel calls)
- ~/.claude/settings.json guarded read/write: timestamped backups, managed env
  keys only, one-click restore
- Tauri 2 shell: spawns the gateway sidecar, parses the random port from stdout,
  stdin pipe watchdog prevents orphans, kills the child on exit
- React frontend: Dashboard/Providers/Settings, zh-CN + en-US, TanStack Query
- Tests: Go table-driven + httptest integration (all green), vitest + RTL
This commit is contained in:
kate522 committed 2026-10-18 17:19:13 +08:00
1 parent 8ee5e98ea3
commit f8acf31ebc
71 files changed
+15727 -1

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { useEffect } from "react";
import i18n from "./i18n";
import { ToastProvider } from "./components/toast";
import { Layout } from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Providers from "./pages/Providers";
import SettingsPage from "./pages/SettingsPage";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, staleTime: 2000, refetchOnWindowFocus: false },
},
});
export default function App({ initialLanguage }: { initialLanguage?: string }) {
useEffect(() => {
if (initialLanguage && i18n.language !== initialLanguage) {
void i18n.changeLanguage(initialLanguage);
}
}, [initialLanguage]);
return (
<QueryClientProvider client={queryClient}>
<I18nextProvider i18n={i18n}>
<ToastProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/providers" element={<Providers />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
</ToastProvider>
</I18nextProvider>
</QueryClientProvider>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { invoke } from "@tauri-apps/api/core";
import { fetch } from "@tauri-apps/plugin-http";
import { SUPPORTED_LANGUAGES, type Language } from "./i18n";
export interface Bootstrap {
port: number | null;
language: Language;
}
// Resolves the gateway port from the Tauri shell and loads the saved UI
// language before the first render.
export async function bootstrap(): Promise<Bootstrap> {
let port: number | null = null;
try {
port = await invoke<number | null>("gateway_port");
} catch {
port = null;
}
let language: Language = "zh-CN";
if (port) {
try {
const response = await fetch(`http://127.0.0.1:${port}/api/settings`);
if (response.ok) {
const text = await response.text();
const parsed = text ? JSON.parse(text) : {};
if (SUPPORTED_LANGUAGES.includes(parsed.language)) {
language = parsed.language;
}
}
} catch {
// Gateway unreachable: fall back to the default language.
}
}
return { port, language };
}
+67
View File
@@ -0,0 +1,67 @@
import { NavLink, Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Gauge, Server, Settings as SettingsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSettingsMutation, useSettings } from "@/lib/queries";
import i18n from "@/i18n";
import { Button } from "@/components/ui/button";
const navItems = [
{ to: "/", key: "nav.dashboard", icon: Gauge },
{ to: "/providers", key: "nav.providers", icon: Server },
{ to: "/settings", key: "nav.settings", icon: SettingsIcon },
];
export function Layout() {
const { t } = useTranslation();
const { data: settings } = useSettings();
const mutation = useSettingsMutation();
const language = settings?.language ?? i18n.language;
const toggleLanguage = () => {
const next = language === "zh-CN" ? "en-US" : "zh-CN";
void i18n.changeLanguage(next);
mutation.mutate({ language: next });
};
return (
<div className="flex h-screen bg-zinc-50 text-zinc-900">
<aside className="flex w-52 shrink-0 flex-col border-r border-zinc-200 bg-white">
<div className="px-4 py-4">
<div className="text-base font-semibold tracking-tight">CrossRelay</div>
<div className="text-xs text-zinc-500">{t("app.tagline")}</div>
</div>
<nav className="flex flex-col gap-0.5 px-2">
{navItems.map(({ to, key, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium transition-colors",
isActive
? "bg-teal-50 text-teal-800"
: "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900",
)
}
>
<Icon size={16} aria-hidden />
{t(key)}
</NavLink>
))}
</nav>
<div className="mt-auto p-3">
<Button variant="outline" size="sm" className="w-full" onClick={toggleLanguage}>
{language === "zh-CN" ? "中文 / EN" : "EN / 中文"}
</Button>
</div>
</aside>
<main className="flex-1 overflow-auto">
<div className="mx-auto max-w-5xl p-6">
<Outlet />
</div>
</main>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface Toast {
id: number;
message: string;
tone: "success" | "error";
}
interface ToastContextValue {
toasts: Toast[];
show: (message: string, tone?: Toast["tone"]) => void;
}
const ToastContext = React.createContext<ToastContextValue | null>(null);
let nextId = 1;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = React.useState<Toast[]>([]);
const show = React.useCallback((message: string, tone: Toast["tone"] = "success") => {
const id = nextId++;
setToasts((current) => [...current, { id, message, tone }]);
window.setTimeout(() => {
setToasts((current) => current.filter((t) => t.id !== id));
}, 4000);
}, []);
const value = React.useMemo(() => ({ toasts, show }), [toasts, show]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-72 flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
role="status"
className={cn(
"pointer-events-auto rounded-md px-3 py-2 text-sm shadow-md",
toast.tone === "success"
? "bg-emerald-600 text-white"
: "bg-red-600 text-white",
)}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = React.useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside ToastProvider");
return ctx;
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type Tone = "neutral" | "success" | "warning" | "danger";
const tones: Record<Tone, string> = {
neutral: "bg-zinc-100 text-zinc-700",
success: "bg-emerald-50 text-emerald-700",
warning: "bg-amber-50 text-amber-700",
danger: "bg-red-50 text-red-700",
};
export function Badge({
tone = "neutral",
className,
...props
}: React.HTMLAttributes<HTMLSpanElement> & { tone?: Tone }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
tones[tone],
className,
)}
{...props}
/>
);
}
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type Variant = "default" | "outline" | "ghost" | "destructive";
type Size = "sm" | "md" | "icon";
const variants: Record<Variant, string> = {
default: "bg-teal-700 text-white hover:bg-teal-800",
outline: "border border-zinc-300 bg-white text-zinc-800 hover:bg-zinc-50",
ghost: "text-zinc-700 hover:bg-zinc-100",
destructive: "bg-red-600 text-white hover:bg-red-700",
};
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-sm",
md: "h-9 px-4 text-sm",
icon: "h-9 w-9",
};
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", ...props }, ref) => (
<button
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-1",
"disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
{...props}
/>
),
);
Button.displayName = "Button";
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-lg border border-zinc-200 bg-white shadow-sm", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1 p-5 pb-3", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("text-sm font-semibold text-zinc-900", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-xs text-zinc-500", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-5 pt-0", className)} {...props} />;
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
"h-9 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900",
"placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600",
"disabled:opacity-50",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label ref={ref} className={cn("text-xs font-medium text-zinc-600", className)} {...props} />
),
);
Label.displayName = "Label";
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Select = React.forwardRef<
HTMLSelectElement,
React.SelectHTMLAttributes<HTMLSelectElement>
>(({ className, ...props }, ref) => (
<select
ref={ref}
className={cn(
"h-9 w-full rounded-md border border-zinc-300 bg-white px-2 text-sm text-zinc-900",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600",
className,
)}
{...props}
/>
));
Select.displayName = "Select";
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
label: string;
id?: string;
}
export function Switch({ checked, onCheckedChange, label, id }: SwitchProps) {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onCheckedChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-teal-700" : "bg-zinc-300",
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
/>
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Table({ className, ...props }: React.TableHTMLAttributes<HTMLTableElement>) {
return <table className={cn("w-full text-sm", className)} {...props} />;
}
export function THead({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("border-b border-zinc-200", className)} {...props} />;
}
export function TBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("divide-y divide-zinc-100", className)} {...props} />;
}
export function TR({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn("text-left", className)} {...props} />;
}
export function TH({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th className={cn("px-3 py-2 text-xs font-medium text-zinc-500", className)} {...props} />
);
}
export function TD({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn("px-3 py-2 text-zinc-800", className)} {...props} />;
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { enUS, zhCN } from "./resources";
function flatten(value: unknown, prefix = ""): string[] {
if (typeof value !== "object" || value === null) return [prefix];
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
flatten(child, prefix ? `${prefix}.${key}` : key),
);
}
describe("i18n resources", () => {
it("zh-CN and en-US define the same keys", () => {
const zhKeys = flatten(zhCN.translation).sort();
const enKeys = flatten(enUS.translation).sort();
expect(zhKeys).toEqual(enKeys);
});
it("has no empty strings", () => {
for (const key of flatten(zhCN.translation)) {
const value = key
.split(".")
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], zhCN.translation);
expect(typeof value).toBe("string");
expect((value as string).length).toBeGreaterThan(0);
}
});
});
+19
View File
@@ -0,0 +1,19 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { enUS, zhCN } from "./resources";
export const SUPPORTED_LANGUAGES = ["zh-CN", "en-US"] as const;
export type Language = (typeof SUPPORTED_LANGUAGES)[number];
void i18n.use(initReactI18next).init({
resources: {
"zh-CN": zhCN,
"en-US": enUS,
},
lng: "zh-CN",
fallbackLng: "en-US",
interpolation: { escapeValue: false },
returnNull: false,
});
export default i18n;
+213
View File
@@ -0,0 +1,213 @@
export const zhCN = {
translation: {
app: {
name: "CrossRelay",
tagline: "Claude Code 与 Codex 的本地协议网关",
},
nav: {
dashboard: "仪表盘",
providers: "供应商",
settings: "设置",
},
common: {
save: "保存",
cancel: "取消",
delete: "删除",
edit: "编辑",
add: "新增",
activate: "激活",
restoring: "还原中",
restore: "还原官方配置",
loading: "加载中",
error: "出错了",
retry: "重试",
none: "无",
optional: "选填",
language: "语言",
actions: "操作",
saved: "已保存",
copyFailed: "操作失败",
yes: "是",
no: "否",
},
dashboard: {
title: "仪表盘",
gateway: "网关",
running: "运行中",
stopped: "已停止",
port: "端口",
uptime: "运行时长",
version: "版本",
activeProvider: "当前供应商",
noActiveProvider: "尚未激活任何供应商",
requests: "请求总数",
failed: "失败请求",
tokensIn: "输入 Token",
tokensOut: "输出 Token",
recentRequests: "最近请求",
quickApply: "写入 Claude 配置",
restoreDone: "已还原为官方配置",
tableTime: "时间",
tableModel: "模型",
tableStatus: "状态",
tableDuration: "耗时",
tableTokens: "Token",
emptyLogs: "暂无请求记录",
statsTitle: "统计",
},
providers: {
title: "供应商",
add: "新增供应商",
edit: "编辑供应商",
name: "名称",
baseUrl: "Base URL",
apiKey: "API Key",
preset: "预设",
presetCustom: "自定义",
models: "模型映射",
defaultModel: "默认模型",
sonnetModel: "Sonnet 模型",
haikuModel: "Haiku 模型",
opusModel: "Opus 模型",
extraMappings: "精确映射",
extraKey: "请求的模型名",
extraValue: "上游模型名",
addMapping: "添加一行",
activated: "已激活",
activate: "激活并写入",
empty: "还没有供应商,先添加一个",
validationName: "请填写名称",
validationBaseUrl: "请填写 http(s) Base URL",
validationApiKey: "请填写 API Key",
appliedEnv: "已写入 ~/.claude/settings.json 的环境变量",
created: "供应商已创建",
updated: "供应商已更新",
deleted: "供应商已删除",
deleteConfirm: "确定删除这个供应商吗?",
keyHint: "留空表示保持原有 Key 不变",
enabledClaude: "对 Claude Code 启用",
},
settings: {
title: "设置",
gateway: "网关",
portMode: "端口模式",
portAuto: "自动(随机端口)",
portFixed: "固定端口",
fixedPort: "固定端口号",
portRestartHint: "端口模式在网关重启后生效",
language: "界面语言",
backups: "配置备份",
backupsEmpty: "暂无备份",
restoreBackup: "还原",
restored: "已还原",
saveFailed: "保存失败",
},
},
};
export const enUS = {
translation: {
app: {
name: "CrossRelay",
tagline: "Local protocol gateway for Claude Code and Codex",
},
nav: {
dashboard: "Dashboard",
providers: "Providers",
settings: "Settings",
},
common: {
save: "Save",
cancel: "Cancel",
delete: "Delete",
edit: "Edit",
add: "Add",
activate: "Activate",
restoring: "Restoring",
restore: "Restore official config",
loading: "Loading",
error: "Something went wrong",
retry: "Retry",
none: "None",
optional: "optional",
language: "Language",
actions: "Actions",
saved: "Saved",
copyFailed: "Action failed",
yes: "Yes",
no: "No",
},
dashboard: {
title: "Dashboard",
gateway: "Gateway",
running: "Running",
stopped: "Stopped",
port: "Port",
uptime: "Uptime",
version: "Version",
activeProvider: "Active provider",
noActiveProvider: "No provider activated yet",
requests: "Requests",
failed: "Failed",
tokensIn: "Input tokens",
tokensOut: "Output tokens",
recentRequests: "Recent requests",
quickApply: "Write to Claude config",
restoreDone: "Official config restored",
tableTime: "Time",
tableModel: "Model",
tableStatus: "Status",
tableDuration: "Duration",
tableTokens: "Tokens",
emptyLogs: "No requests yet",
statsTitle: "Stats",
},
providers: {
title: "Providers",
add: "Add provider",
edit: "Edit provider",
name: "Name",
baseUrl: "Base URL",
apiKey: "API key",
preset: "Preset",
presetCustom: "Custom",
models: "Model mapping",
defaultModel: "Default model",
sonnetModel: "Sonnet model",
haikuModel: "Haiku model",
opusModel: "Opus model",
extraMappings: "Exact mappings",
extraKey: "Requested model",
extraValue: "Upstream model",
addMapping: "Add row",
activated: "Active",
activate: "Activate & apply",
empty: "No providers yet",
validationName: "Name is required",
validationBaseUrl: "An http(s) base URL is required",
validationApiKey: "API key is required",
appliedEnv: "Environment written to ~/.claude/settings.json",
created: "Provider created",
updated: "Provider updated",
deleted: "Provider deleted",
deleteConfirm: "Delete this provider?",
keyHint: "Leave empty to keep the current key",
enabledClaude: "Enabled for Claude Code",
},
settings: {
title: "Settings",
gateway: "Gateway",
portMode: "Port mode",
portAuto: "Automatic (random port)",
portFixed: "Fixed port",
fixedPort: "Fixed port number",
portRestartHint: "Port mode applies after the gateway restarts",
language: "Language",
backups: "Config backups",
backupsEmpty: "No backups yet",
restoreBackup: "Restore",
restored: "Restored",
saveFailed: "Save failed",
},
},
};
+19
View File
@@ -0,0 +1,19 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Cascadia Code", ui-monospace, monospace;
}
@layer base {
html,
body,
#root {
height: 100%;
}
body {
@apply bg-zinc-50 text-zinc-900 antialiased;
font-family: var(--font-sans);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { fetch } from "@tauri-apps/plugin-http";
import type {
ApplyResult,
BackupInfo,
LogEntry,
Models,
Provider,
Settings,
Status,
} from "./types";
const FALLBACK_PORT = 8787;
function baseUrl(): string {
const port =
(window as unknown as { __CROSSRELAY_PORT__?: number }).__CROSSRELAY_PORT__ ?? FALLBACK_PORT;
return `http://127.0.0.1:${port}`;
}
export class ApiError extends Error {}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let response: Response;
try {
response = await fetch(baseUrl() + path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
} catch (err) {
throw new ApiError(`${err instanceof Error ? err.message : String(err)}`);
}
const text = await response.text();
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const body = JSON.parse(text);
if (body?.error?.message) message = body.error.message;
else if (typeof body?.error === "string") message = body.error;
} catch {
if (text) message = text;
}
throw new ApiError(message);
}
return (text ? JSON.parse(text) : null) as T;
}
export interface ProviderInput {
name: string;
preset?: string;
base_url: string;
api_key: string;
models: Models;
}
export const api = {
getStatus: () => request<Status>("/api/status"),
listProviders: () => request<{ items: Provider[] }>("/api/providers"),
createProvider: (input: ProviderInput) =>
request<Provider>("/api/providers", { method: "POST", body: JSON.stringify(input) }),
updateProvider: (id: string, input: ProviderInput) =>
request<Provider>(`/api/providers/${id}`, { method: "PUT", body: JSON.stringify(input) }),
deleteProvider: (id: string) =>
request<{ deleted: string }>(`/api/providers/${id}`, { method: "DELETE" }),
applyProvider: (id: string) =>
request<ApplyResult>(`/api/providers/${id}/apply`, { method: "POST", body: "{}" }),
restore: () => request<{ restored_from: string }>("/api/restore", { method: "POST", body: "{}" }),
listBackups: () => request<{ items: BackupInfo[] }>("/api/backups"),
getSettings: () => request<Settings>("/api/settings"),
updateSettings: (patch: Partial<Settings>) =>
request<Settings>("/api/settings", { method: "PUT", body: JSON.stringify(patch) }),
listLogs: () => request<{ items: LogEntry[] }>("/api/logs"),
};
+76
View File
@@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type ProviderInput } from "./api";
export const queryKeys = {
status: ["status"] as const,
providers: ["providers"] as const,
logs: ["logs"] as const,
backups: ["backups"] as const,
settings: ["settings"] as const,
};
export function useStatus() {
return useQuery({ queryKey: queryKeys.status, queryFn: api.getStatus, refetchInterval: 5000 });
}
export function useProviders() {
return useQuery({ queryKey: queryKeys.providers, queryFn: api.listProviders });
}
export function useLogs() {
return useQuery({ queryKey: queryKeys.logs, queryFn: api.listLogs, refetchInterval: 5000 });
}
export function useBackups() {
return useQuery({ queryKey: queryKeys.backups, queryFn: api.listBackups });
}
export function useSettings() {
return useQuery({ queryKey: queryKeys.settings, queryFn: api.getSettings });
}
function invalidateAll(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.providers }),
queryClient.invalidateQueries({ queryKey: queryKeys.status }),
queryClient.invalidateQueries({ queryKey: queryKeys.backups }),
queryClient.invalidateQueries({ queryKey: queryKeys.settings }),
]);
}
export function useProviderMutations() {
const queryClient = useQueryClient();
const create = useMutation({
mutationFn: (input: ProviderInput) => api.createProvider(input),
onSuccess: () => invalidateAll(queryClient),
});
const update = useMutation({
mutationFn: ({ id, input }: { id: string; input: ProviderInput }) => api.updateProvider(id, input),
onSuccess: () => invalidateAll(queryClient),
});
const remove = useMutation({
mutationFn: (id: string) => api.deleteProvider(id),
onSuccess: () => invalidateAll(queryClient),
});
const apply = useMutation({
mutationFn: (id: string) => api.applyProvider(id),
onSuccess: () => invalidateAll(queryClient),
});
return { create, update, remove, apply };
}
export function useRestore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.restore(),
onSuccess: () => invalidateAll(queryClient),
});
}
export function useSettingsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch: Parameters<typeof api.updateSettings>[0]) => api.updateSettings(patch),
onSuccess: () => invalidateAll(queryClient),
});
}
+102
View File
@@ -0,0 +1,102 @@
export interface Models {
default?: string;
sonnet?: string;
haiku?: string;
opus?: string;
extra?: Record<string, string>;
}
export interface Provider {
id: string;
name: string;
preset?: string;
base_url: string;
api_key: string;
models: Models;
enabled_for_claude: boolean;
created_at: string;
}
export interface Stats {
requests_total: number;
requests_failed: number;
input_tokens: number;
output_tokens: number;
}
export interface ActiveProvider {
id: string;
name: string;
base_url: string;
models: Models;
}
export interface Status {
running: boolean;
version: string;
port: number;
uptime_sec: number;
active_claude_provider: ActiveProvider | null;
stats: Stats;
language: string;
port_mode: string;
fixed_port: number;
}
export interface LogEntry {
time: string;
method: string;
path: string;
model?: string;
status: number;
duration_ms: number;
input_tokens?: number;
output_tokens?: number;
error?: string;
}
export interface BackupInfo {
name: string;
size: number;
modified: string;
}
export interface Settings {
language: string;
port_mode: string;
fixed_port: number;
}
export interface ApplyResult {
provider: Provider;
env: Record<string, string>;
}
export interface Preset {
key: string;
base_url: string;
models: Models;
}
export const PRESETS: Preset[] = [
{
key: "deepseek",
base_url: "https://api.deepseek.com/v1",
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
},
{
key: "moonshot",
base_url: "https://api.moonshot.cn/v1",
models: {
default: "kimi-k2-0905-preview",
sonnet: "kimi-k2-0905-preview",
haiku: "kimi-k2-0905-preview",
opus: "kimi-k2-0905-preview",
},
},
{
key: "zhipu",
base_url: "https://open.bigmodel.cn/api/paas/v4",
models: { default: "glm-4.6", sonnet: "glm-4.6", haiku: "glm-4.6", opus: "glm-4.6" },
},
];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import "./i18n";
import { bootstrap } from "./bootstrap";
bootstrap().then(({ port, language }) => {
(window as unknown as { __CROSSRELAY_PORT__?: number }).__CROSSRELAY_PORT__ = port ?? undefined;
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App initialLanguage={language} />
</React.StrictMode>,
);
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "@/test/utils";
const getStatus = vi.fn();
const listLogs = vi.fn();
const restore = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
listProviders: vi.fn().mockResolvedValue({ items: [] }),
createProvider: vi.fn(),
updateProvider: vi.fn(),
deleteProvider: vi.fn(),
applyProvider: vi.fn(),
restore: () => restore(),
getStatus: () => getStatus(),
getSettings: vi.fn().mockResolvedValue({ language: "zh-CN", port_mode: "auto", fixed_port: 0 }),
updateSettings: vi.fn(),
listLogs: () => listLogs(),
listBackups: vi.fn().mockResolvedValue({ items: [] }),
},
}));
import Dashboard from "./Dashboard";
beforeEach(() => {
vi.clearAllMocks();
getStatus.mockResolvedValue({
running: true,
version: "0.1.0",
port: 52917,
uptime_sec: 95,
active_claude_provider: { id: "p_1", name: "DeepSeek", base_url: "https://api.deepseek.com/v1", models: {} },
stats: { requests_total: 7, requests_failed: 1, input_tokens: 100, output_tokens: 200 },
language: "zh-CN",
port_mode: "auto",
fixed_port: 0,
});
listLogs.mockResolvedValue({
items: [
{
time: "2026-10-18T08:00:00Z",
method: "POST",
path: "/v1/messages",
model: "deepseek-chat",
status: 200,
duration_ms: 820,
input_tokens: 40,
output_tokens: 12,
},
],
});
});
describe("Dashboard", () => {
it("renders gateway status, active provider and stats", async () => {
renderWithProviders(<Dashboard />);
expect(await screen.findByText("运行中")).toBeInTheDocument();
expect(screen.getByText(/52917/)).toBeInTheDocument();
expect(screen.getByText("DeepSeek")).toBeInTheDocument();
expect(screen.getByText("7")).toBeInTheDocument(); // requests total
expect(screen.getByText("deepseek-chat")).toBeInTheDocument(); // log model
// 200 shows up both as output tokens and as the log row status.
expect(screen.getAllByText("200").length).toBeGreaterThanOrEqual(2);
});
});
+170
View File
@@ -0,0 +1,170 @@
import { useTranslation } from "react-i18next";
import { RotateCcw, Zap } from "lucide-react";
import { useRestore, useStatus, useLogs } from "@/lib/queries";
import { useToast } from "@/components/toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function formatTime(iso: string): string {
const date = new Date(iso);
return date.toLocaleTimeString();
}
export default function Dashboard() {
const { t } = useTranslation();
const status = useStatus();
const logs = useLogs();
const restore = useRestore();
const { show } = useToast();
if (status.isPending) {
return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
}
if (status.isError || !status.data) {
return (
<div className="flex flex-col items-start gap-3">
<span className="text-sm text-red-600">{t("common.error")}</span>
<Button variant="outline" size="sm" onClick={() => status.refetch()}>
{t("common.retry")}
</Button>
</div>
);
}
const s = status.data;
const active = s.active_claude_provider;
return (
<div className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{t("dashboard.gateway")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge tone={s.running ? "success" : "danger"}>
{s.running ? t("dashboard.running") : t("dashboard.stopped")}
</Badge>
<span className="text-xs text-zinc-500">
{t("dashboard.port")} {s.port}
</span>
</div>
<div className="text-xs text-zinc-500">
{t("dashboard.uptime")} {formatUptime(s.uptime_sec)} · {t("dashboard.version")}{" "}
{s.version}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("dashboard.activeProvider")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{active ? (
<>
<div className="text-sm font-medium">{active.name}</div>
<div className="truncate text-xs text-zinc-500" title={active.base_url}>
{active.base_url}
</div>
</>
) : (
<div className="text-sm text-zinc-500">{t("dashboard.noActiveProvider")}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("dashboard.statsTitle")}</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-2 text-xs">
<div>
<dt className="text-zinc-500">{t("dashboard.requests")}</dt>
<dd className="text-base font-semibold">{s.stats.requests_total}</dd>
</div>
<div>
<dt className="text-zinc-500">{t("dashboard.failed")}</dt>
<dd className="text-base font-semibold">{s.stats.requests_failed}</dd>
</div>
<div>
<dt className="text-zinc-500">{t("dashboard.tokensIn")}</dt>
<dd className="text-base font-semibold">{s.stats.input_tokens}</dd>
</div>
<div>
<dt className="text-zinc-500">{t("dashboard.tokensOut")}</dt>
<dd className="text-base font-semibold">{s.stats.output_tokens}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t("dashboard.recentRequests")}</CardTitle>
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(undefined, {
onSuccess: () => show(t("dashboard.restoreDone")),
onError: (err) => show(err.message, "error"),
})
}
>
{restore.isPending ? (
<RotateCcw size={14} className="animate-spin" aria-hidden />
) : (
<Zap size={14} aria-hidden />
)}
{restore.isPending ? t("common.restoring") : t("common.restore")}
</Button>
</CardHeader>
<CardContent>
{logs.data && logs.data.items.length > 0 ? (
<Table>
<THead>
<TR>
<TH>{t("dashboard.tableTime")}</TH>
<TH>{t("dashboard.tableModel")}</TH>
<TH>{t("dashboard.tableStatus")}</TH>
<TH>{t("dashboard.tableDuration")}</TH>
<TH>{t("dashboard.tableTokens")}</TH>
</TR>
</THead>
<TBody>
{logs.data.items.slice(0, 12).map((entry, index) => (
<TR key={`${entry.time}-${index}`}>
<TD className="text-xs text-zinc-500">{formatTime(entry.time)}</TD>
<TD className="font-mono text-xs">{entry.model ?? entry.path}</TD>
<TD>
<Badge tone={entry.status < 400 ? "success" : "danger"}>{entry.status}</Badge>
</TD>
<TD className="text-xs">{entry.duration_ms} ms</TD>
<TD className="text-xs">
{entry.input_tokens ?? 0} / {entry.output_tokens ?? 0}
</TD>
</TR>
))}
</TBody>
</Table>
) : (
<div className="text-sm text-zinc-500">{t("dashboard.emptyLogs")}</div>
)}
</CardContent>
</Card>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { renderWithProviders } from "@/test/utils";
import userEvent from "@testing-library/user-event";
const listProviders = vi.fn();
const createProvider = vi.fn();
const deleteProvider = vi.fn();
const applyProvider = vi.fn();
const restore = vi.fn();
const getStatus = vi.fn();
const getSettings = vi.fn();
const listLogs = vi.fn();
const listBackups = vi.fn();
const updateProvider = vi.fn();
const updateSettings = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
listProviders: () => listProviders(),
createProvider: (input: unknown) => createProvider(input),
updateProvider: (id: string, input: unknown) => updateProvider(id, input),
deleteProvider: (id: string) => deleteProvider(id),
applyProvider: (id: string) => applyProvider(id),
restore: () => restore(),
getStatus: () => getStatus(),
getSettings: () => getSettings(),
updateSettings: (patch: unknown) => updateSettings(patch),
listLogs: () => listLogs(),
listBackups: () => listBackups(),
},
}));
import Providers from "./Providers";
const provider = {
id: "p_1",
name: "DeepSeek",
preset: "deepseek",
base_url: "https://api.deepseek.com/v1",
api_key: "sk-x",
models: { default: "deepseek-chat", sonnet: "deepseek-chat", haiku: "deepseek-chat", opus: "deepseek-chat" },
enabled_for_claude: false,
created_at: "2026-10-18T00:00:00Z",
};
function renderPage() {
return renderWithProviders(<Providers />);
}
beforeEach(() => {
vi.clearAllMocks();
listProviders.mockResolvedValue({ items: [provider] });
createProvider.mockResolvedValue({ ...provider, id: "p_2" });
deleteProvider.mockResolvedValue({ deleted: "p_1" });
applyProvider.mockResolvedValue({ provider, env: { ANTHROPIC_BASE_URL: "http://127.0.0.1:1234" } });
});
describe("Providers page", () => {
it("renders the provider list", async () => {
renderPage();
expect(await screen.findByText("DeepSeek")).toBeInTheDocument();
expect(screen.getByText("https://api.deepseek.com/v1")).toBeInTheDocument();
});
it("validates the create form before submitting", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
expect(await screen.findByText(/请填写名称|Name is required/)).toBeInTheDocument();
expect(await screen.findByText(/请填写 http|http\(s\) base URL is required/)).toBeInTheDocument();
expect(createProvider).not.toHaveBeenCalled();
});
it("creates a provider with the preset defaults", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /新增供应商|Add provider/ }));
await user.type(screen.getByLabelText(/名称|Name/), "Kimi");
await user.type(screen.getByLabelText(/Base URL/), "https://api.moonshot.cn/v1");
await user.type(screen.getByLabelText(/API Key/), "sk-kimi");
await user.click(screen.getByRole("button", { name: /保存|Save/ }));
await waitFor(() => expect(createProvider).toHaveBeenCalledTimes(1));
const input = createProvider.mock.calls[0][0];
expect(input).toMatchObject({
name: "Kimi",
base_url: "https://api.moonshot.cn/v1",
api_key: "sk-kimi",
});
});
it("activates a provider and shows the applied env", async () => {
const user = userEvent.setup();
renderPage();
await user.click(await screen.findByRole("button", { name: /激活并写入|Activate & apply/ }));
await waitFor(() => expect(applyProvider).toHaveBeenCalledWith("p_1"));
expect(await screen.findByText(/ANTHROPIC_BASE_URL/)).toBeInTheDocument();
});
});
+295
View File
@@ -0,0 +1,295 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, Pencil } from "lucide-react";
import { PRESETS } from "@/lib/types";
import type { Models, Provider } from "@/lib/types";
import { useProviders, useProviderMutations } from "@/lib/queries";
import { useToast } from "@/components/toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input, Label } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
interface FormState {
id?: string;
name: string;
preset: string;
base_url: string;
api_key: string;
models: Models;
}
const emptyForm: FormState = {
name: "",
preset: "",
base_url: "",
api_key: "",
models: { default: "", sonnet: "", haiku: "", opus: "", extra: {} },
};
export default function Providers() {
const { t } = useTranslation();
const { data, isLoading, isError } = useProviders();
const mutations = useProviderMutations();
const { show } = useToast();
const [form, setForm] = useState<FormState | null>(null);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const [lastAppliedEnv, setLastAppliedEnv] = useState<Record<string, string> | null>(null);
const providers = data?.items ?? [];
const openCreate = (presetKey = "") => {
const preset = PRESETS.find((p) => p.key === presetKey);
setErrors({});
setForm({
...emptyForm,
preset: presetKey,
name: presetKey ? presetKey : "",
base_url: preset?.base_url ?? "",
models: preset ? { ...preset.models } : { ...emptyForm.models },
});
};
const openEdit = (provider: Provider) => {
setErrors({});
setForm({
id: provider.id,
name: provider.name,
preset: provider.preset ?? "",
base_url: provider.base_url,
api_key: "",
models: {
default: provider.models.default ?? "",
sonnet: provider.models.sonnet ?? "",
haiku: provider.models.haiku ?? "",
opus: provider.models.opus ?? "",
extra: { ...(provider.models.extra ?? {}) },
},
});
};
const validate = (): boolean => {
if (!form) return false;
const next: Partial<Record<keyof FormState, string>> = {};
if (!form.name.trim()) next.name = t("providers.validationName");
if (!/^https?:\/\/.+/.test(form.base_url.trim())) next.base_url = t("providers.validationBaseUrl");
if (!form.id && !form.api_key.trim()) next.api_key = t("providers.validationApiKey");
setErrors(next);
return Object.keys(next).length === 0;
};
const submit = () => {
if (!form || !validate()) return;
const input = {
name: form.name.trim(),
preset: form.preset,
base_url: form.base_url.trim(),
api_key: form.api_key.trim(),
models: {
default: form.models.default?.trim(),
sonnet: form.models.sonnet?.trim(),
haiku: form.models.haiku?.trim(),
opus: form.models.opus?.trim(),
extra: Object.fromEntries(
Object.entries(form.models.extra ?? {}).filter(([k, v]) => k.trim() && v.trim()),
),
},
};
const onSuccess = () => {
show(form.id ? t("providers.updated") : t("providers.created"));
setForm(null);
};
const onError = (err: Error) => show(err.message, "error");
if (form.id) {
mutations.update.mutate({ id: form.id, input }, { onSuccess, onError });
} else {
mutations.create.mutate(input, { onSuccess, onError });
}
};
const apply = (provider: Provider) => {
mutations.apply.mutate(provider.id, {
onSuccess: (result) => {
setLastAppliedEnv(result.env);
show(`${t("providers.activated")}: ${provider.name}`);
},
onError: (err) => show(err.message, "error"),
});
};
if (isLoading) return <div className="text-sm text-zinc-500">{t("common.loading")}</div>;
if (isError) return <div className="text-sm text-red-600">{t("common.error")}</div>;
return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{t("providers.title")}</h2>
<Button size="sm" onClick={() => openCreate()}>
<Plus size={14} aria-hidden />
{t("providers.add")}
</Button>
</div>
{form && (
<Card>
<CardHeader>
<CardTitle>{form.id ? t("providers.edit") : t("providers.add")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<Label>{t("providers.name")}</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="DeepSeek"
/>
{errors.name && <span className="text-xs text-red-600">{errors.name}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.preset")}</Label>
<Select
value={form.preset}
onChange={(e) => {
const key = e.target.value;
const preset = PRESETS.find((p) => p.key === key);
setForm({
...form,
preset: key,
base_url: preset?.base_url ?? form.base_url,
models: preset ? { ...preset.models } : form.models,
});
}}
>
<option value="">{t("providers.presetCustom")}</option>
{PRESETS.map((p) => (
<option key={p.key} value={p.key}>
{p.key}
</option>
))}
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("providers.baseUrl")}</Label>
<Input
value={form.base_url}
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
placeholder="https://api.deepseek.com/v1"
/>
{errors.base_url && <span className="text-xs text-red-600">{errors.base_url}</span>}
</label>
<label className="flex flex-col gap-1">
<Label>
{t("providers.apiKey")}
{form.id && <span className="ml-1 text-zinc-400">({t("providers.keyHint")})</span>}
</Label>
<Input
type="password"
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder="sk-..."
/>
{errors.api_key && <span className="text-xs text-red-600">{errors.api_key}</span>}
</label>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="text-xs font-medium text-zinc-600">{t("providers.models")}</legend>
<div className="grid grid-cols-2 gap-3">
{(["default", "sonnet", "haiku", "opus"] as const).map((role) => (
<label key={role} className="flex flex-col gap-1">
<Label>{t(`providers.${role}Model`)}</Label>
<Input
value={form.models[role] ?? ""}
onChange={(e) =>
setForm({ ...form, models: { ...form.models, [role]: e.target.value } })
}
/>
</label>
))}
</div>
</fieldset>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setForm(null)}>
{t("common.cancel")}
</Button>
<Button size="sm" onClick={submit}>
{t("common.save")}
</Button>
</div>
</CardContent>
</Card>
)}
{lastAppliedEnv && (
<Card>
<CardHeader>
<CardTitle>{t("providers.appliedEnv")}</CardTitle>
</CardHeader>
<CardContent>
<ul className="grid grid-cols-2 gap-1 font-mono text-xs text-zinc-600">
{Object.entries(lastAppliedEnv)
.filter(([key]) => key.startsWith("ANTHROPIC_"))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => (
<li key={key}>
<span className="text-zinc-400">{key}</span> = {value}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{providers.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-zinc-500">
{t("providers.empty")}
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-3">
{providers.map((provider) => (
<Card key={provider.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{provider.name}</span>
{provider.enabled_for_claude && <Badge tone="success">{t("providers.activated")}</Badge>}
{provider.preset && <Badge>{provider.preset}</Badge>}
</div>
<div className="truncate text-xs text-zinc-500">{provider.base_url}</div>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" onClick={() => apply(provider)}>
{t("providers.activate")}
</Button>
<Button variant="ghost" size="icon" aria-label={t("common.edit")} onClick={() => openEdit(provider)}>
<Pencil size={14} aria-hidden />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() => {
if (window.confirm(t("providers.deleteConfirm"))) {
mutations.remove.mutate(provider.id, {
onSuccess: () => show(t("providers.deleted")),
onError: (err) => show(err.message, "error"),
});
}
}}
>
<Trash2 size={14} aria-hidden />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useBackups, useRestore, useSettings, useSettingsMutation } from "@/lib/queries";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input, Label } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Table, TBody, TD, TH, THead, TR } from "@/components/ui/table";
export default function SettingsPage() {
const { t, i18n } = useTranslation();
const { data: settings } = useSettings();
const { data: backups } = useBackups();
const mutation = useSettingsMutation();
const restore = useRestore();
const { show } = useToast();
const [portMode, setPortMode] = useState("auto");
const [fixedPort, setFixedPort] = useState("8787");
const [language, setLanguage] = useState(i18n.language);
useEffect(() => {
if (settings) {
setPortMode(settings.port_mode);
setFixedPort(String(settings.fixed_port || 8787));
setLanguage(settings.language);
}
}, [settings]);
const save = () => {
mutation.mutate(
{
port_mode: portMode,
fixed_port: portMode === "fixed" ? Number(fixedPort) || 0 : 0,
language,
},
{
onSuccess: () => {
void i18n.changeLanguage(language);
show(t("common.saved"));
},
onError: (err) => show(err.message, "error"),
},
);
};
return (
<div className="flex flex-col gap-5">
<h2 className="text-lg font-semibold">{t("settings.title")}</h2>
<Card>
<CardHeader>
<CardTitle>{t("settings.gateway")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<Label>{t("settings.portMode")}</Label>
<Select value={portMode} onChange={(e) => setPortMode(e.target.value)}>
<option value="auto">{t("settings.portAuto")}</option>
<option value="fixed">{t("settings.portFixed")}</option>
</Select>
</label>
<label className="flex flex-col gap-1">
<Label>{t("settings.fixedPort")}</Label>
<Input
type="number"
min={1}
max={65535}
value={fixedPort}
disabled={portMode !== "fixed"}
onChange={(e) => setFixedPort(e.target.value)}
/>
</label>
</div>
<p className="text-xs text-zinc-500">{t("settings.portRestartHint")}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("settings.language")}</CardTitle>
</CardHeader>
<CardContent>
<Select value={language} onChange={(e) => setLanguage(e.target.value)} className="max-w-48">
<option value="zh-CN">中文</option>
<option value="en-US">English</option>
</Select>
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{t("settings.backups")}</CardTitle>
<Button
variant="outline"
size="sm"
disabled={restore.isPending}
onClick={() =>
restore.mutate(undefined, {
onSuccess: (result) => show(`${t("settings.restored")}: ${result.restored_from}`),
onError: (err) => show(err.message, "error"),
})
}
>
{t("settings.restoreBackup")}
</Button>
</CardHeader>
<CardContent>
{backups && backups.items && backups.items.length > 0 ? (
<Table>
<THead>
<TR>
<TH>{t("settings.backups")}</TH>
<TH>Size</TH>
<TH>Modified</TH>
</TR>
</THead>
<TBody>
{backups.items.map((item) => (
<TR key={item.name}>
<TD className="font-mono text-xs">{item.name}</TD>
<TD className="text-xs">{item.size} B</TD>
<TD className="text-xs">{new Date(item.modified).toLocaleString()}</TD>
</TR>
))}
</TBody>
</Table>
) : (
<div className="text-sm text-zinc-500">{t("settings.backupsEmpty")}</div>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={save} disabled={mutation.isPending}>
{t("common.save")}
</Button>
</div>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
import "@testing-library/jest-dom/vitest";
import "@testing-library/jest-dom/vitest";
import "../i18n";
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ToastProvider } from "@/components/toast";
export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ToastProvider>{ui}</ToastProvider>
</QueryClientProvider>,
options,
);
}
export { render };