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())
})
g.GET("/version", func(c *gin.Context) {
c.JSON(http.StatusOK, services.Preferences().GetAppVersion())
})
g.GET("/check-update", func(c *gin.Context) {
c.JSON(http.StatusOK, services.Preferences().CheckForUpdate())
})
+3 -22
View File
@@ -3,8 +3,6 @@
package api
import (
"embed"
"io/fs"
"log"
"net/http"
"strings"
@@ -17,7 +15,7 @@ import (
const maxRequestBodySize = 10 << 20 // 10MB
// 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)
r := gin.Default()
@@ -57,9 +55,8 @@ func SetupRouter(assets embed.FS) *gin.Engine {
// Public routes (no auth required)
registerAuthRoutes(r)
r.GET("/api/version", func(c *gin.Context) {
resp := services.Preferences().GetAppVersion()
c.JSON(200, resp)
r.GET("/api/preferences/version", func(c *gin.Context) {
c.JSON(http.StatusOK, services.Preferences().GetAppVersion())
})
// WebSocket endpoint (auth checked via cookie + origin)
@@ -76,22 +73,6 @@ func SetupRouter(assets embed.FS) *gin.Engine {
registerPreferencesRoutes(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
}
+18 -16
View File
@@ -23,17 +23,15 @@ import ImportKeyDialog from '@/components/dialogs/ImportKeyDialog.vue'
import { Info } from 'wailsjs/go/services/systemService.js'
import DecoderDialog from '@/components/dialogs/DecoderDialog.vue'
import { loadModule, trackEvent } from '@/utils/analytics.js'
import { isWeb } from '@/utils/platform.js'
const prefStore = usePreferencesStore()
const connectionStore = useConnectionStore()
const i18n = useI18n()
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
const LoginPage = isWebMode ? defineAsyncComponent(() => import('@/components/LoginPage.vue')) : null
const LoginPage = isWeb() ? defineAsyncComponent(() => import('@/components/LoginPage.vue')) : null
// Viewport management for mobile
const setViewport = (mode) => {
@@ -57,7 +55,7 @@ const setViewport = (mode) => {
}
// 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 authEnabled = ref(false)
@@ -82,33 +80,37 @@ const onLogin = async () => {
// Reconnect WebSocket with auth cookie (dynamic import to avoid desktop issues)
try {
const runtime = await import('wailsjs/runtime/runtime.js')
if (runtime.ReconnectWebSocket) runtime.ReconnectWebSocket()
if (runtime.ReconnectWebSocket) {
runtime.ReconnectWebSocket()
}
} catch {}
// Capture login page choices before loadPreferences overwrites them
const loginTheme = localStorage.getItem('rdm_login_theme')
const loginLang = localStorage.getItem('rdm_login_lang')
await initApp()
// Sync login page choices to preferences
let needSave = false
let prefUpdated = false
if (loginTheme && ['auto', 'light', 'dark'].includes(loginTheme)) {
prefStore.general.theme = loginTheme
needSave = true
prefUpdated = true
}
if (loginLang) {
const validLangs = ['auto', 'zh', 'tw', 'en', 'ja', 'ko', 'es', 'fr', 'ru', 'pt', 'tr']
if (validLangs.includes(loginLang)) {
prefStore.general.language = loginLang
i18n.locale.value = prefStore.currentLanguage
needSave = true
prefUpdated = true
}
}
if (needSave) prefStore.savePreferences()
if (prefUpdated) {
prefStore.savePreferences()
}
}
const initApp = async () => {
try {
initializing.value = true
if (isWebMode) {
if (isWeb()) {
const prefResult = await prefStore.loadPreferences()
// If loadPreferences failed (e.g. 401 from expired session),
// rdm:unauthorized event already fired → silently abort init
@@ -118,7 +120,7 @@ const initApp = async () => {
await prefStore.loadFontList()
await prefStore.loadBuildInDecoder()
await connectionStore.initConnections()
if (prefStore.autoCheckUpdate) {
if (!isWeb() && prefStore.autoCheckUpdate) {
prefStore.checkForUpdate()
}
const env = await Environment()
@@ -196,7 +198,7 @@ const onOrientationChange = () => {
}
onMounted(async () => {
if (isWebMode) {
if (isWeb()) {
// Apply saved login theme before auth check to prevent flash
const savedTheme = localStorage.getItem('rdm_login_theme')
if (savedTheme && ['auto', 'light', 'dark'].includes(savedTheme)) {
@@ -225,7 +227,7 @@ onMounted(async () => {
})
onUnmounted(() => {
if (isWebMode) {
if (isWeb()) {
window.removeEventListener('rdm:unauthorized', onUnauthorized)
window.removeEventListener('orientationchange', onOrientationChange)
window.removeEventListener('resize', onOrientationChange)
@@ -253,10 +255,10 @@ watch(
:theme-overrides="prefStore.isDark ? darkThemeOverrides : themeOverrides"
class="fill-height">
<!-- Web mode: auth gate -->
<template v-if="isWebMode && authChecking">
<template v-if="isWeb() && authChecking">
<div style="width: 100vw; height: 100vh"></div>
</template>
<template v-else-if="isWebMode && authEnabled && !authenticated">
<template v-else-if="isWeb() && authEnabled && !authenticated">
<component :is="LoginPage" @login="onLogin" />
</template>
<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 iconUrl from '@/assets/images/icon.png'
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 prefStore = usePreferencesStore()
@@ -17,21 +21,18 @@ onMounted(() => {
prefStore.general.theme = themeMode.value
})
const themeLabels = {
zh: { auto: '自动', light: '浅色', dark: '暗黑' },
tw: { auto: '自動', light: '淺色', dark: '暗黑' },
ja: { auto: '自動', light: 'ライト', dark: 'ダーク' },
ko: { auto: '자동', light: '라이트', dark: '다크' },
es: { auto: 'Auto', light: 'Claro', dark: 'Oscuro' },
fr: { auto: 'Auto', light: 'Clair', dark: 'Sombre' },
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 getThemeLabels = (langKey) => {
const l = lang[langKey] || lang.en
const g = l.preferences?.general || {}
return {
auto: g.theme_auto || 'Auto',
light: g.theme_light || 'Light',
dark: g.theme_dark || 'Dark',
}
}
const themeOptions = computed(() => {
const labels = themeLabels[currentLang.value] || themeLabels.en
const labels = getThemeLabels(currentLang.value)
return [
{ label: labels.light, key: 'light', icon: renderIcon('sun') },
{ label: labels.dark, key: 'dark', icon: renderIcon('moon') },
@@ -40,7 +41,7 @@ const themeOptions = computed(() => {
})
const currentThemeLabel = computed(() => {
const labels = themeLabels[currentLang.value] || themeLabels.en
const labels = getThemeLabels(currentLang.value)
return labels[themeMode.value]
})
@@ -53,24 +54,22 @@ const onThemeSelect = (key) => {
// --- Language ---
const LANG_KEY = 'rdm_login_lang'
const langNames = {
zh: '简体中文', tw: '繁體中文', en: 'English', ja: '日本語', ko: '한국어',
es: 'Español', fr: 'Français', ru: 'Русский', pt: 'Português', tr: 'Türkçe',
}
const autoLabel = {
zh: '自动', tw: '自動', ja: '自動', ko: '자동', es: 'Auto',
fr: 'Auto', ru: 'Авто', pt: 'Auto', tr: 'Otomatik', en: 'Auto',
}
const langNames = Object.fromEntries(Object.entries(lang).map(([k, v]) => [k, v.name]))
const autoLabel = Object.fromEntries(
Object.entries(lang).map(([k, v]) => [k, v.preferences?.general?.theme_auto || 'Auto']),
)
const detectSystemLang = () => {
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]
return langNames[prefix] ? prefix : 'en'
}
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(() => [
{ label: autoLabel[currentLang.value] || 'Auto', key: 'auto' },
@@ -91,31 +90,88 @@ const onLangSelect = (key) => {
}
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' }, [
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' }),
]),
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',
},
[
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 = {
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' }, [
h('path', { d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' }),
]),
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',
},
[h('path', { d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z' })],
),
}
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' }, [
h('circle', { cx: '12', cy: '12', r: '10' }),
h('path', { d: 'M12 2a10 10 0 0 1 0 20V2' }),
]),
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',
},
[h('circle', { cx: '12', cy: '12', r: '10' }), h('path', { d: 'M12 2a10 10 0 0 1 0 20V2' })],
),
}
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' }, [
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' }),
]),
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',
},
[
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 }
@@ -123,16 +179,116 @@ const renderIcon = (name) => () => h(NIcon, null, { default: () => h(iconMap[nam
// --- i18n texts ---
const langTexts = {
zh: { title: '登录', username: '用户名', password: '密码', usernamePh: '请输入用户名', passwordPh: '请输入密码', submit: '登 录', tooMany: '尝试次数过多,请稍后再试', failed: '用户名或密码错误', 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' },
zh: {
title: '登录',
username: '用户名',
password: '密码',
usernamePh: '请输入用户名',
passwordPh: '请输入密码',
submit: '登 录',
tooMany: '尝试次数过多,请稍后再试',
failed: '用户名或密码错误',
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)
@@ -142,17 +298,6 @@ const username = ref('')
const password = ref('')
const loading = ref(false)
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)
@@ -162,15 +307,15 @@ const handleLogin = async () => {
errorMsg.value = ''
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ username: username.value, password: password.value }),
})
const data = await resp.json()
if (resp.status === 429) { errorMsg.value = t.value.tooMany; return }
if (!data.success) { errorMsg.value = t.value.failed; return }
const { msg, success = false } = await Login(username.value, password.value)
if (msg === 'too_many_attempts') {
errorMsg.value = t.value.tooMany
return
}
if (!success) {
errorMsg.value = t.value.failed
return
}
emit('login')
} catch (e) {
errorMsg.value = t.value.network
@@ -228,21 +373,27 @@ const handleLogin = async () => {
<div class="login-toolbar">
<n-dropdown :options="langOptions" size="small" trigger="hover" @select="onLangSelect">
<span class="toolbar-btn">
<n-icon :component="LangSvg" :size="14" />
<n-icon :component="LangIcon" :size="14" />
<span>{{ currentLangLabel }}</span>
</span>
</n-dropdown>
<n-divider style="margin: 0 4px" vertical />
<n-dropdown :options="themeOptions" size="small" trigger="hover" @select="onThemeSelect">
<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>
</n-dropdown>
<template v-if="appVersion">
<template v-if="prefStore.appVersion">
<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">
{{ appVersion }}
<a
class="toolbar-btn toolbar-link"
href="https://github.com/tiny-craft/tiny-rdm"
rel="noopener noreferrer"
target="_blank">
{{ prefStore.appVersion }}
</a>
</template>
</div>
@@ -317,7 +468,9 @@ const handleLogin = async () => {
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
transition: color 0.2s, background-color 0.2s;
transition:
color 0.2s,
background-color 0.2s;
user-select: none;
white-space: nowrap;
@@ -9,6 +9,7 @@ import { CloseCli, StartCli } from 'wailsjs/go/services/cliService.js'
import usePreferencesStore from 'stores/preferences.js'
import { i18nGlobal } from '@/utils/i18n.js'
import wcwidth from 'wcwidth'
import { isWeb } from '@/utils/platform.js'
const props = defineProps({
name: String,
@@ -96,7 +97,7 @@ onMounted(async () => {
EventsOn(`cmd:output:${props.name}`, receiveTermOutput)
// 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 {
const { WaitForWebSocket } = await import('wailsjs/runtime/runtime.js')
await Promise.race([
@@ -1,20 +1,13 @@
<script setup>
import iconUrl from '@/assets/images/icon.png'
import useDialog from 'stores/dialog.js'
import usePreferencesStore from 'stores/preferences.js'
import { useThemeVars } from 'naive-ui'
import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'
import { GetAppVersion } from 'wailsjs/go/services/preferencesService.js'
import { onMounted, ref } from 'vue'
const themeVars = useThemeVars()
const dialogStore = useDialog()
const version = ref('')
onMounted(() => {
GetAppVersion().then(({ data }) => {
version.value = data.version
})
})
const prefStore = usePreferencesStore()
const onOpenSource = () => {
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-avatar :size="120" :src="iconUrl" color="#0000"></n-avatar>
<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-text class="about-link" @click="onOpenSource">{{ $t('dialogue.about.source') }}</n-text>
<n-divider vertical />
<n-text class="about-link" @click="onOpenWebsite">{{ $t('dialogue.about.website') }}</n-text>
</n-space>
<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>
</n-space>
</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>
<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 d="M33 33L42 24L33 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 23.9917H42" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path
:stroke-width="props.strokeWidth"
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>
</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 Twitter from '@/components/icons/Twitter.vue'
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 { Logout } from '@/utils/api.js'
const themeVars = useThemeVars()
const render = useRender()
@@ -140,7 +141,7 @@ const openGithub = () => {
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' })
await Logout()
} catch {}
window.dispatchEvent(new Event('rdm:unauthorized'))
}
@@ -207,9 +208,9 @@ const exThemeVars = computed(() => {
@click="openGithub" />
<icon-button
v-if="isWeb()"
:icon="Logout"
:icon="LogoutIcon"
:size="iconSize"
:stroke-width="3"
:stroke-width="3.5"
:tooltip-delay="100"
t-tooltip="ribbon.logout"
@click="handleLogout" />
+3 -2
View File
@@ -11,6 +11,7 @@ import usePreferencesStore from 'stores/preferences.js'
import { loadEnvironment } from '@/utils/platform.js'
import { setupMonaco } from '@/utils/monaco.js'
import { setupChart } from '@/utils/chart.js'
import { isWeb } from './utils/platform.js'
dayjs.extend(duration)
dayjs.extend(relativeTime)
@@ -24,8 +25,8 @@ async function setupApp() {
setupMonaco()
setupChart()
const prefStore = usePreferencesStore()
const isWebMode = import.meta.env.VITE_WEB === 'true'
if (!isWebMode) {
if (isWeb()) {
await prefStore.loadAppVersion()
await prefStore.loadPreferences()
}
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 {
CheckForUpdate,
GetAppVersion,
GetBuildInDecoder,
GetFontList,
GetPreferences,
RestorePreferences,
SetPreferences
SetPreferences,
} from 'wailsjs/go/services/preferencesService.js'
import { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'
import { i18nGlobal } from '@/utils/i18n.js'
@@ -75,6 +76,7 @@ const usePreferencesStore = defineStore('preferences', {
decoder: [],
lastPref: {},
fontList: [],
appVersion: '',
}),
getters: {
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
* @returns {Promise<boolean>}
+8
View File
@@ -426,4 +426,12 @@ export async function SaveFile(title, defaultName, ext) {
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"
"fmt"
"runtime"
"time"
"tinyrdm/backend/consts"
"tinyrdm/backend/services"
@@ -113,7 +114,7 @@ func main() {
TitleBar: mac.TitleBarHiddenInset(),
About: &mac.AboutInfo{
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,
},
WebviewIsTransparent: false,
+1 -5
View File
@@ -4,7 +4,6 @@ package main
import (
"context"
"embed"
"fmt"
"log"
"net/http"
@@ -15,9 +14,6 @@ import (
"tinyrdm/backend/services"
)
//go:embed all:frontend/dist
var assets embed.FS
var version = "0.0.0"
func main() {
@@ -60,7 +56,7 @@ func main() {
}
// Setup HTTP server
router := api.SetupRouter(assets)
router := api.SetupRouter()
srv := &http.Server{
Addr: fmt.Sprintf("0.0.0.0:%s", port),
Handler: router,