Merge branch 'r-dev' of github.com:Mai-with-u/MaiBot into r-dev

This commit is contained in:
UnCLAS-Prommer
2026-03-04 21:06:53 +08:00
48 changed files with 9725 additions and 559 deletions

3
.gitignore vendored
View File

@@ -353,4 +353,5 @@ interested_rates.txt
MaiBot.code-workspace
*.lock
actionlint
.sisyphus/
.sisyphus/
dist-electron/

View File

@@ -0,0 +1,135 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'electron-vite'
import path from 'path'
export default defineConfig({
main: {
entry: 'electron/main/index.ts',
build: {
target: 'node18',
lib: {
entry: 'electron/main/index.ts',
},
rollupOptions: {
external: ['electron', 'electron-store'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
},
preload: {
entry: 'electron/preload/index.ts',
build: {
target: 'node18',
rollupOptions: {
input: path.resolve(__dirname, 'electron/preload/index.ts'),
output: {
entryFileNames: '[name].js',
format: 'cjs',
},
},
},
},
renderer: {
root: '.',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [react()],
server: {
port: 7999,
proxy: {
'/api': {
target: 'http://127.0.0.1:8001',
changeOrigin: true,
ws: true,
cookieDomainRewrite: '',
cookiePathRewrite: '/',
},
},
},
build: {
rollupOptions: {
input: path.resolve(__dirname, 'index.html'),
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'],
router: ['@tanstack/react-router', '@tanstack/react-virtual'],
'radix-core': [
'@radix-ui/react-dialog',
'@radix-ui/react-select',
'@radix-ui/react-checkbox',
'@radix-ui/react-label',
'@radix-ui/react-slot',
'@radix-ui/react-toast',
'@radix-ui/react-tooltip',
],
'radix-extra': [
'@radix-ui/react-alert-dialog',
'@radix-ui/react-avatar',
'@radix-ui/react-collapsible',
'@radix-ui/react-context-menu',
'@radix-ui/react-popover',
'@radix-ui/react-progress',
'@radix-ui/react-scroll-area',
'@radix-ui/react-separator',
'@radix-ui/react-slider',
'@radix-ui/react-switch',
'@radix-ui/react-tabs',
],
icons: ['lucide-react'],
charts: ['recharts'],
codemirror: [
'@uiw/react-codemirror',
'@codemirror/lang-javascript',
'@codemirror/lang-json',
'@codemirror/lang-python',
'@codemirror/lint',
'@codemirror/theme-one-dark',
],
reactflow: ['reactflow', 'dagre'],
markdown: [
'react-markdown',
'remark-gfm',
'remark-math',
'rehype-katex',
'katex',
],
uppy: [
'@uppy/core',
'@uppy/dashboard',
'@uppy/react',
'@uppy/xhr-upload',
],
dnd: ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'],
utils: [
'date-fns',
'clsx',
'tailwind-merge',
'class-variance-authority',
'axios',
],
misc: ['react-joyride', 'react-day-picker', 'cmdk'],
},
},
},
chunkSizeWarningLimit: 500,
},
},
})

View File

@@ -0,0 +1,195 @@
import { app, BrowserWindow, ipcMain, protocol, session } from 'electron'
import path from 'path'
import { fileURLToPath } from 'url'
import { registerAppProtocol } from './protocol'
import {
addBackend,
getActiveBackend,
getBackends,
getWindowBounds,
isFirstLaunch,
markFirstLaunchComplete,
removeBackend,
setActiveBackend,
setWindowBounds,
updateBackend,
} from './store'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
let mainWindow: BrowserWindow | null = null
/**
* Register app:// custom protocol BEFORE app.whenReady()
* This is critical for electron-vite to work correctly
*/
function registerAppScheme() {
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
corsEnabled: true,
secure: true,
allowServiceWorkers: true,
standard: true,
supportFetchAPI: true,
stream: true,
},
},
])
}
/**
* Register all IPC handlers for window control and store CRUD
*/
function registerIpcHandlers() {
// ── Window control ───────────────────────────────────────────────────────
ipcMain.handle('electron:minimize-window', () => mainWindow?.minimize())
ipcMain.handle('electron:maximize-window', () => {
if (mainWindow?.isMaximized()) mainWindow.unmaximize()
else mainWindow?.maximize()
})
ipcMain.handle('electron:close-window', () => mainWindow?.close())
ipcMain.handle('electron:is-maximized', () => mainWindow?.isMaximized() ?? false)
// ── Backend CRUD ─────────────────────────────────────────────────────────
ipcMain.handle('electron:get-backends', () => getBackends())
ipcMain.handle('electron:add-backend', (_e, conn) => addBackend(conn))
ipcMain.handle('electron:update-backend', (_e, id, patch) => updateBackend(id, patch))
ipcMain.handle('electron:remove-backend', (_e, id) => removeBackend(id))
ipcMain.handle('electron:set-active-backend', (_e, id) => {
setActiveBackend(id)
const backend = getActiveBackend()
mainWindow?.webContents.send('electron:backend-changed', backend)
})
ipcMain.handle('electron:get-active-backend', () => getActiveBackend())
ipcMain.handle('electron:get-active-url', () => getActiveBackend()?.url ?? null)
// ── App state ────────────────────────────────────────────────────────────
ipcMain.handle('electron:is-first-launch', () => isFirstLaunch())
ipcMain.handle('electron:mark-first-launch-complete', () => markFirstLaunchComplete())
ipcMain.handle('electron:get-app-version', () => app.getVersion())
}
/**
* Create the main application window
*/
function createWindow() {
const isMac = process.platform === 'darwin'
// Restore window bounds from store
const bounds = getWindowBounds()
mainWindow = new BrowserWindow({
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
minWidth: 800,
minHeight: 600,
// macOS: hide native title bar but keep traffic light buttons
...(isMac
? {
titleBarStyle: 'hidden' as const,
trafficLightPosition: { x: 12, y: 8 },
}
: {}),
// Windows/Linux: overlay title bar (custom title bar integrated)
...(!isMac
? {
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 32,
},
}
: {}),
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
})
// Load the app using app:// protocol
// electron-vite will handle serving the renderer from app://host/index.html
if (process.env.ELECTRON_RENDERER_URL) {
// Development: load from electron-vite dev server
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
// Production: load from bundled renderer
mainWindow.loadURL('app://host/index.html')
}
// Persist window size/position on close
mainWindow.on('close', () => {
if (mainWindow) {
const { x, y, width, height } = mainWindow.getBounds()
setWindowBounds({ x, y, width, height })
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// Push maximize/unmaximize events to renderer
mainWindow.on('maximize', () => {
mainWindow?.webContents.send('electron:window-maximized')
})
mainWindow.on('unmaximize', () => {
mainWindow?.webContents.send('electron:window-unmaximized')
})
}
/**
* App event: when app is ready
*/
app.whenReady().then(() => {
registerAppProtocol()
// Set Content Security Policy
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self' app:; " +
"script-src 'self' 'unsafe-inline' app:; " +
"style-src 'self' 'unsafe-inline' app:; " +
"img-src 'self' app: data: blob:; " +
"font-src 'self' app: data:; " +
"connect-src 'self' app: ws: wss: http: https:; " +
"worker-src 'self' blob:;"
],
},
})
})
registerIpcHandlers()
createWindow()
})
/**
* App event: when all windows are closed (non-macOS behavior)
*/
app.on('window-all-closed', () => {
// On macOS, applications typically stay open until the user quits
if (process.platform !== 'darwin') {
app.quit()
}
})
/**
* App event: when app is activated (macOS)
*/
app.on('activate', () => {
if (mainWindow === null) {
createWindow()
}
})
registerAppScheme()

View File

@@ -0,0 +1,89 @@
import { net, protocol } from 'electron'
import { readFile } from 'fs/promises'
import { dirname, extname, join } from 'path'
import { fileURLToPath } from 'url'
import { getActiveBackend } from './store'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const MIME_TYPES: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.mjs': 'application/javascript',
'.cjs': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.txt': 'text/plain',
'.webp': 'image/webp',
}
export function registerAppProtocol(): void {
protocol.handle('app', async (request) => {
const url = new URL(request.url)
const pathname = url.pathname
if (pathname.startsWith('/api/')) {
const backend = getActiveBackend()
const targetUrl = backend
? `${backend.url.replace(/\/$/, '')}${pathname}${url.search}`
: null
if (!targetUrl) {
return new Response(JSON.stringify({ error: 'No backend configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
}
const headers = new Headers(request.headers)
headers.delete('host')
return net.fetch(targetUrl, {
method: request.method,
headers,
body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
duplex: 'half',
})
}
// Dev mode: renderer is served by vite dev server, not app:// protocol
if (process.env.ELECTRON_RENDERER_URL) {
return new Response(null, { status: 204 })
}
const rendererDir = join(__dirname, '../renderer')
const safePath = decodeURIComponent(pathname)
.replace(/\.\./g, '')
.replace(/^\/+/, '')
const resolvedPath = safePath === '' ? 'index.html' : safePath
const filePath = resolvedPath.endsWith('/')
? join(rendererDir, resolvedPath, 'index.html')
: join(rendererDir, resolvedPath)
const tryReadFile = async (path: string) => {
const ext = extname(path)
const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream'
const data = await readFile(path)
return new Response(data, { headers: { 'Content-Type': mimeType } })
}
try {
return await tryReadFile(filePath)
} catch {
const indexPath = join(rendererDir, 'index.html')
return tryReadFile(indexPath)
}
})
}

View File

@@ -0,0 +1,215 @@
import { randomUUID } from 'crypto'
import Store, { type Schema } from 'electron-store'
/**
* Backend connection data model
*/
export interface BackendConnection {
id: string
name: string
url: string
isDefault: boolean
lastConnected?: number
}
/**
* Application settings data model
*/
export interface AppSettings {
backends: BackendConnection[]
activeBackendId: string | null
windowBounds: {
x: number
y: number
width: number
height: number
}
firstLaunchComplete: boolean
}
/**
* JSON Schema for validating store contents
*/
const SCHEMA: Schema<AppSettings> = {
backends: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
url: { type: 'string' },
isDefault: { type: 'boolean' },
lastConnected: { type: 'number' },
},
required: ['id', 'name', 'url', 'isDefault'],
},
},
activeBackendId: { type: ['string', 'null'] },
windowBounds: {
type: 'object',
properties: {
x: { type: 'number' },
y: { type: 'number' },
width: { type: 'number' },
height: { type: 'number' },
},
required: ['x', 'y', 'width', 'height'],
},
firstLaunchComplete: { type: 'boolean' },
}
/**
* Default settings
*/
const DEFAULTS: AppSettings = {
backends: [],
activeBackendId: null,
windowBounds: {
x: 100,
y: 100,
width: 1280,
height: 800,
},
firstLaunchComplete: false,
}
/**
* Initialize electron-store with encryption and schema validation
*/
const store = new Store<AppSettings>({
schema: SCHEMA,
defaults: DEFAULTS,
encryptionKey: process.env.MAIBOT_STORE_KEY,
})
/**
* Get all backends
*/
export function getBackends(): BackendConnection[] {
return store.get('backends', [])
}
/**
* Add a new backend connection
* Generates UUID for new backend
*/
export function addBackend(
conn: Omit<BackendConnection, 'id'>,
): BackendConnection {
const newBackend: BackendConnection = {
...conn,
id: randomUUID(),
}
const backends = getBackends()
backends.push(newBackend)
store.set('backends', backends)
return newBackend
}
/**
* Update an existing backend connection
*/
export function updateBackend(
id: string,
patch: Partial<Omit<BackendConnection, 'id'>>,
): void {
const backends = getBackends()
const index = backends.findIndex((b) => b.id === id)
if (index === -1) {
throw new Error(`Backend with id ${id} not found`)
}
backends[index] = {
...backends[index],
...patch,
}
store.set('backends', backends)
}
/**
* Remove a backend connection by id
*/
export function removeBackend(id: string): void {
const backends = getBackends()
const filtered = backends.filter((b) => b.id !== id)
store.set('backends', filtered)
// Clear active backend if it was the removed one
if (store.get('activeBackendId') === id) {
store.set('activeBackendId', null)
}
}
/**
* Set the active backend
*/
export function setActiveBackend(id: string): void {
const backends = getBackends()
if (!backends.find((b) => b.id === id)) {
throw new Error(`Backend with id ${id} not found`)
}
store.set('activeBackendId', id)
}
/**
* Get the currently active backend connection
*/
export function getActiveBackend(): BackendConnection | null {
const activeId = store.get('activeBackendId')
if (!activeId) {
return null
}
const backends = getBackends()
return backends.find((b) => b.id === activeId) || null
}
/**
* Get window bounds
*/
export function getWindowBounds(): AppSettings['windowBounds'] {
return store.get('windowBounds', DEFAULTS.windowBounds)
}
/**
* Set window bounds
*/
export function setWindowBounds(bounds: AppSettings['windowBounds']): void {
store.set('windowBounds', bounds)
}
/**
* Check if this is the first launch
*/
export function isFirstLaunch(): boolean {
return !store.get('firstLaunchComplete', false)
}
/**
* Mark first launch as complete
*/
export function markFirstLaunchComplete(): void {
store.set('firstLaunchComplete', true)
}
/**
* Get complete app settings
*/
export function getSettings(): AppSettings {
return {
backends: getBackends(),
activeBackendId: store.get('activeBackendId', null),
windowBounds: getWindowBounds(),
firstLaunchComplete: store.get('firstLaunchComplete', false),
}
}

View File

@@ -0,0 +1,56 @@
import { contextBridge, ipcRenderer } from 'electron'
// Write __RUNTIME__ tag into the isolated world so renderer can detect Electron
contextBridge.exposeInMainWorld('__RUNTIME__', {
kind: 'electron' as const,
versions: process.versions as unknown as Record<string, string>,
source: 'tag' as const,
})
// Expose the full ElectronAPI surface to the renderer process
contextBridge.exposeInMainWorld('electronAPI', {
// ── Platform detection ──────────────────────────────────────────────────
getPlatform: () => process.platform,
// ── Window control ──────────────────────────────────────────────────────
minimizeWindow: () => ipcRenderer.invoke('electron:minimize-window'),
maximizeWindow: () => ipcRenderer.invoke('electron:maximize-window'),
closeWindow: () => ipcRenderer.invoke('electron:close-window'),
isMaximized: () => ipcRenderer.invoke('electron:is-maximized'),
// ── Window event listeners ───────────────────────────────────────────────
onWindowMaximized: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('electron:window-maximized', listener)
return () => ipcRenderer.removeListener('electron:window-maximized', listener)
},
onWindowUnmaximized: (callback: () => void) => {
const listener = () => callback()
ipcRenderer.on('electron:window-unmaximized', listener)
return () => ipcRenderer.removeListener('electron:window-unmaximized', listener)
},
// ── Backend CRUD ─────────────────────────────────────────────────────────
getBackends: () => ipcRenderer.invoke('electron:get-backends'),
addBackend: (conn: object) => ipcRenderer.invoke('electron:add-backend', conn),
updateBackend: (id: string, patch: object) =>
ipcRenderer.invoke('electron:update-backend', id, patch),
removeBackend: (id: string) => ipcRenderer.invoke('electron:remove-backend', id),
setActiveBackend: (id: string) =>
ipcRenderer.invoke('electron:set-active-backend', id),
getActiveBackend: () => ipcRenderer.invoke('electron:get-active-backend'),
getActiveBackendUrl: () => ipcRenderer.invoke('electron:get-active-url'),
// ── App state ───────────────────────────────────────────────────────────
isFirstLaunch: () => ipcRenderer.invoke('electron:is-first-launch'),
markFirstLaunchComplete: () =>
ipcRenderer.invoke('electron:mark-first-launch-complete'),
getAppVersion: () => ipcRenderer.invoke('electron:get-app-version'),
// ── Backend event listener ──────────────────────────────────────────────
onBackendChanged: (callback: (backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => void) => {
const listener = (_event: unknown, backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => callback(backend)
ipcRenderer.on('electron:backend-changed', listener)
return () => ipcRenderer.removeListener('electron:backend-changed', listener)
},
})

View File

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"private": true,
"version": "1.0.0",
"type": "module",
"main": "./out/main/index.js",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -10,7 +11,83 @@
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"test": "vitest",
"test:ui": "vitest --ui"
"test:ui": "vitest --ui",
"electron:dev": "electron-vite dev",
"electron:build": "electron-vite build",
"electron:preview": "electron-vite preview",
"electron:dist": "electron-vite build && electron-builder",
"electron:dist:mac": "electron-vite build && electron-builder --mac",
"electron:dist:win": "electron-vite build && electron-builder --win",
"electron:dist:linux": "electron-vite build && electron-builder --linux"
},
"build": {
"appId": "org.maibot.dashboard",
"productName": "MaiBot Dashboard",
"directories": {
"output": "dist-electron",
"buildResources": "electron/resources"
},
"files": [
"out/**/*",
"package.json"
],
"mac": {
"category": "public.app-category.utilities",
"target": [
{
"target": "dmg",
"arch": [
"x64",
"arm64"
]
}
],
"icon": "electron/resources/icon.icns",
"darkModeSupport": true
},
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "electron/resources/icon.ico"
},
"linux": {
"target": [
{
"target": "AppImage",
"arch": [
"x64"
]
}
],
"icon": "electron/resources/icon.png",
"category": "Utility"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
}
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@@ -60,12 +137,15 @@
"dagre": "^0.8.5",
"date-fns": "^4.1.0",
"html-to-image": "^1.11.13",
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"katex": "^0.16.27",
"lucide-react": "^0.556.0",
"react": "^19.2.1",
"react-day-picker": "^9.12.0",
"react-dom": "^19.2.1",
"react-i18next": "^16.5.4",
"react-joyride": "^2.9.3",
"react-markdown": "^10.1.0",
"reactflow": "^11.11.4",
@@ -88,6 +168,10 @@
"@vitejs/plugin-react": "^5.1.2",
"@vitest/ui": "^4.0.18",
"autoprefixer": "^10.4.22",
"electron": "^40.6.1",
"electron-builder": "^26.8.1",
"electron-store": "11.0.2",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

View File

