feat: update frontend

This commit is contained in:
xxnuo
2025-11-17 02:52:51 +08:00
parent 01a6b2c852
commit 9bdcf930ed
12 changed files with 513 additions and 104 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: download download-core download-records generate-docs
.PHONY: download download-core download-records generate-docs build-ui
# Detect OS and architecture
GOOS ?= $(shell go env GOOS)
@@ -39,11 +39,16 @@ generate-docs:
@GOOS= GOARCH= go run github.com/swaggo/swag/cmd/swag@latest init -g ./cmd/mtranserver/main.go -o ./internal/docs
@echo "Docs generated successfully"
build-ui:
@echo "Building UI..."
@cd ui && pnpm install && pnpm build
@echo "UI built successfully"
# Get version from git tag
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev")
LDFLAGS := -X github.com/xxnuo/MTranServer/internal/version.Version=$(VERSION)
build: generate-docs
build: generate-docs build-ui
@echo "Building version $(VERSION)..."
@go build -ldflags "$(LDFLAGS)" -o ./dist/mtranserver-$(GOOS)-$(GOARCH)$(SUFFIX) ./cmd/mtranserver
@echo "Built successfully"

View File

@@ -1,6 +1,8 @@
package routes
import (
"net/http"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
@@ -8,6 +10,7 @@ import (
"github.com/xxnuo/MTranServer/internal/docs"
"github.com/xxnuo/MTranServer/internal/handlers"
"github.com/xxnuo/MTranServer/internal/middleware"
"github.com/xxnuo/MTranServer/ui"
)
// Setup 设置所有路由
@@ -43,4 +46,14 @@ func Setup(r *gin.Engine, apiToken string) {
r.POST("/google/language/translate/v2", handlers.HandleGoogleCompatTranslate(apiToken))
r.GET("/google/translate_a/single", handlers.HandleGoogleTranslateSingle(apiToken))
r.POST("/hcfy", handlers.HandleHcfyTranslate(apiToken))
// 前端静态文件服务
distFS, err := ui.GetDistFS()
if err == nil {
r.StaticFS("/ui", http.FS(distFS))
// 根路径重定向到 /ui
r.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/ui/")
})
}
}

View File

@@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -2,9 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title>
<meta name="darkreader-lock" />
<title>MTranServer</title>
</head>
<body>
<div id="root"></div>

View File

@@ -43,6 +43,8 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^25.6.2",
"i18next-browser-languagedetector": "^8.2.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.553.0",
"next-themes": "^0.4.6",
@@ -50,6 +52,7 @@
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
"react-i18next": "^16.3.3",
"react-resizable-panels": "^3.0.6",
"recharts": "2.15.4",
"sonner": "^2.0.7",

View File

