pref: merge web api to one file

This commit is contained in:
Lykin
2026-02-27 15:56:07 +08:00
parent 29537f0ec4
commit bf7bdb132e
14 changed files with 354 additions and 148 deletions
-4
View File
@@ -47,10 +47,6 @@ func registerPreferencesRoutes(rg *gin.RouterGroup) {
c.JSON(http.StatusOK, services.Preferences().GetBuildInDecoder()) c.JSON(http.StatusOK, services.Preferences().GetBuildInDecoder())
}) })
g.GET("/version", func(c *gin.Context) {
c.JSON(http.StatusOK, services.Preferences().GetAppVersion())
})
g.GET("/check-update", func(c *gin.Context) { g.GET("/check-update", func(c *gin.Context) {
c.JSON(http.StatusOK, services.Preferences().CheckForUpdate()) c.JSON(http.StatusOK, services.Preferences().CheckForUpdate())
}) })
+3 -22
View File
@@ -3,8 +3,6 @@
package api package api
import ( import (
"embed"
"io/fs"
"log" "log"
"net/http" "net/http"
"strings" "strings"
@@ -17,7 +15,7 @@ import (
const maxRequestBodySize = 10 << 20 // 10MB const maxRequestBodySize = 10 << 20 // 10MB
// SetupRouter creates the Gin router with all API routes and static file serving // SetupRouter creates the Gin router with all API routes and static file serving
func SetupRouter(assets embed.FS) *gin.Engine { func SetupRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.Default() r := gin.Default()
@@ -57,9 +55,8 @@ func SetupRouter(assets embed.FS) *gin.Engine {
// Public routes (no auth required) // Public routes (no auth required)
registerAuthRoutes(r) registerAuthRoutes(r)
r.GET("/api/version", func(c *gin.Context) { r.GET("/api/preferences/version", func(c *gin.Context) {
resp := services.Preferences().GetAppVersion() c.JSON(http.StatusOK, services.Preferences().GetAppVersion())
c.JSON(200, resp)
}) })
// WebSocket endpoint (auth checked via cookie + origin) // WebSocket endpoint (auth checked via cookie + origin)
@@ -76,22 +73,6 @@ func SetupRouter(assets embed.FS) *gin.Engine {
registerPreferencesRoutes(api) registerPreferencesRoutes(api)
registerSystemRoutes(api) registerSystemRoutes(api)
// Serve frontend static files from embedded assets
distFS, err := fs.Sub(assets, "frontend/dist")
if err == nil {
fileServer := http.FileServer(http.FS(distFS))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
f, ferr := http.FS(distFS).Open(path)
if ferr == nil {
f.Close()
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
c.FileFromFS("/", http.FS(distFS))
})
}
return r return r
} }
+18 -16
View File
@@ -23,17 +23,15 @@ import ImportKeyDialog from '@/components/dialogs/ImportKeyDialog.vue'
import { Info } from 'wailsjs/go/services/systemService.js' import { Info } from 'wailsjs/go/services/systemService.js'
import DecoderDialog from '@/components/dialogs/DecoderDialog.vue' import DecoderDialog from '@/components/dialogs/DecoderDialog.vue'
import { loadModule, trackEvent } from '@/utils/analytics.js' import { loadModule, trackEvent } from '@/utils/analytics.js'
import { isWeb } from '@/utils/platform.js'
const prefStore = usePreferencesStore() const prefStore = usePreferencesStore()
const connectionStore = useConnectionStore() const connectionStore = useConnectionStore()
const i18n = useI18n() const i18n = useI18n()
const initializing = ref(true) const initializing = ref(true)
// Detect if running in web mode (VITE_WEB=true at build time)
const isWebMode = import.meta.env.VITE_WEB === 'true'
// Web-only: lazy load LoginPage to avoid importing websocket.js in desktop mode // Web-only: lazy load LoginPage to avoid importing websocket.js in desktop mode
const LoginPage = isWebMode ? defineAsyncComponent(() => import('@/components/LoginPage.vue')) : null const LoginPage = isWeb() ? defineAsyncComponent(() => import('@/components/LoginPage.vue')) : null
// Viewport management for mobile // Viewport management for mobile
const setViewport = (mode) => { const setViewport = (mode) => {
@@ -57,7 +55,7 @@ const setViewport = (mode) => {
} }
// Auth state (web mode only) // Auth state (web mode only)
const authChecking = ref(isWebMode) // desktop: false (skip), web: true (checking) const authChecking = ref(isWeb()) // desktop: false (skip), web: true (checking)
const authenticated = ref(false) const authenticated = ref(false)
const authEnabled = ref(false) const authEnabled = ref(false)
@@ -82,33 +80,37 @@ const onLogin = async () => {
// Reconnect WebSocket with auth cookie (dynamic import to avoid desktop issues) // Reconnect WebSocket with auth cookie (dynamic import to avoid desktop issues)
try { try {
const runtime = await import('wailsjs/runtime/runtime.js') const runtime = await import('wailsjs/runtime/runtime.js')
if (runtime.ReconnectWebSocket) runtime.ReconnectWebSocket() if (runtime.ReconnectWebSocket) {
runtime.ReconnectWebSocket()
}
} catch {} } catch {}
// Capture login page choices before loadPreferences overwrites them // Capture login page choices before loadPreferences overwrites them
const loginTheme = localStorage.getItem('rdm_login_theme') const loginTheme = localStorage.getItem('rdm_login_theme')
const loginLang = localStorage.getItem('rdm_login_lang') const loginLang = localStorage.getItem('rdm_login_lang')
await initApp() await initApp()
// Sync login page choices to preferences // Sync login page choices to preferences
let needSave = false let prefUpdated = false
if (loginTheme && ['auto', 'light', 'dark'].includes(loginTheme)) { if (loginTheme && ['auto', 'light', 'dark'].includes(loginTheme)) {
prefStore.general.theme = loginTheme prefStore.general.theme = loginTheme
needSave = true prefUpdated = true
} }
if (loginLang) { if (loginLang) {
const validLangs = ['auto', 'zh', 'tw', 'en', 'ja', 'ko', 'es', 'fr', 'ru', 'pt', 'tr'] const validLangs = ['auto', 'zh', 'tw', 'en', 'ja', 'ko', 'es', 'fr', 'ru', 'pt', 'tr']
if (validLangs.includes(loginLang)) { if (validLangs.includes(loginLang)) {
prefStore.general.language = loginLang prefStore.general.language = loginLang
i18n.locale.value = prefStore.currentLanguage i18n.locale.value = prefStore.currentLanguage
needSave = true prefUpdated = true
} }
} }
if (needSave) prefStore.savePreferences() if (prefUpdated) {
prefStore.savePreferences()
}
} }
const initApp = async () => { const initApp = async () => {
try { try {
initializing.value = true initializing.value = true
if (isWebMode) { if (isWeb()) {
const prefResult = await prefStore.loadPreferences() const prefResult = await prefStore.loadPreferences()
// If loadPreferences failed (e.g. 401 from expired session), // If loadPreferences failed (e.g. 401 from expired session),
// rdm:unauthorized event already fired → silently abort init // rdm:unauthorized event already fired → silently abort init
@@ -118,7 +120,7 @@ const initApp = async () => {
await prefStore.loadFontList() await prefStore.loadFontList()
await prefStore.loadBuildInDecoder() await prefStore.loadBuildInDecoder()
await connectionStore.initConnections() await connectionStore.initConnections()
if (prefStore.autoCheckUpdate) { if (!isWeb() && prefStore.autoCheckUpdate) {
prefStore.checkForUpdate() prefStore.checkForUpdate()
} }
const env = await Environment() const env = await Environment()
@@ -196,7 +198,7 @@ const onOrientationChange = () => {
} }
onMounted(async () => { onMounted(async () => {
if (isWebMode) { if (isWeb()) {
// Apply saved login theme before auth check to prevent flash // Apply saved login theme before auth check to prevent flash
const savedTheme = localStorage.getItem('rdm_login_theme') const savedTheme = localStorage.getItem('rdm_login_theme')
if (savedTheme && ['auto', 'light', 'dark'].includes(savedTheme)) { if (savedTheme && ['auto', 'light', 'dark'].includes(savedTheme)) {
@@ -225,7 +227,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (isWebMode) { if (isWeb()) {
window.removeEventListener('rdm:unauthorized', onUnauthorized) window.removeEventListener('rdm:unauthorized', onUnauthorized)
window.removeEventListener('orientationchange', onOrientationChange) window.removeEventListener('orientationchange', onOrientationChange)
window.removeEventListener('resize', onOrientationChange) window.removeEventListener('resize', onOrientationChange)
@@ -253,10 +255,10 @@ watch(
:theme-overrides="prefStore.isDark ? darkThemeOverrides : themeOverrides" :theme-overrides="prefStore.isDark ? darkThemeOverrides : themeOverrides"
class="fill-height"> class="fill-height">
<!-- Web mode: auth gate --> <!-- Web mode: auth gate -->
<template v-if="isWebMode && authChecking"> <template v-if="isWeb() && authChecking">
<div style="width: 100vw; height: 100vh"></div> <div style="width: 100vw; height: 100vh"></div>
</template> </template>
<template v-else-if="isWebMode && authEnabled && !authenticated"> <template v-else-if="isWeb() && authEnabled && !authenticated">
<component :is="LoginPage" @login="onLogin" /> <component :is="LoginPage" @login="onLogin" />
</template> </template>
<template v-else> <template v-else>
+231 -78
View File
@@ -4,6 +4,10 @@ import { computed, h, onMounted, ref } from 'vue'
import { NIcon, useThemeVars } from 'naive-ui' import { NIcon, useThemeVars } from 'naive-ui'
import iconUrl from '@/assets/images/icon.png' import iconUrl from '@/assets/images/icon.png'
import usePreferencesStore from '@/stores/preferences.js' import usePreferencesStore from '@/stores/preferences.js'
import LangIcon from '@/components/icons/Lang.vue'
import { Login } from '@/utils/api.js'
import { lang } from '@/langs/index.js'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const prefStore = usePreferencesStore() const prefStore = usePreferencesStore()
@@ -17,21 +21,18 @@ onMounted(() => {
prefStore.general.theme = themeMode.value prefStore.general.theme = themeMode.value
}) })
const themeLabels = { const getThemeLabels = (langKey) => {
zh: { auto: '自动', light: '浅色', dark: '暗黑' }, const l = lang[langKey] || lang.en
tw: { auto: '自動', light: '淺色', dark: '暗黑' }, const g = l.preferences?.general || {}
ja: { auto: '自動', light: 'ライト', dark: 'ダーク' }, return {
ko: { auto: '자동', light: '라이트', dark: '다크' }, auto: g.theme_auto || 'Auto',
es: { auto: 'Auto', light: 'Claro', dark: 'Oscuro' }, light: g.theme_light || 'Light',
fr: { auto: 'Auto', light: 'Clair', dark: 'Sombre' }, dark: g.theme_dark || 'Dark',
ru: { auto: 'Авто', light: 'Светлая', dark: 'Тёмная' }, }
pt: { auto: 'Auto', light: 'Claro', dark: 'Escuro' },
tr: { auto: 'Otomatik', light: 'Açık', dark: 'Koyu' },
en: { auto: 'Auto', light: 'Light', dark: 'Dark' },
} }
const themeOptions = computed(() => { const themeOptions = computed(() => {
const labels = themeLabels[currentLang.value] || themeLabels.en const labels = getThemeLabels(currentLang.value)
return [ return [
{ label: labels.light, key: 'light', icon: renderIcon('sun') }, { label: labels.light, key: 'light', icon: renderIcon('sun') },
{ label: labels.dark, key: 'dark', icon: renderIcon('moon') }, { label: labels.dark, key: 'dark', icon: renderIcon('moon') },
@@ -40,7 +41,7 @@ const themeOptions = computed(() => {
}) })
const currentThemeLabel = computed(() => { const currentThemeLabel = computed(() => {
const labels = themeLabels[currentLang.value] || themeLabels.en const labels = getThemeLabels(currentLang.value)
return labels[themeMode.value] return labels[themeMode.value]
}) })
@@ -53,24 +54,22 @@ const onThemeSelect = (key) => {
// --- Language --- // --- Language ---
const LANG_KEY = 'rdm_login_lang' const LANG_KEY = 'rdm_login_lang'
const langNames = { const langNames = Object.fromEntries(Object.entries(lang).map(([k, v]) => [k, v.name]))
zh: '简体中文', tw: '繁體中文', en: 'English', ja: '日本語', ko: '한국어', const autoLabel = Object.fromEntries(
es: 'Español', fr: 'Français', ru: 'Русский', pt: 'Português', tr: 'Türkçe', Object.entries(lang).map(([k, v]) => [k, v.preferences?.general?.theme_auto || 'Auto']),
} )
const autoLabel = {
zh: '自动', tw: '自動', ja: '自動', ko: '자동', es: 'Auto',
fr: 'Auto', ru: 'Авто', pt: 'Auto', tr: 'Otomatik', en: 'Auto',
}
const detectSystemLang = () => { const detectSystemLang = () => {
const sysLang = (navigator.language || '').toLowerCase() const sysLang = (navigator.language || '').toLowerCase()
if (sysLang.startsWith('zh-tw') || sysLang.startsWith('zh-hant')) return 'tw' if (sysLang.startsWith('zh-tw') || sysLang.startsWith('zh-hant')) {
return 'tw'
}
const prefix = sysLang.split('-')[0] const prefix = sysLang.split('-')[0]
return langNames[prefix] ? prefix : 'en' return langNames[prefix] ? prefix : 'en'
} }
const langSetting = ref(localStorage.getItem(LANG_KEY) || 'auto') const langSetting = ref(localStorage.getItem(LANG_KEY) || 'auto')
const currentLang = computed(() => langSetting.value === 'auto' ? detectSystemLang() : langSetting.value) const currentLang = computed(() => (langSetting.value === 'auto' ? detectSystemLang() : langSetting.value))
const langOptions = computed(() => [ const langOptions = computed(() => [
{ label: autoLabel[currentLang.value] || 'Auto', key: 'auto' }, { label: autoLabel[currentLang.value] || 'Auto', key: 'auto' },
@@ -91,31 +90,88 @@ const onLangSelect = (key) => {
} }
const SunSvg = { const SunSvg = {
render: () => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [ render: () =>
h('circle', { cx: '12', cy: '12', r: '5' }), h(
h('path', { d: 'M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42' }), 'svg',
]), {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
width: '1em',
height: '1em',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
[
h('circle', { cx: '12', cy: '12', r: '5' }),
h('path', {
d: 'M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42',
}),
],
),
} }
const MoonSvg = { const MoonSvg = {
render: () => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [ render: () =>
h('path', { d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' }), h(
]), 'svg',
{
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
width: '1em',
height: '1em',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
[h('path', { d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' })],
),
} }
const AutoSvg = { const AutoSvg = {
render: () => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [ render: () =>
h('circle', { cx: '12', cy: '12', r: '10' }), h(
h('path', { d: 'M12 2a10 10 0 0 1 0 20V2' }), 'svg',
]), {
xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24',
width: '1em',
height: '1em',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
[h('circle', { cx: '12', cy: '12', r: '10' }), h('path', { d: 'M12 2a10 10 0 0 1 0 20V2' })],
),
} }
const LangSvg = { const LangSvg = {
render: () => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', width: '1em', height: '1em', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, [ render: () =>
h('path', { d: 'M5 8l6 6' }), h(
h('path', { d: 'M4 14l6-6 2-3' }), 'svg',
h('path', { d: 'M2 5h12' }), {
h('path', { d: 'M7 2h1' }), xmlns: 'http://www.w3.org/2000/svg',
h('path', { d: 'M22 22l-5-10-5 10' }), viewBox: '0 0 24 24',
h('path', { d: 'M14 18h6' }), width: '1em',
]), height: '1em',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
[
h('path', { d: 'M5 8l6 6' }),
h('path', { d: 'M4 14l6-6 2-3' }),
h('path', { d: 'M2 5h12' }),
h('path', { d: 'M7 2h1' }),
h('path', { d: 'M22 22l-5-10-5 10' }),
h('path', { d: 'M14 18h6' }),
],
),
} }
const iconMap = { sun: SunSvg, moon: MoonSvg, auto: AutoSvg, lang: LangSvg } const iconMap = { sun: SunSvg, moon: MoonSvg, auto: AutoSvg, lang: LangSvg }
@@ -123,16 +179,116 @@ const renderIcon = (name) => () => h(NIcon, null, { default: () => h(iconMap[nam
// --- i18n texts --- // --- i18n texts ---
const langTexts = { const langTexts = {
zh: { title: '登录', username: '用户名', password: '密码', usernamePh: '请输入用户名', passwordPh: '请输入密码', submit: '登 录', tooMany: '尝试次数过多,请稍后再试', failed: '用户名或密码错误', network: '网络错误' }, zh: {
tw: { title: '登入', username: '使用者名稱', password: '密碼', usernamePh: '請輸入使用者名稱', passwordPh: '請輸入密碼', submit: '登 入', tooMany: '嘗試次數過多,請稍後再試', failed: '使用者名稱或密碼錯誤', network: '網路錯誤' }, title: '登录',
ja: { title: 'ログイン', username: 'ユーザー名', password: 'パスワード', usernamePh: 'ユーザー名を入力', passwordPh: 'パスワードを入力', submit: 'ログイン', tooMany: '試行回数が多すぎます', failed: 'ユーザー名またはパスワードが正しくありません', network: 'ネットワークエラー' }, username: '用户名',
ko: { title: '로그인', username: '사용자 이름', password: '비밀번호', usernamePh: '사용자 이름 입력', passwordPh: '비밀번호 입력', submit: '로그인', tooMany: '시도 횟수 초과, 잠시 후 다시 시도하세요', failed: '사용자 이름 또는 비밀번호가 올바르지 않습니다', network: '네트워크 오류' }, password: '密码',
es: { title: 'Iniciar sesión', username: 'Usuario', password: 'Contraseña', usernamePh: 'Ingrese usuario', passwordPh: 'Ingrese contraseña', submit: 'Entrar', tooMany: 'Demasiados intentos, intente más tarde', failed: 'Credenciales inválidas', network: 'Error de red' }, usernamePh: '请输入用户名',
fr: { title: 'Connexion', username: "Nom d'utilisateur", password: 'Mot de passe', usernamePh: "Entrez le nom d'utilisateur", passwordPh: 'Entrez le mot de passe', submit: 'Se connecter', tooMany: 'Trop de tentatives, réessayez plus tard', failed: 'Identifiants invalides', network: 'Erreur réseau' }, passwordPh: '请输入密码',
ru: { title: 'Вход', username: 'Имя пользователя', password: 'Пароль', usernamePh: 'Введите имя пользователя', passwordPh: 'Введите пароль', submit: 'Войти', tooMany: 'Слишком много попыток, попробуйте позже', failed: 'Неверные учётные данные', network: 'Ошибка сети' }, submit: '登 录',
pt: { title: 'Entrar', username: 'Usuário', password: 'Senha', usernamePh: 'Digite o usuário', passwordPh: 'Digite a senha', submit: 'Entrar', tooMany: 'Muitas tentativas, tente novamente mais tarde', failed: 'Credenciais inválidas', network: 'Erro de rede' }, tooMany: '尝试次数过多,请稍后再试',
tr: { title: 'Giriş', username: 'Kullanıcı adı', password: 'Şifre', usernamePh: 'Kullanıcı adını girin', passwordPh: 'Şifreyi girin', submit: 'Giriş Yap', tooMany: 'Çok fazla deneme, lütfen daha sonra tekrar deneyin', failed: 'Geçersiz kimlik bilgileri', network: 'Ağ hatası' }, failed: '用户名或密码错误',
en: { title: 'Sign In', username: 'Username', password: 'Password', usernamePh: 'Enter username', passwordPh: 'Enter password', submit: 'Sign In', tooMany: 'Too many attempts, please try later', failed: 'Invalid credentials', network: 'Network error' }, network: '网络错误',
},
tw: {
title: '登入',
username: '使用者名稱',
password: '密碼',
usernamePh: '請輸入使用者名稱',
passwordPh: '請輸入密碼',
submit: '登 入',
tooMany: '嘗試次數過多,請稍後再試',
failed: '使用者名稱或密碼錯誤',
network: '網路錯誤',
},
ja: {
title: 'ログイン',
username: 'ユーザー名',
password: 'パスワード',
usernamePh: 'ユーザー名を入力',
passwordPh: 'パスワードを入力',
submit: 'ログイン',
tooMany: '試行回数が多すぎます',
failed: 'ユーザー名またはパスワードが正しくありません',
network: 'ネットワークエラー',
},
ko: {
title: '로그인',
username: '사용자 이름',
password: '비밀번호',
usernamePh: '사용자 이름 입력',
passwordPh: '비밀번호 입력',
submit: '로그인',
tooMany: '시도 횟수 초과, 잠시 후 다시 시도하세요',
failed: '사용자 이름 또는 비밀번호가 올바르지 않습니다',
network: '네트워크 오류',
},
es: {
title: 'Iniciar sesión',
username: 'Usuario',
password: 'Contraseña',
usernamePh: 'Ingrese usuario',
passwordPh: 'Ingrese contraseña',
submit: 'Entrar',
tooMany: 'Demasiados intentos, intente más tarde',
failed: 'Credenciales inválidas',
network: 'Error de red',
},
fr: {
title: 'Connexion',
username: "Nom d'utilisateur",
password: 'Mot de passe',
usernamePh: "Entrez le nom d'utilisateur",
passwordPh: 'Entrez le mot de passe',
submit: 'Se connecter',
tooMany: 'Trop de tentatives, réessayez plus tard',
failed: 'Identifiants invalides',
network: 'Erreur réseau',
},
ru: {
title: 'Вход',
username: 'Имя пользователя',
password: 'Пароль',
usernamePh: 'Введите имя пользователя',
passwordPh: 'Введите пароль',
submit: 'Войти',
tooMany: 'Слишком много попыток, попробуйте позже',
failed: 'Неверные учётные данные',
network: 'Ошибка сети',
},
pt: {
title: 'Entrar',
username: 'Usuário',
password: 'Senha',
usernamePh: 'Digite o usuário',
passwordPh: 'Digite a senha',
submit: 'Entrar',
tooMany: 'Muitas tentativas, tente novamente mais tarde',
failed: 'Credenciais inválidas',
network: 'Erro de rede',
},
tr: {
title: 'Giriş',
username: 'Kullanıcı adı',
password: 'Şifre',
usernamePh: 'Kullanıcı adını girin',
passwordPh: 'Şifreyi girin',
submit: 'Giriş Yap',
tooMany: 'Çok fazla deneme, lütfen daha sonra tekrar deneyin',
failed: 'Geçersiz kimlik bilgileri',
network: 'Ağ hatası',
},
en: {
title: 'Sign In',
username: 'Username',
password: 'Password',
usernamePh: 'Enter username',
passwordPh: 'Enter password',
submit: 'Sign In',
tooMany: 'Too many attempts, please try later',
failed: 'Invalid credentials',
network: 'Network error',
},
} }
const t = computed(() => langTexts[currentLang.value] || langTexts.en) const t = computed(() => langTexts[currentLang.value] || langTexts.en)
@@ -142,17 +298,6 @@ const username = ref('')
const password = ref('') const password = ref('')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
const appVersion = ref('')
;(async () => {
try {
const resp = await fetch('/api/version')
const result = await resp.json()
if (result.success && result.data?.version) {
appVersion.value = result.data.version
}
} catch {}
})()
const canSubmit = computed(() => username.value.length > 0 && password.value.length > 0) const canSubmit = computed(() => username.value.length > 0 && password.value.length > 0)
@@ -162,15 +307,15 @@ const handleLogin = async () => {
errorMsg.value = '' errorMsg.value = ''
try { try {
const resp = await fetch('/api/auth/login', { const { msg, success = false } = await Login(username.value, password.value)
method: 'POST', if (msg === 'too_many_attempts') {
headers: { 'Content-Type': 'application/json' }, errorMsg.value = t.value.tooMany
credentials: 'same-origin', return
body: JSON.stringify({ username: username.value, password: password.value }), }
}) if (!success) {
const data = await resp.json() errorMsg.value = t.value.failed
if (resp.status === 429) { errorMsg.value = t.value.tooMany; return } return
if (!data.success) { errorMsg.value = t.value.failed; return } }
emit('login') emit('login')
} catch (e) { } catch (e) {
errorMsg.value = t.value.network errorMsg.value = t.value.network
@@ -228,21 +373,27 @@ const handleLogin = async () => {
<div class="login-toolbar"> <div class="login-toolbar">
<n-dropdown :options="langOptions" size="small" trigger="hover" @select="onLangSelect"> <n-dropdown :options="langOptions" size="small" trigger="hover" @select="onLangSelect">
<span class="toolbar-btn"> <span class="toolbar-btn">
<n-icon :component="LangSvg" :size="14" /> <n-icon :component="LangIcon" :size="14" />
<span>{{ currentLangLabel }}</span> <span>{{ currentLangLabel }}</span>
</span> </span>
</n-dropdown> </n-dropdown>
<n-divider style="margin: 0 4px" vertical /> <n-divider style="margin: 0 4px" vertical />
<n-dropdown :options="themeOptions" size="small" trigger="hover" @select="onThemeSelect"> <n-dropdown :options="themeOptions" size="small" trigger="hover" @select="onThemeSelect">
<span class="toolbar-btn"> <span class="toolbar-btn">
<n-icon :component="themeMode === 'dark' ? MoonSvg : themeMode === 'light' ? SunSvg : AutoSvg" :size="14" /> <n-icon
:component="themeMode === 'dark' ? MoonSvg : themeMode === 'light' ? SunSvg : AutoSvg"
:size="14" />
<span>{{ currentThemeLabel }}</span> <span>{{ currentThemeLabel }}</span>
</span> </span>
</n-dropdown> </n-dropdown>
<template v-if="appVersion"> <template v-if="prefStore.appVersion">
<n-divider style="margin: 0 4px" vertical /> <n-divider style="margin: 0 4px" vertical />
<a class="toolbar-btn toolbar-link" href="https://github.com/tiny-craft/tiny-rdm" rel="noopener noreferrer" target="_blank"> <a
{{ appVersion }} class="toolbar-btn toolbar-link"
href="https://github.com/tiny-craft/tiny-rdm"
rel="noopener noreferrer"
target="_blank">
{{ prefStore.appVersion }}
</a> </a>
</template> </template>
</div> </div>
@@ -317,7 +468,9 @@ const handleLogin = async () => {
cursor: pointer; cursor: pointer;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
transition: color 0.2s, background-color 0.2s; transition:
color 0.2s,
background-color 0.2s;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
@@ -9,6 +9,7 @@ import { CloseCli, StartCli } from 'wailsjs/go/services/cliService.js'
import usePreferencesStore from 'stores/preferences.js' import usePreferencesStore from 'stores/preferences.js'
import { i18nGlobal } from '@/utils/i18n.js' import { i18nGlobal } from '@/utils/i18n.js'
import wcwidth from 'wcwidth' import wcwidth from 'wcwidth'
import { isWeb } from '@/utils/platform.js'
const props = defineProps({ const props = defineProps({
name: String, name: String,
@@ -96,7 +97,7 @@ onMounted(async () => {
EventsOn(`cmd:output:${props.name}`, receiveTermOutput) EventsOn(`cmd:output:${props.name}`, receiveTermOutput)
// Wait for WebSocket with timeout (CLI needs it for real-time I/O, web mode only) // Wait for WebSocket with timeout (CLI needs it for real-time I/O, web mode only)
if (import.meta.env.VITE_WEB === 'true') { if (isWeb()) {
try { try {
const { WaitForWebSocket } = await import('wailsjs/runtime/runtime.js') const { WaitForWebSocket } = await import('wailsjs/runtime/runtime.js')
await Promise.race([ await Promise.race([
@@ -1,20 +1,13 @@
<script setup> <script setup>
import iconUrl from '@/assets/images/icon.png' import iconUrl from '@/assets/images/icon.png'
import useDialog from 'stores/dialog.js' import useDialog from 'stores/dialog.js'
import usePreferencesStore from 'stores/preferences.js'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js' import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'
import { GetAppVersion } from 'wailsjs/go/services/preferencesService.js'
import { onMounted, ref } from 'vue'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const dialogStore = useDialog() const dialogStore = useDialog()
const version = ref('') const prefStore = usePreferencesStore()
onMounted(() => {
GetAppVersion().then(({ data }) => {
version.value = data.version
})
})
const onOpenSource = () => { const onOpenSource = () => {
BrowserOpenURL('https://github.com/tiny-craft/tiny-rdm') BrowserOpenURL('https://github.com/tiny-craft/tiny-rdm')
@@ -30,14 +23,14 @@ const onOpenWebsite = () => {
<n-space :size="10" :wrap="false" :wrap-item="false" align="center" vertical> <n-space :size="10" :wrap="false" :wrap-item="false" align="center" vertical>
<n-avatar :size="120" :src="iconUrl" color="#0000"></n-avatar> <n-avatar :size="120" :src="iconUrl" color="#0000"></n-avatar>
<div class="about-app-title">Tiny RDM</div> <div class="about-app-title">Tiny RDM</div>
<n-text>{{ version }}</n-text> <n-text>{{ prefStore.appVersion }}</n-text>
<n-space :size="5" :wrap="false" :wrap-item="false" align="center"> <n-space :size="5" :wrap="false" :wrap-item="false" align="center">
<n-text class="about-link" @click="onOpenSource">{{ $t('dialogue.about.source') }}</n-text> <n-text class="about-link" @click="onOpenSource">{{ $t('dialogue.about.source') }}</n-text>
<n-divider vertical /> <n-divider vertical />
<n-text class="about-link" @click="onOpenWebsite">{{ $t('dialogue.about.website') }}</n-text> <n-text class="about-link" @click="onOpenWebsite">{{ $t('dialogue.about.website') }}</n-text>
</n-space> </n-space>
<div :style="{ color: themeVars.textColor3 }" class="about-copyright"> <div :style="{ color: themeVars.textColor3 }" class="about-copyright">
Copyright © 2026 Tinycraft.cc All rights reserved Copyright © {{ new Date().getFullYear() }} Tinycraft.cc All rights reserved
</div> </div>
</n-space> </n-space>
</n-modal> </n-modal>
+45
View File
@@ -0,0 +1,45 @@
<script setup>
const props = defineProps({
strokeWidth: {
type: [Number, String],
default: 3,
},
})
</script>
<template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path
:stroke-width="props.strokeWidth"
d="M28.2857 37H39.7143M42 42L39.7143 37L42 42ZM26 42L28.2857 37L26 42ZM28.2857 37L34 24L39.7143 37H28.2857Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M16 6L17 9"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M6 11H28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M10 16C10 16 11.7895 22.2609 16.2632 25.7391C20.7368 29.2174 28 32 28 32"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M24 11C24 11 22.2105 19.2174 17.7368 23.7826C13.2632 28.3478 6 32 6 32"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>
<style lang="scss" scoped></style>
+18 -3
View File
@@ -9,9 +9,24 @@ const props = defineProps({
<template> <template>
<svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"> <svg fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<path d="M23.9917 6H6V42H24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/> <path
<path d="M33 33L42 24L33 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/> :stroke-width="props.strokeWidth"
<path d="M16 23.9917H42" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/> d="M23.9917 6H6V42H24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M33 33L42 24L33 15"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
<path
:stroke-width="props.strokeWidth"
d="M16 23.9917H42"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round" />
</svg> </svg>
</template> </template>
+5 -4
View File
@@ -18,8 +18,9 @@ import bilibiliUrl from '@/assets/images/bilibili_official.png'
import QRCode from '@/components/icons/QRCode.vue' import QRCode from '@/components/icons/QRCode.vue'
import Twitter from '@/components/icons/Twitter.vue' import Twitter from '@/components/icons/Twitter.vue'
import { trackEvent } from '@/utils/analytics.js' import { trackEvent } from '@/utils/analytics.js'
import Logout from '@/components/icons/Logout.vue' import LogoutIcon from '@/components/icons/Logout.vue'
import { isWeb } from '@/utils/platform.js' import { isWeb } from '@/utils/platform.js'
import { Logout } from '@/utils/api.js'
const themeVars = useThemeVars() const themeVars = useThemeVars()
const render = useRender() const render = useRender()
@@ -140,7 +141,7 @@ const openGithub = () => {
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }) await Logout()
} catch {} } catch {}
window.dispatchEvent(new Event('rdm:unauthorized')) window.dispatchEvent(new Event('rdm:unauthorized'))
} }
@@ -207,9 +208,9 @@ const exThemeVars = computed(() => {
@click="openGithub" /> @click="openGithub" />
<icon-button <icon-button
v-if="isWeb()" v-if="isWeb()"
:icon="Logout" :icon="LogoutIcon"
:size="iconSize" :size="iconSize"
:stroke-width="3" :stroke-width="3.5"
:tooltip-delay="100" :tooltip-delay="100"
t-tooltip="ribbon.logout" t-tooltip="ribbon.logout"
@click="handleLogout" /> @click="handleLogout" />
+3 -2
View File
@@ -11,6 +11,7 @@ import usePreferencesStore from 'stores/preferences.js'
import { loadEnvironment } from '@/utils/platform.js' import { loadEnvironment } from '@/utils/platform.js'
import { setupMonaco } from '@/utils/monaco.js' import { setupMonaco } from '@/utils/monaco.js'
import { setupChart } from '@/utils/chart.js' import { setupChart } from '@/utils/chart.js'
import { isWeb } from './utils/platform.js'
dayjs.extend(duration) dayjs.extend(duration)
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@@ -24,8 +25,8 @@ async function setupApp() {
setupMonaco() setupMonaco()
setupChart() setupChart()
const prefStore = usePreferencesStore() const prefStore = usePreferencesStore()
const isWebMode = import.meta.env.VITE_WEB === 'true' if (isWeb()) {
if (!isWebMode) { await prefStore.loadAppVersion()
await prefStore.loadPreferences() await prefStore.loadPreferences()
} }
await setupDiscreteApi() await setupDiscreteApi()
+14 -1
View File
@@ -3,11 +3,12 @@ import { lang } from '@/langs/index.js'
import { cloneDeep, findIndex, get, isEmpty, join, map, pick, set, some, split } from 'lodash' import { cloneDeep, findIndex, get, isEmpty, join, map, pick, set, some, split } from 'lodash'
import { import {
CheckForUpdate, CheckForUpdate,
GetAppVersion,
GetBuildInDecoder, GetBuildInDecoder,
GetFontList, GetFontList,
GetPreferences, GetPreferences,
RestorePreferences, RestorePreferences,
SetPreferences SetPreferences,
} from 'wailsjs/go/services/preferencesService.js' } from 'wailsjs/go/services/preferencesService.js'
import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js' import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'
import { i18nGlobal } from '@/utils/i18n.js' import { i18nGlobal } from '@/utils/i18n.js'
@@ -75,6 +76,7 @@ const usePreferencesStore = defineStore('preferences', {
decoder: [], decoder: [],
lastPref: {}, lastPref: {},
fontList: [], fontList: [],
appVersion: '',
}), }),
getters: { getters: {
getSeparator() { getSeparator() {
@@ -345,6 +347,17 @@ const usePreferencesStore = defineStore('preferences', {
} }
}, },
/**
* load app version
* @return {Promise<void>}
*/
async loadAppVersion() {
const { success, data } = await GetAppVersion()
if (success && data?.version) {
this.appVersion = data.version
}
},
/** /**
* save preferences to local * save preferences to local
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
+8
View File
@@ -426,4 +426,12 @@ export async function SaveFile(title, defaultName, ext) {
return { success: true, data: { path: '' } } return { success: true, data: { path: '' } }
} }
// ==================== Auth Service ====================
export async function Login(username, password) {
return await post('/auth/login', { username, password })
}
export async function Logout() {
return await post('/auth/logout')
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"embed" "embed"
"fmt" "fmt"
"runtime" "runtime"
"time"
"tinyrdm/backend/consts" "tinyrdm/backend/consts"
"tinyrdm/backend/services" "tinyrdm/backend/services"
@@ -113,7 +114,7 @@ func main() {
TitleBar: mac.TitleBarHiddenInset(), TitleBar: mac.TitleBarHiddenInset(),
About: &mac.AboutInfo{ About: &mac.AboutInfo{
Title: fmt.Sprintf("%s %s", appName, version), Title: fmt.Sprintf("%s %s", appName, version),
Message: "A modern lightweight cross-platform Redis desktop client.\n\nCopyright © 2026", Message: "A modern lightweight cross-platform Redis desktop client.\n\nCopyright © " + time.Now().Format("2006"),
Icon: icon, Icon: icon,
}, },
WebviewIsTransparent: false, WebviewIsTransparent: false,
+1 -5
View File
@@ -4,7 +4,6 @@ package main
import ( import (
"context" "context"
"embed"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -15,9 +14,6 @@ import (
"tinyrdm/backend/services" "tinyrdm/backend/services"
) )
//go:embed all:frontend/dist
var assets embed.FS
var version = "0.0.0" var version = "0.0.0"
func main() { func main() {
@@ -60,7 +56,7 @@ func main() {
} }
// Setup HTTP server // Setup HTTP server
router := api.SetupRouter(assets) router := api.SetupRouter()
srv := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%s", port), Addr: fmt.Sprintf("0.0.0.0:%s", port),
Handler: router, Handler: router,