@@ -0,0 +1,244 @@
import { useState } from 'react'
import { Check, Loader2, Pencil, Plus, Server, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useBackendConnections } from '@/hooks/useBackendConnections'
import { isElectron } from '@/lib/runtime'
import type { BackendConnection } from '@/types/electron'
export interface BackendManagerProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
const {
activeId,
addBackend,
backends,
loading,
removeBackend,
switchBackend,
updateBackend,
} = useBackendConnections()
const [editConn, setEditConn] = useState<Partial<BackendConnection> | null>(null)
const [deleteConn, setDeleteConn] = useState<BackendConnection | null>(null)
if (!isElectron()) return null
const handleSave = async () => {
if (!editConn?.name || !editConn?.url) return
const urlPattern = /^https?:\/\//
if (!urlPattern.test(editConn.url)) return
if (editConn.id) {
await updateBackend(editConn.id, editConn)
} else {
await addBackend({
name: editConn.name,
url: editConn.url,
isDefault: editConn.isDefault ?? false,
})
}
setEditConn(null)
}
const handleDelete = async () => {
if (!deleteConn) return
if (deleteConn.id === activeId) return
await removeBackend(deleteConn.id)
setDeleteConn(null)
}
const handleSwitch = async (id: string) => {
if (id === activeId) return
await switchBackend(id)
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="max-h-[60vh] pr-4">
<div className="flex flex-col gap-3 py-4">
{backends.map((backend) => {
const isActive = backend.id === activeId
return (
<div
key={backend.id}
className={`flex items-center justify-between rounded-lg border p-3 transition-colors ${
isActive ? 'border-blue-500 bg-blue-500/10' : 'border-border'
}`}
>
<div className="flex flex-1 items-center gap-3 overflow-hidden">
<div className="flex-shrink-0">
{isActive ? (
<Check className="h-5 w-5 text-blue-500" />
) : (
<div className="h-3 w-3 rounded-full bg-muted-foreground/30 ml-1" title="未知状态" />
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="truncate font-medium leading-none">
{backend.name}
</span>
<span className="truncate text-xs text-muted-foreground mt-1">
{backend.url}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
{!isActive && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleSwitch(backend.id)}
title="切换到此后端"
>
<Server className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setEditConn(backend)}
title="编辑"
>
<Pencil className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setDeleteConn(backend)}
disabled={isActive}
title={isActive ? '无法删除活跃后端' : '删除'}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</div>
</div>
)
})}
</div>
</ScrollArea>
)}
<div className="flex justify-end pt-4 border-t">
<Button
className="w-full"
onClick={() => setEditConn({ name: '', url: 'http://', isDefault: false })}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
{/* Edit/Add Dialog */}
<Dialog open={!!editConn} onOpenChange={(open) => !open && setEditConn(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{editConn?.id ? '编辑连接' : '添加连接'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={editConn?.name || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
placeholder="我的服务器"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="url">URL</Label>
<Input
id="url"
value={editConn?.url || ''}
onChange={(e) =>
setEditConn((prev) => (prev ? { ...prev, url: e.target.value } : null))
}
placeholder="http://192.168.1.100:8001"
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setEditConn(null)}>
</Button>
<Button
onClick={handleSave}
disabled={
!editConn?.name ||
!editConn?.url ||
!/^https?:\/\//.test(editConn.url)
}
>
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={!!deleteConn} onOpenChange={(open) => !open && setDeleteConn(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteConn?.name}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,256 @@
import { useState } from 'react'
import {
ArrowRight,
Bot,
CheckCircle2,
Loader2,
XCircle,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { isElectron } from '@/lib/runtime'
interface BackendSetupWizardProps {
open: boolean
}
type TestStatus = 'idle' | 'loading' | 'success' | 'error'
/**
* First-launch backend setup wizard for Electron environment.
* Full-screen modal that guides users to configure their first backend connection.
* Cannot be dismissed until configuration is complete.
*/
export function BackendSetupWizard({ open }: BackendSetupWizardProps) {
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const [testStatus, setTestStatus] = useState<TestStatus>('idle')
const [testError, setTestError] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Validation errors
const [nameError, setNameError] = useState('')
const [urlError, setUrlError] = useState('')
// Only render in Electron environment
if (!isElectron()) {
return null
}
if (!open) {
return null
}
const validateName = (value: string): boolean => {
if (!value.trim()) {
setNameError('后端名称不能为空')
return false
}
setNameError('')
return true
}
const validateUrl = (value: string): boolean => {
if (!value.trim()) {
setUrlError('后端地址不能为空')
return false
}
if (!/^https?:\/\/.+/.test(value)) {
setUrlError('地址必须以 http:// 或 https:// 开头')
return false
}
if (value.endsWith('/')) {
setUrlError('地址末尾不能包含 /')
return false
}
setUrlError('')
return true
}
const handleTestConnection = async () => {
if (!validateUrl(url)) return
setTestStatus('loading')
setTestError('')
try {
const response = await fetch(`${url}/api/webui/system/health`, {
method: 'GET',
signal: AbortSignal.timeout(10000),
})
if (response.ok) {
setTestStatus('success')
} else {
setTestStatus('error')
setTestError(`服务器返回状态码 ${response.status}`)
}
} catch (err) {
setTestStatus('error')
if (err instanceof DOMException && err.name === 'TimeoutError') {
setTestError('连接超时,请检查地址是否正确')
} else if (err instanceof TypeError) {
setTestError('无法连接到服务器,请检查地址和网络')
} else {
setTestError(err instanceof Error ? err.message : '未知错误')
}
}
}
const handleFinish = async () => {
const isNameValid = validateName(name)
const isUrlValid = validateUrl(url)
if (!isNameValid || !isUrlValid) return
setIsSubmitting(true)
try {
const newBackend = await window.electronAPI!.addBackend({
name: name.trim(),
url: url.trim(),
isDefault: true,
})
await window.electronAPI!.setActiveBackend(newBackend.id)
await window.electronAPI!.markFirstLaunchComplete()
window.location.reload()
} catch (err) {
setIsSubmitting(false)
setTestStatus('error')
setTestError(
err instanceof Error ? err.message : '保存配置失败,请重试'
)
}
}
const isFormValid = name.trim() !== '' && /^https?:\/\/.+/.test(url) && !url.endsWith('/')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute left-1/4 top-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-primary/5 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-secondary/5 blur-3xl" />
</div>
<Card className="relative z-10 max-w-md w-full mx-4 shadow-lg">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
<Bot className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">使 MaiBot</CardTitle>
<CardDescription>
使
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Backend name field */}
<div className="space-y-2">
<Label htmlFor="backend-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-name"
placeholder="例如:本地服务器"
value={name}
onChange={(e) => {
setName(e.target.value)
if (nameError) validateName(e.target.value)
}}
onBlur={() => validateName(name)}
/>
{nameError && (
<p className="text-sm text-destructive">{nameError}</p>
)}
</div>
{/* Backend URL field */}
<div className="space-y-2">
<Label htmlFor="backend-url">
<span className="text-destructive">*</span>
</Label>
<Input
id="backend-url"
placeholder="例如http://192.168.1.100:8001"
value={url}
onChange={(e) => {
setUrl(e.target.value)
if (urlError) validateUrl(e.target.value)
// Reset test status when URL changes
if (testStatus !== 'idle') {
setTestStatus('idle')
setTestError('')
}
}}
onBlur={() => validateUrl(url)}
/>
{urlError && (
<p className="text-sm text-destructive">{urlError}</p>
)}
</div>
{/* Test connection */}
<div className="space-y-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testStatus === 'loading' || !url.trim()}
className="w-full"
>
{testStatus === 'loading' ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
'测试连接'
)}
</Button>
{testStatus === 'success' && (
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" />
</div>
)}
{testStatus === 'error' && (
<div className="flex items-start gap-2 text-sm text-destructive">
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{testError || '无法连接'}</span>
</div>
)}
</div>
{/* Submit button */}
<Button
onClick={handleFinish}
disabled={!isFormValid || isSubmitting}
className="w-full"
>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
使
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { Copy, Minus, Square, X } from 'lucide-react'
import { useMemo } from 'react'
import { useWindowControls } from '@/hooks/useWindowControls'
import { getPlatform, isElectron } from '@/lib/runtime'
const dragStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties & { WebkitAppRegion: string }
const noDragStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties & { WebkitAppRegion: string }
export function TitleBar() {
const { close, isMaximized, minimize, toggleMaximize } = useWindowControls()
const isMac = useMemo(() => getPlatform() === 'darwin', [])
if (!isElectron()) return null
return (
<div
className={`flex items-center justify-between border-b border-border bg-background select-none ${isMac ? 'h-7' : 'h-8'}`}
style={dragStyle}
>
{/* macOS traffic light padding */}
{isMac && <div className="h-full w-[78px]" style={noDragStyle} />}
{/* Title / Drag area */}
<div className="flex flex-1 items-center justify-center text-xs font-semibold text-foreground/80">
MaiBot
</div>
{/* Windows / Linux Controls */}
{!isMac && (
<div className="flex h-full items-center" style={noDragStyle}>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={minimize}
tabIndex={-1}
type="button"
>
<Minus className="h-3.5 w-3.5" />
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
onClick={toggleMaximize}
tabIndex={-1}
type="button"
>
{isMaximized ? (
<Copy className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</button>
<button
className="flex h-8 w-11 items-center justify-center hover:bg-destructive hover:text-destructive-foreground"
onClick={close}
tabIndex={-1}
type="button"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { Component } from 'react'
import { useTranslation } from 'react-i18next'
import type { ErrorInfo, ReactNode } from 'react'
import { AlertTriangle, RefreshCw, Home, ChevronDown, ChevronUp, Copy, Check, Bug } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -65,6 +66,7 @@ function ErrorDetails({ error, errorInfo }: { error: Error; errorInfo: ErrorInfo
const [isStackOpen, setIsStackOpen] = useState(true)
const [isComponentStackOpen, setIsComponentStackOpen] = useState(false)
const [copied, setCopied] = useState(false)
const { t } = useTranslation()
const stackFrames = error.stack ? parseStackTrace(error.stack) : []
@@ -183,12 +185,12 @@ Time: ${new Date().toISOString()}
{copied ? (
<>
<Check className="mr-2 h-4 w-4 text-green-500" />
{t('errorBoundary.copiedToClipboard')}
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
{t('errorBoundary.copyError')}
</>
)}
</Button>
@@ -204,6 +206,7 @@ function ErrorFallback({
error: Error
errorInfo: ErrorInfo | null
}) {
const { t } = useTranslation()
const handleGoHome = () => {
window.location.href = '/'
}
@@ -219,9 +222,9 @@ function ErrorFallback({
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
<CardTitle className="text-2xl font-bold"></CardTitle>
<CardTitle className="text-2xl font-bold">{t('errorBoundary.title')}</CardTitle>
<CardDescription className="text-base mt-2">
{t('errorBoundary.description')}
</CardDescription>
</CardHeader>
@@ -232,17 +235,17 @@ function ErrorFallback({
<div className="flex flex-col sm:flex-row gap-2 pt-2">
<Button onClick={handleRefresh} className="flex-1">
<RefreshCw className="mr-2 h-4 w-4" />
{t('errorBoundary.refreshPage')}
</Button>
<Button onClick={handleGoHome} variant="outline" className="flex-1">
<Home className="mr-2 h-4 w-4" />
{t('errorBoundary.goHome')}
</Button>
</div>
{/* 提示信息 */}
<p className="text-xs text-center text-muted-foreground pt-2">
{t('errorBoundary.footer')}
</p>
</CardContent>
</Card>

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -7,6 +8,7 @@ import { Button } from '@/components/ui/button'
* 当用户通过 HTTP 访问时显示安全警告
*/
export function HttpWarningBanner() {
const { t } = useTranslation()
// 直接计算初始状态,避免 effect 中调用 setState
const isHttp = window.location.protocol === 'http:'
const hostname = window.location.hostname.toLowerCase()
@@ -35,11 +37,11 @@ export function HttpWarningBanner() {
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
<span className="font-semibold"></span>
使 <strong>HTTP</strong> 访 MaiBot WebUI
<span className="font-semibold">{t('httpWarning.title')}</span>
{t('httpWarning.message')}
</p>
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
Token使 HTTPS 访使
{t('httpWarning.description')}
</p>
</div>
</div>
@@ -48,7 +50,7 @@ export function HttpWarningBanner() {
size="icon"
onClick={handleDismiss}
className="h-8 w-8 text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 flex-shrink-0"
aria-label="关闭警告"
aria-label={t('httpWarning.dismiss')}
>
<X className="h-4 w-4" />
</Button>

View File

@@ -1,14 +1,26 @@
import { BookOpen, ChevronLeft, LogOut, Menu, Moon, PieChart, Search, Sun } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { BookOpen, ChevronLeft, Globe, LogOut, Menu, Moon, PieChart, Search, Server, Sun } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BackgroundLayer } from '@/components/background-layer'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import { BackendManager } from '@/components/electron/BackendManager'
import { SearchDialog } from '@/components/search-dialog'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Kbd } from '@/components/ui/kbd'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { useBackground } from '@/hooks/use-background'
import { logout } from '@/lib/fetch-with-auth'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const
interface HeaderProps {
sidebarOpen: boolean
@@ -23,7 +35,7 @@ interface HeaderProps {
export function Header({
sidebarOpen,
// mobileMenuOpen, // unused - kept in props for API compatibility
searchOpen,
actualTheme,
onSidebarToggle,
@@ -31,7 +43,18 @@ export function Header({
onSearchOpenChange,
onThemeChange,
}: HeaderProps) {
const { t, i18n: i18nInstance } = useTranslation()
const currentLang = i18nInstance.language || 'zh'
const headerBg = useBackground('header')
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
const [activeBackendName, setActiveBackendName] = useState<string>('')
useEffect(() => {
if (!isElectron()) return
window.electronAPI!.getActiveBackend().then((b) => {
setActiveBackendName(b?.name ?? t('header.notConnected'))
})
}, [])
const handleLogout = async () => {
await logout()
@@ -53,7 +76,7 @@ export function Header({
<button
onClick={onSidebarToggle}
className="hidden rounded-lg p-2 hover:bg-accent lg:block"
title={sidebarOpen ? '收起侧边栏' : '展开侧边栏'}
title={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
>
<ChevronLeft
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
@@ -62,16 +85,35 @@ export function Header({
</div>
<div className="flex items-center gap-2">
{/* 后端切换按钮(仅 Electron */}
{isElectron() && (
<>
<Button
variant="ghost"
size="sm"
className="gap-2"
onClick={() => setBackendManagerOpen(true)}
title={t('header.toggleConnection')}
>
<Server className="h-4 w-4" />
<span className="hidden sm:inline text-xs text-muted-foreground truncate max-w-[100px]">
{activeBackendName}
</span>
</Button>
<BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} />
<div className="h-6 w-px bg-border" />
</>
)}
{/* 年度总结入口 */}
<Link to="/annual-report">
<Button
variant="ghost"
size="sm"
className="gap-2 bg-gradient-to-r from-pink-500/10 to-purple-500/10 hover:from-pink-500/20 hover:to-purple-500/20 border border-pink-500/20"
title="查看年度总结"
title={t('header.viewAnnualSummary')}
>
<PieChart className="h-4 w-4 text-pink-500" />
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">2025 </span>
<span className="hidden sm:inline bg-gradient-to-r from-pink-500 to-purple-500 bg-clip-text text-transparent font-medium">{t('header.annualSummary')}</span>
</Button>
</Link>
@@ -81,7 +123,7 @@ export function Header({
className="relative hidden md:flex items-center w-64 h-9 pl-9 pr-16 bg-background/50 border rounded-md hover:bg-accent/50 transition-colors text-left"
>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<span className="text-sm text-muted-foreground">...</span>
<span className="text-sm text-muted-foreground">{t('header.searchPlaceholder')}</span>
<Kbd size="sm" className="absolute right-2 top-1/2 -translate-y-1/2">
<span className="text-xs"></span>K
</Kbd>
@@ -96,12 +138,41 @@ export function Header({
size="sm"
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
className="gap-2"
title="查看麦麦文档"
title={t('header.viewDocs')}
>
<BookOpen className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline">{t('header.docs')}</span>
</Button>
{/* 语言切换 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline text-xs">
{t(`language.${currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'}`) ?? currentLang}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LANGUAGE_CODES.map((code) => (
<DropdownMenuItem
key={code}
onClick={() => i18nInstance.changeLanguage(code)}
className={cn(
'cursor-pointer',
currentLang.split('-')[0] === code && 'font-semibold text-primary'
)}
>
{currentLang.split('-')[0] === code && (
<span className="mr-2"></span>
)}
{t(`language.${code}`)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* 主题切换按钮 */}
<button
onClick={(e) => {
@@ -109,7 +180,7 @@ export function Header({
toggleThemeWithTransition(newTheme, onThemeChange, e)
}}
className="rounded-lg p-2 hover:bg-accent"
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
title={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
>
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
@@ -123,10 +194,10 @@ export function Header({
size="sm"
onClick={handleLogout}
className="gap-2"
title="登出系统"
title={t('header.logout')}
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline">{t('header.logoutLabel')}</span>
</Button>
</div>
</header>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BackgroundLayer } from '@/components/background-layer'
import { BackToTop } from '@/components/back-to-top'
@@ -8,11 +9,15 @@ import { useTheme } from '@/components/use-theme'
import { useAuthGuard } from '@/hooks/use-auth'
import { useBackground } from '@/hooks/use-background'
import { TitleBar } from '@/components/electron/TitleBar'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import { Header } from './Header'
import { Sidebar } from './Sidebar'
import type { LayoutProps } from './types'
export function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const { checking } = useAuthGuard() // 检查认证状态
const [sidebarOpen, setSidebarOpen] = useState(true)
@@ -52,7 +57,7 @@ export function Layout({ children }: LayoutProps) {
if (checking) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-muted-foreground">...</div>
<div className="text-muted-foreground">{t('layout.verifyingLogin')}</div>
</div>
)
}
@@ -70,7 +75,8 @@ export function Layout({ children }: LayoutProps) {
return (
<TooltipProvider delayDuration={300}>
<div className="flex h-screen overflow-hidden">
{isElectron() && <TitleBar />}
<div className={cn('flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
{/* Sidebar */}
<Sidebar
sidebarOpen={sidebarOpen}
@@ -113,7 +119,7 @@ export function Layout({ children }: LayoutProps) {
{/* Back to Top Button */}
<BackToTop />
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -1,4 +1,5 @@
import { Link, useMatchRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@@ -13,6 +14,7 @@ interface NavItemProps {
}
export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose }: NavItemProps) {
const { t } = useTranslation()
const matchRoute = useMatchRoute()
const isActive = matchRoute({ to: item.path })
const Icon = item.icon
@@ -42,7 +44,7 @@ export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose
? 'opacity-100 max-w-[200px]'
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
)}>
{item.label}
{t(item.label)}
</span>
</div>
</>
@@ -70,7 +72,7 @@ export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose
</TooltipTrigger>
{tooltipsEnabled && (
<TooltipContent side="right" className="hidden lg:block">
<p>{item.label}</p>
<p>{t(item.label)}</p>
</TooltipContent>
)}
</Tooltip>

View File

@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { useBackground } from '@/hooks/use-background'
@@ -20,6 +22,7 @@ export function Sidebar({
tooltipsEnabled,
onMobileMenuClose
}: SidebarProps) {
const { t } = useTranslation()
const sidebarBg = useBackground('sidebar')
return (
@@ -60,7 +63,7 @@ export function Sidebar({
!sidebarOpen && "lg:mb-1 lg:invisible"
)}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap">
{section.title}
{t(section.title)}
</h3>
</div>

View File

@@ -4,46 +4,46 @@ import type { MenuSection } from './types'
export const menuSections: MenuSection[] = [
{
title: '概览',
title: 'sidebar.groups.overview',
items: [
{ icon: Home, label: '首页', path: '/' },
{ icon: Home, label: 'sidebar.menu.home', path: '/' },
],
},
{
title: '麦麦配置编辑',
title: 'sidebar.groups.botConfig',
items: [
{ icon: FileText, label: '麦麦主程序配置', path: '/config/bot' },
{ icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' },
{ icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' },
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot' },
{ icon: Server, label: 'sidebar.menu.aiModelProvider', path: '/config/modelProvider', tourId: 'sidebar-model-provider' },
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', tourId: 'sidebar-model-management' },
{ icon: Sliders, label: 'sidebar.menu.adapterConfig', path: '/config/adapter' },
],
},
{
title: '麦麦资源管理',
title: 'sidebar.groups.botResources',
items: [
{ icon: Smile, label: '表情包管理', path: '/resource/emoji' },
{ icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' },
{ icon: Hash, label: '黑话管理', path: '/resource/jargon' },
{ icon: UserCircle, label: '人物信息管理', path: '/resource/person' },
{ icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' },
{ icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' },
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji' },
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression' },
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon' },
{ icon: UserCircle, label: 'sidebar.menu.personInfo', path: '/resource/person' },
{ icon: Network, label: 'sidebar.menu.knowledgeGraph', path: '/resource/knowledge-graph' },
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
],
},
{
title: '扩展与监控',
title: 'sidebar.groups.extensionsMonitor',
items: [
{ icon: Package, label: '插件市场', path: '/plugins' },
{ icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' },
{ icon: Sliders, label: '插件配置', path: '/plugin-config' },
{ icon: FileSearch, label: '日志查看器', path: '/logs' },
{ icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' },
{ icon: MessageSquare, label: '本地聊天室', path: '/chat' },
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins' },
{ icon: LayoutGrid, label: 'sidebar.menu.configTemplate', path: '/config/pack-market' },
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs' },
{ icon: Activity, label: 'sidebar.menu.plannerMonitor', path: '/planner-monitor' },
{ icon: MessageSquare, label: 'sidebar.menu.localChat', path: '/chat' },
],
},
{
title: '系统',
title: 'sidebar.groups.system',
items: [
{ icon: Settings, label: '系统设置', path: '/settings' },
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings' },
],
},
]

View File

@@ -17,6 +17,7 @@
*/
import { useEffect, useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Loader2,
CheckCircle2,
@@ -70,6 +71,7 @@ const getStatusConfig = (
status: RestartStatus,
checkAttempts: number,
maxAttempts: number,
t: (key: string, opts?: Record<string, unknown>) => string,
customTitle?: string,
customDescription?: string
): StatusConfig => {
@@ -82,33 +84,33 @@ const getStatusConfig = (
},
requesting: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: customTitle ?? '准备重启',
description: customDescription ?? '正在发送重启请求...',
tip: '🔄 正在准备重启麦麦...',
title: customTitle ?? t('restart.preparing'),
description: customDescription ?? t('restart.preparingDesc'),
tip: t('restart.preparingTip'),
},
restarting: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: customTitle ?? '正在重启麦麦',
description: customDescription ?? '请稍候,麦麦正在重启中...',
tip: '🔄 配置已保存,正在重启主程序...',
title: customTitle ?? t('restart.restarting'),
description: customDescription ?? t('restart.restartingDesc'),
tip: t('restart.restartingTip'),
},
checking: {
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
title: '检查服务状态',
description: `等待服务恢复... (${checkAttempts}/${maxAttempts})`,
tip: '⏳ 正在等待服务恢复,请勿关闭页面...',
title: t('restart.checking'),
description: t('restart.checkingDesc', { current: checkAttempts, max: maxAttempts }),
tip: t('restart.checkingTip'),
},
success: {
icon: <CheckCircle2 className="h-16 w-16 text-green-500" />,
title: '重启成功',
description: '正在跳转到登录页面...',
tip: '✅ 配置已生效,服务运行正常',
title: t('restart.success'),
description: t('restart.successDesc'),
tip: t('restart.successTip'),
},
failed: {
icon: <AlertCircle className="h-16 w-16 text-destructive" />,
title: '重启超时',
description: '服务未能在预期时间内恢复',
tip: '⚠️ 如果长时间无响应,请尝试手动重启',
title: t('restart.failed'),
description: t('restart.failedDesc'),
tip: t('restart.failedTip'),
},
}
return configs[status]
@@ -192,6 +194,7 @@ function RestartOverlayContent({
className,
}: RestartOverlayContentProps) {
const { status, progress, elapsedTime, checkAttempts, maxAttempts } = state
const { t } = useTranslation()
// 回调处理
useEffect(() => {
@@ -206,6 +209,7 @@ function RestartOverlayContent({
status,
checkAttempts,
maxAttempts,
t,
title,
description
)
@@ -246,7 +250,7 @@ function RestartOverlayContent({
<Progress value={progress} className="h-2" />
<div className="flex justify-between text-sm text-muted-foreground">
<span>{progress}%</span>
<span>: {formatTime(elapsedTime)}</span>
<span>{t('restart.elapsed')} {formatTime(elapsedTime)}</span>
</div>
</div>
)}
@@ -265,11 +269,11 @@ function RestartOverlayContent({
className="flex-1"
>
<RefreshCw className="mr-2 h-4 w-4" />
{t('restart.refreshPage')}
</Button>
<Button onClick={onRetry} variant="secondary" className="flex-1">
<RotateCcw className="mr-2 h-4 w-4" />
{t('restart.retryCheck')}
</Button>
</div>
)}

View File

@@ -1,6 +1,8 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useMemo } from 'react'
import { Search, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, BarChart3, Package, Settings, Home, Hash } from 'lucide-react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
@@ -24,97 +26,98 @@ interface SearchItem {
category: string
}
const searchItems: SearchItem[] = [
{
icon: Home,
title: '首页',
description: '查看仪表板概览',
path: '/',
category: '概览',
},
{
icon: FileText,
title: '麦麦主程序配置',
description: '配置麦麦的核心设置',
path: '/config/bot',
category: '配置',
},
{
icon: Server,
title: '麦麦模型提供商配置',
description: '配置模型提供商',
path: '/config/modelProvider',
category: '配置',
},
{
icon: Boxes,
title: '麦麦模型配置',
description: '配置模型参数',
path: '/config/model',
category: '配置',
},
{
icon: Smile,
title: '表情包管理',
description: '管理麦麦的表情包',
path: '/resource/emoji',
category: '资源',
},
{
icon: MessageSquare,
title: '表达方式管理',
description: '管理麦麦的表达方式',
path: '/resource/expression',
category: '资源',
},
{
icon: UserCircle,
title: '人物信息管理',
description: '管理人物信息',
path: '/resource/person',
category: '资源',
},
{
icon: Hash,
title: '黑话管理',
description: '管理麦麦学习到的黑话和俚语',
path: '/resource/jargon',
category: '资源',
},
{
icon: BarChart3,
title: '统计信息',
description: '查看使用统计',
path: '/statistics',
category: '监控',
},
{
icon: Package,
title: '插件市场',
description: '浏览和安装插件',
path: '/plugins',
category: '扩展',
},
{
icon: FileSearch,
title: '日志查看器',
description: '查看系统日志',
path: '/logs',
category: '监控',
},
{
icon: Settings,
title: '系统设置',
description: '配置系统参数',
path: '/settings',
category: '系统',
},
]
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const navigate = useNavigate()
const { t } = useTranslation()
const searchItems: SearchItem[] = useMemo(() => [
{
icon: Home,
title: t('search.items.home'),
description: t('search.items.homeDesc'),
path: '/',
category: t('search.categories.overview'),
},
{
icon: FileText,
title: t('search.items.botConfig'),
description: t('search.items.botConfigDesc'),
path: '/config/bot',
category: t('search.categories.config'),
},
{
icon: Server,
title: t('search.items.modelProvider'),
description: t('search.items.modelProviderDesc'),
path: '/config/modelProvider',
category: t('search.categories.config'),
},
{
icon: Boxes,
title: t('search.items.model'),
description: t('search.items.modelDesc'),
path: '/config/model',
category: t('search.categories.config'),
},
{
icon: Smile,
title: t('search.items.emoji'),
description: t('search.items.emojiDesc'),
path: '/resource/emoji',
category: t('search.categories.resources'),
},
{
icon: MessageSquare,
title: t('search.items.expression'),
description: t('search.items.expressionDesc'),
path: '/resource/expression',
category: t('search.categories.resources'),
},
{
icon: UserCircle,
title: t('search.items.person'),
description: t('search.items.personDesc'),
path: '/resource/person',
category: t('search.categories.resources'),
},
{
icon: Hash,
title: t('search.items.jargon'),
description: t('search.items.jargonDesc'),
path: '/resource/jargon',
category: t('search.categories.resources'),
},
{
icon: BarChart3,
title: t('search.items.statistics'),
description: t('search.items.statisticsDesc'),
path: '/statistics',
category: t('search.categories.monitor'),
},
{
icon: Package,
title: t('search.items.plugins'),
description: t('search.items.pluginsDesc'),
path: '/plugins',
category: t('search.categories.extensions'),
},
{
icon: FileSearch,
title: t('search.items.logs'),
description: t('search.items.logsDesc'),
path: '/logs',
category: t('search.categories.monitor'),
},
{
icon: Settings,
title: t('search.items.settings'),
description: t('search.items.settingsDesc'),
path: '/settings',
category: t('search.categories.system'),
},
], [t])
// 过滤搜索结果
const filteredItems = searchItems.filter(
@@ -154,7 +157,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 gap-0">
<DialogHeader className="px-4 pt-4 pb-0">
<DialogTitle className="sr-only"></DialogTitle>
<DialogTitle className="sr-only">{t('search.title')}</DialogTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -164,7 +167,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
setSelectedIndex(0)
}}
onKeyDown={handleKeyDown}
placeholder="搜索页面..."
placeholder={t('search.placeholder')}
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
autoFocus
/>
@@ -207,7 +210,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-sm text-muted-foreground">
{searchQuery ? '未找到匹配的页面' : '输入关键词开始搜索'}
{searchQuery ? t('search.noResults') : t('search.startSearch')}
</p>
</div>
)}
@@ -219,15 +222,15 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border"></kbd>
<kbd className="px-1.5 py-0.5 bg-muted rounded border"></kbd>
{t('search.navigate')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Enter</kbd>
{t('search.select')}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 bg-muted rounded border">Esc</kbd>
{t('search.close')}
</span>
</div>
</div>

View File

@@ -0,0 +1,62 @@
import { useCallback, useEffect, useState } from 'react'
import { isElectron } from '@/lib/runtime'
import type { BackendConnection } from '@/types/electron'
export function useBackendConnections() {
const [backends, setBackends] = useState<BackendConnection[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const refresh = useCallback(async () => {
if (!isElectron()) return
const [list, active] = await Promise.all([
window.electronAPI!.getBackends(),
window.electronAPI!.getActiveBackend(),
])
setBackends(list)
setActiveId(active?.id ?? null)
setLoading(false)
}, [])
useEffect(() => {
refresh()
}, [refresh])
const addBackend = useCallback(async (conn: Omit<BackendConnection, 'id'>) => {
if (!isElectron()) return
await window.electronAPI!.addBackend(conn)
await refresh()
}, [refresh])
const updateBackend = useCallback(async (id: string, patch: Partial<BackendConnection>) => {
if (!isElectron()) return
await window.electronAPI!.updateBackend(id, patch)
await refresh()
}, [refresh])
const removeBackend = useCallback(async (id: string) => {
if (!isElectron()) return
await window.electronAPI!.removeBackend(id)
await refresh()
}, [refresh])
const switchBackend = useCallback(async (id: string) => {
if (!isElectron()) return
await window.electronAPI!.setActiveBackend(id)
setActiveId(id)
// 重新加载页面以使用新后端
window.location.reload()
}, [])
return {
backends,
activeId,
loading,
addBackend,
updateBackend,
removeBackend,
switchBackend,
refresh
}
}

View File

@@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from 'react'
import { isElectron } from '@/lib/runtime'
export function useWindowControls() {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!isElectron()) return
const api = window.electronAPI
if (!api) return
api.isMaximized().then(setIsMaximized)
const unsubMax = api.onWindowMaximized(() => setIsMaximized(true))
const unsubUnmax = api.onWindowUnmaximized(() => setIsMaximized(false))
return () => {
unsubMax?.()
unsubUnmax?.()
}
}, [])
const minimize = useCallback(() => window.electronAPI?.minimizeWindow(), [])
const toggleMaximize = useCallback(() => window.electronAPI?.maximizeWindow(), [])
const close = useCallback(() => window.electronAPI?.closeWindow(), [])
return { close, isMaximized, minimize, toggleMaximize }
}

View File

@@ -0,0 +1,37 @@
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import i18next from 'i18next'
import en from './locales/en.json'
import ja from './locales/ja.json'
import ko from './locales/ko.json'
import zh from './locales/zh.json'
i18next
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
zh: { translation: zh },
en: { translation: en },
ja: { translation: ja },
ko: { translation: ko },
},
fallbackLng: 'en',
supportedLngs: ['zh', 'en', 'ja', 'ko'],
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
lookupLocalStorage: 'maibot-locale',
caches: ['localStorage'],
},
keySeparator: '.',
})
i18next.on('languageChanged', (lng) => {
document.documentElement.lang = lng
})
export default i18next

View File

@@ -0,0 +1,477 @@
{
"language": { "zh": "中文", "en": "English", "ja": "日本語", "ko": "한국어" },
"header": {
"collapseSidebar": "Collapse sidebar",
"expandSidebar": "Expand sidebar",
"toggleConnection": "Toggle backend connection",
"viewAnnualSummary": "View annual summary",
"annualSummary": "2025 Annual Summary",
"searchPlaceholder": "Search...",
"viewDocs": "View MaiBot docs",
"docs": "MaiBot Docs",
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode",
"logout": "Log out",
"logoutLabel": "Logout",
"notConnected": "Not connected"
},
"sidebar": {
"groups": {
"overview": "Overview",
"botConfig": "Bot Configuration",
"botResources": "Bot Resources",
"extensionsMonitor": "Extensions & Monitor",
"system": "System"
},
"menu": {
"home": "Home",
"botMainConfig": "Bot Main Config",
"aiModelProvider": "AI Model Providers",
"modelManagement": "Model Management",
"adapterConfig": "Adapter Config",
"emojiManagement": "Emoji Management",
"expressionManagement": "Expression Management",
"slangManagement": "Slang Management",
"personInfo": "Person Info",
"knowledgeGraph": "Knowledge Graph",
"knowledgeBase": "Knowledge Base",
"pluginMarket": "Plugin Market",
"configTemplate": "Config Templates",
"pluginConfig": "Plugin Config",
"logViewer": "Log Viewer",
"plannerMonitor": "Planner & Replier Monitor",
"localChat": "Local Chat",
"settings": "Settings"
}
},
"layout": {
"verifyingLogin": "Verifying login status...",
"logoTitle": "MaiBot WebUI",
"logoTitleShort": "M"
},
"settings": {
"title": "Settings",
"description": "Manage your application preferences",
"tabs": {
"appearance": "Appearance",
"security": "Security",
"other": "Other",
"about": "About"
},
"appearance": {
"themeMode": "Theme Mode",
"themeModeDesc": "Light / Dark / Follow system",
"light": "Light",
"dark": "Dark",
"system": "System",
"accentColor": "Accent Color",
"resetDefault": "Reset Default",
"colorPreview": "Live Color Preview",
"styleTweaks": "Style Tweaks",
"typography": "Typography",
"visualEffects": "Visual Effects",
"layout": "Layout",
"animation": "Animation",
"background": "Background",
"customCss": "Custom CSS",
"animationEffect": "Animation Effect",
"importExportTheme": "Import / Export Theme",
"importTheme": "Import Theme",
"exportTheme": "Export Theme",
"importSuccess": "Import successful",
"importFailed": "Import failed",
"resetSuccess": "Reset successful",
"fontFamily": "Font Family",
"fontSize": "Font Size",
"borderRadius": "Border Radius",
"contentWidth": "Content Width",
"sidebarWidth": "Sidebar Width",
"animationSpeed": "Animation Speed",
"backgroundImage": "Background Image",
"backgroundBlur": "Background Blur",
"backgroundOpacity": "Background Opacity",
"lightDesc": "Always use light theme",
"darkDesc": "Always use dark theme",
"systemDesc": "Auto-switch based on system settings",
"accentPrimary": "Primary Color",
"accentHint": "Click the color ring or enter a HEX value",
"resetTheme": "Reset to Default",
"confirmResetTheme": "Confirm Reset Theme",
"confirmResetThemeDesc": "This will reset all theme settings to default, including colors, fonts, layout and custom CSS. This cannot be undone, are you sure?",
"confirmResetAction": "Confirm Reset",
"cssWarningTitle": "The following have been filtered for safety:",
"cssPlaceholder": "/* Enter custom CSS here */\n\n/* Example: */\n/* .sidebar { background: #1a1a2e; } */",
"cssDescription": "Write custom CSS to further personalize the interface. Dangerous CSS (like @import, url()) will be automatically filtered.",
"clearCss": "Clear",
"exportDesc": "Export theme as a JSON file for sharing or backup; all settings will be applied automatically on import.",
"importSuccessDesc": "Theme config imported, page will reload automatically",
"resetSuccessDesc": "Theme has been reset to default",
"enableAnimations": "Enable Animations",
"enableAnimationsDesc": "Disabling this will turn off all transition animations and effects, improving performance",
"loginWavesBackground": "Login Page Wave Background",
"loginWavesBackgroundDesc": "Disabling this will use a solid color background on the login page, suitable for low-performance devices",
"inheritParentBg": "Inherit Parent Background",
"inheritParentBgDesc": "When enabled, uses the background config from the parent layer",
"fontFamilyLabel": "Font Family",
"fontFamilyPlaceholder": "Select font family",
"fontFamilySystem": "System Default (System)",
"fontFamilySans": "Sans-serif",
"fontFamilySerif": "Serif",
"fontFamilyMono": "Monospace",
"baseFontSize": "Base Font Size",
"lineHeight": "Line Height",
"lineHeightPlaceholder": "Select line height",
"lineHeightCompact": "Compact (1.2)",
"lineHeightNormal": "Normal (1.5)",
"lineHeightLoose": "Loose (1.75)",
"borderRadiusLabel": "Border Radius",
"shadowLabel": "Shadow Intensity",
"shadowPlaceholder": "Select shadow intensity",
"shadowNone": "None",
"shadowSm": "Small",
"shadowMd": "Medium",
"shadowLg": "Large",
"shadowXl": "Extra Large",
"blurLabel": "Blur Effect",
"sidebarWidthLabel": "Sidebar Width",
"maxContentWidth": "Max Content Width",
"spacingUnit": "Spacing Unit",
"animationSpeedLabel": "Animation Speed",
"animationSpeedPlaceholder": "Select animation speed",
"animationFast": "Fast (100ms)",
"animationNormal": "Normal (300ms)",
"animationSlow": "Slow (500ms)",
"animationOff": "Off (0ms)",
"bgPage": "Page",
"bgSidebar": "Sidebar",
"typographyGroup": "Typography",
"visualGroup": "Visual Effects",
"layoutGroup": "Layout",
"animationGroup": "Animation",
"backgroundGroup": "Background Settings"
},
"security": {
"currentToken": "Current Access Token",
"yourToken": "Your Access Token",
"regenerate": "Regenerate",
"customToken": "Custom Access Token",
"securityTip": "Security Tips",
"cannotCopy": "Cannot copy",
"copySuccess": "Copied",
"copyFailed": "Copy failed",
"updateSuccess": "Updated",
"updateFailed": "Update failed",
"generateSuccess": "Generated",
"generateFailed": "Generation failed",
"newToken": "New Access Token",
"confirmRegenerate": "Confirm Regenerate Token",
"confirmRegenerateDesc": "The old token will be invalidated after regeneration. You will need to log in again.",
"cancel": "Cancel",
"confirm": "Confirm",
"cannotCopyDesc": "Token is stored in a secure Cookie. Please regenerate to get a new Token.",
"copySuccessDesc": "Token copied to clipboard",
"copyFailedDesc": "Please copy the Token manually",
"inputError": "Input error",
"inputErrorDesc": "Please enter a new Token",
"formatError": "Format error",
"formatErrorDesc": "Token does not meet requirements: {{failedRules}}",
"updateSuccessDesc": "Access Token updated. Redirecting to login page.",
"updateFailedDesc": "Unable to update Token",
"updateFailedConn": "Failed to connect to server",
"generateSuccessDesc": "New Access Token generated. Please save it immediately.",
"generateFailedDesc": "Unable to generate new Token",
"generateFailedConn": "Failed to connect to server",
"cannotView": "Cannot view",
"cannotViewDesc": "Token is stored in a secure Cookie. Click \"Regenerate\" to get a new Token.",
"hide": "Hide",
"show": "Show",
"copyTip": "Copy to clipboard",
"regenerateShort": "Generate",
"confirmRegenerateFullDesc": "This will generate a new 64-character secure token, immediately invalidating the current Token. You will need to log in again with the new Token. This action cannot be undone. Are you sure?",
"confirmGenerate": "Confirm Generate",
"tokenStorePlaceholder": "Token is stored in a secure Cookie",
"safekeepTip": "Keep your Access Token safe and do not share it with others.",
"newTokenLabel": "New Access Token",
"customTokenPlaceholder": "Enter custom Token",
"tokenReqTitle": "Token security requirements:",
"tokenValid": "Token format is valid and ready to use",
"updateBtn": "Update Custom Token",
"updating": "Updating...",
"dialogTitle": "New Access Token",
"dialogDesc": "This is your new Token. Please save it immediately. You will be redirected to the login page after closing this window.",
"dialogTokenLabel": "Your new Token (64-char secure token)",
"important": "Important Notice",
"tip1": "This Token is only shown once and cannot be viewed after closing",
"tip2": "Copy and save it to a secure location immediately",
"tip3": "You will be automatically redirected to the login page after closing",
"tip4": "Use the new Token to log back in",
"copied": "Copied",
"copyToken": "Copy Token",
"savedClose": "Saved, close",
"securityTip1": "Regenerating creates a new system-generated 64-character secure token",
"securityTip2": "Custom Tokens must meet all security requirements",
"securityTip3": "After updating, the old Token will be immediately invalidated",
"securityTip4": "View and copy your Token only in a secure environment",
"securityTip5": "If you suspect a Token leak, regenerate or update it immediately",
"securityTip6": "System-generated Tokens are recommended for maximum security"
},
"other": {
"performance": "Performance & Storage",
"localStorage": "Local Storage Usage",
"logCache": "Log Cache Size",
"importExport": "Import / Export Settings",
"configWizard": "Config Wizard",
"devTools": "Developer Tools",
"clearStorage": "Clear Local Storage",
"clearStorageDesc": "Clear all local storage data",
"clearStorageConfirm": "Confirm Clear",
"clearLogCache": "Clear Log Cache",
"clearLogCacheDesc": "Clear all cached log data",
"clearLogCacheConfirm": "Confirm Clear",
"importSettings": "Import Settings",
"exportSettings": "Export Settings",
"resetAllSettings": "Reset All Settings",
"resetAllSettingsDesc": "Restore all settings to defaults",
"resetAllSettingsConfirm": "Confirm Reset",
"clearStorageSuccess": "Local storage cleared",
"clearStorageFailed": "Failed to clear",
"clearLogSuccess": "Log cache cleared",
"clearLogFailed": "Failed to clear",
"importSuccess": "Import successful",
"importFailed": "Import failed",
"exportSuccess": "Export successful",
"exportFailed": "Export failed",
"resetSuccess": "Reset successful",
"resetFailed": "Reset failed",
"storageItems": "{{count}} storage items",
"logCacheSizeDesc": "Controls the maximum number of log entries cached. Larger values use more memory.",
"logCacheSizeUnit": "entries",
"dataSyncIntervalLabel": "Home Data Refresh Interval",
"dataSyncIntervalUnit": "s",
"dataSyncIntervalDesc": "Controls the auto-refresh interval for home page statistics",
"wsReconnectLabel": "WebSocket Reconnect Interval",
"wsReconnectUnit": "s",
"wsReconnectDesc": "Base reconnect interval after log WebSocket disconnects",
"wsMaxReconnectLabel": "WebSocket Max Reconnect Attempts",
"wsMaxReconnectUnit": "times",
"wsMaxReconnectDesc": "Maximum reconnect attempts after connection failure",
"clearLogCacheFn": "Clear Log Cache",
"clearLocalCache": "Clear Local Cache",
"confirmClearCache": "Confirm Clear Local Cache",
"confirmClearCacheDesc": "This will clear all locally cached settings and data (excluding login credentials). You may need to reconfigure some preferences. Are you sure?",
"confirmClear": "Confirm Clear",
"importExportDesc": "Export current interface settings for backup, or restore from a previously exported file.",
"exporting": "Exporting...",
"importing": "Importing...",
"resetAllSettingsBtn": "Reset All Settings to Defaults",
"confirmResetAll": "Confirm Reset All Settings",
"confirmResetAllDesc": "This will restore all interface settings to their defaults, including theme, colors, animation, and other preferences. This will not affect your login status. Are you sure?",
"configWizardDesc": "Re-run the initial setup wizard to reconfigure system basics.",
"rerunSetup": "Re-run Initial Setup",
"confirmRerunSetup": "Confirm Re-run Setup",
"confirmRerunSetupDesc": "This will take you back to the initial setup wizard. You can reconfigure the system basics. Are you sure?",
"devToolsDesc": "The following features are for development and debugging only. They may cause crashes or abnormal behavior.",
"triggerError": "Trigger Test Error",
"confirmTriggerError": "Confirm Trigger Error",
"confirmTriggerErrorDesc": "This will manually trigger a React error to test the error boundary component. The page will display an error view; you can recover by refreshing the page or clicking Go Home.",
"confirmTrigger": "Confirm Trigger",
"logCleared": "Log cleared",
"logClearedDesc": "Log cache has been cleared",
"cacheCleared": "Cache cleared",
"cacheClearedDesc": "Cleared {{count}} cached items",
"exportSuccessDesc": "Settings exported as a JSON file",
"exportFailedDesc": "Unable to export settings",
"importSuccessDesc": "Successfully imported {{imported}} settings",
"importSkippedSuffix": ", skipped {{skipped}}",
"importRefreshHint": "Note",
"importRefreshHintDesc": "Some settings require a page refresh to take full effect",
"importNoDataDesc": "No valid settings to import",
"importInvalidDesc": "Invalid file format",
"resetDone": "Reset done",
"resetDoneDesc": "All settings restored to defaults. Refresh the page to apply changes."
},
"about": {
"openSource": "Open Source",
"aboutApp": "About MaiBot Dashboard",
"version": "Version:",
"author": "Author",
"techStack": "Tech Stack",
"frontendFramework": "Frontend Framework",
"uiComponents": "UI Components",
"backend": "Backend",
"buildTool": "Build Tool",
"openSourceThanks": "Open Source Libraries",
"openSourceLicense": "Open Source License",
"openSourceDesc": "This project is open source on GitHub. Give us a Star ⭐!",
"visitGitHub": "Visit GitHub",
"appDesc": "A modern Web management interface for MaiBot",
"maimaiCore": "MaiBot Core",
"uiFrameworkGroup": "UI Framework & Components",
"routingStateGroup": "Routing & State Management",
"formGroup": "Form Handling",
"utilsGroup": "Utility Libraries",
"animationGroup": "Animation",
"backendGroup": "Backend Framework",
"devToolsGroup": "Developer Tools",
"openSourceThanksDesc": "This project uses the following excellent open source libraries. Thank you for your contributions:",
"licenseDesc": "This project is licensed under the GNU General Public License v3.0. You are free to use, modify, and distribute the software, provided you keep the same open source license.",
"licenseDeps": "All open source libraries used by this project comply with their respective licenses (MIT, Apache-2.0, BSD, etc.). Thank you to all open source contributors for your selfless work.",
"lib": {
"react": "UI library for building user interfaces",
"shadcn": "Elegant React component library",
"radix": "Unstyled accessible component primitives",
"tailwind": "Utility-first CSS framework",
"lucide": "Beautiful icon library",
"tanstackRouter": "Type-safe routing library",
"zustand": "Lightweight state management",
"reactHookForm": "High-performance form library",
"zod": "TypeScript-first schema validation",
"clsx": "Conditional className builder",
"tailwindMerge": "Tailwind class name merger",
"cva": "Component variant management",
"dateFns": "Modern date utility library",
"framerMotion": "React animation library",
"vaul": "Drawer component animation",
"fastapi": "Modern Python web framework",
"uvicorn": "ASGI server",
"pydantic": "Data validation library",
"pythonMultipart": "File upload support",
"typescript": "Superset of JavaScript",
"vite": "Next-generation frontend build tool",
"eslint": "JavaScript code linter",
"postcss": "CSS transformation tool"
}
}
},
"auth": {
"title": "Login",
"description": "Enter your access token to continue",
"tokenLabel": "Access Token",
"tokenPlaceholder": "Enter Access Token",
"loginButton": "Login",
"loggingIn": "Logging in...",
"loginFailed": "Login failed",
"loginSuccess": "Login successful",
"checkingAuth": "Checking login status...",
"welcome": "Welcome to MaiBot",
"accessDesc": "Enter your Access Token to continue accessing the system",
"tokenRequired": "Please enter your Access Token",
"verifyingLabel": "Verifying...",
"verifyEnter": "Verify & Enter",
"helpLink": "I don't have a Token. Where can I get one?",
"helpTitle": "How to Get an Access Token",
"helpDesc": "Access Token is the only credential to access MaiBot WebUI. Get yours in one of the following ways",
"method1Title": "Method 1: Check Startup Log",
"method1Desc": "When MaiBot starts, the console will display the WebUI Access Token.",
"method1Example1": "🔑 WebUI Access Token: abc123...",
"method1Example2": "💡 Use this Token to log in to WebUI",
"method2Title": "Method 2: Check Config File",
"method2Desc": "The Token is saved in the config file at the project root:",
"method2FileHint": "Open this file and copy the value of the access_token field",
"securityTipTitle": "Security Notice",
"securityTip1": "Keep your Token safe and never share it with others",
"securityTip2": "To reset the Token, go to System Settings after logging in",
"slowLink": "The interface feels laggy. What can I do?",
"disableAnimTitle": "Disable Background Animation",
"disableAnimDesc": "Background animation may cause lag on low-performance devices. Disabling it can significantly improve smoothness.",
"disableAnimDetail": "After disabling, the background will be a solid color, but all features remain fully functional. You can re-enable it anytime in System Settings.",
"disableAnimBtn": "Disable Animation",
"verifyFailed": "Token verification failed. Please check and try again.",
"connFailed": "Failed to connect to the server. Please check your network connection.",
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode"
},
"common": {
"loading": "Loading...",
"error": "Error",
"retry": "Retry",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"search": "Search",
"noData": "No data",
"success": "Success",
"failed": "Failed"
},
"restart": {
"preparing": "Preparing to restart",
"preparingDesc": "Sending restart request...",
"preparingTip": "🔄 Preparing to restart MaiBot...",
"restarting": "Restarting MaiBot",
"restartingDesc": "Please wait, MaiBot is restarting...",
"restartingTip": "🔄 Config saved, restarting main process...",
"checking": "Checking service status",
"checkingDesc": "Waiting for service to recover... ({{current}}/{{max}})",
"checkingTip": "⏳ Waiting for service to recover, do not close this page...",
"success": "Restart successful",
"successDesc": "Redirecting to login page...",
"successTip": "✅ Config applied, service is running normally",
"failed": "Restart timed out",
"failedDesc": "Service failed to recover within the expected time",
"failedTip": "⚠️ If unresponsive for a long time, try restarting manually",
"refreshPage": "Refresh page",
"retryCheck": "Retry check",
"elapsed": "Elapsed:"
},
"errorBoundary": {
"title": "Something went wrong",
"description": "The application encountered an unexpected error. You can try refreshing the page or going back home.",
"refreshPage": "Refresh page",
"goHome": "Go home",
"footer": "If the problem persists, copy the error info and report it to the developer",
"copiedToClipboard": "Copied to clipboard",
"copyError": "Copy error info"
},
"search": {
"placeholder": "Search pages...",
"title": "Search",
"noResults": "No matching pages found",
"startSearch": "Type a keyword to start searching",
"navigate": "Navigate",
"select": "Select",
"close": "Close",
"categories": {
"overview": "Overview",
"config": "Config",
"resources": "Resources",
"monitor": "Monitor",
"extensions": "Extensions",
"system": "System"
},
"items": {
"home": "Home",
"homeDesc": "View dashboard overview",
"botConfig": "Bot Main Config",
"botConfigDesc": "Configure bot core settings",
"modelProvider": "Model Provider Config",
"modelProviderDesc": "Configure model providers",
"model": "Model Config",
"modelDesc": "Configure model parameters",
"emoji": "Emoji Management",
"emojiDesc": "Manage bot emoji",
"expression": "Expression Management",
"expressionDesc": "Manage bot expressions",
"person": "Person Info",
"personDesc": "Manage person info",
"jargon": "Slang Management",
"jargonDesc": "Manage bot learned slang and jargon",
"statistics": "Statistics",
"statisticsDesc": "View usage statistics",
"plugins": "Plugin Market",
"pluginsDesc": "Browse and install plugins",
"logs": "Log Viewer",
"logsDesc": "View system logs",
"settings": "Settings",
"settingsDesc": "Configure system settings"
}
},
"httpWarning": {
"title": "Security Warning:",
"message": "You are accessing MaiBot WebUI via HTTP",
"description": "If this is a public server, your data (including Token, chat history, etc.) may be intercepted in transit. It is strongly recommended to use HTTPS or access only from a local network.",
"dismiss": "Dismiss warning"
}
}

View File

@@ -0,0 +1,477 @@
{
"language": { "zh": "中文", "en": "English", "ja": "日本語", "ko": "한국어" },
"header": {
"collapseSidebar": "サイドバーを折りたたむ",
"expandSidebar": "サイドバーを展開する",
"toggleConnection": "バックエンド接続を切り替える",
"viewAnnualSummary": "年間サマリーを表示",
"annualSummary": "2025 年間サマリー",
"searchPlaceholder": "検索...",
"viewDocs": "MaiBot ドキュメントを表示",
"docs": "MaiBot ドキュメント",
"switchToLight": "ライトモードに切り替える",
"switchToDark": "ダークモードに切り替える",
"logout": "ログアウト",
"logoutLabel": "ログアウト",
"notConnected": "未接続"
},
"sidebar": {
"groups": {
"overview": "概要",
"botConfig": "ボット設定",
"botResources": "ボットリソース",
"extensionsMonitor": "拡張機能 & 監視",
"system": "システム"
},
"menu": {
"home": "ホーム",
"botMainConfig": "ボットメイン設定",
"aiModelProvider": "AIモデルプロバイダー",
"modelManagement": "モデル管理",
"adapterConfig": "アダプター設定",
"emojiManagement": "絵文字管理",
"expressionManagement": "表現管理",
"slangManagement": "スラング管理",
"personInfo": "人物情報",
"knowledgeGraph": "知識グラフ",
"knowledgeBase": "ナレッジベース",
"pluginMarket": "プラグインマーケット",
"configTemplate": "設定テンプレート",
"pluginConfig": "プラグイン設定",
"logViewer": "ログビューア",
"plannerMonitor": "プランナー & リプライヤー監視",
"localChat": "ローカルチャット",
"settings": "設定"
}
},
"layout": {
"verifyingLogin": "ログイン状態を確認中...",
"logoTitle": "MaiBot WebUI",
"logoTitleShort": "M"
},
"settings": {
"title": "設定",
"description": "アプリの設定を管理する",
"tabs": {
"appearance": "外観",
"security": "セキュリティ",
"other": "その他",
"about": "について"
},
"appearance": {
"themeMode": "テーマモード",
"themeModeDesc": "ライト / ダーク / システムに従う",
"light": "ライト",
"dark": "ダーク",
"system": "システム",
"accentColor": "アクセントカラー",
"resetDefault": "デフォルトにリセット",
"colorPreview": "カラープレビュー",
"styleTweaks": "スタイル調整",
"typography": "タイポグラフィ",
"visualEffects": "視覚効果",
"layout": "レイアウト",
"animation": "アニメーション",
"background": "背景",
"customCss": "カスタム CSS",
"animationEffect": "アニメーション効果",
"importExportTheme": "テーマのインポート / エクスポート",
"importTheme": "テーマをインポート",
"exportTheme": "テーマをエクスポート",
"importSuccess": "インポート成功",
"importFailed": "インポート失敗",
"resetSuccess": "リセット成功",
"fontFamily": "フォントファミリー",
"fontSize": "フォントサイズ",
"borderRadius": "ボーダー半径",
"contentWidth": "コンテンツ幅",
"sidebarWidth": "サイドバー幅",
"animationSpeed": "アニメーション速度",
"backgroundImage": "背景画像",
"backgroundBlur": "背景ぼかし",
"backgroundOpacity": "背景透明度",
"lightDesc": "常にライトテーマを使用",
"darkDesc": "常にダークテーマを使用",
"systemDesc": "システム設定に従って自動切り替え",
"accentPrimary": "メインカラー",
"accentHint": "カラーリングをクリックするか、HEX 値を入力してください",
"resetTheme": "デフォルトにリセット",
"confirmResetTheme": "テーマのリセットを確認",
"confirmResetThemeDesc": "これにより、色、フォント、レイアウト、カスタム CSS を含むすべてのテーマ設定がデフォルトにリセットされます。この操作は元に戻せません。よろしいですか?",
"confirmResetAction": "リセットを確認",
"cssWarningTitle": "以下の内容はセキュリティフィルターされました:",
"cssPlaceholder": "/* カスタム CSS をここに入力 */\n\n/* 例: */\n/* .sidebar { background: #1a1a2e; } */",
"cssDescription": "カスタム CSS を記述して、インターフェースをさらにカスタマイズしてください。危険な CSS@import、url() など)は自動的にフィルターされます。",
"clearCss": "クリア",
"exportDesc": "テーマを JSON ファイルとしてエクスポートして共有またはバックアップ。インポート時にすべての設定が自動適用されます。",
"importSuccessDesc": "テーマ設定がインポートされました。ページが自動的にリロードされます",
"resetSuccessDesc": "テーマがデフォルトにリセットされました",
"enableAnimations": "アニメーションを有効にする",
"enableAnimationsDesc": "無効にすると、すべてのトランジションアニメーションとエフェクトが無効化されパフォーマンスが向上します",
"loginWavesBackground": "ログインページのウェーブ背景",
"loginWavesBackgroundDesc": "無効にするとログインページが単色背景になります。低スペックのデバイスに適しています",
"inheritParentBg": "上位背景を継承",
"inheritParentBgDesc": "有効にすると上位レイヤーの背景設定を使用します",
"fontFamilyLabel": "フォントファミリー",
"fontFamilyPlaceholder": "フォントファミリーを選択",
"fontFamilySystem": "システムデフォルト (System)",
"fontFamilySans": "サンセリフ (Sans-serif)",
"fontFamilySerif": "セリフ (Serif)",
"fontFamilyMono": "等幅 (Monospace)",
"baseFontSize": "基準フォントサイズ (Base Size)",
"lineHeight": "行高 (Line Height)",
"lineHeightPlaceholder": "行高を選択",
"lineHeightCompact": "コンパクト (1.2)",
"lineHeightNormal": "標準 (1.5)",
"lineHeightLoose": "ルーズ (1.75)",
"borderRadiusLabel": "圆角の大きさ (Radius)",
"shadowLabel": "シャドウの強度 (Shadow)",
"shadowPlaceholder": "シャドウの強度を選択",
"shadowNone": "なし (None)",
"shadowSm": "軽微 (Small)",
"shadowMd": "中程度 (Medium)",
"shadowLg": "強い (Large)",
"shadowXl": "極強 (Extra Large)",
"blurLabel": "ボカシ効果 (Blur)",
"sidebarWidthLabel": "サイドバー幅 (Sidebar Width)",
"maxContentWidth": "コンテンツ最大幅 (Max Width)",
"spacingUnit": "基準間隔 (Spacing Unit)",
"animationSpeedLabel": "アニメーション速度 (Speed)",
"animationSpeedPlaceholder": "アニメーション速度を選択",
"animationFast": "高速 (100ms)",
"animationNormal": "標準 (300ms)",
"animationSlow": "低速 (500ms)",
"animationOff": "オフ (0ms)",
"bgPage": "ページ",
"bgSidebar": "サイドバー",
"typographyGroup": "タイポグラフィ (Typography)",
"visualGroup": "視覚効果 (Visual)",
"layoutGroup": "レイアウト (Layout)",
"animationGroup": "アニメーション (Animation)",
"backgroundGroup": "背景設定 (Backgrounds)"
},
"security": {
"currentToken": "現在のアクセストークン",
"yourToken": "あなたのアクセストークン",
"regenerate": "再生成",
"customToken": "カスタムアクセストークン",
"securityTip": "セキュリティのヒント",
"cannotCopy": "コピーできません",
"copySuccess": "コピーしました",
"copyFailed": "コピー失敗",
"updateSuccess": "更新しました",
"updateFailed": "更新失敗",
"generateSuccess": "生成しました",
"generateFailed": "生成失敗",
"newToken": "新しいアクセストークン",
"confirmRegenerate": "トークンの再生成を確認",
"confirmRegenerateDesc": "再生成後、古いトークンは無効になります。再ログインが必要です。",
"cancel": "キャンセル",
"confirm": "確認",
"cannotCopyDesc": "Token はセキュアな Cookie に保存されています。新しい Token を取得するには再生成してください。",
"copySuccessDesc": "Token をクリップボードにコピーしました",
"copyFailedDesc": "Token を手動でコピーしてください",
"inputError": "入力エラー",
"inputErrorDesc": "新しい Token を入力してください",
"formatError": "フォーマットエラー",
"formatErrorDesc": "Token が要件を満たしていません: {{failedRules}}",
"updateSuccessDesc": "Access Token を更新しました。ログインページにリダイレクトします。",
"updateFailedDesc": "Token を更新できません",
"updateFailedConn": "サーバーへの接続に失敗しました",
"generateSuccessDesc": "新しい Access Token を生成しました。すぐに保存してください。",
"generateFailedDesc": "新しい Token を生成できません",
"generateFailedConn": "サーバーへの接続に失敗しました",
"cannotView": "表示できません",
"cannotViewDesc": "Token はセキュアな Cookie に保存されています。新しい Token が必要な場合は\"再生成\"をクリックしてください。",
"hide": "非表示",
"show": "表示",
"copyTip": "クリップボードにコピー",
"regenerateShort": "生成",
"confirmRegenerateFullDesc": "新しい 64 桁のセキュアトークンを生成し、現在の Token を即座に無効にします。新しい Token で再ログインが必要です。この操作は元に戻せません。続けますか?",
"confirmGenerate": "生成を確認",
"tokenStorePlaceholder": "Token はセキュアな Cookie に保存されています",
"safekeepTip": "Access Token を安全に保管し、他人に漏洩しないでください。",
"newTokenLabel": "新しいアクセストークン",
"customTokenPlaceholder": "カスタム Token を入力",
"tokenReqTitle": "Token セキュリティ要件:",
"tokenValid": "Token のフォーマットは正しく使用できます",
"updateBtn": "カスタム Token を更新",
"updating": "更新中...",
"dialogTitle": "新しい Access Token",
"dialogDesc": "これが新しい Token です。すぐに保存してください。このウィンドウを閉じるとログインページにリダイレクトされます。",
"dialogTokenLabel": "新しい Token (64 桁セキュアトークン)",
"important": "重要なお知らせ",
"tip1": "この Token は一度しか表示されません。閉じた後は表示できません",
"tip2": "すぐにコピーして安全な場所に保存してください",
"tip3": "閉じると自動的にログインページにリダイレクトされます",
"tip4": "新しい Token で再ログインしてください",
"copied": "コピー済み",
"copyToken": "Token をコピー",
"savedClose": "保存しました。閉じる",
"securityTip1": "再生成するとシステムがランダムに生成した 64 桁のセキュアトークンが作成されます",
"securityTip2": "カスタム Token はすべてのセキュリティ要件を満たす必要があります",
"securityTip3": "Token を更新すると、古い Token は即座に無効になります",
"securityTip4": "安全な環境で Token を表示してコピーしてください",
"securityTip5": "Token の漏洩が疏われる場合は、すぐに再生成または更新してください",
"securityTip6": "最高のセキュリティのためにシステム生成の Token を推奨します"
},
"other": {
"performance": "パフォーマンス & ストレージ",
"localStorage": "ローカルストレージ使用量",
"logCache": "ログキャッシュサイズ",
"importExport": "設定のインポート / エクスポート",
"configWizard": "設定ウィザード",
"devTools": "開発者ツール",
"clearStorage": "ローカルストレージを削除",
"clearStorageDesc": "すべてのローカルストレージデータを削除します",
"clearStorageConfirm": "削除を確認",
"clearLogCache": "ログキャッシュを削除",
"clearLogCacheDesc": "すべてのキャッシュされたログデータを削除します",
"clearLogCacheConfirm": "削除を確認",
"importSettings": "設定をインポート",
"exportSettings": "設定をエクスポート",
"resetAllSettings": "すべての設定をリセット",
"resetAllSettingsDesc": "すべての設定をデフォルトに戻します",
"resetAllSettingsConfirm": "リセットを確認",
"clearStorageSuccess": "ローカルストレージを削除しました",
"clearStorageFailed": "削除失敗",
"clearLogSuccess": "ログキャッシュを削除しました",
"clearLogFailed": "削除失敗",
"importSuccess": "インポート成功",
"importFailed": "インポート失敗",
"exportSuccess": "エクスポート成功",
"exportFailed": "エクスポート失敗",
"resetSuccess": "リセット成功",
"resetFailed": "リセット失敗",
"storageItems": "{{count}} 件のアイテム",
"logCacheSizeDesc": "ログビューアがキャッシュする最大ログ数を制御します。大きい値はより多くのメモリを使用します。",
"logCacheSizeUnit": "件",
"dataSyncIntervalLabel": "ホームデータ更新間隔",
"dataSyncIntervalUnit": "秒",
"dataSyncIntervalDesc": "ホーム画面の統計データの自動更新間隔を制御します",
"wsReconnectLabel": "WebSocket 再接続間隔",
"wsReconnectUnit": "秒",
"wsReconnectDesc": "ログ WebSocket 切断後の再接続基本間隔",
"wsMaxReconnectLabel": "WebSocket 最大再接続回数",
"wsMaxReconnectUnit": "回",
"wsMaxReconnectDesc": "接続失敗後の最大再接続試行回数",
"clearLogCacheFn": "ログキャッシュをクリア",
"clearLocalCache": "ローカルキャッシュをクリア",
"confirmClearCache": "ローカルキャッシュのクリアを確認",
"confirmClearCacheDesc": "ローカルにキャッシュされたすべての設定とデータ(ログイン資格情報を除く)をクリアします。一部の設定を再構成する必要があるかもしれません。続けますか?",
"confirmClear": "クリアを確認",
"importExportDesc": "現在のインターフェース設定をエクスポートしてバックアップしたり、以前のファイルから復元したりできます。",
"exporting": "エクスポート中...",
"importing": "インポート中...",
"resetAllSettingsBtn": "すべての設定をデフォルトにリセット",
"confirmResetAll": "すべての設定のリセットを確認",
"confirmResetAllDesc": "テーマ、色、アニメーションなどのインターフェース設定をデフォルトに戻します。ログイン状態には影響しません。続けますか?",
"configWizardDesc": "初回設定ウィザードを再実行してシステムの基本設定を再構成できます。",
"rerunSetup": "初回設定を再実行",
"confirmRerunSetup": "再構成を確認",
"confirmRerunSetupDesc": "初回設定ウィザードに戻ります。システムの基本設定を再設定できます。続けますか?",
"devToolsDesc": "以下の機能は開発・デバッグ目的のみです。クラッシュや異常動作を引き起こす可能性があります。",
"triggerError": "テストエラーを発生させる",
"confirmTriggerError": "エラーの発生を確認",
"confirmTriggerErrorDesc": "React エラーを手動で発生させ、エラーボーダーコンポーネントをテストします。ページを更新するかホームに戻ることで復元できます。",
"confirmTrigger": "発生を確認",
"logCleared": "ログをクリアしました",
"logClearedDesc": "ログキャッシュをクリアしました",
"cacheCleared": "キャッシュをクリアしました",
"cacheClearedDesc": "{{count}} 件のキャッシュデータをクリアしました",
"exportSuccessDesc": "設定を JSON ファイルとしてエクスポートしました",
"exportFailedDesc": "設定のエクスポートに失敗しました",
"importSuccessDesc": "{{imported}} 件の設定をインポートしました",
"importSkippedSuffix": "、{{skipped}} 件をスキップ",
"importRefreshHint": "お知らせ",
"importRefreshHintDesc": "一部の設定はページを更新するまで完全に有効になりません",
"importNoDataDesc": "インポートする有効な設定がありません",
"importInvalidDesc": "ファイルフォーマットが無効です",
"resetDone": "リセット完了",
"resetDoneDesc": "すべての設定がデフォルトに戻りました。変更を適用するにはページを更新してください。"
},
"about": {
"openSource": "オープンソース",
"aboutApp": "MaiBot Dashboard について",
"version": "バージョン:",
"author": "作者",
"techStack": "技術スタック",
"frontendFramework": "フロントエンドフレームワーク",
"uiComponents": "UI コンポーネント",
"backend": "バックエンド",
"buildTool": "ビルドツール",
"openSourceThanks": "使用オープンソースライブラリ",
"openSourceLicense": "オープンソースライセンス",
"openSourceDesc": "このプロジェクトは GitHub で公開されています。Star ⭐ でサポートしてください!",
"visitGitHub": "GitHub へ進む",
"appDesc": "MaiBot のモダンな Web 管理インターフェース",
"maimaiCore": "MaiBot コア",
"uiFrameworkGroup": "UI フレームワーク & コンポーネント",
"routingStateGroup": "ルーティング & 状態管理",
"formGroup": "フォーム処理",
"utilsGroup": "ユーティリティライブラリ",
"animationGroup": "アニメーション",
"backendGroup": "バックエンドフレームワーク",
"devToolsGroup": "開発ツール",
"openSourceThanksDesc": "このプロジェクトは以下の優れたオープンソースライブラリを使用しています。貢献に感謝します:",
"licenseDesc": "このプロジェクトは GNU General Public License v3.0 でライセンスされています。同じオープンソースライセンスを保持する限り、自由に使用・修改・配布できます。",
"licenseDeps": "このプロジェクトのすべての依存オープンソースライブラリはそれぞれのライセンスMIT、Apache-2.0、BSD など)に従っています。すべてのオープンソース貢献者に感謝します。",
"lib": {
"react": "UIを構築するためのライブラリ",
"shadcn": "エレガントな React コンポーネントライブラリ",
"radix": "スタイルなしのアクセシブルなコンポーネント",
"tailwind": "ユーティリティファーストの CSS フレームワーク",
"lucide": "美しいアイコンライブラリ",
"tanstackRouter": "型安全なルーティングライブラリ",
"zustand": "軽量な状態管理ライブラリ",
"reactHookForm": "高パフォーマンスなフォームライブラリ",
"zod": "TypeScript ファーストのスキーマ検証",
"clsx": "条件付き className ビルダー",
"tailwindMerge": "Tailwind クラス名マージツール",
"cva": "コンポーネントバリアント管理",
"dateFns": "モダンな日付ユーティリティライブラリ",
"framerMotion": "React アニメーションライブラリ",
"vaul": "ドロワーコンポーネントアニメーション",
"fastapi": "モダンな Python Web フレームワーク",
"uvicorn": "ASGI サーバー",
"pydantic": "データ検証ライブラリ",
"pythonMultipart": "ファイルアップロードサポート",
"typescript": "JavaScript のスーパーセット",
"vite": "次世代フロントエンドビルドツール",
"eslint": "JavaScript コードリンター",
"postcss": "CSS 変換ツール"
}
}
},
"auth": {
"title": "ログイン",
"description": "続行するにはアクセストークンを入力してください",
"tokenLabel": "アクセストークン",
"tokenPlaceholder": "アクセストークンを入力",
"loginButton": "ログイン",
"loggingIn": "ログイン中...",
"loginFailed": "ログイン失敗",
"loginSuccess": "ログイン成功",
"checkingAuth": "ログイン状態を確認中...",
"welcome": "MaiBot へようこそ",
"accessDesc": "システムにアクセスするためにアクセストークンを入力してください",
"tokenRequired": "アクセストークンを入力してください",
"verifyingLabel": "验証中...",
"verifyEnter": "验証して入る",
"helpLink": "Token がありません。どこで取得できますか?",
"helpTitle": "Access Token の取得方法",
"helpDesc": "Access Token は MaiBot WebUI にアクセスする唯一の証明情報です。次の方法で取得してください",
"method1Title": "方法1:起動ログを確認",
"method1Desc": "MaiBot 起動時にコンソールに WebUI Access Token が表示されます。",
"method1Example1": "🔑 WebUI Access Token: abc123...",
"method1Example2": "💡 この Token で WebUI にログインしてください",
"method2Title": "方法2:設定ファイルを確認",
"method2Desc": "Token はプロジェクトルートの設定ファイルに保存されています:",
"method2FileHint": "このファイルを開いて access_token フィールドの値をコピーしてください",
"securityTipTitle": "セキュリティノート",
"securityTip1": "Token を安全に保管し、他人に漏洱しないでください",
"securityTip2": "Token をリセットするには、ログイン後にシステム設定へ進んでください",
"slowLink": "インターフェースが重いです。どうすればいいですか?",
"disableAnimTitle": "バックグラウンドアニメーションを無効にする",
"disableAnimDesc": "バックグラウンドアニメーションは低スペックのデバイスで遅延を引き起こす可能性があります。無効にすると活百度が大幅に向上します。",
"disableAnimDetail": "無効にすると背景が単色になりますが、機能には影響しません。システム設定からいつでも再有効化できます。",
"disableAnimBtn": "アニメーションを無効にする",
"verifyFailed": "Token の検証に失敗しました。确認して再試行してください。",
"connFailed": "サーバーへの接続に失敗しました。ネットワーク接続を確認してください。",
"switchToLight": "ライトモードに切り替える",
"switchToDark": "ダークモードに切り替える"
},
"common": {
"loading": "読み込み中...",
"error": "エラー",
"retry": "再試行",
"save": "保存",
"cancel": "キャンセル",
"confirm": "確認",
"delete": "削除",
"edit": "編集",
"close": "閉じる",
"search": "検索",
"noData": "データなし",
"success": "成功",
"failed": "失敗"
},
"restart": {
"preparing": "再起動を準備中",
"preparingDesc": "再起動リクエストを送信中...",
"preparingTip": "🔄 MaiBot の再起動を準備中...",
"restarting": "MaiBot を再起動中",
"restartingDesc": "しばらお待ちください、MaiBot が再起動中です...",
"restartingTip": "🔄 設定を保存しました、メインプロセスを再起動中...",
"checking": "サービス状態を確認中",
"checkingDesc": "サービスの回復を待機中... ({{current}}/{{max}})",
"checkingTip": "⏳ サービスの回復を待機中、ページを閉じないでください...",
"success": "再起動成功",
"successDesc": "ログインページにリダイレクト中...",
"successTip": "✅ 設定が適用されました、サービスは正常に動作しています",
"failed": "再起動タイムアウト",
"failedDesc": "サービスが予定時間内に回復しませんでした",
"failedTip": "⚠️ 長時間応答がない場合は、手動で再起動してください",
"refreshPage": "ページを更新",
"retryCheck": "再試行",
"elapsed": "経過時間:"
},
"errorBoundary": {
"title": "問題が発生しました",
"description": "アプリケーションが予期しないエラーを検出しました。ページを更新するかホームに戻ることができます。",
"refreshPage": "ページを更新",
"goHome": "ホームに戻る",
"footer": "問題が解決しない場合は、エラー情報をコピーして開発者に報告してください",
"copiedToClipboard": "クリップボードにコピーしました",
"copyError": "エラー情報をコピー"
},
"search": {
"placeholder": "ページを検索...",
"title": "検索",
"noResults": "一致するページが見つかりません",
"startSearch": "キーワードを入力して検索を開始",
"navigate": "ナビゲート",
"select": "選択",
"close": "閉じる",
"categories": {
"overview": "概要",
"config": "設定",
"resources": "リソース",
"monitor": "監視",
"extensions": "拡張機能",
"system": "システム"
},
"items": {
"home": "ホーム",
"homeDesc": "ダッシュボード概要を表示",
"botConfig": "ボットメイン設定",
"botConfigDesc": "ボットのコア設定を構成",
"modelProvider": "モデルプロバイダー設定",
"modelProviderDesc": "モデルプロバイダーを設定",
"model": "モデル設定",
"modelDesc": "モデルパラメーターを設定",
"emoji": "絵文字管理",
"emojiDesc": "ボットの絵文字を管理",
"expression": "表現管理",
"expressionDesc": "ボットの表現を管理",
"person": "人物情報",
"personDesc": "人物情報を管理",
"jargon": "スラング管理",
"jargonDesc": "ボットが学習したスラングを管理",
"statistics": "統計情報",
"statisticsDesc": "使用統計を表示",
"plugins": "プラグインマーケット",
"pluginsDesc": "プラグインを閉覧してインストール",
"logs": "ログビューア",
"logsDesc": "システムログを表示",
"settings": "設定",
"settingsDesc": "システム設定を構成"
}
},
"httpWarning": {
"title": "セキュリティ警告:",
"message": "HTTP で MaiBot WebUI にアクセスしています",
"description": "これが公開サーバーの場合、データToken、チャット履歴などが転送中に傍受される可能性があります。HTTPS を使用するか、ローカルネットワークからのみアクセスすることを強くお勧めします。",
"dismiss": "警告を閉じる"
}
}

View File

@@ -0,0 +1,477 @@
{
"language": { "zh": "中文", "en": "English", "ja": "日本語", "ko": "한국어" },
"header": {
"collapseSidebar": "사이드바 접기",
"expandSidebar": "사이드바 펼치기",
"toggleConnection": "백엔드 연결 전환",
"viewAnnualSummary": "연간 요약 보기",
"annualSummary": "2025 연간 요약",
"searchPlaceholder": "검색...",
"viewDocs": "MaiBot 문서 보기",
"docs": "MaiBot 문서",
"switchToLight": "라이트 모드로 전환",
"switchToDark": "다크 모드로 전환",
"logout": "로그아웃",
"logoutLabel": "로그아웃",
"notConnected": "연결 안됨"
},
"sidebar": {
"groups": {
"overview": "개요",
"botConfig": "봇 설정",
"botResources": "봇 리소스",
"extensionsMonitor": "확장 기능 & 모니터",
"system": "시스템"
},
"menu": {
"home": "홈",
"botMainConfig": "봇 메인 설정",
"aiModelProvider": "AI 모델 공급자",
"modelManagement": "모델 관리",
"adapterConfig": "어댑터 설정",
"emojiManagement": "이모티콘 관리",
"expressionManagement": "표현 관리",
"slangManagement": "슬랭 관리",
"personInfo": "인물 정보",
"knowledgeGraph": "지식 그래프",
"knowledgeBase": "지식 베이스",
"pluginMarket": "플러그인 마켓",
"configTemplate": "설정 템플릿",
"pluginConfig": "플러그인 설정",
"logViewer": "로그 뷰어",
"plannerMonitor": "플래너 & 리플라이어 모니터",
"localChat": "로컬 채팅",
"settings": "설정"
}
},
"layout": {
"verifyingLogin": "로그인 상태 확인 중...",
"logoTitle": "MaiBot WebUI",
"logoTitleShort": "M"
},
"settings": {
"title": "설정",
"description": "앱 환경 설정 관리",
"tabs": {
"appearance": "외관",
"security": "보안",
"other": "기타",
"about": "정보"
},
"appearance": {
"themeMode": "테마 모드",
"themeModeDesc": "라이트 / 다크 / 시스템 따라가기",
"light": "라이트",
"dark": "다크",
"system": "시스템",
"accentColor": "강조 색상",
"resetDefault": "기본값으로 재설정",
"colorPreview": "색상 미리보기",
"styleTweaks": "스타일 조정",
"typography": "타이포그래피",
"visualEffects": "시각 효과",
"layout": "레이아웃",
"animation": "애니메이션",
"background": "배경",
"customCss": "사용자 정의 CSS",
"animationEffect": "애니메이션 효과",
"importExportTheme": "테마 가져오기 / 내보내기",
"importTheme": "테마 가져오기",
"exportTheme": "테마 내보내기",
"importSuccess": "가져오기 성공",
"importFailed": "가져오기 실패",
"resetSuccess": "재설정 성공",
"fontFamily": "글꼴",
"fontSize": "글자 크기",
"borderRadius": "테두리 반경",
"contentWidth": "콘텐츠 너비",
"sidebarWidth": "사이드바 너비",
"animationSpeed": "애니메이션 속도",
"backgroundImage": "배경 이미지",
"backgroundBlur": "배경 흐림",
"backgroundOpacity": "배경 투명도",
"lightDesc": "항상 라이트 테마 사용",
"darkDesc": "항상 다크 테마 사용",
"systemDesc": "시스템 설정에 따라 자동 전환",
"accentPrimary": "링 컴러",
"accentHint": "색상 퐹을 클릭하거나 HEX 값을 입력하세요",
"resetTheme": "기본값으로 재설정",
"confirmResetTheme": "테마 재설정 확인",
"confirmResetThemeDesc": "이렇게 하면 색상, 글꼴, 레이아웃 및 사용자 정의 CSS를 포함한 모든 테마 설정이 기본값으로 재설정됩니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?",
"confirmResetAction": "재설정 확인",
"cssWarningTitle": "다음 내용이 안전 필터를 거쳣습니다:",
"cssPlaceholder": "/* 사용자 정의 CSS를 여기에 입력 */\n\n/* 예시: */\n/* .sidebar { background: #1a1a2e; } */",
"cssDescription": "인터페이스를 더욱 개인화하도록 사용자 정의 CSS를 작성하세요. 위험한 CSS(@import, url() 등)는 자동으로 필터됩니다.",
"clearCss": "지우기",
"exportDesc": "주제를 JSON 파일로 내보내 공유하거나 백업합니다. 가져올 때 모든 설정이 자동으로 적용됩니다.",
"importSuccessDesc": "테마 설정을 가져왔습니다. 페이지가 자동으로 새로고침됩니다",
"resetSuccessDesc": "테마가 기본값으로 재설정되었습니다",
"enableAnimations": "애니메이션 활성화",
"enableAnimationsDesc": "비활성화하면 모든 전환 애니메이션과 효과가 긺히고 성능이 향상됩니다",
"loginWavesBackground": "로그인 페이지 파도 배경",
"loginWavesBackgroundDesc": "비활성화하면 로그인 페이지가 단색 배경이 됩니다. 저사양 디바이스에 적합합니다",
"inheritParentBg": "상위 배경 상속",
"inheritParentBgDesc": "활성화하면 상위 레이어의 배경 설정을 사용합니다",
"fontFamilyLabel": "글꼴 패밀리",
"fontFamilyPlaceholder": "글꼴 패밀리 선택",
"fontFamilySystem": "시스템 기본 (System)",
"fontFamilySans": "돋움체 (Sans-serif)",
"fontFamilySerif": "세리프 (Serif)",
"fontFamilyMono": "등폭 (Monospace)",
"baseFontSize": "기본 글자 크기 (Base Size)",
"lineHeight": "줄 높이 (Line Height)",
"lineHeightPlaceholder": "줄 높이 선택",
"lineHeightCompact": "콤팬트 (1.2)",
"lineHeightNormal": "보통 (1.5)",
"lineHeightLoose": "느슨 (1.75)",
"borderRadiusLabel": "테두리 반경 (Radius)",
"shadowLabel": "그림자 강도 (Shadow)",
"shadowPlaceholder": "그림자 강도 선택",
"shadowNone": "없음 (None)",
"shadowSm": "약함 (Small)",
"shadowMd": "중간 (Medium)",
"shadowLg": "강함 (Large)",
"shadowXl": "매우 강함 (Extra Large)",
"blurLabel": "흘림 효과 (Blur)",
"sidebarWidthLabel": "사이드바드 너비 (Sidebar Width)",
"maxContentWidth": "콘텐츠 최대 너비 (Max Width)",
"spacingUnit": "기본 간격 (Spacing Unit)",
"animationSpeedLabel": "애니메이션 속도 (Speed)",
"animationSpeedPlaceholder": "애니메이션 속도 선택",
"animationFast": "빠름 (100ms)",
"animationNormal": "보통 (300ms)",
"animationSlow": "느림 (500ms)",
"animationOff": "끄기 (0ms)",
"bgPage": "페이지",
"bgSidebar": "사이드바드",
"typographyGroup": "타이포그래피 (Typography)",
"visualGroup": "시각 효과 (Visual)",
"layoutGroup": "레이아웃 (Layout)",
"animationGroup": "애니메이션 (Animation)",
"backgroundGroup": "배경 설정 (Backgrounds)"
},
"security": {
"currentToken": "현재 액세스 토큰",
"yourToken": "액세스 토큰",
"regenerate": "재생성",
"customToken": "사용자 정의 액세스 토큰",
"securityTip": "보안 팁",
"cannotCopy": "복사할 수 없습니다",
"copySuccess": "복사됨",
"copyFailed": "복사 실패",
"updateSuccess": "업데이트됨",
"updateFailed": "업데이트 실패",
"generateSuccess": "생성됨",
"generateFailed": "생성 실패",
"newToken": "새 액세스 토큰",
"confirmRegenerate": "토큰 재생성 확인",
"confirmRegenerateDesc": "재생성 후 이전 토큰은 무효화됩니다. 다시 로그인해야 합니다.",
"cancel": "취소",
"confirm": "확인",
"cannotCopyDesc": "토큰이 보안 쿠키에 저장되어 있습니다. 새 토큰을 얻으려면 재생성하세요.",
"copySuccessDesc": "토큰이 클립보드에 복사되었습니다",
"copyFailedDesc": "토큰을 수동으로 복사하세요",
"inputError": "입력 오류",
"inputErrorDesc": "새 토큰을 입력하세요",
"formatError": "형식 오류",
"formatErrorDesc": "토큰이 요구 사항을 충족하지 않습니다: {{failedRules}}",
"updateSuccessDesc": "액세스 토큰이 업데이트되었습니다. 로그인 페이지로 이동합니다.",
"updateFailedDesc": "토큰을 업데이트할 수 없습니다",
"updateFailedConn": "서버 연결에 실패했습니다",
"generateSuccessDesc": "새 액세스 토큰이 생성되었습니다. 즉시 저장하세요.",
"generateFailedDesc": "새 토큰을 생성할 수 없습니다",
"generateFailedConn": "서버 연결에 실패했습니다",
"cannotView": "볼 수 없습니다",
"cannotViewDesc": "토큰이 보안 쿠키에 저장되어 있습니다. 새 토큰이 필요하면 \"재생성\"을 클릭하세요.",
"hide": "숨기기",
"show": "표시",
"copyTip": "클립보드에 복사",
"regenerateShort": "생성",
"confirmRegenerateFullDesc": "새로운 64자 보안 토큰을 생성하고 현재 토큰을 즉시 무효화합니다. 새 토큰으로 다시 로그인해야 합니다. 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?",
"confirmGenerate": "생성 확인",
"tokenStorePlaceholder": "토큰이 보안 쿠키에 저장되어 있습니다",
"safekeepTip": "액세스 토큰을 안전하게 보관하고 다른 사람과 공유하지 마세요.",
"newTokenLabel": "새 액세스 토큰",
"customTokenPlaceholder": "사용자 정의 토큰 입력",
"tokenReqTitle": "토큰 보안 요구 사항:",
"tokenValid": "토큰 형식이 올바르며 사용 가능합니다",
"updateBtn": "사용자 정의 토큰 업데이트",
"updating": "업데이트 중...",
"dialogTitle": "새 액세스 토큰",
"dialogDesc": "새 토큰입니다. 즉시 저장하세요. 창을 닫으면 로그인 페이지로 이동합니다.",
"dialogTokenLabel": "새 토큰 (64자 보안 토큰)",
"important": "중요 공지",
"tip1": "이 토큰은 한 번만 표시됩니다. 닫은 후에는 볼 수 없습니다",
"tip2": "즉시 복사하여 안전한 위치에 저장하세요",
"tip3": "닫으면 자동으로 로그인 페이지로 이동합니다",
"tip4": "새 토큰으로 다시 로그인하세요",
"copied": "복사됨",
"copyToken": "토큰 복사",
"savedClose": "저장했습니다, 닫기",
"securityTip1": "재생성하면 시스템이 랜덤 64자 보안 토큰을 생성합니다",
"securityTip2": "사용자 정의 토큰은 모든 보안 요구 사항을 충족해야 합니다",
"securityTip3": "토큰을 업데이트하면 이전 토큰이 즉시 무효화됩니다",
"securityTip4": "안전한 환경에서 토큰을 확인하고 복사하세요",
"securityTip5": "토큰 유출이 의심되면 즉시 재생성하거나 업데이트하세요",
"securityTip6": "최고의 보안을 위해 시스템 생성 토큰을 권장합니다"
},
"other": {
"performance": "성능 & 저장소",
"localStorage": "로컬 저장소 사용량",
"logCache": "로그 캐시 크기",
"importExport": "설정 가져오기 / 내보내기",
"configWizard": "설정 마법사",
"devTools": "개발자 도구",
"clearStorage": "로컬 저장소 지우기",
"clearStorageDesc": "모든 로컬 저장소 데이터를 지웁니다",
"clearStorageConfirm": "지우기 확인",
"clearLogCache": "로그 캐시 지우기",
"clearLogCacheDesc": "모든 캐시된 로그 데이터를 지웁니다",
"clearLogCacheConfirm": "지우기 확인",
"importSettings": "설정 가져오기",
"exportSettings": "설정 내보내기",
"resetAllSettings": "모든 설정 재설정",
"resetAllSettingsDesc": "모든 설정을 기본값으로 되돌립니다",
"resetAllSettingsConfirm": "재설정 확인",
"clearStorageSuccess": "로컬 저장소를 지웠습니다",
"clearStorageFailed": "지우기 실패",
"clearLogSuccess": "로그 캐시를 지웠습니다",
"clearLogFailed": "지우기 실패",
"importSuccess": "가져오기 성공",
"importFailed": "가져오기 실패",
"exportSuccess": "내보내기 성공",
"exportFailed": "내보내기 실패",
"resetSuccess": "재설정 성공",
"resetFailed": "재설정 실패",
"storageItems": "{{count}}개 저장 항목",
"logCacheSizeDesc": "로그 뷰어가 캐시할 최대 로그 수를 제어합니다. 값이 클수록 메모리를 더 사용합니다.",
"logCacheSizeUnit": "개",
"dataSyncIntervalLabel": "홈 데이터 새로고침 간격",
"dataSyncIntervalUnit": "초",
"dataSyncIntervalDesc": "홈 화면 통계 데이터의 자동 새로고침 간격을 제어합니다",
"wsReconnectLabel": "WebSocket 재연결 간격",
"wsReconnectUnit": "초",
"wsReconnectDesc": "로그 WebSocket 연결 해제 후 재연결 기본 간격",
"wsMaxReconnectLabel": "WebSocket 최대 재연결 횟수",
"wsMaxReconnectUnit": "회",
"wsMaxReconnectDesc": "연결 실패 후 최대 재연결 시도 횟수",
"clearLogCacheFn": "로그 캐시 지우기",
"clearLocalCache": "로컬 캐시 지우기",
"confirmClearCache": "로컬 캐시 지우기 확인",
"confirmClearCacheDesc": "로그인 자격 증명을 제외한 모든 로컬 캐시 설정과 데이터를 지웁니다. 일부 기본 설정을 다시 구성해야 할 수 있습니다. 계속하시겠습니까?",
"confirmClear": "지우기 확인",
"importExportDesc": "현재 인터페이스 설정을 내보내 백업하거나, 이전에 내보낸 파일에서 복원하세요.",
"exporting": "내보내는 중...",
"importing": "가져오는 중...",
"resetAllSettingsBtn": "모든 설정을 기본값으로 재설정",
"confirmResetAll": "모든 설정 재설정 확인",
"confirmResetAllDesc": "테마, 색상, 애니메이션 등 모든 인터페이스 설정을 기본값으로 복원합니다. 로그인 상태에는 영향이 없습니다. 계속하시겠습니까?",
"configWizardDesc": "초기 설정 마법사를 다시 실행하여 시스템 기본 설정을 재구성할 수 있습니다.",
"rerunSetup": "초기 설정 다시 실행",
"confirmRerunSetup": "재구성 확인",
"confirmRerunSetupDesc": "초기 설정 마법사로 돌아갑니다. 시스템 기본 설정을 재구성할 수 있습니다. 계속하시겠습니까?",
"devToolsDesc": "아래 기능은 개발 및 디버깅 전용입니다. 페이지 충돌이나 비정상 동작을 유발할 수 있습니다.",
"triggerError": "테스트 오류 발생",
"confirmTriggerError": "오류 발생 확인",
"confirmTriggerErrorDesc": "React 오류를 수동으로 발생시켜 오류 경계 컴포넌트를 테스트합니다. 페이지를 새로고침하거나 홈으로 돌아가면 복구됩니다.",
"confirmTrigger": "발생 확인",
"logCleared": "로그 지워짐",
"logClearedDesc": "로그 캐시가 지워졌습니다",
"cacheCleared": "캐시 지워짐",
"cacheClearedDesc": "{{count}}개의 캐시 데이터를 지웠습니다",
"exportSuccessDesc": "설정을 JSON 파일로 내보냈습니다",
"exportFailedDesc": "설정을 내보낼 수 없습니다",
"importSuccessDesc": "{{imported}}개 설정을 가져왔습니다",
"importSkippedSuffix": ", {{skipped}}개 건너뜀",
"importRefreshHint": "참고",
"importRefreshHintDesc": "일부 설정은 페이지를 새로고침해야 완전히 적용됩니다",
"importNoDataDesc": "가져올 유효한 설정이 없습니다",
"importInvalidDesc": "유효하지 않은 파일 형식",
"resetDone": "재설정 완료",
"resetDoneDesc": "모든 설정이 기본값으로 복원되었습니다. 변경 사항을 적용하려면 페이지를 새로고침하세요."
},
"about": {
"openSource": "오픈 소스",
"aboutApp": "MaiBot Dashboard 정보",
"version": "버전:",
"author": "작성자",
"techStack": "기술 스택",
"frontendFramework": "프론트엔드 프레임워크",
"uiComponents": "UI 컴포넌트",
"backend": "백엔드",
"buildTool": "빌드 도구",
"openSourceThanks": "오픈 소스 라이브러리",
"openSourceLicense": "오픈 소스 라이선스",
"openSourceDesc": "이 프로젝트는 GitHub에서 오픈 소스입니다. Star ⭐로 응원해 주세요!",
"visitGitHub": "GitHub 방문",
"appDesc": "MaiBot의 현대적인 웹 관리 인터페이스",
"maimaiCore": "MaiBot 코어",
"uiFrameworkGroup": "UI 프레임워크 & 컴포넌트",
"routingStateGroup": "라우팅 & 상태 관리",
"formGroup": "폼 처리",
"utilsGroup": "유틸리티 라이브러리",
"animationGroup": "애니메이션",
"backendGroup": "백엔드 프레임워크",
"devToolsGroup": "개발자 도구",
"openSourceThanksDesc": "이 프로젝트는 다음 훌륭한 오픈 소스 라이브러리를 사용합니다. 기여에 감사드립니다:",
"licenseDesc": "이 프로젝트는 GNU General Public License v3.0으로 라이선스됩니다. 동일한 오픈 소스 라이선스를 유지하는 한 자유롭게 사용, 수정, 배포할 수 있습니다.",
"licenseDeps": "이 프로젝트의 모든 오픈 소스 라이브러리는 각각의 라이선스(MIT, Apache-2.0, BSD 등)를 따릅니다. 모든 오픈 소스 기여자에게 감사드립니다.",
"lib": {
"react": "UI 구축을 위한 라이브러리",
"shadcn": "우아한 React 컴포넌트 라이브러리",
"radix": "스타일 없는 접근 가능한 컴포넌트",
"tailwind": "유틸리티 우선 CSS 프레임워크",
"lucide": "아름다운 아이콘 라이브러리",
"tanstackRouter": "타입 안전한 라우팅 라이브러리",
"zustand": "경량 상태 관리 라이브러리",
"reactHookForm": "고성능 폼 라이브러리",
"zod": "TypeScript 우선 스키마 검증",
"clsx": "조건부 className 빌더",
"tailwindMerge": "Tailwind 클래스 이름 병합 도구",
"cva": "컴포넌트 변형 관리",
"dateFns": "현대적인 날짜 유틸리티 라이브러리",
"framerMotion": "React 애니메이션 라이브러리",
"vaul": "드로어 컴포넌트 애니메이션",
"fastapi": "현대적인 Python 웹 프레임워크",
"uvicorn": "ASGI 서버",
"pydantic": "데이터 검증 라이브러리",
"pythonMultipart": "파일 업로드 지원",
"typescript": "JavaScript의 슈퍼셋",
"vite": "차세대 프론트엔드 빌드 도구",
"eslint": "JavaScript 코드 린터",
"postcss": "CSS 변환 도구"
}
}
},
"auth": {
"title": "로그인",
"description": "계속하려면 액세스 토큰을 입력하세요",
"tokenLabel": "액세스 토큰",
"tokenPlaceholder": "액세스 토큰 입력",
"loginButton": "로그인",
"loggingIn": "로그인 중...",
"loginFailed": "로그인 실패",
"loginSuccess": "로그인 성공",
"checkingAuth": "로그인 상태 확인 중...",
"welcome": "MaiBot에 오신 것을 환영합니다",
"accessDesc": "시스템에 액세스하려면 액세스 토큰을 입력하세요",
"tokenRequired": "액세스 토큰을 입력해 주세요",
"verifyingLabel": "확인 중...",
"verifyEnter": "확인 후 입장",
"helpLink": "Token이 없습니다. 어디서 얻을 수 있나요?",
"helpTitle": "Access Token 얻는 방법",
"helpDesc": "Access Token은 MaiBot WebUI에 액세스하는 유일한 자격 증명입니다. 다음 방법으로 얻으세요",
"method1Title": "방법 1: 시작 로그 확인",
"method1Desc": "MaiBot 시작 시 콘솔에 WebUI Access Token이 표시됩니다.",
"method1Example1": "🔑 WebUI Access Token: abc123...",
"method1Example2": "💡 이 Token으로 WebUI에 로그인하세요",
"method2Title": "방법 2: 설정 파일 확인",
"method2Desc": "Token은 프로젝트 루트의 설정 파일에 저장됩니다:",
"method2FileHint": "이 파일을 열고 access_token 필드의 값을 복사하세요",
"securityTipTitle": "보안 안내",
"securityTip1": "Token을 안전하게 유지하고 타인에게 노출하지 마세요",
"securityTip2": "Token을 재설정하려면 로그인 후 시스템 설정으로 이동하세요",
"slowLink": "인터페이스가 느립니다. 어떻게 하나요?",
"disableAnimTitle": "배경 애니메이션 비활성화",
"disableAnimDesc": "배경 애니메이션은 저사양 디바이스에서 느리게 동작할 수 있습니다. 비활성화하면 화면이 훨씬 부드러워집니다.",
"disableAnimDetail": "비활성화 후 배경은 단색으로 바뀐지지만 모든 기능은 정상 작동합니다. 시스템 설정에서 언제든지 다시 활성화할 수 있습니다.",
"disableAnimBtn": "애니메이션 비활성화",
"verifyFailed": "Token 확인에 실패했습니다. 확인 후 다시 시도해 주세요.",
"connFailed": "서버에 연결하지 못했습니다. 네트워크 연결을 확인해 주세요.",
"switchToLight": "라이트 모드로 전환",
"switchToDark": "다크 모드로 전환"
},
"common": {
"loading": "로딩 중...",
"error": "오류",
"retry": "재시도",
"save": "저장",
"cancel": "취소",
"confirm": "확인",
"delete": "삭제",
"edit": "편집",
"close": "닫기",
"search": "검색",
"noData": "데이터 없음",
"success": "성공",
"failed": "실패"
},
"restart": {
"preparing": "재시작 준비 중",
"preparingDesc": "재시작 요청 전송 중...",
"preparingTip": "🔄 MaiBot 재시작을 준비 중...",
"restarting": "MaiBot 재시작 중",
"restartingDesc": "잠시 기다려주세요, MaiBot이 재시작 중입니다...",
"restartingTip": "🔄 설정을 저장했습니다, 메인 프로세스를 재시작 중...",
"checking": "서비스 상태 확인 중",
"checkingDesc": "서비스 복구 대기 중... ({{current}}/{{max}})",
"checkingTip": "⏳ 서비스 복구를 기다리는 중, 페이지를 닫지 마세요...",
"success": "재시작 성공",
"successDesc": "로그인 페이지로 이동 중...",
"successTip": "✅ 설정이 적용되었습니다, 서비스가 정상적으로 실행 중입니다",
"failed": "재시작 시간 초과",
"failedDesc": "서비스가 예상 시간 내에 복구되지 않았습니다",
"failedTip": "⚠️ 장시간 응답이 없으면 수동으로 재시작해 보세요",
"refreshPage": "페이지 새로고침",
"retryCheck": "재시도",
"elapsed": "경과 시간:"
},
"errorBoundary": {
"title": "문제가 발생했습니다",
"description": "앱이 예기치 않은 오류를 만났습니다. 페이지를 새로고침하거나 홈으로 돌아갈 수 있습니다.",
"refreshPage": "페이지 새로고침",
"goHome": "홈으로 이동",
"footer": "문제가 계속되면 오류 정보를 복사하여 개발자에게 보고해 주세요",
"copiedToClipboard": "클립보드에 복사됨",
"copyError": "오류 정보 복사"
},
"search": {
"placeholder": "페이지 검색...",
"title": "검색",
"noResults": "일치하는 페이지를 찾을 수 없습니다",
"startSearch": "키워드를 입력하여 검색 시작",
"navigate": "탐색",
"select": "선택",
"close": "닫기",
"categories": {
"overview": "개요",
"config": "설정",
"resources": "리소스",
"monitor": "모니터",
"extensions": "확장 기능",
"system": "시스템"
},
"items": {
"home": "홈",
"homeDesc": "대시보드 개요 보기",
"botConfig": "속 메인 설정",
"botConfigDesc": "속 핵심 설정 구성",
"modelProvider": "모델 공급자 설정",
"modelProviderDesc": "모델 공급자 구성",
"model": "모델 설정",
"modelDesc": "모델 매개변수 구성",
"emoji": "이모티콘 관리",
"emojiDesc": "속 이모티콘 관리",
"expression": "표현 관리",
"expressionDesc": "속 표현 관리",
"person": "인물 정보",
"personDesc": "인물 정보 관리",
"jargon": "슬랭 관리",
"jargonDesc": "속이 학습한 슬랭 관리",
"statistics": "통계 정보",
"statisticsDesc": "사용 통계 보기",
"plugins": "플러그인 마켓",
"pluginsDesc": "플러그인 탐색 및 설치",
"logs": "로그 뷰어",
"logsDesc": "시스템 로그 보기",
"settings": "설정",
"settingsDesc": "시스템 설정 구성"
}
},
"httpWarning": {
"title": "보안 경고:",
"message": "HTTP를 통해 MaiBot WebUI에 접속하고 있습니다",
"description": "이것이 공개 서버인 경우, 데이터(Token, 채팅 기록 등)가 전송 중에 가로챔질 수 있습니다. HTTPS를 사용하거나 로컈 네트워크에서만 접속하는 것을 강력히 권장합니다.",
"dismiss": "경고 닫기"
}
}

View File

@@ -0,0 +1,477 @@
{
"language": { "zh": "中文", "en": "English", "ja": "日本語", "ko": "한국어" },
"header": {
"collapseSidebar": "收起侧边栏",
"expandSidebar": "展开侧边栏",
"toggleConnection": "切换后端连接",
"viewAnnualSummary": "查看年度总结",
"annualSummary": "2025 年度总结",
"searchPlaceholder": "搜索...",
"viewDocs": "查看麦麦文档",
"docs": "麦麦文档",
"switchToLight": "切换到浅色模式",
"switchToDark": "切换到深色模式",
"logout": "登出系统",
"logoutLabel": "登出",
"notConnected": "未连接"
},
"sidebar": {
"groups": {
"overview": "概览",
"botConfig": "麦麦配置编辑",
"botResources": "麦麦资源管理",
"extensionsMonitor": "扩展与监控",
"system": "系统"
},
"menu": {
"home": "首页",
"botMainConfig": "麦麦主程序配置",
"aiModelProvider": "AI模型厂商配置",
"modelManagement": "模型管理与分配",
"adapterConfig": "麦麦适配器配置",
"emojiManagement": "表情包管理",
"expressionManagement": "表达方式管理",
"slangManagement": "黑话管理",
"personInfo": "人物信息管理",
"knowledgeGraph": "知识库图谱可视化",
"knowledgeBase": "麦麦知识库管理",
"pluginMarket": "插件市场",
"configTemplate": "配置模板市场",
"pluginConfig": "插件配置",
"logViewer": "日志查看器",
"plannerMonitor": "计划器&回复器监控",
"localChat": "本地聊天室",
"settings": "系统设置"
}
},
"layout": {
"verifyingLogin": "正在验证登录状态...",
"logoTitle": "MaiBot WebUI",
"logoTitleShort": "M"
},
"settings": {
"title": "系统设置",
"description": "管理您的应用偏好设置",
"tabs": {
"appearance": "外观",
"security": "安全",
"other": "其他",
"about": "关于"
},
"appearance": {
"themeMode": "主题模式",
"themeModeDesc": "浅色/深色/跟随系统",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"accentColor": "主题色",
"resetDefault": "重置默认",
"colorPreview": "实时色板预览",
"styleTweaks": "界面样式微调",
"typography": "字体排版",
"visualEffects": "视觉效果",
"layout": "布局",
"animation": "动画",
"background": "背景设置",
"customCss": "自定义 CSS",
"animationEffect": "动画效果",
"importExportTheme": "主题导入/导出",
"importTheme": "导入主题",
"exportTheme": "导出主题",
"importSuccess": "导入成功",
"importFailed": "导入失败",
"resetSuccess": "重置成功",
"fontFamily": "字体",
"fontSize": "字号",
"borderRadius": "圆角",
"contentWidth": "内容宽度",
"sidebarWidth": "侧边栏宽度",
"animationSpeed": "动画速度",
"backgroundImage": "背景图片",
"backgroundBlur": "背景模糊",
"backgroundOpacity": "背景透明度",
"lightDesc": "始终使用浅色主题",
"darkDesc": "始终使用深色主题",
"systemDesc": "根据系统设置自动切换",
"accentPrimary": "主色调",
"accentHint": "点击色环选择或输入 HEX 值",
"resetTheme": "重置为默认",
"confirmResetTheme": "确认重置主题",
"confirmResetThemeDesc": "这将重置所有主题设置为默认值,包括颜色、字体、布局和自定义 CSS。此操作不可撤销确定要继续吗",
"confirmResetAction": "确认重置",
"cssWarningTitle": "以下内容已被安全过滤:",
"cssPlaceholder": "/* 在这里输入自定义 CSS */\n\n/* 例如: */\n/* .sidebar { background: #1a1a2e; } */",
"cssDescription": "编写自定义 CSS 来进一步个性化界面。危险的 CSS如 @import、url())将被自动过滤。",
"clearCss": "清除",
"exportDesc": "导出主题为 JSON 文件便于分享或备份,导入时会自动应用所有配置。",
"importSuccessDesc": "主题配置已导入,页面将自动刷新",
"resetSuccessDesc": "主题已重置为默认值",
"enableAnimations": "启用动画效果",
"enableAnimationsDesc": "关闭后将禁用所有过渡动画和特效,提升性能",
"loginWavesBackground": "登录页波浪背景",
"loginWavesBackgroundDesc": "关闭后登录页将使用纯色背景,适合低性能设备",
"inheritParentBg": "继承上级背景",
"inheritParentBgDesc": "开启后将使用上级层级的背景配置",
"fontFamilyLabel": "字体族 (Font Family)",
"fontFamilyPlaceholder": "选择字体族",
"fontFamilySystem": "系统默认 (System)",
"fontFamilySans": "无衬线 (Sans-serif)",
"fontFamilySerif": "衬线 (Serif)",
"fontFamilyMono": "等宽 (Monospace)",
"baseFontSize": "基准字体大小 (Base Size)",
"lineHeight": "行高 (Line Height)",
"lineHeightPlaceholder": "选择行高",
"lineHeightCompact": "紧凑 (1.2)",
"lineHeightNormal": "正常 (1.5)",
"lineHeightLoose": "宽松 (1.75)",
"borderRadiusLabel": "圆角大小 (Radius)",
"shadowLabel": "阴影强度 (Shadow)",
"shadowPlaceholder": "选择阴影强度",
"shadowNone": "无阴影 (None)",
"shadowSm": "轻微 (Small)",
"shadowMd": "中等 (Medium)",
"shadowLg": "强烈 (Large)",
"shadowXl": "极强 (Extra Large)",
"blurLabel": "模糊效果 (Blur)",
"sidebarWidthLabel": "侧边栏宽度 (Sidebar Width)",
"maxContentWidth": "内容区最大宽度 (Max Width)",
"spacingUnit": "基准间距 (Spacing Unit)",
"animationSpeedLabel": "动画速度 (Speed)",
"animationSpeedPlaceholder": "选择动画速度",
"animationFast": "快速 (100ms)",
"animationNormal": "正常 (300ms)",
"animationSlow": "慢速 (500ms)",
"animationOff": "关闭 (0ms)",
"bgPage": "页面",
"bgSidebar": "侧边栏",
"typographyGroup": "字体排版 (Typography)",
"visualGroup": "视觉效果 (Visual)",
"layoutGroup": "布局 (Layout)",
"animationGroup": "动画 (Animation)",
"backgroundGroup": "背景设置 (Backgrounds)"
},
"security": {
"currentToken": "当前 Access Token",
"yourToken": "您的访问令牌",
"regenerate": "重新生成",
"customToken": "自定义 Access Token",
"securityTip": "安全提示",
"cannotCopy": "无法复制",
"copySuccess": "复制成功",
"copyFailed": "复制失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
"generateSuccess": "生成成功",
"generateFailed": "生成失败",
"newToken": "新的 Access Token",
"confirmRegenerate": "确认重新生成 Token",
"confirmRegenerateDesc": "重新生成后,旧 Token 将失效,需重新登录",
"cancel": "取消",
"confirm": "确认",
"cannotCopyDesc": "Token 存储在安全 Cookie 中,请重新生成以获取新 Token",
"copySuccessDesc": "Token 已复制到剪贴板",
"copyFailedDesc": "请手动复制 Token",
"inputError": "输入错误",
"inputErrorDesc": "请输入新的 Token",
"formatError": "格式错误",
"formatErrorDesc": "Token 不符合要求: {{failedRules}}",
"updateSuccessDesc": "Access Token 已更新,即将跳转到登录页",
"updateFailedDesc": "无法更新 Token",
"updateFailedConn": "连接服务器失败",
"generateSuccessDesc": "新的 Access Token 已生成,请及时保存",
"generateFailedDesc": "无法生成新 Token",
"generateFailedConn": "连接服务器失败",
"cannotView": "无法查看",
"cannotViewDesc": "Token 存储在安全 Cookie 中,如需新 Token 请点击\"重新生成\"",
"hide": "隐藏",
"show": "显示",
"copyTip": "复制到剪贴板",
"regenerateShort": "生成",
"confirmRegenerateFullDesc": "这将生成一个新的 64 位安全令牌,并使当前 Token 立即失效。您需要使用新 Token 重新登录系统。此操作不可撤销,确定要继续吗?",
"confirmGenerate": "确认生成",
"tokenStorePlaceholder": "Token 存储在安全 Cookie 中",
"safekeepTip": "请妥善保管您的 Access Token不要泄露给他人",
"newTokenLabel": "新的访问令牌",
"customTokenPlaceholder": "输入自定义 Token",
"tokenReqTitle": "Token 安全要求:",
"tokenValid": "Token 格式正确,可以使用",
"updateBtn": "更新自定义 Token",
"updating": "更新中...",
"dialogTitle": "新的 Access Token",
"dialogDesc": "这是您的新 Token请立即保存。关闭此窗口后将跳转到登录页面。",
"dialogTokenLabel": "您的新 Token (64位安全令牌)",
"important": "重要提示",
"tip1": "此 Token 仅显示一次,关闭后无法再查看",
"tip2": "请立即复制并保存到安全的位置",
"tip3": "关闭窗口后将自动跳转到登录页面",
"tip4": "请使用新 Token 重新登录系统",
"copied": "已复制",
"copyToken": "复制 Token",
"savedClose": "我已保存,关闭",
"securityTip1": "重新生成 Token 会创建系统随机生成的 64 位安全令牌",
"securityTip2": "自定义 Token 必须满足所有安全要求才能使用",
"securityTip3": "更新 Token 后,旧的 Token 将立即失效",
"securityTip4": "请在安全的环境下查看和复制 Token",
"securityTip5": "如果怀疑 Token 泄露,请立即重新生成或更新",
"securityTip6": "建议使用系统生成的 Token 以获得最高安全性"
},
"other": {
"performance": "性能与存储",
"localStorage": "本地存储使用",
"logCache": "日志缓存大小",
"importExport": "导入/导出设置",
"configWizard": "配置向导",
"devTools": "开发者工具",
"clearStorage": "清空本地存储",
"clearStorageDesc": "清空所有本地存储数据",
"clearStorageConfirm": "确认清空",
"clearLogCache": "清空日志缓存",
"clearLogCacheDesc": "清空所有缓存的日志数据",
"clearLogCacheConfirm": "确认清空",
"importSettings": "导入设置",
"exportSettings": "导出设置",
"resetAllSettings": "重置所有设置",
"resetAllSettingsDesc": "将所有设置恢复到默认值",
"resetAllSettingsConfirm": "确认重置",
"clearStorageSuccess": "本地存储已清空",
"clearStorageFailed": "清空失败",
"clearLogSuccess": "日志缓存已清空",
"clearLogFailed": "清空失败",
"importSuccess": "导入成功",
"importFailed": "导入失败",
"exportSuccess": "导出成功",
"exportFailed": "导出失败",
"resetSuccess": "重置成功",
"resetFailed": "重置失败",
"storageItems": "{{count}} 个存储项",
"logCacheSizeDesc": "控制日志查看器最多缓存的日志条数,较大的值会占用更多内存",
"logCacheSizeUnit": "条",
"dataSyncIntervalLabel": "首页数据刷新间隔",
"dataSyncIntervalUnit": "秒",
"dataSyncIntervalDesc": "控制首页统计数据的自动刷新间隔",
"wsReconnectLabel": "WebSocket 重连间隔",
"wsReconnectUnit": "秒",
"wsReconnectDesc": "日志 WebSocket 连接断开后的重连基础间隔",
"wsMaxReconnectLabel": "WebSocket 最大重连次数",
"wsMaxReconnectUnit": "次",
"wsMaxReconnectDesc": "连接失败后的最大重连尝试次数",
"clearLogCacheFn": "清除日志缓存",
"clearLocalCache": "清除本地缓存",
"confirmClearCache": "确认清除本地缓存",
"confirmClearCacheDesc": "这将清除所有本地缓存的设置和数据(不包括登录凭证)。您可能需要重新配置部分偏好设置。确定要继续吗?",
"confirmClear": "确认清除",
"importExportDesc": "导出当前的界面设置以便备份,或从之前导出的文件中恢复设置。",
"exporting": "导出中...",
"importing": "导入中...",
"resetAllSettingsBtn": "重置所有设置为默认值",
"confirmResetAll": "确认重置所有设置",
"confirmResetAllDesc": "这将把所有界面设置恢复为默认值,包括主题、颜色、动画等偏好设置。此操作不会影响您的登录状态。确定要继续吗?",
"configWizardDesc": "重新进行初次配置向导,可以帮助您重新设置系统的基础配置。",
"rerunSetup": "重新进行初次配置",
"confirmRerunSetup": "确认重新配置",
"confirmRerunSetupDesc": "这将带您重新进入初次配置向导。您可以重新设置系统的基础配置项。确定要继续吗?",
"devToolsDesc": "以下功能仅供开发调试使用,可能会导致页面崩溃或异常。",
"triggerError": "触发测试错误",
"confirmTriggerError": "确认触发错误",
"confirmTriggerErrorDesc": "这将手动触发一个 React 错误,用于测试错误边界组件的显示效果。页面将显示错误界面,您可以通过刷新页面或点击返回首页来恢复。",
"confirmTrigger": "确认触发",
"logCleared": "日志已清除",
"logClearedDesc": "日志缓存已清空",
"cacheCleared": "缓存已清除",
"cacheClearedDesc": "已清除 {{count}} 项缓存数据",
"exportSuccessDesc": "设置已导出为 JSON 文件",
"exportFailedDesc": "无法导出设置",
"importSuccessDesc": "成功导入 {{imported}} 项设置",
"importSkippedSuffix": ",跳过 {{skipped}} 项",
"importRefreshHint": "提示",
"importRefreshHintDesc": "部分设置需要刷新页面才能完全生效",
"importNoDataDesc": "没有有效的设置项可导入",
"importInvalidDesc": "文件格式无效",
"resetDone": "已重置",
"resetDoneDesc": "所有设置已恢复为默认值,刷新页面以应用更改"
},
"about": {
"openSource": "开源项目",
"aboutApp": "关于 MaiBot Dashboard",
"version": "版本:",
"author": "作者",
"techStack": "技术栈",
"frontendFramework": "前端框架",
"uiComponents": "UI 组件",
"backend": "后端",
"buildTool": "构建工具",
"openSourceThanks": "开源库感谢",
"openSourceLicense": "开源许可",
"openSourceDesc": "本项目在 GitHub 开源,欢迎 Star ⭐ 支持!",
"visitGitHub": "前往 GitHub",
"appDesc": "麦麦MaiBot的现代化 Web 管理界面",
"maimaiCore": "MaiBot 核心",
"uiFrameworkGroup": "UI 框架与组件",
"routingStateGroup": "路由与状态管理",
"formGroup": "表单处理",
"utilsGroup": "工具库",
"animationGroup": "动画效果",
"backendGroup": "后端框架",
"devToolsGroup": "开发工具",
"openSourceThanksDesc": "本项目使用了以下优秀的开源库,感谢他们的贡献:",
"licenseDesc": "本项目采用 GNU General Public License v3.0 开源许可证。您可以自由地使用、修改和分发本软件,但必须保持相同的开源许可。",
"licenseDeps": "本项目依赖的所有开源库均遵循各自的开源许可证MIT、Apache-2.0、BSD 等)。感谢所有开源贡献者的无私奉献。",
"lib": {
"react": "用户界面构建库",
"shadcn": "优雅的 React 组件库",
"radix": "无样式的可访问组件库",
"tailwind": "实用优先的 CSS 框架",
"lucide": "精美的图标库",
"tanstackRouter": "类型安全的路由库",
"zustand": "轻量级状态管理",
"reactHookForm": "高性能表单库",
"zod": "TypeScript 优先的 schema 验证",
"clsx": "条件 className 构建工具",
"tailwindMerge": "Tailwind 类名合并工具",
"cva": "组件变体管理",
"dateFns": "现代化日期处理库",
"framerMotion": "React 动画库",
"vaul": "抽屉组件动画",
"fastapi": "现代化 Python Web 框架",
"uvicorn": "ASGI 服务器",
"pydantic": "数据验证库",
"pythonMultipart": "文件上传支持",
"typescript": "JavaScript 的超集",
"vite": "下一代前端构建工具",
"eslint": "JavaScript 代码检查工具",
"postcss": "CSS 转换工具"
}
}
},
"auth": {
"title": "登录",
"description": "请输入访问令牌以继续",
"tokenLabel": "Access Token",
"tokenPlaceholder": "请输入 Access Token",
"loginButton": "登录",
"loggingIn": "登录中...",
"loginFailed": "登录失败",
"loginSuccess": "登录成功",
"checkingAuth": "正在检查登录状态...",
"welcome": "欢迎使用 MaiBot",
"accessDesc": "请输入您的 Access Token 以继续访问系统",
"tokenRequired": "请输入 Access Token",
"verifyingLabel": "验证中...",
"verifyEnter": "验证并进入",
"helpLink": "我没有 Token我该去哪里获得 Token",
"helpTitle": "如何获取 Access Token",
"helpDesc": "Access Token 是访问 MaiBot WebUI 的唯一凭证,请按以下方式获取",
"method1Title": "方式一:查看启动日志",
"method1Desc": "在 MaiBot 启动时,控制台会显示 WebUI Access Token。",
"method1Example1": "🔑 WebUI Access Token: abc123...",
"method1Example2": "💡 请使用此 Token 登录 WebUI",
"method2Title": "方式二:查看配置文件",
"method2Desc": "Token 保存在项目根目录的配置文件中:",
"method2FileHint": "打开此文件,复制 access_token 字段的值",
"securityTipTitle": "安全提示",
"securityTip1": "请妥善保管您的 Token不要泄露给他人",
"securityTip2": "如需重置 Token请在登录后前往系统设置",
"slowLink": "我觉得这个界面很卡怎么办?",
"disableAnimTitle": "关闭背景动画",
"disableAnimDesc": "背景动画可能会在低性能设备上造成卡顿。关闭动画可以显著提升界面流畅度。",
"disableAnimDetail": "关闭动画后,背景将变为纯色,但不影响任何功能的使用。您可以随时在系统设置中重新开启动画。",
"disableAnimBtn": "关闭动画",
"verifyFailed": "Token 验证失败,请检查后重试",
"connFailed": "连接服务器失败,请检查网络连接",
"switchToLight": "切换到浅色模式",
"switchToDark": "切换到深色模式"
},
"common": {
"loading": "加载中...",
"error": "错误",
"retry": "重试",
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"delete": "删除",
"edit": "编辑",
"close": "关闭",
"search": "搜索",
"noData": "暂无数据",
"success": "成功",
"failed": "失败"
},
"restart": {
"preparing": "准备重启",
"preparingDesc": "正在发送重启请求...",
"preparingTip": "🔄 正在准备重启麦麦...",
"restarting": "正在重启麦麦",
"restartingDesc": "请稍候,麦麦正在重启中...",
"restartingTip": "🔄 配置已保存,正在重启主程序...",
"checking": "检查服务状态",
"checkingDesc": "等待服务恢复... ({{current}}/{{max}})",
"checkingTip": "⏳ 正在等待服务恢复,请勿关闭页面...",
"success": "重启成功",
"successDesc": "正在跳转到登录页面...",
"successTip": "✅ 配置已生效,服务运行正常",
"failed": "重启超时",
"failedDesc": "服务未能在预期时间内恢复",
"failedTip": "⚠️ 如果长时间无响应,请尝试手动重启",
"refreshPage": "刷新页面",
"retryCheck": "重试检测",
"elapsed": "已用时:"
},
"errorBoundary": {
"title": "页面出现了问题",
"description": "应用程序遇到了意外错误。您可以尝试刷新页面或返回首页。",
"refreshPage": "刷新页面",
"goHome": "返回首页",
"footer": "如果问题持续存在,请将错误信息复制并反馈给开发者",
"copiedToClipboard": "已复制到剪贴板",
"copyError": "复制错误信息"
},
"search": {
"placeholder": "搜索页面...",
"title": "搜索",
"noResults": "未找到匹配的页面",
"startSearch": "输入关键词开始搜索",
"navigate": "导航",
"select": "选择",
"close": "关闭",
"categories": {
"overview": "概览",
"config": "配置",
"resources": "资源",
"monitor": "监控",
"extensions": "扩展",
"system": "系统"
},
"items": {
"home": "首页",
"homeDesc": "查看仪表板概览",
"botConfig": "麦麦主程序配置",
"botConfigDesc": "配置麦麦的核心设置",
"modelProvider": "麦麦模型提供商配置",
"modelProviderDesc": "配置模型提供商",
"model": "麦麦模型配置",
"modelDesc": "配置模型参数",
"emoji": "表情包管理",
"emojiDesc": "管理麦麦的表情包",
"expression": "表达方式管理",
"expressionDesc": "管理麦麦的表达方式",
"person": "人物信息管理",
"personDesc": "管理人物信息",
"jargon": "黑话管理",
"jargonDesc": "管理麦麦学习到的黑话和俚语",
"statistics": "统计信息",
"statisticsDesc": "查看使用统计",
"plugins": "插件市场",
"pluginsDesc": "浏览和安装插件",
"logs": "日志查看器",
"logsDesc": "查看系统日志",
"settings": "系统设置",
"settingsDesc": "配置系统参数"
}
},
"httpWarning": {
"title": "安全警告:",
"message": "您正在使用 HTTP 访问 MaiBot WebUI",
"description": "如果这是公网服务器,您的数据(包括 Token、聊天记录等可能在传输过程中被窃取。强烈建议使用 HTTPS 访问或仅在本地网络使用。",
"dismiss": "关闭警告"
}
}

View File

@@ -0,0 +1,119 @@
/**
* Centralized API base URL utility
* Provides single source of truth for all URL construction across the application
* Handles environment-specific configuration (Electron, Browser DEV, Browser PROD)
*/
import type { BackendConnection } from '@/types/electron'
import { isElectron } from './runtime'
/**
* Get API base URL for HTTP/HTTPS requests
* - Electron: User-configured backend URL from main process
* - Browser DEV: Empty string (Vite proxy handles /api prefix)
* - Browser PROD: Empty string (same-origin deployment)
*/
export async function getApiBaseUrl(): Promise<string> {
if (isElectron()) {
// Electron: Get configured backend URL from IPC
const backendUrl = await window.electronAPI?.getActiveBackendUrl()
return backendUrl ?? ''
}
// Browser (DEV & PROD): Return empty string
// In DEV: Vite proxy forwards /api requests to backend
// In PROD: API is deployed on same origin as frontend
return ''
}
/**
* Get WebSocket base URL
* - Electron: Convert HTTP/HTTPS URL to WS/WSS
* - Browser DEV: ws://127.0.0.1:8001 (hardcoded, same as log-websocket.ts)
* - Browser PROD: Construct WS URL from window.location
*/
export async function getWsBaseUrl(): Promise<string> {
if (isElectron()) {
// Electron: Convert API URL protocol to WS protocol
const apiUrl = await getApiBaseUrl()
if (!apiUrl) {
return ''
}
// Convert http -> ws, https -> wss
return apiUrl.replace(/^https?/, (match) => {
return match === 'https' ? 'wss' : 'ws'
})
}
// Browser DEV: Use hardcoded WebSocket server
if (import.meta.env.DEV) {
return 'ws://127.0.0.1:8001'
}
// Browser PROD: Construct WS URL from current location
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}`
}
/**
* Get synchronous API base URL for axios baseURL configuration
* Note: axios instance baseURL is set at module initialization time (synchronous).
* Since window.electronAPI.getActiveBackendUrl() is async, this function returns
* empty string. The actual Electron backend URL will be injected via axios request
* interceptor (Task 7) to support dynamic backend switching at runtime.
*/
export function getAxiosBaseUrl(): string {
// Always return empty string:
// - Browser: Vite proxy / same-origin handles paths
// - Electron: axios interceptor injects dynamic baseURL
return ''
}
/**
* Resolve full API path by prepending base URL if needed
* - Electron: Prepends configured backend URL
* - Browser: Path remains unchanged (proxy/same-origin handling)
*/
export async function resolveApiPath(path: string): Promise<string> {
if (isElectron()) {
const baseUrl = await getApiBaseUrl()
return baseUrl ? `${baseUrl}${path}` : path
}
// Browser: Path is used as-is
return path
}
/**
* Subscribe to backend URL changes
* Electron: Listens to IPC backend change events
* Browser: No-op (backend cannot change at runtime)
*
* @param callback Function called when backend URL changes
* @returns Unsubscribe function
*/
export function onBackendUrlChanged(
callback: (newUrl: string | null) => void
): () => void {
if (!isElectron()) {
// Browser: No-op, return empty unsubscribe function
return () => {}
}
// Electron: Register IPC listener and return unsubscribe function
if (!window.electronAPI?.onBackendChanged) {
return () => {}
}
// Wrap callback to extract URL from BackendConnection
const wrappedCallback = (backend: BackendConnection | null) => {
const url = backend?.url ?? null
callback(url)
}
// Get and return the unsubscribe function from preload
return window.electronAPI.onBackendChanged(wrappedCallback)
}

View File

@@ -1,8 +1,19 @@
import axios from 'axios'
import { getApiBaseUrl } from './api-base'
const apiClient = axios.create({
baseURL: import.meta.env.DEV ? 'http://localhost:8000' : '',
baseURL: '', // 统一为空,通过拦截器动态设置
timeout: 10000,
})
// Electron 端:动态注入后端 URL浏览器端 getApiBaseUrl() 返回空字符串,行为不变
apiClient.interceptors.request.use(async (config) => {
const baseUrl = await getApiBaseUrl()
if (baseUrl && !config.baseURL) {
config.baseURL = baseUrl
}
return config
})
export default apiClient

View File

@@ -1,5 +1,20 @@
import { getApiBaseUrl } from './api-base'
import { isElectron } from './runtime'
// 带自动认证处理的 fetch 封装
/**
* 将相对路径在 Electron 端转换为绝对路径
* 浏览器端直接返回原始 input行为不变
*/
async function resolveUrl(input: RequestInfo | URL): Promise<RequestInfo | URL> {
if (isElectron() && typeof input === 'string' && input.startsWith('/')) {
const base = await getApiBaseUrl()
return base ? `${base}${input}` : input
}
return input
}
/**
* 增强的 fetch 函数,自动处理 401 错误并跳转到登录页
* 使用 HttpOnly Cookie 进行认证,自动携带 credentials
@@ -25,7 +40,7 @@ export async function fetchWithAuth(
headers,
}
const response = await fetch(input, config)
const response = await fetch(await resolveUrl(input), config)
// 检测 401 未授权错误
if (response.status === 401) {
@@ -54,7 +69,7 @@ export function getAuthHeaders(): HeadersInit {
*/
export async function logout(): Promise<void> {
try {
await fetch('/api/webui/auth/logout', {
await fetch(await resolveUrl('/api/webui/auth/logout'), {
method: 'POST',
credentials: 'include',
})
@@ -70,7 +85,7 @@ export async function logout(): Promise<void> {
*/
export async function checkAuthStatus(): Promise<boolean> {
try {
const response = await fetch('/api/webui/auth/check', {
const response = await fetch(await resolveUrl('/api/webui/auth/check'), {
method: 'GET',
credentials: 'include',
})

View File

@@ -2,7 +2,16 @@
* 知识库 API
*/
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/webui'
import { getApiBaseUrl } from './api-base'
import { isElectron } from './runtime'
async function getKnowledgeApiBase(): Promise<string> {
if (isElectron()) {
const base = await getApiBaseUrl()
return base ? `${base}/api/webui` : '/api/webui'
}
return import.meta.env.VITE_API_BASE_URL || '/api/webui'
}
export interface KnowledgeNode {
id: string
@@ -35,7 +44,7 @@ export interface KnowledgeStats {
* 获取知识图谱数据
*/
export async function getKnowledgeGraph(limit: number = 100, nodeType: 'all' | 'entity' | 'paragraph' = 'all'): Promise<KnowledgeGraph> {
const url = `${API_BASE_URL}/knowledge/graph?limit=${limit}&node_type=${nodeType}`
const url = `${await getKnowledgeApiBase()}/knowledge/graph?limit=${limit}&node_type=${nodeType}`
const response = await fetch(url)
@@ -50,7 +59,7 @@ export async function getKnowledgeGraph(limit: number = 100, nodeType: 'all' | '
* 获取知识图谱统计信息
*/
export async function getKnowledgeStats(): Promise<KnowledgeStats> {
const response = await fetch(`${API_BASE_URL}/knowledge/stats`)
const response = await fetch(`${await getKnowledgeApiBase()}/knowledge/stats`)
if (!response.ok) {
throw new Error('获取知识图谱统计信息失败')
}
@@ -61,7 +70,7 @@ export async function getKnowledgeStats(): Promise<KnowledgeStats> {
* 搜索知识节点
*/
export async function searchKnowledgeNode(query: string): Promise<KnowledgeNode[]> {
const response = await fetch(`${API_BASE_URL}/knowledge/search?query=${encodeURIComponent(query)}`)
const response = await fetch(`${await getKnowledgeApiBase()}/knowledge/search?query=${encodeURIComponent(query)}`)
if (!response.ok) {
throw new Error('搜索知识节点失败')
}

View File

@@ -7,6 +7,8 @@ import { checkAuthStatus } from './fetch-with-auth'
import { getSetting } from './settings-manager'
import { createReconnectingWebSocket } from './ws-utils'
import { getWsBaseUrl } from '@/lib/api-base'
export interface LogEntry {
id: string
timestamp: string
@@ -54,18 +56,9 @@ class LogWebSocketManager {
/**
* 获取 WebSocket URL不含 token 参数)
*/
private getWebSocketUrl(): string {
let baseUrl: string
if (import.meta.env.DEV) {
// 开发模式:连接到 WebUI 后端服务器
baseUrl = 'ws://127.0.0.1:8001/ws/logs'
} else {
// 生产模式:使用当前页面的 host
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
baseUrl = `${protocol}//${host}/ws/logs`
}
return baseUrl
private async getWebSocketUrl(): Promise<string> {
const wsBase = await getWsBaseUrl()
return `${wsBase}/ws/logs`
}
/**
@@ -85,7 +78,7 @@ class LogWebSocketManager {
return
}
const wsUrl = this.getWebSocketUrl()
const wsUrl = await this.getWebSocketUrl()
// 使用 ws-utils 创建 WebSocket
this.wsControl = createReconnectingWebSocket(wsUrl, {

View File

@@ -1,9 +1,9 @@
import type { ApiResponse } from '@/types/api'
import type { PluginInfo } from '@/types/plugin'
import { getWsBaseUrl } from '@/lib/api-base'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import { parseResponse } from '@/lib/api-helpers'
import type { GitStatus, MaimaiVersion } from './types'
/**
@@ -213,9 +213,8 @@ export async function connectPluginProgressWebSocket(
onProgress: (progress: import('./types').PluginLoadProgress) => void,
onError?: (error: Event) => void
): Promise<WebSocket | null> {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress`
const wsBase = await getWsBaseUrl()
const wsUrl = `${wsBase}/api/webui/ws/plugin-progress`
// 使用 ws-utils 创建 WebSocket
const { createReconnectingWebSocket } = await import('@/lib/ws-utils')

View File

@@ -0,0 +1,77 @@
/**
* Runtime environment detection and information
* Provides unified interface for checking execution environment (Electron vs Browser)
*/
/**
* Type of runtime environment
*/
export type RuntimeKind = 'electron' | 'browser'
/**
* Runtime information object
*/
export interface RuntimeInfo {
/** Type of runtime (electron or browser) */
kind: RuntimeKind
/** Version information (electron versions, etc) */
versions?: Record<string, string>
/** User agent string */
userAgent?: string
/** Source of runtime detection (tag means set by preload, fallback means default for browser) */
source: 'tag' | 'fallback'
}
/**
* Build default browser runtime info
* Used as fallback when not in Electron environment
*/
function buildBrowserRuntime(): RuntimeInfo {
return {
kind: 'browser',
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
source: 'fallback',
}
}
/**
* Get current runtime information
* Reads from globalThis.__RUNTIME__ if available (set by Electron preload)
* Falls back to browser runtime if not running in Electron
*/
export function getRuntime(): RuntimeInfo {
// Check if running in Electron (preload sets __RUNTIME__)
if (typeof globalThis !== 'undefined' && globalThis.__RUNTIME__) {
return globalThis.__RUNTIME__
}
// Fallback to browser runtime
return buildBrowserRuntime()
}
/**
* Check if running in Electron environment
* Safe to use across browser and Electron - always returns boolean
*/
export function isElectron(): boolean {
return getRuntime().kind === 'electron'
}
/**
* Get platform information
* In Electron: calls window.electronAPI.getPlatform() for actual platform
* In browser: returns 'browser' as identifier
*/
export function getPlatform(): string {
if (!isElectron()) {
return 'browser'
}
// Safe to access electronAPI because isElectron() confirms it's available
if (typeof window !== 'undefined' && window.electronAPI?.getPlatform) {
return window.electronAPI.getPlatform()
}
// Fallback if electronAPI unavailable
return 'unknown'
}

View File

@@ -1,7 +1,8 @@
import { StrictMode } from 'react'
import { StrictMode, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from '@tanstack/react-router'
import './index.css'
import './i18n'
import { router } from './router'
import { AssetStoreProvider } from './components/asset-provider'
import { ThemeProvider } from './components/theme-provider'
@@ -9,6 +10,18 @@ import { AnimationProvider } from './components/animation-provider'
import { TourProvider, TourRenderer } from './components/tour'
import { Toaster } from './components/ui/toaster'
import { ErrorBoundary } from './components/error-boundary'
import { BackendSetupWizard } from './components/electron/BackendSetupWizard'
import { isElectron } from './lib/runtime'
function ElectronShell() {
const [isFirstLaunch, setIsFirstLaunch] = useState(false)
useEffect(() => {
window.electronAPI!.isFirstLaunch().then(setIsFirstLaunch)
}, [])
return <BackendSetupWizard open={isFirstLaunch} />
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -17,6 +30,7 @@ createRoot(document.getElementById('root')!).render(
<ThemeProvider defaultTheme="system">
<AnimationProvider>
<TourProvider>
{isElectron() && <ElectronShell />}
<RouterProvider router={router} />
<TourRenderer />
<Toaster />

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
AlertCircle,
@@ -59,6 +60,7 @@ export function AuthPage() {
const [error, setError] = useState('')
const [checkingAuth, setCheckingAuth] = useState(true)
const navigate = useNavigate()
const { t } = useTranslation()
const { enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { theme, setTheme } = useTheme()
@@ -100,7 +102,7 @@ export function AuthPage() {
setError('')
if (!token.trim()) {
setError('请输入 Access Token')
setError(t('auth.tokenRequired'))
return
}
@@ -160,12 +162,12 @@ export function AuthPage() {
}
} else {
console.error('Token 验证失败:', data.message)
setError(data.message || 'Token 验证失败,请检查后重试')
setError(data.message || t('auth.verifyFailed'))
}
} catch (err) {
console.error('Token 验证错误:', err)
setError(
err instanceof Error ? err.message : '连接服务器失败,请检查网络连接'
err instanceof Error ? err.message : t('auth.connFailed')
)
} finally {
setIsValidating(false)
@@ -177,7 +179,7 @@ export function AuthPage() {
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background p-4">
{enableWavesBackground && <WavesBackground />}
<div className="text-muted-foreground">...</div>
<div className="text-muted-foreground">{t('auth.checkingAuth')}</div>
</div>
)
}
@@ -193,7 +195,7 @@ export function AuthPage() {
<button
onClick={toggleTheme}
className="absolute right-4 top-4 rounded-lg p-2 hover:bg-accent transition-colors z-10 text-foreground"
title={actualTheme === 'dark' ? '切换到浅色模式' : '切换到深色模式'}
title={actualTheme === 'dark' ? t('auth.switchToLight') : t('auth.switchToDark')}
>
{actualTheme === 'dark' ? (
<Sun className="h-5 w-5" strokeWidth={2.5} fill="none" />
@@ -209,9 +211,9 @@ export function AuthPage() {
</div>
<div className="space-y-2">
<CardTitle className="text-2xl font-bold">使 MaiBot</CardTitle>
<CardTitle className="text-2xl font-bold">{t('auth.welcome')}</CardTitle>
<CardDescription className="text-base">
Access Token 访
{t('auth.accessDesc')}
</CardDescription>
</div>
</CardHeader>
@@ -228,7 +230,7 @@ export function AuthPage() {
<Input
id="token"
type="password"
placeholder="请输入您的 Access Token"
placeholder={t('auth.tokenPlaceholder')}
value={token}
onChange={(e) => setToken(e.target.value)}
className={cn('pl-10', error && 'border-red-500 focus-visible:ring-red-500')}
@@ -252,10 +254,10 @@ export function AuthPage() {
{isValidating ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
{t('auth.verifyingLabel')}
</>
) : (
'验证并进入'
t('auth.verifyEnter')
)}
</Button>
@@ -264,17 +266,17 @@ export function AuthPage() {
<DialogTrigger asChild>
<button className="w-full text-center text-sm text-primary hover:text-primary/80 transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<HelpCircle className="h-4 w-4" strokeWidth={2} fill="none" />
Token Token
{t('auth.helpLink')}
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
Access Token
{t('auth.helpTitle')}
</DialogTitle>
<DialogDescription>
Access Token 访 MaiBot WebUI
{t('auth.helpDesc')}
</DialogDescription>
</DialogHeader>
@@ -284,13 +286,13 @@ export function AuthPage() {
<div className="flex items-start gap-3">
<Terminal className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<h4 className="font-semibold text-sm">{t('auth.method1Title')}</h4>
<p className="text-sm text-muted-foreground">
MaiBot WebUI Access Token
{t('auth.method1Desc')}
</p>
<div className="rounded bg-background p-2 font-mono text-xs">
<p className="text-muted-foreground">🔑 WebUI Access Token: abc123...</p>
<p className="text-muted-foreground">💡 使 Token WebUI</p>
<p className="text-muted-foreground">{t('auth.method1Example1')}</p>
<p className="text-muted-foreground">{t('auth.method1Example2')}</p>
</div>
</div>
</div>
@@ -301,15 +303,15 @@ export function AuthPage() {
<div className="flex items-start gap-3">
<FileText className="h-5 w-5 text-primary flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="flex-1 space-y-2">
<h4 className="font-semibold text-sm"></h4>
<h4 className="font-semibold text-sm">{t('auth.method2Title')}</h4>
<p className="text-sm text-muted-foreground">
Token
{t('auth.method2Desc')}
</p>
<div className="rounded bg-background p-2 font-mono text-xs break-all">
<code className="text-primary">data/webui.json</code>
</div>
<p className="text-xs text-muted-foreground">
<code className="px-1 py-0.5 bg-background rounded">access_token</code>
{t('auth.method2FileHint')} <code className="px-1 py-0.5 bg-background rounded">access_token</code>
</p>
</div>
</div>
@@ -320,10 +322,10 @@ export function AuthPage() {
<div className="flex gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" strokeWidth={2} fill="none" />
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
<p className="font-semibold"></p>
<p className="font-semibold">{t('auth.securityTipTitle')}</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li> Token</li>
<li> Token</li>
<li>{t('auth.securityTip1')}</li>
<li>{t('auth.securityTip2')}</li>
</ul>
</div>
</div>
@@ -337,30 +339,30 @@ export function AuthPage() {
<AlertDialogTrigger asChild>
<button className="w-full text-center text-sm text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline flex items-center justify-center gap-1">
<Zap className="h-4 w-4" strokeWidth={2} fill="none" />
{t('auth.slowLink')}
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" strokeWidth={2} fill="none" />
{t('auth.disableAnimTitle')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('auth.disableAnimDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<p className="text-sm text-muted-foreground">
使
{t('auth.disableAnimDetail')}
</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => setEnableWavesBackground(false)}
>
{t('auth.disableAnimBtn')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { useToast } from '@/hooks/use-toast'
import { getWsBaseUrl } from '@/lib/api-base'
import { fetchWithAuth } from '@/lib/fetch-with-auth'
import { cn } from '@/lib/utils'
import { Bot, Edit2, Loader2, RefreshCw, User, Send, Wifi, WifiOff, UserCircle2 } from 'lucide-react'
@@ -299,7 +300,7 @@ export function ChatPage() {
return
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsBase = await getWsBaseUrl()
const params = new URLSearchParams()
// 添加 token 到参数
@@ -320,7 +321,7 @@ export function ChatPage() {
params.append('user_name', userName)
}
const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?${params.toString()}`
const wsUrl = `${wsBase}/api/chat/ws?${params.toString()}`
console.log(`[Tab ${tabId}] 正在连接 WebSocket:`, wsUrl)
try {

View File

@@ -1,3 +1,5 @@
import { useTranslation } from 'react-i18next'
import { ScrollArea } from '@/components/ui/scroll-area'
import { APP_NAME, APP_VERSION } from '@/lib/version'
@@ -6,6 +8,8 @@ import { cn } from '@/lib/utils'
import { LibraryItem } from './LibraryItem'
export function AboutTab() {
const { t } = useTranslation()
return (
<div className="space-y-4 sm:space-y-6">
{/* GitHub 开源地址 */}
@@ -27,10 +31,10 @@ export function AboutTab() {
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg sm:text-xl font-bold text-foreground mb-2">
{t('settings.about.openSource')}
</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-3">
GitHub Star
{t('settings.about.openSourceDesc')}
</p>
<a
href="https://github.com/Mai-with-u/MaiBot-Dashboard"
@@ -55,7 +59,7 @@ export function AboutTab() {
clipRule="evenodd"
/>
</svg>
GitHub
{t('settings.about.visitGitHub')}
<svg
className="h-4 w-4"
fill="none"
@@ -76,19 +80,19 @@ export function AboutTab() {
{/* 应用信息 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> {APP_NAME}</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.about.aboutApp')} {APP_NAME}</h3>
<div className="space-y-2 text-xs sm:text-sm text-muted-foreground">
<p>: {APP_VERSION}</p>
<p>MaiBot Web </p>
<p>{t('settings.about.version')} {APP_VERSION}</p>
<p>{t('settings.about.appDesc')}</p>
</div>
</div>
{/* 作者信息 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.about.author')}</h3>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">MaiBot </p>
<p className="text-sm font-medium">{t('settings.about.maimaiCore')}</p>
<p className="text-xs sm:text-sm text-muted-foreground">Mai-with-u</p>
</div>
<div className="space-y-1">
@@ -100,10 +104,10 @@ export function AboutTab() {
{/* 技术栈 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.about.techStack')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs sm:text-sm text-muted-foreground">
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<p className="font-medium text-foreground">{t('settings.about.frontendFramework')}</p>
<ul className="space-y-0.5 list-disc list-inside">
<li>React 19.2.0</li>
<li>TypeScript 5.7.2</li>
@@ -112,7 +116,7 @@ export function AboutTab() {
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground">UI </p>
<p className="font-medium text-foreground">{t('settings.about.uiComponents')}</p>
<ul className="space-y-0.5 list-disc list-inside">
<li>shadcn/ui</li>
<li>Radix UI</li>
@@ -121,7 +125,7 @@ export function AboutTab() {
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<p className="font-medium text-foreground">{t('settings.about.backend')}</p>
<ul className="space-y-0.5 list-disc list-inside">
<li>Python 3.12+</li>
<li>FastAPI</li>
@@ -130,7 +134,7 @@ export function AboutTab() {
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<p className="font-medium text-foreground">{t('settings.about.buildTool')}</p>
<ul className="space-y-0.5 list-disc list-inside">
<li>Bun / npm</li>
<li>ESLint 9.17.0</li>
@@ -142,81 +146,81 @@ export function AboutTab() {
{/* 开源感谢 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.about.openSourceThanks')}</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-3">
使
{t('settings.about.openSourceThanksDesc')}
</p>
<ScrollArea className="h-[300px] sm:h-[400px]">
<div className="space-y-4 pr-4">
{/* UI 框架 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">UI </p>
<p className="text-sm font-medium text-foreground">{t('settings.about.uiFrameworkGroup')}</p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="React" description="用户界面构建库" license="MIT" />
<LibraryItem name="shadcn/ui" description="优雅的 React 组件库" license="MIT" />
<LibraryItem name="Radix UI" description="无样式的可访问组件库" license="MIT" />
<LibraryItem name="Tailwind CSS" description="实用优先的 CSS 框架" license="MIT" />
<LibraryItem name="Lucide React" description="精美的图标库" license="ISC" />
<LibraryItem name="React" description={t('settings.about.lib.react')} license="MIT" />
<LibraryItem name="shadcn/ui" description={t('settings.about.lib.shadcn')} license="MIT" />
<LibraryItem name="Radix UI" description={t('settings.about.lib.radix')} license="MIT" />
<LibraryItem name="Tailwind CSS" description={t('settings.about.lib.tailwind')} license="MIT" />
<LibraryItem name="Lucide React" description={t('settings.about.lib.lucide')} license="ISC" />
</div>
</div>
{/* 路由与状态 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-sm font-medium text-foreground">{t('settings.about.routingStateGroup')}</p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="TanStack Router" description="类型安全的路由库" license="MIT" />
<LibraryItem name="Zustand" description="轻量级状态管理" license="MIT" />
<LibraryItem name="TanStack Router" description={t('settings.about.lib.tanstackRouter')} license="MIT" />
<LibraryItem name="Zustand" description={t('settings.about.lib.zustand')} license="MIT" />
</div>
</div>
{/* 表单与验证 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-sm font-medium text-foreground">{t('settings.about.formGroup')}</p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="React Hook Form" description="高性能表单库" license="MIT" />
<LibraryItem name="Zod" description="TypeScript 优先的 schema 验证" license="MIT" />
<LibraryItem name="React Hook Form" description={t('settings.about.lib.reactHookForm')} license="MIT" />
<LibraryItem name="Zod" description={t('settings.about.lib.zod')} license="MIT" />
</div>
</div>
{/* 工具库 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-sm font-medium text-foreground">{t('settings.about.utilsGroup')}</p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="clsx" description="条件 className 构建工具" license="MIT" />
<LibraryItem name="tailwind-merge" description="Tailwind 类名合并工具" license="MIT" />
<LibraryItem name="class-variance-authority" description="组件变体管理" license="Apache-2.0" />
<LibraryItem name="date-fns" description="现代化日期处理库" license="MIT" />
<LibraryItem name="clsx" description={t('settings.about.lib.clsx')} license="MIT" />
<LibraryItem name="tailwind-merge" description={t('settings.about.lib.tailwindMerge')} license="MIT" />
<LibraryItem name="class-variance-authority" description={t('settings.about.lib.cva')} license="Apache-2.0" />
<LibraryItem name="date-fns" description={t('settings.about.lib.dateFns')} license="MIT" />
</div>
</div>
{/* 动画 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-sm font-medium text-foreground">{t('settings.about.animationGroup')}</p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="Framer Motion" description="React 动画库" license="MIT" />
<LibraryItem name="vaul" description="抽屉组件动画" license="MIT" />
<LibraryItem name="Framer Motion" description={t('settings.about.lib.framerMotion')} license="MIT" />
<LibraryItem name="vaul" description={t('settings.about.lib.vaul')} license="MIT" />
</div>
</div>
{/* 后端相关 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-sm font-medium text-foreground">{t('settings.about.backendGroup')}</p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="FastAPI" description="现代化 Python Web 框架" license="MIT" />
<LibraryItem name="Uvicorn" description="ASGI 服务器" license="BSD-3-Clause" />
<LibraryItem name="Pydantic" description="数据验证库" license="MIT" />
<LibraryItem name="python-multipart" description="文件上传支持" license="Apache-2.0" />
<LibraryItem name="FastAPI" description={t('settings.about.lib.fastapi')} license="MIT" />
<LibraryItem name="Uvicorn" description={t('settings.about.lib.uvicorn')} license="BSD-3-Clause" />
<LibraryItem name="Pydantic" description={t('settings.about.lib.pydantic')} license="MIT" />
<LibraryItem name="python-multipart" description={t('settings.about.lib.pythonMultipart')} license="Apache-2.0" />
</div>
</div>
{/* 开发工具 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<p className="text-sm font-medium text-foreground">{t('settings.about.devToolsGroup')}</p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="TypeScript" description="JavaScript 的超集" license="Apache-2.0" />
<LibraryItem name="Vite" description="下一代前端构建工具" license="MIT" />
<LibraryItem name="ESLint" description="JavaScript 代码检查工具" license="MIT" />
<LibraryItem name="PostCSS" description="CSS 转换工具" license="MIT" />
<LibraryItem name="TypeScript" description={t('settings.about.lib.typescript')} license="Apache-2.0" />
<LibraryItem name="Vite" description={t('settings.about.lib.vite')} license="MIT" />
<LibraryItem name="ESLint" description={t('settings.about.lib.eslint')} license="MIT" />
<LibraryItem name="PostCSS" description={t('settings.about.lib.postcss')} license="MIT" />
</div>
</div>
</div>
@@ -225,7 +229,7 @@ export function AboutTab() {
{/* 许可证 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.about.openSourceLicense')}</h3>
<div className="space-y-3">
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
@@ -239,15 +243,13 @@ export function AboutTab() {
MaiBot WebUI
</p>
<p className="text-xs sm:text-sm text-muted-foreground">
GNU General Public License v3.0
使
{t('settings.about.licenseDesc')}
</p>
</div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground">
MITApache-2.0BSD
{t('settings.about.licenseDeps')}
</p>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
import { useAnimation } from '@/hooks/use-animation'
@@ -77,6 +78,7 @@ export function AppearanceTab() {
const { theme, setTheme, themeConfig, updateThemeConfig, resolvedTheme, resetTheme } = useTheme()
const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { toast } = useToast()
const { t } = useTranslation()
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
const [cssWarnings, setCssWarnings] = useState<string[]>([])
@@ -157,10 +159,10 @@ export function AppearanceTab() {
const result = importThemeJSON(json)
if (result.success) {
// 导入成功后需要刷新页面使配置生效(因为 ThemeProvider 需要重新读取 localStorage
toast({ title: '导入成功', description: '主题配置已导入,页面将自动刷新' })
toast({ title: t('settings.appearance.importSuccess'), description: t('settings.appearance.importSuccessDesc') })
setTimeout(() => window.location.reload(), 1000)
} else {
toast({ title: '导入失败', description: result.errors.join('; '), variant: 'destructive' })
toast({ title: t('settings.appearance.importFailed'), description: result.errors.join('; '), variant: 'destructive' })
}
}
reader.readAsText(file)
@@ -172,7 +174,7 @@ export function AppearanceTab() {
resetTheme()
setLocalCSS('')
setCssWarnings([])
toast({ title: '重置成功', description: '主题已重置为默认值' })
toast({ title: t('settings.appearance.resetSuccess'), description: t('settings.appearance.resetSuccessDesc') })
}
const previewTokens = useMemo(() => {
@@ -216,28 +218,28 @@ export function AppearanceTab() {
<div className="space-y-6 sm:space-y-8">
{/* 主题模式 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.appearance.themeMode')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<ThemeOption
value="light"
current={theme}
onChange={setTheme}
label="浅色"
description="始终使用浅色主题"
label={t('settings.appearance.light')}
description={t('settings.appearance.lightDesc')}
/>
<ThemeOption
value="dark"
current={theme}
onChange={setTheme}
label="深色"
description="始终使用深色主题"
label={t('settings.appearance.dark')}
description={t('settings.appearance.darkDesc')}
/>
<ThemeOption
value="system"
current={theme}
onChange={setTheme}
label="跟随系统"
description="根据系统设置自动切换"
label={t('settings.appearance.system')}
description={t('settings.appearance.systemDesc')}
/>
</div>
</div>
@@ -245,7 +247,7 @@ export function AppearanceTab() {
{/* 主题色配置 */}
<div>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-semibold"></h3>
<h3 className="text-base sm:text-lg font-semibold">{t('settings.appearance.accentColor')}</h3>
<Button
variant="outline"
size="sm"
@@ -254,7 +256,7 @@ export function AppearanceTab() {
className="h-8"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t('settings.appearance.resetDefault')}
</Button>
</div>
@@ -271,8 +273,8 @@ export function AppearanceTab() {
/>
</div>
<div className="space-y-1">
<Label htmlFor="accent-color-input" className="font-medium"></Label>
<p className="text-xs text-muted-foreground"> HEX </p>
<Label htmlFor="accent-color-input" className="font-medium">{t('settings.appearance.accentPrimary')}</Label>
<p className="text-xs text-muted-foreground">{t('settings.appearance.accentHint')}</p>
</div>
</div>
@@ -290,7 +292,7 @@ export function AppearanceTab() {
{/* 实时色板预览 */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground"></h4>
<h4 className="text-sm font-medium text-muted-foreground">{t('settings.appearance.colorPreview')}</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-3">
<ColorTokenPreview name="primary" value={previewTokens.primary} foreground={previewTokens['primary-foreground']} />
<ColorTokenPreview name="secondary" value={previewTokens.secondary} foreground={previewTokens['secondary-foreground']} />
@@ -307,13 +309,13 @@ export function AppearanceTab() {
{/* 样式微调 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.appearance.styleTweaks')}</h3>
<Accordion type="single" collapsible className="w-full">
{/* 1. 字体排版 (Typography) */}
<AccordionItem value="typography">
<AccordionTrigger> (Typography)</AccordionTrigger>
<AccordionTrigger>{t('settings.appearance.typographyGroup')}</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
@@ -325,12 +327,12 @@ export function AppearanceTab() {
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t('settings.appearance.resetDefault')}
</Button>
</div>
<div className="space-y-2">
<Label> (Font Family)</Label>
<Label>{t('settings.appearance.fontFamilyLabel')}</Label>
<Select
value={(() => {
const fontFamily = getTokenValue(themeConfig.tokenOverrides, 'typography', 'font-family-base', '')
@@ -351,20 +353,20 @@ export function AppearanceTab() {
}}
>
<SelectTrigger>
<SelectValue placeholder="选择字体族" />
<SelectValue placeholder={t('settings.appearance.fontFamilyPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="system"> (System)</SelectItem>
<SelectItem value="sans">线 (Sans-serif)</SelectItem>
<SelectItem value="serif">线 (Serif)</SelectItem>
<SelectItem value="mono"> (Monospace)</SelectItem>
<SelectItem value="system">{t('settings.appearance.fontFamilySystem')}</SelectItem>
<SelectItem value="sans">{t('settings.appearance.fontFamilySans')}</SelectItem>
<SelectItem value="serif">{t('settings.appearance.fontFamilySerif')}</SelectItem>
<SelectItem value="mono">{t('settings.appearance.fontFamilyMono')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Base Size)</Label>
<Label>{t('settings.appearance.baseFontSize')}</Label>
<span className="text-sm text-muted-foreground">
{parseFloat(getTokenValue(themeConfig.tokenOverrides, 'typography', 'font-size-base', '1')) * 16}px
</span>
@@ -384,7 +386,7 @@ export function AppearanceTab() {
</div>
<div className="space-y-2">
<Label> (Line Height)</Label>
<Label>{t('settings.appearance.lineHeight')}</Label>
<Select
value={String(getTokenValue(themeConfig.tokenOverrides, 'typography', 'line-height-normal', 1.5))}
onValueChange={(val) => {
@@ -394,12 +396,12 @@ export function AppearanceTab() {
}}
>
<SelectTrigger>
<SelectValue placeholder="选择行高" />
<SelectValue placeholder={t('settings.appearance.lineHeightPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="1.2"> (1.2)</SelectItem>
<SelectItem value="1.5"> (1.5)</SelectItem>
<SelectItem value="1.75"> (1.75)</SelectItem>
<SelectItem value="1.2">{t('settings.appearance.lineHeightCompact')}</SelectItem>
<SelectItem value="1.5">{t('settings.appearance.lineHeightNormal')}</SelectItem>
<SelectItem value="1.75">{t('settings.appearance.lineHeightLoose')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -409,7 +411,7 @@ export function AppearanceTab() {
{/* 2. 视觉效果 (Visual) */}
<AccordionItem value="visual">
<AccordionTrigger> (Visual)</AccordionTrigger>
<AccordionTrigger>{t('settings.appearance.visualGroup')}</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
@@ -421,13 +423,13 @@ export function AppearanceTab() {
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t('settings.appearance.resetDefault')}
</Button>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Radius)</Label>
<Label>{t('settings.appearance.borderRadiusLabel')}</Label>
<span className="text-sm text-muted-foreground">
{Math.round(parseFloat(getTokenValue(themeConfig.tokenOverrides, 'visual', 'radius-md', '0.375')) * 16)}px
</span>
@@ -447,7 +449,7 @@ export function AppearanceTab() {
</div>
<div className="space-y-2">
<Label> (Shadow)</Label>
<Label>{t('settings.appearance.shadowLabel')}</Label>
<Select
value={(() => {
const shadowMd = String(getTokenValue(themeConfig.tokenOverrides, 'visual', 'shadow-md', ''))
@@ -470,20 +472,20 @@ export function AppearanceTab() {
}}
>
<SelectTrigger>
<SelectValue placeholder="选择阴影强度" />
<SelectValue placeholder={t('settings.appearance.shadowPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> (None)</SelectItem>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
<SelectItem value="none">{t('settings.appearance.shadowNone')}</SelectItem>
<SelectItem value="sm">{t('settings.appearance.shadowSm')}</SelectItem>
<SelectItem value="md">{t('settings.appearance.shadowMd')}</SelectItem>
<SelectItem value="lg">{t('settings.appearance.shadowLg')}</SelectItem>
<SelectItem value="xl">{t('settings.appearance.shadowXl')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="blur-switch"> (Blur)</Label>
<Label htmlFor="blur-switch">{t('settings.appearance.blurLabel')}</Label>
<Switch
id="blur-switch"
checked={getTokenValue(themeConfig.tokenOverrides, 'visual', 'blur-md', '0px') !== '0px'}
@@ -500,7 +502,7 @@ export function AppearanceTab() {
{/* 3. 布局 (Layout) */}
<AccordionItem value="layout">
<AccordionTrigger> (Layout)</AccordionTrigger>
<AccordionTrigger>{t('settings.appearance.layoutGroup')}</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
@@ -512,13 +514,13 @@ export function AppearanceTab() {
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t('settings.appearance.resetDefault')}
</Button>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Sidebar Width)</Label>
<Label>{t('settings.appearance.sidebarWidthLabel')}</Label>
<span className="text-sm text-muted-foreground">
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'sidebar-width', '16rem')}
</span>
@@ -539,7 +541,7 @@ export function AppearanceTab() {
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Max Width)</Label>
<Label>{t('settings.appearance.maxContentWidth')}</Label>
<span className="text-sm text-muted-foreground">
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'max-content-width', '1280px')}
</span>
@@ -560,7 +562,7 @@ export function AppearanceTab() {
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Spacing Unit)</Label>
<Label>{t('settings.appearance.spacingUnit')}</Label>
<span className="text-sm text-muted-foreground">
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'space-unit', '0.25rem')}
</span>
@@ -584,7 +586,7 @@ export function AppearanceTab() {
{/* 4. 动画 (Animation) */}
<AccordionItem value="animation">
<AccordionTrigger> (Animation)</AccordionTrigger>
<AccordionTrigger>{t('settings.appearance.animationGroup')}</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
@@ -596,12 +598,12 @@ export function AppearanceTab() {
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
{t('settings.appearance.resetDefault')}
</Button>
</div>
<div className="space-y-2">
<Label> (Speed)</Label>
<Label>{t('settings.appearance.animationSpeedLabel')}</Label>
<Select
value={(() => {
const duration = String(getTokenValue(themeConfig.tokenOverrides, 'animation', 'anim-duration-normal', '300ms'))
@@ -629,13 +631,13 @@ export function AppearanceTab() {
}}
>
<SelectTrigger>
<SelectValue placeholder="选择动画速度" />
<SelectValue placeholder={t('settings.appearance.animationSpeedPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="fast"> (100ms)</SelectItem>
<SelectItem value="normal"> (300ms)</SelectItem>
<SelectItem value="slow"> (500ms)</SelectItem>
<SelectItem value="off"> (0ms)</SelectItem>
<SelectItem value="fast">{t('settings.appearance.animationFast')}</SelectItem>
<SelectItem value="normal">{t('settings.appearance.animationNormal')}</SelectItem>
<SelectItem value="slow">{t('settings.appearance.animationSlow')}</SelectItem>
<SelectItem value="off">{t('settings.appearance.animationOff')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -645,13 +647,13 @@ export function AppearanceTab() {
{/* 5. 背景设置 (Backgrounds) */}
<AccordionItem value="backgrounds">
<AccordionTrigger> (Backgrounds)</AccordionTrigger>
<AccordionTrigger>{t('settings.appearance.backgroundGroup')}</AccordionTrigger>
<AccordionContent>
<div className="pt-2">
<Tabs defaultValue="page">
<TabsList className="w-full grid grid-cols-5">
<TabsTrigger value="page"></TabsTrigger>
<TabsTrigger value="sidebar"></TabsTrigger>
<TabsTrigger value="page">{t('settings.appearance.bgPage')}</TabsTrigger>
<TabsTrigger value="sidebar">{t('settings.appearance.bgSidebar')}</TabsTrigger>
<TabsTrigger value="header">Header</TabsTrigger>
<TabsTrigger value="card">Card</TabsTrigger>
<TabsTrigger value="dialog">Dialog</TabsTrigger>
@@ -662,8 +664,8 @@ export function AppearanceTab() {
{layerId !== 'page' && (
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium"></Label>
<p className="text-xs text-muted-foreground">使</p>
<Label className="text-sm font-medium">{t('settings.appearance.inheritParentBg')}</Label>
<p className="text-xs text-muted-foreground">{t('settings.appearance.inheritParentBgDesc')}</p>
</div>
<Switch
checked={bgConfig[layerId]?.inherit ?? false}
@@ -696,9 +698,9 @@ export function AppearanceTab() {
<div>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div>
<h3 className="text-base sm:text-lg font-semibold"> CSS</h3>
<h3 className="text-base sm:text-lg font-semibold">{t('settings.appearance.customCss')}</h3>
<p className="text-sm text-muted-foreground mt-1">
CSS CSS @importurl()
{t('settings.appearance.cssDescription')}
</p>
</div>
<Button
@@ -712,7 +714,7 @@ export function AppearanceTab() {
disabled={!themeConfig.customCSS}
>
<Trash2 className="h-4 w-4 mr-1" />
{t('settings.appearance.clearCss')}
</Button>
</div>
@@ -721,7 +723,7 @@ export function AppearanceTab() {
value={localCSS}
language="css"
height="250px"
placeholder={`/* 在这里输入自定义 CSS */\n\n/* 例如: */\n/* .sidebar { background: #1a1a2e; } */`}
placeholder={t('settings.appearance.cssPlaceholder')}
onChange={handleCSSChange}
/>
@@ -729,7 +731,7 @@ export function AppearanceTab() {
<div className="rounded-md bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 p-3">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-sm font-medium mb-1">
<AlertTriangle className="h-4 w-4" />
{t('settings.appearance.cssWarningTitle')}
</div>
<ul className="text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-6 list-disc">
{cssWarnings.map((w, i) => <li key={i}>{w}</li>)}
@@ -741,17 +743,17 @@ export function AppearanceTab() {
{/* 动效设置 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.appearance.animationEffect')}</h3>
<div className="space-y-2 sm:space-y-3">
{/* 全局动画开关 */}
<div className="rounded-lg border bg-card p-3 sm:p-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="animations" className="text-base font-medium cursor-pointer">
{t('settings.appearance.enableAnimations')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.appearance.enableAnimationsDesc')}
</p>
</div>
<Switch
@@ -767,10 +769,10 @@ export function AppearanceTab() {
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="waves-background" className="text-base font-medium cursor-pointer">
{t('settings.appearance.loginWavesBackground')}
</Label>
<p className="text-sm text-muted-foreground">
使
{t('settings.appearance.loginWavesBackgroundDesc')}
</p>
</div>
<Switch
@@ -785,7 +787,7 @@ export function AppearanceTab() {
{/* 主题导入/导出 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">/</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.appearance.importExportTheme')}</h3>
<div className="rounded-lg border bg-card p-3 sm:p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{/* 导出按钮 */}
@@ -795,7 +797,7 @@ export function AppearanceTab() {
className="gap-2"
>
<Download className="h-4 w-4" />
{t('settings.appearance.exportTheme')}
</Button>
{/* 导入按钮 */}
@@ -805,7 +807,7 @@ export function AppearanceTab() {
className="gap-2"
>
<Upload className="h-4 w-4" />
{t('settings.appearance.importTheme')}
</Button>
{/* 重置按钮 */}
@@ -816,20 +818,20 @@ export function AppearanceTab() {
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
{t('settings.appearance.resetTheme')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t('settings.appearance.confirmResetTheme')}</AlertDialogTitle>
<AlertDialogDescription>
CSS
{t('settings.appearance.confirmResetThemeDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleResetTheme}>
{t('settings.appearance.confirmResetAction')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -846,7 +848,7 @@ export function AppearanceTab() {
/>
<p className="text-xs text-muted-foreground">
JSON 便
{t('settings.appearance.exportDesc')}
</p>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { AlertTriangle, Database, Download, HardDrive, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
@@ -14,6 +15,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
// 其他设置标签页
export function OtherTab() {
const { t } = useTranslation()
const navigate = useNavigate()
const { toast } = useToast()
const [isResetting, setIsResetting] = useState(false)
@@ -73,8 +75,8 @@ export function OtherTab() {
const handleClearLogCache = () => {
logWebSocket.clearLogs()
toast({
title: '日志已清除',
description: '日志缓存已清空',
title: t('settings.other.logCleared'),
description: t('settings.other.logClearedDesc'),
})
}
@@ -83,8 +85,8 @@ export function OtherTab() {
const result = clearLocalCache()
refreshStorageUsage()
toast({
title: '缓存已清除',
description: `已清除 ${result.clearedKeys.length} 项缓存数据`,
title: t('settings.other.cacheCleared'),
description: t('settings.other.cacheClearedDesc', { count: result.clearedKeys.length }),
})
}
@@ -104,14 +106,14 @@ export function OtherTab() {
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({
title: '导出成功',
description: '设置已导出为 JSON 文件',
title: t('settings.other.exportSuccess'),
description: t('settings.other.exportSuccessDesc'),
})
} catch (error) {
console.error('导出设置失败:', error)
toast({
title: '导出失败',
description: '无法导出设置',
title: t('settings.other.exportFailed'),
description: t('settings.other.exportFailedDesc'),
variant: 'destructive',
})
} finally {
@@ -141,29 +143,29 @@ export function OtherTab() {
refreshStorageUsage()
toast({
title: '导入成功',
description: `成功导入 ${result.imported.length} 项设置${result.skipped.length > 0 ? `,跳过 ${result.skipped.length}` : ''}`,
title: t('settings.other.importSuccess'),
description: t('settings.other.importSuccessDesc', { imported: result.imported.length }) + (result.skipped.length > 0 ? t('settings.other.importSkippedSuffix', { skipped: result.skipped.length }) : ''),
})
// 提示用户刷新页面以应用所有更改
if (result.imported.includes('theme') || result.imported.includes('accentColor')) {
toast({
title: '提示',
description: '部分设置需要刷新页面才能完全生效',
title: t('settings.other.importRefreshHint'),
description: t('settings.other.importRefreshHintDesc'),
})
}
} else {
toast({
title: '导入失败',
description: '没有有效的设置项可导入',
title: t('settings.other.importFailed'),
description: t('settings.other.importNoDataDesc'),
variant: 'destructive',
})
}
} catch (error) {
console.error('导入设置失败:', error)
toast({
title: '导入失败',
description: '文件格式无效',
title: t('settings.other.importFailed'),
description: t('settings.other.importInvalidDesc'),
variant: 'destructive',
})
} finally {
@@ -187,8 +189,8 @@ export function OtherTab() {
setDataSyncInterval(DEFAULT_SETTINGS.dataSyncInterval)
refreshStorageUsage()
toast({
title: '已重置',
description: '所有设置已恢复为默认值,刷新页面以应用更改',
title: t('settings.other.resetDone'),
description: t('settings.other.resetDoneDesc'),
})
}
@@ -205,8 +207,8 @@ export function OtherTab() {
if (response.ok && data.success) {
toast({
title: '重置成功',
description: '即将进入初次配置向导',
title: t('settings.other.resetSuccess'),
description: t('settings.other.clearStorageSuccess'),
})
// 延迟跳转到配置向导
@@ -215,16 +217,16 @@ export function OtherTab() {
}, 1000)
} else {
toast({
title: '重置失败',
description: data.message || '无法重置配置状态',
title: t('settings.other.resetFailed'),
description: data.message || t('settings.other.clearStorageFailed'),
variant: 'destructive',
})
}
} catch (error) {
console.error('重置配置状态错误:', error)
toast({
title: '重置失败',
description: '连接服务器失败',
title: t('settings.other.resetFailed'),
description: t('settings.other.clearStorageFailed'),
variant: 'destructive',
})
} finally {
@@ -238,7 +240,7 @@ export function OtherTab() {
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<Database className="h-5 w-5" />
{t('settings.other.performance')}
</h3>
<div className="space-y-4 sm:space-y-5">
{/* 存储使用情况 */}
@@ -246,21 +248,21 @@ export function OtherTab() {
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium flex items-center gap-2">
<HardDrive className="h-4 w-4" />
使
{t('settings.other.localStorage')}
</span>
<Button variant="ghost" size="sm" onClick={refreshStorageUsage} className="h-7 px-2">
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div className="text-2xl font-bold text-primary">{formatBytes(storageUsage.used)}</div>
<p className="text-xs text-muted-foreground mt-1">{storageUsage.items} </p>
<p className="text-xs text-muted-foreground mt-1">{t('settings.other.storageItems', { count: storageUsage.items })}</p>
</div>
{/* 日志缓存大小 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"></Label>
<span className="text-sm text-muted-foreground">{logCacheSize} </span>
<Label className="text-sm font-medium">{t('settings.other.logCache')}</Label>
<span className="text-sm text-muted-foreground">{logCacheSize} {t('settings.other.logCacheSizeUnit')}</span>
</div>
<Slider
value={[logCacheSize]}
@@ -271,15 +273,15 @@ export function OtherTab() {
className="w-full"
/>
<p className="text-xs text-muted-foreground">
{t('settings.other.logCacheSizeDesc')}
</p>
</div>
{/* 数据刷新间隔 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"></Label>
<span className="text-sm text-muted-foreground">{dataSyncInterval} </span>
<Label className="text-sm font-medium">{t('settings.other.dataSyncIntervalLabel')}</Label>
<span className="text-sm text-muted-foreground">{dataSyncInterval} {t('settings.other.dataSyncIntervalUnit')}</span>
</div>
<Slider
value={[dataSyncInterval]}
@@ -290,15 +292,15 @@ export function OtherTab() {
className="w-full"
/>
<p className="text-xs text-muted-foreground">
{t('settings.other.dataSyncIntervalDesc')}
</p>
</div>
{/* WebSocket 重连间隔 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">WebSocket </Label>
<span className="text-sm text-muted-foreground">{wsReconnectInterval / 1000} </span>
<Label className="text-sm font-medium">{t('settings.other.wsReconnectLabel')}</Label>
<span className="text-sm text-muted-foreground">{wsReconnectInterval / 1000} {t('settings.other.wsReconnectUnit')}</span>
</div>
<Slider
value={[wsReconnectInterval]}
@@ -309,15 +311,15 @@ export function OtherTab() {
className="w-full"
/>
<p className="text-xs text-muted-foreground">
WebSocket
{t('settings.other.wsReconnectDesc')}
</p>
</div>
{/* WebSocket 最大重连次数 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">WebSocket </Label>
<span className="text-sm text-muted-foreground">{wsMaxReconnectAttempts} </span>
<Label className="text-sm font-medium">{t('settings.other.wsMaxReconnectLabel')}</Label>
<span className="text-sm text-muted-foreground">{wsMaxReconnectAttempts} {t('settings.other.wsMaxReconnectUnit')}</span>
</div>
<Slider
value={[wsMaxReconnectAttempts]}
@@ -328,7 +330,7 @@ export function OtherTab() {
className="w-full"
/>
<p className="text-xs text-muted-foreground">
{t('settings.other.wsMaxReconnectDesc')}
</p>
</div>
@@ -336,27 +338,26 @@ export function OtherTab() {
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="outline" size="sm" onClick={handleClearLogCache} className="gap-2">
<Trash2 className="h-4 w-4" />
{t('settings.other.clearLogCacheFn')}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Trash2 className="h-4 w-4" />
{t('settings.other.clearLocalCache')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t('settings.other.confirmClearCache')}</AlertDialogTitle>
<AlertDialogDescription>
{t('settings.other.confirmClearCacheDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleClearLocalCache}>
{t('settings.other.confirmClear')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -369,11 +370,11 @@ export function OtherTab() {
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<Download className="h-5 w-5" />
/
{t('settings.other.importExport')}
</h3>
<div className="space-y-4">
<p className="text-xs sm:text-sm text-muted-foreground">
便
{t('settings.other.importExportDesc')}
</p>
<div className="flex flex-wrap gap-2">
@@ -384,7 +385,7 @@ export function OtherTab() {
className="gap-2"
>
<Download className="h-4 w-4" />
{isExporting ? '导出中...' : '导出设置'}
{isExporting ? t('settings.other.exporting') : t('settings.other.exportSettings')}
</Button>
<input
@@ -401,7 +402,7 @@ export function OtherTab() {
className="gap-2"
>
<Upload className="h-4 w-4" />
{isImporting ? '导入中...' : '导入设置'}
{isImporting ? t('settings.other.importing') : t('settings.other.importSettings')}
</Button>
</div>
@@ -411,21 +412,20 @@ export function OtherTab() {
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive">
<RotateCcw className="h-4 w-4" />
{t('settings.other.resetAllSettingsBtn')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t('settings.other.confirmResetAll')}</AlertDialogTitle>
<AlertDialogDescription>
{t('settings.other.confirmResetAllDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleResetAllSettings}>
{t('settings.other.resetAllSettingsConfirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -436,31 +436,31 @@ export function OtherTab() {
{/* 配置向导 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.other.configWizard')}</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<p className="text-xs sm:text-sm text-muted-foreground">
{t('settings.other.configWizardDesc')}
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={isResetting} className="gap-2">
<RotateCcw className={cn('h-4 w-4', isResetting && 'animate-spin')} />
{t('settings.other.rerunSetup')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t('settings.other.confirmRerunSetup')}</AlertDialogTitle>
<AlertDialogDescription>
{t('settings.other.confirmRerunSetupDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleResetSetup}>
{t('settings.other.resetAllSettingsConfirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -472,36 +472,35 @@ export function OtherTab() {
<div className="rounded-lg border border-dashed border-yellow-500/50 bg-yellow-500/5 p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
{t('settings.other.devTools')}
</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<p className="text-xs sm:text-sm text-muted-foreground">
使
{t('settings.other.devToolsDesc')}
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<AlertTriangle className="h-4 w-4" />
{t('settings.other.triggerError')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t('settings.other.confirmTriggerError')}</AlertDialogTitle>
<AlertDialogDescription>
React
{t('settings.other.confirmTriggerErrorDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => setShouldThrowError(true)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('settings.other.confirmTrigger')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -9,6 +9,7 @@ import {
XCircle,
} from 'lucide-react'
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
@@ -38,6 +39,7 @@ import {
} from '@/components/ui/alert-dialog'
export function SecurityTab() {
const { t } = useTranslation()
const navigate = useNavigate()
const [currentToken, setCurrentToken] = useState('')
const [newToken, setNewToken] = useState('')
@@ -58,8 +60,8 @@ export function SecurityTab() {
const copyToClipboard = async (text: string) => {
if (!currentToken) {
toast({
title: '无法复制',
description: 'Token 存储在安全 Cookie 中,请重新生成以获取新 Token',
title: t('settings.security.cannotCopy'),
description: t('settings.security.cannotCopyDesc'),
variant: 'destructive',
})
return
@@ -68,14 +70,14 @@ export function SecurityTab() {
await navigator.clipboard.writeText(text)
setCopied(true)
toast({
title: '复制成功',
description: 'Token 已复制到剪贴板',
title: t('settings.security.copySuccess'),
description: t('settings.security.copySuccessDesc'),
})
setTimeout(() => setCopied(false), 2000)
} catch {
toast({
title: '复制失败',
description: '请手动复制 Token',
title: t('settings.security.copyFailed'),
description: t('settings.security.copyFailedDesc'),
variant: 'destructive',
})
}
@@ -85,8 +87,8 @@ export function SecurityTab() {
const handleUpdateToken = async () => {
if (!newToken.trim()) {
toast({
title: '输入错误',
description: '请输入新的 Token',
title: t('settings.security.inputError'),
description: t('settings.security.inputErrorDesc'),
variant: 'destructive',
})
return
@@ -100,8 +102,8 @@ export function SecurityTab() {
.join(', ')
toast({
title: '格式错误',
description: `Token 不符合要求: ${failedRules}`,
title: t('settings.security.formatError'),
description: t('settings.security.formatErrorDesc', { failedRules }),
variant: 'destructive',
})
return
@@ -129,8 +131,8 @@ export function SecurityTab() {
setCurrentToken(newToken.trim())
toast({
title: '更新成功',
description: 'Access Token 已更新,即将跳转到登录页',
title: t('settings.security.updateSuccess'),
description: t('settings.security.updateSuccessDesc'),
})
// 延迟跳转到登录页
@@ -139,16 +141,16 @@ export function SecurityTab() {
}, 1500)
} else {
toast({
title: '更新失败',
description: data.message || '无法更新 Token',
title: t('settings.security.updateFailed'),
description: data.message || t('settings.security.updateFailedDesc'),
variant: 'destructive',
})
}
} catch (err) {
console.error('更新 Token 错误:', err)
toast({
title: '更新失败',
description: '连接服务器失败',
title: t('settings.security.updateFailed'),
description: t('settings.security.updateFailedConn'),
variant: 'destructive',
})
} finally {
@@ -181,21 +183,21 @@ export function SecurityTab() {
setTokenCopied(false)
toast({
title: '生成成功',
description: '新的 Access Token 已生成,请及时保存',
title: t('settings.security.generateSuccess'),
description: t('settings.security.generateSuccessDesc'),
})
} else {
toast({
title: '生成失败',
description: data.message || '无法生成新 Token',
title: t('settings.security.generateFailed'),
description: data.message || t('settings.security.generateFailedDesc'),
variant: 'destructive',
})
}
} catch (err) {
console.error('生成 Token 错误:', err)
toast({
title: '生成失败',
description: '连接服务器失败',
title: t('settings.security.generateFailed'),
description: t('settings.security.generateFailedConn'),
variant: 'destructive',
})
} finally {
@@ -209,13 +211,13 @@ export function SecurityTab() {
await navigator.clipboard.writeText(generatedToken)
setTokenCopied(true)
toast({
title: '复制成功',
description: 'Token 已复制到剪贴板',
title: t('settings.security.copySuccess'),
description: t('settings.security.copySuccessDesc'),
})
} catch {
toast({
title: '复制失败',
description: '请手动复制 Token',
title: t('settings.security.copyFailed'),
description: t('settings.security.copyFailedDesc'),
variant: 'destructive',
})
}
@@ -251,10 +253,10 @@ export function SecurityTab() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Access Token
{t('settings.security.dialogTitle')}
</DialogTitle>
<DialogDescription>
Token
{t('settings.security.dialogDesc')}
</DialogDescription>
</DialogHeader>
@@ -262,7 +264,7 @@ export function SecurityTab() {
{/* Token 显示区域 */}
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
<Label className="text-xs text-muted-foreground mb-2 block">
Token (64)
{t('settings.security.dialogTokenLabel')}
</Label>
<div className="font-mono text-sm break-all select-all bg-background p-3 rounded border">
{generatedToken}
@@ -274,12 +276,12 @@ export function SecurityTab() {
<div className="flex gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
<p className="font-semibold"></p>
<p className="font-semibold">{t('settings.security.important')}</p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li> Token </li>
<li></li>
<li></li>
<li>使 Token </li>
<li>{t('settings.security.tip1')}</li>
<li>{t('settings.security.tip2')}</li>
<li>{t('settings.security.tip3')}</li>
<li>{t('settings.security.tip4')}</li>
</ul>
</div>
</div>
@@ -295,17 +297,17 @@ export function SecurityTab() {
{tokenCopied ? (
<>
<Check className="h-4 w-4 text-green-500" />
{t('settings.security.copied')}
</>
) : (
<>
<Copy className="h-4 w-4" />
Token
{t('settings.security.copyToken')}
</>
)}
</Button>
<Button onClick={handleCloseDialog}>
{t('settings.security.savedClose')}
</Button>
</DialogFooter>
</DialogContent>
@@ -313,10 +315,10 @@ export function SecurityTab() {
{/* 当前 Token */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> Access Token</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.security.currentToken')}</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label htmlFor="current-token" className="text-sm">访</Label>
<Label htmlFor="current-token" className="text-sm">{t('settings.security.yourToken')}</Label>
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Input
@@ -325,7 +327,7 @@ export function SecurityTab() {
value={currentToken || '••••••••••••••••••••••••••••••••'}
readOnly
className="pr-10 font-mono text-sm"
placeholder="Token 存储在安全 Cookie 中"
placeholder={t('settings.security.tokenStorePlaceholder')}
/>
<button
onClick={() => {
@@ -333,13 +335,13 @@ export function SecurityTab() {
setShowCurrentToken(!showCurrentToken)
} else {
toast({
title: '无法查看',
description: 'Token 存储在安全 Cookie 中,如需新 Token 请点击"重新生成"',
title: t('settings.security.cannotView'),
description: t('settings.security.cannotViewDesc'),
})
}
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-accent rounded"
title={showCurrentToken ? '隐藏' : '显示'}
title={showCurrentToken ? t('settings.security.hide') : t('settings.security.show')}
>
{showCurrentToken ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
@@ -353,7 +355,7 @@ export function SecurityTab() {
variant="outline"
size="icon"
onClick={() => copyToClipboard(currentToken)}
title="复制到剪贴板"
title={t('settings.security.copyTip')}
className="flex-shrink-0"
disabled={!currentToken}
>
@@ -371,22 +373,21 @@ export function SecurityTab() {
className="gap-2 flex-1 sm:flex-none"
>
<RefreshCw className={cn('h-4 w-4', isRegenerating && 'animate-spin')} />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">{t('settings.security.regenerate')}</span>
<span className="sm:hidden">{t('settings.security.regenerateShort')}</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> Token</AlertDialogTitle>
<AlertDialogTitle>{t('settings.security.confirmRegenerate')}</AlertDialogTitle>
<AlertDialogDescription>
64 使 Token
使 Token
{t('settings.security.confirmRegenerateFullDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogCancel>{t('settings.security.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={executeRegenerateToken}>
{t('settings.security.confirmGenerate')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -394,7 +395,7 @@ export function SecurityTab() {
</div>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Access Token
{t('settings.security.safekeepTip')}
</p>
</div>
</div>
@@ -402,10 +403,10 @@ export function SecurityTab() {
{/* 更新 Token */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> Access Token</h3>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">{t('settings.security.customToken')}</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label htmlFor="new-token" className="text-sm">访</Label>
<Label htmlFor="new-token" className="text-sm">{t('settings.security.newTokenLabel')}</Label>
<div className="relative">
<Input
id="new-token"
@@ -413,12 +414,12 @@ export function SecurityTab() {
value={newToken}
onChange={(e) => setNewToken(e.target.value)}
className="pr-10 font-mono text-sm"
placeholder="输入自定义 Token"
placeholder={t('settings.security.customTokenPlaceholder')}
/>
<button
onClick={() => setShowNewToken(!showNewToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-accent rounded"
title={showNewToken ? '隐藏' : '显示'}
title={showNewToken ? t('settings.security.hide') : t('settings.security.show')}
>
{showNewToken ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
@@ -431,7 +432,7 @@ export function SecurityTab() {
{/* Token 验证规则显示 */}
{newToken && (
<div className="mt-3 space-y-2 p-3 rounded-lg bg-muted/50">
<p className="text-sm font-medium text-foreground">Token :</p>
<p className="text-sm font-medium text-foreground">{t('settings.security.tokenReqTitle')}</p>
<div className="space-y-1.5">
{tokenValidation.rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-2 text-sm">
@@ -452,7 +453,7 @@ export function SecurityTab() {
<div className="mt-2 pt-2 border-t border-border">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Check className="h-4 w-4" />
<span className="font-medium">Token 使</span>
<span className="font-medium">{t('settings.security.tokenValid')}</span>
</div>
</div>
)}
@@ -464,21 +465,21 @@ export function SecurityTab() {
disabled={isUpdating || !tokenValidation.isValid || !newToken}
className="w-full sm:w-auto"
>
{isUpdating ? '更新中...' : '更新自定义 Token'}
{isUpdating ? t('settings.security.updating') : t('settings.security.updateBtn')}
</Button>
</div>
</div>
{/* 安全提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3 sm:p-4">
<h4 className="text-sm sm:text-base font-semibold text-yellow-900 dark:text-yellow-200 mb-2"></h4>
<h4 className="text-sm sm:text-base font-semibold text-yellow-900 dark:text-yellow-200 mb-2">{t('settings.security.securityTip')}</h4>
<ul className="text-xs sm:text-sm text-yellow-800 dark:text-yellow-300 space-y-1 list-disc list-inside">
<li> Token 64 </li>
<li> Token 使</li>
<li> Token Token </li>
<li> Token</li>
<li>怀 Token </li>
<li>使 Token </li>
<li>{t('settings.security.securityTip1')}</li>
<li>{t('settings.security.securityTip2')}</li>
<li>{t('settings.security.securityTip3')}</li>
<li>{t('settings.security.securityTip4')}</li>
<li>{t('settings.security.securityTip5')}</li>
<li>{t('settings.security.securityTip6')}</li>
</ul>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { Info, Palette, Settings, Shield } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -9,13 +10,14 @@ import { OtherTab } from './OtherTab'
import { SecurityTab } from './SecurityTab'
export function SettingsPage() {
const { t } = useTranslation()
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base"></p>
<h1 className="text-2xl sm:text-3xl font-bold">{t('settings.title')}</h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base">{t('settings.description')}</p>
</div>
</div>
@@ -24,19 +26,19 @@ export function SettingsPage() {
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 gap-0.5 sm:gap-1 h-auto p-1">
<TabsTrigger value="appearance" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Palette className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
<span>{t('settings.tabs.appearance')}</span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Shield className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
<span>{t('settings.tabs.security')}</span>
</TabsTrigger>
<TabsTrigger value="other" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Settings className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
<span>{t('settings.tabs.other')}</span>
</TabsTrigger>
<TabsTrigger value="about" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
<span>{t('settings.tabs.about')}</span>
</TabsTrigger>
</TabsList>

95
dashboard/src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,95 @@
/**
* Electron API type definitions
* Declares Window.electronAPI and globalThis.__RUNTIME__ for frontend use
*/
import type { RuntimeInfo } from '@/lib/runtime'
/**
* Backend connection configuration
*/
export interface BackendConnection {
/** Unique identifier */
id: string
/** Display name */
name: string
/** Connection URL */
url: string
/** Whether this is the default backend */
isDefault: boolean
/** Last connection timestamp */
lastConnected?: number
}
/**
* Electron IPC API exposed to renderer process
* All methods communicate via IPC bridges to main process
*/
export interface ElectronAPI {
// Window control
/** Minimize the application window */
minimizeWindow(): void
/** Maximize the application window */
maximizeWindow(): void
/** Close the application window */
closeWindow(): void
/** Check if window is currently maximized */
isMaximized(): Promise<boolean>
// Window event listeners
/** Register callback for window maximized event */
onWindowMaximized(callback: () => void): () => void
/** Register callback for window unmaximized event */
onWindowUnmaximized(callback: () => void): () => void
// Backend management
/** Get list of all configured backends */
getBackends(): Promise<BackendConnection[]>
/** Add a new backend connection */
addBackend(conn: Omit<BackendConnection, 'id'>): Promise<BackendConnection>
/** Update an existing backend configuration */
updateBackend(id: string, patch: Partial<BackendConnection>): Promise<void>
/** Remove a backend by ID */
removeBackend(id: string): Promise<void>
/** Set the active backend */
setActiveBackend(id: string): Promise<void>
/** Get the currently active backend */
getActiveBackend(): Promise<BackendConnection | null>
/** Get the active backend's URL for API requests */
getActiveBackendUrl(): Promise<string | null>
// Application state
/** Mark that first-launch setup has been completed */
markFirstLaunchComplete(): Promise<void>
/** Check if this is the first launch */
isFirstLaunch(): Promise<boolean>
/** Get application version */
getAppVersion(): Promise<string>
// Backend event listener
/** Register callback for backend change events */
onBackendChanged(callback: (backend: BackendConnection | null) => void): () => void
// Platform detection
/** Get platform identifier (darwin, win32, linux) */
getPlatform(): string
}
// Extend Window interface to include electronAPI
declare global {
interface Window {
/** Electron API bridge for main process communication */
electronAPI?: ElectronAPI
}
/**
* Global runtime information
* Set by Electron preload, undefined in browser
*/
namespace globalThis {
var __RUNTIME__: RuntimeInfo | undefined
}
}
// Ensure this file is treated as a module
export {}

View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.electron.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["electron"],
"skipLibCheck": true,
/* Module resolution */
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"skipLibCheck": true
},
"include": ["electron/**/*"]
}

View File

@@ -3,6 +3,7 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.vitest.json" }
{ "path": "./tsconfig.vitest.json" },
{ "path": "./tsconfig.electron.json" }
]
}

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# MaiCore & NapCat Adapter一键安装脚本 by Cookie_987
# 适用于Arch/Ubuntu 24.10/Debian 12/CentOS 9
# 适用于macOS/Arch/Ubuntu 24.10/Debian 12/CentOS 9
# 请小心使用任何一键脚本!
INSTALLER_VERSION="0.0.5-refactor"
@@ -15,36 +15,297 @@ GREEN="\e[32m"
RED="\e[31m"
RESET="\e[0m"
# 需要的基本软件包
declare -A REQUIRED_PACKAGES=(
["common"]="git sudo python3 curl gnupg"
["debian"]="python3-venv python3-pip build-essential"
["ubuntu"]="python3-venv python3-pip build-essential"
["centos"]="epel-release python3-pip python3-devel gcc gcc-c++ make"
["arch"]="python-virtualenv python-pip base-devel"
)
# 默认项目目录
DEFAULT_INSTALL_DIR="/opt/maicore"
# 需要的基本软件包(兼容 Bash 3避免使用关联数组
REQUIRED_PACKAGES_COMMON="git sudo python3 curl gnupg"
REQUIRED_PACKAGES_DEBIAN="python3-venv python3-pip build-essential"
REQUIRED_PACKAGES_UBUNTU="python3-venv python3-pip build-essential"
REQUIRED_PACKAGES_CENTOS="epel-release python3-pip python3-devel gcc gcc-c++ make"
REQUIRED_PACKAGES_ARCH="python-virtualenv python-pip base-devel"
REQUIRED_PACKAGES_MACOS="git gnupg python"
# 服务名称
SERVICE_NAME="maicore"
SERVICE_NAME_WEB="maicore-web"
SERVICE_NAME_NBADAPTER="maibot-napcat-adapter"
SERVICE_USER="${SUDO_USER:-$USER}"
SERVICE_HOME="$(eval echo "~${SERVICE_USER}" 2>/dev/null)"
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "~${SERVICE_USER}" ]]; then
SERVICE_HOME="$HOME"
fi
IS_MACOS=false
[[ "$(uname -s)" == "Darwin" ]] && IS_MACOS=true
INSTALL_CONF="/etc/maicore_install.conf"
# 默认项目目录
DEFAULT_INSTALL_DIR="/opt/maicore"
if [[ "$IS_MACOS" == true ]]; then
DEFAULT_INSTALL_DIR="${SERVICE_HOME}/maicore"
INSTALL_CONF="${SERVICE_HOME}/.config/maicore/maicore_install.conf"
fi
LAUNCHD_DOMAIN=""
LAUNCHD_AGENT_DIR=""
LAUNCHD_LABEL_MAIN="com.maicore.${SERVICE_NAME}"
LAUNCHD_LABEL_NBADAPTER="com.maicore.${SERVICE_NAME_NBADAPTER}"
LAUNCHD_PLIST_MAIN=""
LAUNCHD_PLIST_NBADAPTER=""
if [[ "$IS_MACOS" == true ]]; then
SERVICE_UID="$(id -u "${SERVICE_USER}" 2>/dev/null || id -u)"
LAUNCHD_DOMAIN="gui/${SERVICE_UID}"
LAUNCHD_AGENT_DIR="${SERVICE_HOME}/Library/LaunchAgents"
LAUNCHD_PLIST_MAIN="${LAUNCHD_AGENT_DIR}/${LAUNCHD_LABEL_MAIN}.plist"
LAUNCHD_PLIST_NBADAPTER="${LAUNCHD_AGENT_DIR}/${LAUNCHD_LABEL_NBADAPTER}.plist"
fi
get_required_packages() {
local distro="$1"
case "$distro" in
debian)
echo "${REQUIRED_PACKAGES_COMMON} ${REQUIRED_PACKAGES_DEBIAN}"
;;
ubuntu)
echo "${REQUIRED_PACKAGES_COMMON} ${REQUIRED_PACKAGES_UBUNTU}"
;;
centos)
echo "${REQUIRED_PACKAGES_COMMON} ${REQUIRED_PACKAGES_CENTOS}"
;;
arch)
echo "${REQUIRED_PACKAGES_COMMON} ${REQUIRED_PACKAGES_ARCH}"
;;
macos)
echo "${REQUIRED_PACKAGES_MACOS}"
;;
*)
echo "${REQUIRED_PACKAGES_COMMON}"
;;
esac
}
IS_INSTALL_NAPCAT=false
IS_INSTALL_DEPENDENCIES=false
resolve_brew_bin() {
local brew_bin
brew_bin="$(command -v brew)"
[[ -z "$brew_bin" && -x /opt/homebrew/bin/brew ]] && brew_bin="/opt/homebrew/bin/brew"
[[ -z "$brew_bin" && -x /usr/local/bin/brew ]] && brew_bin="/usr/local/bin/brew"
[[ -n "$brew_bin" ]] && echo "$brew_bin"
}
run_brew() {
local brew_bin
brew_bin="$(resolve_brew_bin)"
[[ -z "$brew_bin" ]] && return 1
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
sudo -u "${SUDO_USER}" "${brew_bin}" "$@"
else
"${brew_bin}" "$@"
fi
}
run_launchctl() {
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
sudo -u "${SUDO_USER}" launchctl "$@"
else
launchctl "$@"
fi
}
ensure_writable_parent() {
local path="$1"
local parent
parent="$(dirname "$path")"
mkdir -p "$parent"
if [[ "$IS_MACOS" == true && "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" ]]; then
chown "${SUDO_USER}" "$parent" 2>/dev/null || true
fi
}
save_install_info() {
ensure_writable_parent "$INSTALL_CONF"
cat > "$INSTALL_CONF" <<EOF
INSTALLER_VERSION=${INSTALLER_VERSION}
INSTALL_DIR=${INSTALL_DIR}
BRANCH=${BRANCH}
EOF
}
compute_md5() {
local file_path="$1"
if command -v md5sum &>/dev/null; then
md5sum "$file_path" | awk '{print $1}'
elif command -v md5 &>/dev/null; then
md5 -q "$file_path"
else
return 1
fi
}
launchd_label_for_service() {
local service="$1"
case "$service" in
${SERVICE_NAME})
echo "$LAUNCHD_LABEL_MAIN"
;;
${SERVICE_NAME_NBADAPTER})
echo "$LAUNCHD_LABEL_NBADAPTER"
;;
*)
return 1
;;
esac
}
launchd_plist_for_service() {
local service="$1"
case "$service" in
${SERVICE_NAME})
echo "$LAUNCHD_PLIST_MAIN"
;;
${SERVICE_NAME_NBADAPTER})
echo "$LAUNCHD_PLIST_NBADAPTER"
;;
*)
return 1
;;
esac
}
is_launchd_service_loaded() {
local service="$1"
local label
label="$(launchd_label_for_service "$service")" || return 1
run_launchctl print "${LAUNCHD_DOMAIN}/${label}" &>/dev/null
}
start_service() {
local service="$1"
if [[ "$IS_MACOS" == true ]]; then
local label
local plist
label="$(launchd_label_for_service "$service")" || return 1
plist="$(launchd_plist_for_service "$service")" || return 1
if [[ ! -f "$plist" && -d "${INSTALL_DIR}/MaiBot" ]]; then
create_launchd_services
fi
if [[ ! -f "$plist" ]]; then
echo -e "${RED}未找到服务配置文件:${plist}${RESET}"
return 1
fi
if is_launchd_service_loaded "$service"; then
run_launchctl kickstart -k "${LAUNCHD_DOMAIN}/${label}"
else
run_launchctl bootstrap "${LAUNCHD_DOMAIN}" "$plist"
fi
else
systemctl start "$service"
fi
}
stop_service() {
local service="$1"
if [[ "$IS_MACOS" == true ]]; then
local label
label="$(launchd_label_for_service "$service")" || return 1
if is_launchd_service_loaded "$service"; then
run_launchctl bootout "${LAUNCHD_DOMAIN}/${label}"
fi
else
systemctl stop "$service"
fi
}
restart_service() {
local service="$1"
if [[ "$IS_MACOS" == true ]]; then
stop_service "$service"
start_service "$service"
else
systemctl restart "$service"
fi
}
create_launchd_services() {
mkdir -p "${LAUNCHD_AGENT_DIR}"
mkdir -p "${INSTALL_DIR}/logs"
cat > "${LAUNCHD_PLIST_MAIN}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LAUNCHD_LABEL_MAIN}</string>
<key>ProgramArguments</key>
<array>
<string>${INSTALL_DIR}/venv/bin/python3</string>
<string>bot.py</string>
</array>
<key>WorkingDirectory</key>
<string>${INSTALL_DIR}/MaiBot</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${INSTALL_DIR}/logs/${SERVICE_NAME}.log</string>
<key>StandardErrorPath</key>
<string>${INSTALL_DIR}/logs/${SERVICE_NAME}.error.log</string>
</dict>
</plist>
EOF
cat > "${LAUNCHD_PLIST_NBADAPTER}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LAUNCHD_LABEL_NBADAPTER}</string>
<key>ProgramArguments</key>
<array>
<string>${INSTALL_DIR}/venv/bin/python3</string>
<string>main.py</string>
</array>
<key>WorkingDirectory</key>
<string>${INSTALL_DIR}/MaiBot-Napcat-Adapter</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${INSTALL_DIR}/logs/${SERVICE_NAME_NBADAPTER}.log</string>
<key>StandardErrorPath</key>
<string>${INSTALL_DIR}/logs/${SERVICE_NAME_NBADAPTER}.error.log</string>
</dict>
</plist>
EOF
if [[ "$(id -u)" -eq 0 && -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
chown "${SUDO_USER}" "${LAUNCHD_PLIST_MAIN}" "${LAUNCHD_PLIST_NBADAPTER}" "${LAUNCHD_AGENT_DIR}" 2>/dev/null || true
fi
}
# 检查是否已安装
check_installed() {
[[ -f /etc/systemd/system/${SERVICE_NAME}.service ]]
if [[ "$IS_MACOS" == true ]]; then
[[ -f "$INSTALL_CONF" ]]
else
[[ -f /etc/systemd/system/${SERVICE_NAME}.service ]]
fi
}
# 加载安装信息
load_install_info() {
if [[ -f /etc/maicore_install.conf ]]; then
source /etc/maicore_install.conf
if [[ -f "$INSTALL_CONF" ]]; then
source "$INSTALL_CONF"
else
INSTALL_DIR="$DEFAULT_INSTALL_DIR"
BRANCH="refactor"
@@ -69,27 +330,27 @@ show_menu() {
case "$choice" in
1)
systemctl start ${SERVICE_NAME}
start_service "${SERVICE_NAME}"
whiptail --msgbox "✅MaiCore已启动" 10 60
;;
2)
systemctl stop ${SERVICE_NAME}
stop_service "${SERVICE_NAME}"
whiptail --msgbox "🛑MaiCore已停止" 10 60
;;
3)
systemctl restart ${SERVICE_NAME}
restart_service "${SERVICE_NAME}"
whiptail --msgbox "🔄MaiCore已重启" 10 60
;;
4)
systemctl start ${SERVICE_NAME_NBADAPTER}
start_service "${SERVICE_NAME_NBADAPTER}"
whiptail --msgbox "✅NapCat Adapter已启动" 10 60
;;
5)
systemctl stop ${SERVICE_NAME_NBADAPTER}
stop_service "${SERVICE_NAME_NBADAPTER}"
whiptail --msgbox "🛑NapCat Adapter已停止" 10 60
;;
6)
systemctl restart ${SERVICE_NAME_NBADAPTER}
restart_service "${SERVICE_NAME_NBADAPTER}"
whiptail --msgbox "🔄NapCat Adapter已重启" 10 60
;;
7)
@@ -111,7 +372,7 @@ show_menu() {
# 更新依赖
update_dependencies() {
whiptail --title "⚠" --msgbox "更新后请阅读教程" 10 60
systemctl stop ${SERVICE_NAME}
stop_service "${SERVICE_NAME}"
cd "${INSTALL_DIR}/MaiBot" || {
whiptail --msgbox "🚫 无法进入安装目录!" 10 60
return 1
@@ -157,23 +418,23 @@ switch_branch() {
whiptail --msgbox "🚫 代码拉取失败!" 10 60
return 1
fi
systemctl stop ${SERVICE_NAME}
stop_service "${SERVICE_NAME}"
source "${INSTALL_DIR}/venv/bin/activate"
pip install -r requirements.txt
deactivate
sed -i "s/^BRANCH=.*/BRANCH=${new_branch}/" /etc/maicore_install.conf
BRANCH="${new_branch}"
save_install_info
check_eula
whiptail --msgbox "✅ 已停止服务并切换到分支 ${new_branch} " 10 60
}
check_eula() {
# 首先计算当前EULA的MD5值
current_md5=$(md5sum "${INSTALL_DIR}/MaiBot/EULA.md" | awk '{print $1}')
current_md5=$(compute_md5 "${INSTALL_DIR}/MaiBot/EULA.md")
# 首先计算当前隐私条款文件的哈希值
current_md5_privacy=$(md5sum "${INSTALL_DIR}/MaiBot/PRIVACY.md" | awk '{print $1}')
current_md5_privacy=$(compute_md5 "${INSTALL_DIR}/MaiBot/PRIVACY.md")
# 如果当前的md5值为空则直接返回
if [[ -z $current_md5 || -z $current_md5_privacy ]]; then
@@ -183,7 +444,7 @@ check_eula() {
# 检查eula.confirmed文件是否存在
if [[ -f ${INSTALL_DIR}/MaiBot/eula.confirmed ]]; then
# 如果存在则检查其中包含的md5与current_md5是否一致
confirmed_md5=$(cat ${INSTALL_DIR}/MaiBot/eula.confirmed)
confirmed_md5=$(cat "${INSTALL_DIR}/MaiBot/eula.confirmed")
else
confirmed_md5=""
fi
@@ -191,7 +452,7 @@ check_eula() {
# 检查privacy.confirmed文件是否存在
if [[ -f ${INSTALL_DIR}/MaiBot/privacy.confirmed ]]; then
# 如果存在则检查其中包含的md5与current_md5是否一致
confirmed_md5_privacy=$(cat ${INSTALL_DIR}/MaiBot/privacy.confirmed)
confirmed_md5_privacy=$(cat "${INSTALL_DIR}/MaiBot/privacy.confirmed")
else
confirmed_md5_privacy=""
fi
@@ -200,8 +461,8 @@ check_eula() {
if [[ $current_md5 != $confirmed_md5 || $current_md5_privacy != $confirmed_md5_privacy ]]; then
whiptail --title "📜 使用协议更新" --yesno "检测到MaiCore EULA或隐私条款已更新。\nhttps://github.com/MaiM-with-u/MaiBot/blob/refactor/EULA.md\nhttps://github.com/MaiM-with-u/MaiBot/blob/refactor/PRIVACY.md\n\n您是否同意上述协议 \n\n " 12 70
if [[ $? -eq 0 ]]; then
echo -n $current_md5 > ${INSTALL_DIR}/MaiBot/eula.confirmed
echo -n $current_md5_privacy > ${INSTALL_DIR}/MaiBot/privacy.confirmed
echo -n "$current_md5" > "${INSTALL_DIR}/MaiBot/eula.confirmed"
echo -n "$current_md5_privacy" > "${INSTALL_DIR}/MaiBot/privacy.confirmed"
else
exit 1
fi
@@ -209,6 +470,98 @@ check_eula() {
}
# 测速并选择PyPI源仅当阿里云更快时使用阿里云
measure_url_latency() {
local url="$1"
local latency
latency=$(curl -sS -o /dev/null -w "%{time_total}" --connect-timeout 3 --max-time 8 "$url" 2>/dev/null)
if [[ $? -eq 0 && "$latency" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
echo "$latency"
return 0
else
echo "999999"
return 1
fi
}
resolve_default_pypi_index_url() {
local default_url=""
if [[ -n "${PIP_INDEX_URL:-}" ]]; then
default_url="$PIP_INDEX_URL"
elif [[ -n "${UV_INDEX_URL:-}" ]]; then
default_url="$UV_INDEX_URL"
elif command -v pip &>/dev/null; then
default_url=$(pip config get global.index-url 2>/dev/null | head -n 1)
if [[ -z "$default_url" ]]; then
default_url=$(pip config get install.index-url 2>/dev/null | head -n 1)
fi
fi
if [[ -z "$default_url" ]]; then
default_url="https://pypi.org/simple"
fi
echo "$default_url"
}
select_pypi_index_url() {
local default_url
local aliyun_url="https://mirrors.aliyun.com/pypi/simple"
local default_latency
local aliyun_latency
local default_status
local aliyun_status
default_url=$(resolve_default_pypi_index_url)
default_latency=$(measure_url_latency "$default_url")
default_status=$?
aliyun_latency=$(measure_url_latency "$aliyun_url")
aliyun_status=$?
if [[ $aliyun_status -eq 0 && $default_status -ne 0 ]]; then
PYPI_INDEX_URL="$aliyun_url"
PYPI_INDEX_NAME="阿里云镜像(默认源测速失败)"
UV_PIP_INDEX_OPTION=(-i "$aliyun_url")
echo -e "${RED}默认源测速失败,已选择${PYPI_INDEX_NAME}${PYPI_INDEX_URL}${RESET}"
return
fi
if [[ $aliyun_status -ne 0 && $default_status -eq 0 ]]; then
PYPI_INDEX_URL="$default_url"
PYPI_INDEX_NAME="默认源(阿里云测速失败)"
UV_PIP_INDEX_OPTION=()
echo -e "${RED}阿里云测速失败,已选择${PYPI_INDEX_NAME}:不显式指定 -i 参数${RESET}"
return
fi
if [[ $aliyun_status -ne 0 && $default_status -ne 0 ]]; then
PYPI_INDEX_URL="$default_url"
PYPI_INDEX_NAME="默认源(双源测速失败)"
UV_PIP_INDEX_OPTION=()
echo -e "${RED}默认源和阿里云测速均失败,回退到${PYPI_INDEX_NAME}:不显式指定 -i 参数${RESET}"
return
fi
if awk "BEGIN {exit !(${aliyun_latency} < ${default_latency})}"; then
PYPI_INDEX_URL="$aliyun_url"
PYPI_INDEX_NAME="阿里云镜像"
UV_PIP_INDEX_OPTION=(-i "$aliyun_url")
else
PYPI_INDEX_URL="$default_url"
PYPI_INDEX_NAME="默认源"
UV_PIP_INDEX_OPTION=()
fi
if [[ ${#UV_PIP_INDEX_OPTION[@]} -gt 0 ]]; then
echo -e "${GREEN}已选择${PYPI_INDEX_NAME}${PYPI_INDEX_URL}${RESET}"
else
echo -e "${GREEN}已选择${PYPI_INDEX_NAME}:不显式指定 -i 参数${RESET}"
fi
}
# ----------- 主安装流程 -----------
run_installation() {
# 1/6: 检测是否安装 whiptail
@@ -221,10 +574,21 @@ run_installation() {
pacman -Syu --noconfirm whiptail
elif command -v yum &>/dev/null; then
yum install -y whiptail
elif command -v brew &>/dev/null || [[ -x /opt/homebrew/bin/brew ]] || [[ -x /usr/local/bin/brew ]]; then
run_brew install newt
# 确保当前 shell 能找到 Homebrew 安装的 whiptail。
[[ -x /opt/homebrew/bin/whiptail ]] && export PATH="/opt/homebrew/bin:${PATH}"
[[ -x /usr/local/bin/whiptail ]] && export PATH="/usr/local/bin:${PATH}"
else
echo -e "${RED}[Error] 无受支持的包管理器,无法安装 whiptail!${RESET}"
exit 1
fi
if ! command -v whiptail &>/dev/null; then
echo -e "${RED}[Error] whiptail 安装失败或不可用,请手动安装后重试。${RESET}"
exit 1
fi
fi
whiptail --title " 提示" --msgbox "如果您没有特殊需求请优先使用docker方式部署。" 10 60
@@ -239,6 +603,13 @@ run_installation() {
# 系统检查
check_system() {
if [[ "$IS_MACOS" == true ]]; then
ID="macos"
VERSION_ID="$(sw_vers -productVersion 2>/dev/null)"
PRETTY_NAME="macOS ${VERSION_ID}"
return
fi
if [[ "$(id -u)" -ne 0 ]]; then
whiptail --title "🚫 权限不足" --msgbox "请使用 root 用户运行此脚本!\n执行方式: sudo bash $0" 10 60
exit 1
@@ -278,6 +649,9 @@ run_installation() {
# 添加arch包管理器
PKG_MANAGER="pacman"
;;
macos)
PKG_MANAGER="brew"
;;
esac
# 检查NapCat
@@ -294,7 +668,7 @@ run_installation() {
install_packages() {
missing_packages=()
# 检查 common 及当前系统专属依赖
for package in ${REQUIRED_PACKAGES["common"]} ${REQUIRED_PACKAGES["$ID"]}; do
for package in $(get_required_packages "$ID"); do
case "$PKG_MANAGER" in
apt)
dpkg -s "$package" &>/dev/null || missing_packages+=("$package")
@@ -305,6 +679,22 @@ run_installation() {
pacman)
pacman -Qi "$package" &>/dev/null || missing_packages+=("$package")
;;
brew)
case "$package" in
git)
command -v git &>/dev/null || missing_packages+=("$package")
;;
gnupg)
command -v gpg &>/dev/null || missing_packages+=("$package")
;;
python)
command -v python3 &>/dev/null || missing_packages+=("$package")
;;
*)
run_brew list --formula "$package" &>/dev/null || missing_packages+=("$package")
;;
esac
;;
esac
done
@@ -327,8 +717,12 @@ run_installation() {
}
}
# 仅在Arch系统上安装NapCat
[[ "$ID" != "arch" ]] && install_napcat
# 仅在 Linux 非 Arch 系统上安装 NapCatmacOS 仅支持远程 NapCat。
if [[ "$ID" == "macos" ]]; then
whiptail --title "⚠️ NapCat 安装提示" --msgbox "当前为 macOS暂不支持自动安装 NapCat。\n如需使用 NapCat请配置远程实例后再连接。 " 10 60
elif [[ "$ID" != "arch" ]]; then
install_napcat
fi
# Python版本检查
check_python() {
@@ -412,6 +806,9 @@ run_installation() {
pacman)
pacman -S --noconfirm "${missing_packages[@]}"
;;
brew)
run_brew update && run_brew install "${missing_packages[@]}"
;;
esac
fi
@@ -448,35 +845,42 @@ run_installation() {
echo -e "${GREEN}安装Python依赖...${RESET}"
select_pypi_index_url
pip install -r MaiBot/requirements.txt
cd MaiBot
pip install uv
uv pip install -i https://mirrors.aliyun.com/pypi/simple -r requirements.txt
uv pip install "${UV_PIP_INDEX_OPTION[@]}" -r requirements.txt
cd ..
echo -e "${GREEN}安装maim_message依赖...${RESET}"
cd maim_message
uv pip install -i https://mirrors.aliyun.com/pypi/simple -e .
uv pip install "${UV_PIP_INDEX_OPTION[@]}" -e .
cd ..
echo -e "${GREEN}部署MaiBot Napcat Adapter...${RESET}"
cd MaiBot-Napcat-Adapter
uv pip install -i https://mirrors.aliyun.com/pypi/simple -r requirements.txt
uv pip install "${UV_PIP_INDEX_OPTION[@]}" -r requirements.txt
cd ..
echo -e "${GREEN}同意协议...${RESET}"
# 首先计算当前EULA的MD5值
current_md5=$(md5sum "MaiBot/EULA.md" | awk '{print $1}')
current_md5=$(compute_md5 "MaiBot/EULA.md")
# 首先计算当前隐私条款文件的哈希值
current_md5_privacy=$(md5sum "MaiBot/PRIVACY.md" | awk '{print $1}')
current_md5_privacy=$(compute_md5 "MaiBot/PRIVACY.md")
echo -n $current_md5 > MaiBot/eula.confirmed
echo -n $current_md5_privacy > MaiBot/privacy.confirmed
echo -n "$current_md5" > MaiBot/eula.confirmed
echo -n "$current_md5_privacy" > MaiBot/privacy.confirmed
echo -e "${GREEN}创建系统服务...${RESET}"
cat > /etc/systemd/system/${SERVICE_NAME}.service <<EOF
if [[ "$IS_MACOS" == true ]]; then
echo -e "${GREEN}创建 launchctl 服务...${RESET}"
create_launchd_services
stop_service "${SERVICE_NAME}" >/dev/null 2>&1 || true
stop_service "${SERVICE_NAME_NBADAPTER}" >/dev/null 2>&1 || true
else
echo -e "${GREEN}创建系统服务...${RESET}"
cat > /etc/systemd/system/${SERVICE_NAME}.service <<EOF
[Unit]
Description=MaiCore
After=network.target ${SERVICE_NAME_NBADAPTER}.service
@@ -508,7 +912,7 @@ EOF
# WantedBy=multi-user.target
# EOF
cat > /etc/systemd/system/${SERVICE_NAME_NBADAPTER}.service <<EOF
cat > /etc/systemd/system/${SERVICE_NAME_NBADAPTER}.service <<EOF
[Unit]
Description=MaiBot Napcat Adapter
After=network.target mongod.service ${SERVICE_NAME}.service
@@ -524,22 +928,30 @@ RestartSec=10s
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl daemon-reload
fi
# 保存安装信息
echo "INSTALLER_VERSION=${INSTALLER_VERSION}" > /etc/maicore_install.conf
echo "INSTALL_DIR=${INSTALL_DIR}" >> /etc/maicore_install.conf
echo "BRANCH=${BRANCH}" >> /etc/maicore_install.conf
save_install_info
whiptail --title "🎉 安装完成" --msgbox "MaiCore安装完成\n已创建系统服务${SERVICE_NAME}${SERVICE_NAME_WEB}${SERVICE_NAME_NBADAPTER}\n\n使用以下命令管理服务\n启动服务systemctl start ${SERVICE_NAME}\n查看状态systemctl status ${SERVICE_NAME}" 14 60
if [[ "$IS_MACOS" == true ]]; then
whiptail --title "🎉 安装完成" --msgbox "MaiCore安装完成\n已创建 launchctl 服务:${LAUNCHD_LABEL_MAIN}${LAUNCHD_LABEL_NBADAPTER}\n\n首次加载launchctl bootstrap ${LAUNCHD_DOMAIN} ${LAUNCHD_PLIST_MAIN}\n重启服务launchctl kickstart -k ${LAUNCHD_DOMAIN}/${LAUNCHD_LABEL_MAIN}\n查看状态launchctl print ${LAUNCHD_DOMAIN}/${LAUNCHD_LABEL_MAIN}" 14 100
else
whiptail --title "🎉 安装完成" --msgbox "MaiCore安装完成\n已创建系统服务${SERVICE_NAME}${SERVICE_NAME_WEB}${SERVICE_NAME_NBADAPTER}\n\n使用以下命令管理服务\n启动服务systemctl start ${SERVICE_NAME}\n查看状态systemctl status ${SERVICE_NAME}" 14 60
fi
}
# ----------- 主执行流程 -----------
# 检查root权限
[[ $(id -u) -ne 0 ]] && {
# Linux 仍需 rootmacOS 使用用户级 launchctl无需 root)。
if [[ "$IS_MACOS" == true && $(id -u) -eq 0 ]]; then
echo -e "${RED}macOS 请勿使用 root/sudo 运行此脚本,请直接以当前登录用户执行。${RESET}"
exit 1
fi
if [[ "$IS_MACOS" != true && $(id -u) -ne 0 ]]; then
echo -e "${RED}请使用root用户运行此脚本${RESET}"
exit 1
}
fi
# 如果已安装显示菜单,并检查协议是否更新
if check_installed; then
@@ -550,7 +962,11 @@ else
run_installation
# 安装完成后询问是否启动
if whiptail --title "安装完成" --yesno "是否立即启动MaiCore服务" 10 60; then
systemctl start ${SERVICE_NAME}
whiptail --msgbox "✅ 服务已启动!\n使用 systemctl status ${SERVICE_NAME} 查看状态" 10 60
start_service "${SERVICE_NAME}"
if [[ "$IS_MACOS" == true ]]; then
whiptail --msgbox "✅ 服务已启动!\n使用 launchctl print ${LAUNCHD_DOMAIN}/${LAUNCHD_LABEL_MAIN} 查看状态" 10 80
else
whiptail --msgbox "✅ 服务已启动!\n使用 systemctl status ${SERVICE_NAME} 查看状态" 10 60
fi
fi
fi

View File

@@ -1,16 +1,23 @@
from typing import TYPE_CHECKING, Optional
import json
import re
import time
from typing import TYPE_CHECKING, Any, Optional
from src.common.logger import get_logger
from json_repair import repair_json
from src.chat.utils.chat_message_builder import (
build_readable_messages,
get_raw_msg_by_timestamp_with_chat,
)
from src.common.database.database import get_db_session
from src.llm_models.utils_model import LLMRequest
from src.common.logger import get_logger
from src.config.config import model_config
from src.llm_models.utils_model import LLMRequest
from src.prompt.prompt_manager import prompt_manager
if TYPE_CHECKING:
from src.common.data_models.expression_data_model import MaiExpression
# TODO: 这个LLMRequest实例被更优雅的方式替换掉
judge_model = LLMRequest(model_set=model_config.model_task_config.tool_use, request_type="reflect.tracker")
logger = get_logger("reflect_tracker")
@@ -41,6 +48,7 @@ class ReflectTracker:
self.tracking = False
self.last_check_msg_count = 0
# TODO test it
async def trigger_tracker(self) -> bool:
"""
触发追踪检查
@@ -49,12 +57,131 @@ class ReflectTracker:
return (bool): 如果返回True表示追踪完成Tracker运行结束运行状态置为`False`如果返回False表示继续追踪
"""
# 对于没有正在追踪的表达直接返回False
if not self.tracking:
if not self.tracking or not self.expression:
return False
# Type narrowing: expression is guaranteed non-None when tracking
assert self.expression is not None
expr = self.expression
# 检查是否超时(无论是消息数量还是时间)
if time.time() - self.tracking_start_time > self.max_duration:
self._reset_tracker()
return True
# TODO: 完成追踪检查逻辑
# 获取消息列表
msg_list = get_raw_msg_by_timestamp_with_chat(
chat_id=self.session_id,
timestamp_start=self.tracking_start_time,
timestamp_end=time.time(),
)
current_msg_count = len(msg_list)
# 检查消息数量是否超限
if current_msg_count > self.max_msg_count:
logger.info(f"ReflectTracker for expr {expr.item_id} timed out (message count).")
self._reset_tracker()
return True
# 如果没有新消息,跳过本次检查
if current_msg_count <= self.last_check_msg_count:
return False
self.last_check_msg_count = current_msg_count
# 构建上下文
context_block = build_readable_messages(
msg_list,
replace_bot_name=True,
timestamp_mode="relative",
read_mark=0.0,
show_actions=False,
)
# LLM 判断
try:
prompt_template = prompt_manager.get_prompt("reflect_judge")
prompt_template.add_context("situation", str(expr.situation))
prompt_template.add_context("style", str(expr.style))
prompt_template.add_context("context_block", context_block)
prompt = await prompt_manager.render_prompt(prompt_template)
logger.info(f"ReflectTracker LLM Prompt: {prompt}")
response, _ = await judge_model.generate_response_async(prompt, temperature=0.1)
logger.info(f"ReflectTracker LLM Response: {response}")
# 解析 JSON 响应
json_pattern = r"```json\s*(.*?)\s*```"
matches = re.findall(json_pattern, response, re.DOTALL)
if not matches:
matches = [response]
json_obj = json.loads(repair_json(matches[0]))
judgment = json_obj.get("judgment")
if judgment == "Approve":
self._update_expression(checked=True, rejected=False, modified_by="ai")
logger.info(f"Expression {expr.item_id} approved by operator.")
self._reset_tracker()
return True
elif judgment == "Reject":
corrected_situation = json_obj.get("corrected_situation")
corrected_style = json_obj.get("corrected_style")
has_update = bool(corrected_situation or corrected_style)
update_kwargs: dict[str, Any] = {"checked": True, "modified_by": "ai"}
if corrected_situation:
update_kwargs["situation"] = corrected_situation
if corrected_style:
update_kwargs["style"] = corrected_style
if not has_update:
update_kwargs["rejected"] = True
else:
update_kwargs["rejected"] = False
self._update_expression(**update_kwargs)
if has_update:
logger.info(
f"Expression {expr.item_id} rejected and updated. "
f"New situation: {corrected_situation}, New style: {corrected_style}"
)
else:
logger.info(
f"Expression {expr.item_id} rejected but no correction provided, marked as rejected."
)
self._reset_tracker()
return True
elif judgment == "Ignore":
logger.info(f"ReflectTracker for expr {expr.item_id} judged as Ignore.")
return False
except Exception as e:
logger.error(f"Error in ReflectTracker check: {e}")
return False
return False
def _update_expression(self, **kwargs: Any) -> None:
"""更新表达并持久化到数据库"""
if not self.expression:
return
# 更新内存中的表达对象
for key, value in kwargs.items():
if hasattr(self.expression, key):
setattr(self.expression, key, value)
# 持久化到数据库
try:
with get_db_session() as session:
db_expr = self.expression.to_db_instance()
session.merge(db_expr)
session.commit()
except Exception as e:
logger.error(f"Failed to persist expression update: {e}")