@@ -1,34 +1,247 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { toast } from 'sonner'
import { ArrowRightLeft } from 'lucide-react'
import { SettingsMenu } from '@/components/SettingsMenu'
interface TranslateRequest {
from: string
to: string
text: string
html: boolean
}
interface TranslateResponse {
result: string
}
function App() {
const [count, setCount] = useState(0)
const { t } = useTranslation()
const [languages, setLanguages] = useState<string[]>([])
const [sourceLang, setSourceLang] = useState('auto')
const [targetLang, setTargetLang] = useState('zh-Hans')
const [sourceText, setSourceText] = useState('')
const [translatedText, setTranslatedText] = useState('')
const [loading, setLoading] = useState(false)
const [loadingLanguages, setLoadingLanguages] = useState(true)
const [autoTranslate, setAutoTranslate] = useState(false)
const translateTimeoutRef = useRef<number | null>(null)
const fetchLanguages = useCallback(async () => {
try {
const response = await fetch('/languages')
if (!response.ok) throw new Error('Failed to fetch languages')
const data = await response.json()
setLanguages(['auto', ...(data.languages || [])])
} catch (error) {
console.error('Error fetching languages:', error)
toast.error(t('failedToLoadLanguages'))
} finally {
setLoadingLanguages(false)
}
}, [t])
useEffect(() => {
fetchLanguages()
}, [fetchLanguages])
const handleTranslate = useCallback(async (text?: string, showToast = true) => {
const textToTranslate = text ?? sourceText
if (!textToTranslate.trim()) {
if (showToast) {
toast.error(t('enterTextError'))
}
return
}
setLoading(true)
setTranslatedText('')
try {
const request: TranslateRequest = {
from: sourceLang,
to: targetLang,
text: textToTranslate,
html: false
}
const response = await fetch('/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(request)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || t('translationFailed'))
}
const data: TranslateResponse = await response.json()
setTranslatedText(data.result)
if (showToast) {
toast.success(t('translationCompleted'))
}
} catch (error) {
console.error('Translation error:', error)
if (showToast) {
toast.error(error instanceof Error ? error.message : t('translationFailed'))
}
} finally {
setLoading(false)
}
}, [sourceLang, targetLang, sourceText, t])
const handleSourceTextChange = (text: string) => {
setSourceText(text)
if (autoTranslate && text.trim()) {
if (translateTimeoutRef.current) {
clearTimeout(translateTimeoutRef.current)
}
translateTimeoutRef.current = window.setTimeout(() => {
handleTranslate(text, false)
}, 800)
}
}
useEffect(() => {
return () => {
if (translateTimeoutRef.current) {
window.clearTimeout(translateTimeoutRef.current)
}
}
}, [])
const handleSwapLanguages = () => {
setSourceLang(targetLang)
setTargetLang(sourceLang)
setSourceText(translatedText)
setTranslatedText(sourceText)
}
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<div className="min-h-screen bg-background p-4 md:p-8">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-foreground">
{t('title')}
</h1>
<SettingsMenu />
</div>
<Card className="shadow-lg">
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2 justify-between">
<div className="flex items-center gap-2 flex-1">
<Select
value={sourceLang}
onValueChange={setSourceLang}
disabled={loadingLanguages}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang === 'auto' ? t('autoDetect') : lang}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
onClick={handleSwapLanguages}
disabled={loadingLanguages || sourceLang === 'auto'}
className="h-9 w-9"
>
<ArrowRightLeft className="h-4 w-4" />
</Button>
<Select
value={targetLang}
onValueChange={setTargetLang}
disabled={loadingLanguages}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.filter(lang => lang !== 'auto').map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch
id="auto-translate"
checked={autoTranslate}
onCheckedChange={setAutoTranslate}
/>
<Label htmlFor="auto-translate" className="text-xs cursor-pointer whitespace-nowrap">
{t('autoTranslate')}
</Label>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Textarea
id="source-text"
placeholder={t('enterText')}
value={sourceText}
onChange={(e) => handleSourceTextChange(e.target.value)}
className="min-h-[300px] resize-none text-base"
disabled={loading}
/>
<Textarea
id="translated-text"
placeholder={t('translationWillAppear')}
value={translatedText}
readOnly
className="min-h-[300px] resize-none text-base bg-muted"
/>
</div>
{!autoTranslate && (
<div className="flex justify-center">
<Button
onClick={() => handleTranslate()}
disabled={loading || loadingLanguages || !sourceText.trim()}
className="min-w-[200px]"
size="lg"
>
{loading ? (
<>
<Spinner className="mr-2 h-4 w-4" />
{t('translating')}
</>
) : (
t('translate')
)}
</Button>
</div>
)}
</CardContent>
</Card>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
import { useTheme } from '@/contexts/ThemeContext'
import { Button } from '@/components/ui/button'
import { Sun, Moon, Globe } from 'lucide-react'
export function SettingsMenu() {
const { i18n } = useTranslation()
const { actualTheme, setTheme } = useTheme()
const languages = [
{ code: 'en', name: 'EN' },
{ code: 'zh', name: '中' },
{ code: 'ja', name: '日' }
]
const toggleTheme = () => {
setTheme(actualTheme === 'dark' ? 'light' : 'dark')
}
const cycleLanguage = () => {
const currentIndex = languages.findIndex(lang => lang.code === i18n.language)
const nextIndex = (currentIndex + 1) % languages.length
i18n.changeLanguage(languages[nextIndex].code)
}
const currentLang = languages.find(lang => lang.code === i18n.language) || languages[0]
return (
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={cycleLanguage}
className="h-9 w-9"
title="Switch Language"
>
<Globe className="h-4 w-4" />
<span className="sr-only">{currentLang.name}</span>
</Button>
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-9 w-9"
title="Toggle Theme"
>
{actualTheme === 'dark' ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { createContext, useContext, useEffect, useState } from 'react'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
actualTheme: 'light' | 'dark'
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem('theme')
return (stored as Theme) || 'system'
})
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('light')
useEffect(() => {
const root = window.document.documentElement
const getSystemTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const applyTheme = (t: Theme) => {
const themeToApply = t === 'system' ? getSystemTheme() : t
setActualTheme(themeToApply)
root.classList.remove('light', 'dark')
root.classList.add(themeToApply)
}
applyTheme(theme)
localStorage.setItem('theme', theme)
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => applyTheme('system')
mediaQuery.addEventListener('change', handler)
return () => mediaQuery.removeEventListener('change', handler)
}
}, [theme])
return (
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}

105
ui/src/i18n/index.ts Normal file
View File

@@ -0,0 +1,105 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
const resources = {
en: {
translation: {
title: 'MTranServer',
subtitle: 'Fast and reliable translation service',
translate: 'Translate',
translating: 'Translating...',
sourceLanguage: 'Source Language',
targetLanguage: 'Target Language',
sourceText: 'Source Text',
translation: 'Translation',
enterText: 'Enter text to translate...',
translationWillAppear: 'Translation will appear here...',
selectLanguage: 'Select language',
autoDetect: 'Auto Detect',
translationCompleted: 'Translation completed',
translationFailed: 'Translation failed',
enterTextError: 'Please enter text to translate',
failedToLoadLanguages: 'Failed to load languages',
theme: 'Theme',
language: 'Language',
light: 'Light',
dark: 'Dark',
system: 'System',
autoTranslate: 'Auto Translate',
autoTranslateDesc: 'Automatically translate as you type'
}
},
zh: {
translation: {
title: 'MTranServer',
subtitle: '快速可靠的翻译服务',
translate: '翻译',
translating: '翻译中...',
sourceLanguage: '源语言',
targetLanguage: '目标语言',
sourceText: '源文本',
translation: '翻译结果',
enterText: '输入要翻译的文本...',
translationWillAppear: '翻译结果将显示在这里...',
selectLanguage: '选择语言',
autoDetect: '自动检测',
translationCompleted: '翻译完成',
translationFailed: '翻译失败',
enterTextError: '请输入要翻译的文本',
failedToLoadLanguages: '加载语言列表失败',
theme: '主题',
language: '语言',
light: '浅色',
dark: '深色',
system: '跟随系统',
autoTranslate: '自动翻译',
autoTranslateDesc: '输入时自动翻译'
}
},
ja: {
translation: {
title: 'MTranServer',
subtitle: '高速で信頼性の高い翻訳サービス',
translate: '翻訳',
translating: '翻訳中...',
sourceLanguage: 'ソース言語',
targetLanguage: 'ターゲット言語',
sourceText: 'ソーステキスト',
translation: '翻訳',
enterText: '翻訳するテキストを入力...',
translationWillAppear: '翻訳がここに表示されます...',
selectLanguage: '言語を選択',
autoDetect: '自動検出',
translationCompleted: '翻訳完了',
translationFailed: '翻訳失敗',
enterTextError: '翻訳するテキストを入力してください',
failedToLoadLanguages: '言語の読み込みに失敗しました',
theme: 'テーマ',
language: '言語',
light: 'ライト',
dark: 'ダーク',
system: 'システム',
autoTranslate: '自動翻訳',
autoTranslateDesc: '入力時に自動翻訳'
}
}
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage']
}
})
export default i18n

View File

@@ -1,10 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './i18n'
import App from './App.tsx'
import { Toaster } from '@/components/ui/sonner'
import { ThemeProvider } from './contexts/ThemeContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ThemeProvider>
<App />
<Toaster />
</ThemeProvider>
</StrictMode>,
)

14
ui/ui.go Normal file
View File

@@ -0,0 +1,14 @@
package ui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
// GetDistFS returns the embedded dist filesystem
func GetDistFS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}

View File

@@ -5,10 +5,15 @@ import tailwindcss from "@tailwindcss/vite"
// https://vite.dev/config/
export default defineConfig({
base: '/ui/',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})