Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions spx-gui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions spx-gui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@types/file-saver": "^2.0.7",
"@types/node": "^24.10.1",
"@types/sortablejs": "^1.15.8",
"@types/ua-parser-js": "^0.7.36",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
Expand Down Expand Up @@ -75,6 +76,7 @@
"vue-konva": "^3.1.0",
"vue-router": "^4.2.5",
"vue-tsc": "^3.1.1",
"ua-parser-js": "^1.0.41",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
},
Expand Down
12 changes: 10 additions & 2 deletions spx-gui/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<template>
<UIConfigProvider :config="config">
<UIMessageProvider>
<UIModalProvider>
<MobileReminder v-if="showMobileReminder" />
<UIModalProvider v-else>
<BrowserVersionReminder />
<CopilotRoot>
<TutorialRoot>
<AgentCopilotProvider>
Expand All @@ -17,7 +19,7 @@
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
import { UIConfigProvider, UIModalProvider, UIMessageProvider, type Config } from '@/components/ui'
import AgentCopilotProvider from '@/components/agent-copilot/CopilotProvider.vue'
import CopilotRoot from '@/components/copilot/CopilotRoot.vue'
Expand All @@ -26,6 +28,12 @@ import TutorialRoot from '@/components/tutorials/TutorialRoot.vue'
import { SpotlightUI } from '@/utils/spotlight'
import { useI18n } from '@/utils/i18n'
import { useInstallRouteLoading } from '@/utils/route-loading'
import { isMobile } from '@/utils/ua'

const MobileReminder = defineAsyncComponent(() => import('@/components/ua/MobileReminder.vue'))
const BrowserVersionReminder = defineAsyncComponent(() => import('@/components/ua/BrowserVersionReminder.vue'))

const showMobileReminder = isMobile()

const { t } = useI18n()

Expand Down
99 changes: 99 additions & 0 deletions spx-gui/src/components/ua/BrowserVersionReminder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<template>
<UIDialog
type="warning"
size="small"
:title="$t({ en: 'Browser Version Unsupported', zh: '浏览器版本不支持' })"
:visible="visible"
:mask-closable="true"
@update:visible="visible = $event"
>
<div>{{ $t(content) }}</div>
<footer class="footer">
<UIButton
v-radar="{
name: 'Do not show again button',
desc: 'Click to close browser version reminder modal and suppress it show again.'
}"
color="boring"
@click="handleDoNotShowAgain"
>
{{ $t({ en: 'Do not show again', zh: '不再提示' }) }}
</UIButton>
<UIButton
v-radar="{ name: 'Confirm button', desc: 'Click to close browser version reminder modal' }"
color="primary"
@click="handleConfirm"
>
{{ $t({ en: 'Got it', zh: '我知道了' }) }}
</UIButton>
</footer>
</UIDialog>
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { checkBrowserVersion } from '@/utils/ua'
import { type LocaleMessage } from '@/utils/i18n'
import UIDialog from '@/components/ui/dialog/UIDialog.vue'
import UIButton from '../ui/UIButton.vue'

const fallbackMessage: LocaleMessage = {
en: 'Your browser version may not support all features. Please update your browser for the best experience.',
zh: '您的浏览器版本可能无法支持全部功能,建议更新浏览器以获得最佳体验。'
}

const visible = ref(false)
const content = ref(fallbackMessage)

const localStorageKey = 'spx-gui-browser-version-reminder-ignored'

function handleDoNotShowAgain() {
visible.value = false
try {
localStorage.setItem(localStorageKey, 'true')
} catch (e) {
console.warn('Failed to save preference:', e)
}
}

function handleConfirm() {
visible.value = false
}

onMounted(() => {
let isIgnored = false
try {
isIgnored = localStorage.getItem(localStorageKey) === 'true'
} catch (e) {
console.warn('Failed to read localStorage:', e)
}
if (isIgnored) return

const checkResult = checkBrowserVersion()
if (checkResult.ok) return

if (checkResult.browserName == null) {
const { recommendedBrowser, recommendedVersion } = checkResult
content.value = {
en: `Your browser may not support all features. For the best experience, please use ${recommendedBrowser} (version ${recommendedVersion} or later).`,
zh: `您的浏览器可能无法支持全部功能,建议使用 ${recommendedBrowser}(${recommendedVersion} 或更高版本)以获得最佳体验。`
}
} else {
const { recommendedVersion } = checkResult
content.value = {
en: `Your browser version may not support all features. Please update to version ${recommendedVersion} or later for the best experience.`,
zh: `您的浏览器版本可能无法支持全部功能,建议更新到 ${recommendedVersion} 或更高版本以获得最佳体验。`
}
}
visible.value = true
})
</script>

<style scoped lang="scss">
.footer {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
</style>
81 changes: 81 additions & 0 deletions spx-gui/src/components/ua/MobileReminder.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<template>
<div class="mobile-reminder">
<div class="guide-img">
<img :src="imgDesktopRequired" alt="Desktop Required Image" />
</div>
<div class="guide-text">
<h1>{{ t({ en: 'Desktop Required', zh: '访问限制' }) }}</h1>
<p>
{{
t({
en: 'This application is not supported on mobile devices. Please access it from a desktop.',
zh: '该应用暂不支持移动端,请使用电脑访问'
})
}}
</p>
</div>
<UIButton
v-if="isClipboardSupported"
v-radar="{ name: 'Copy Access URL', desc: 'Button to copy the access URL' }"
size="large"
icon="copy"
@click="handleCopy"
>
{{ t({ en: 'Copy Access URL', zh: '复制访问地址' }) }}
</UIButton>
</div>
</template>

<script setup lang="ts">
import { useI18n } from '@/utils/i18n'
import imgDesktopRequired from './desktop-required.png'
import { UIButton } from '@/components/ui'
import { useMessageHandle } from '@/utils/exception'

const { t } = useI18n()

const isClipboardSupported = !!navigator.clipboard

const handleCopy = useMessageHandle(
() => navigator.clipboard.writeText(window.location.href),
{ en: 'Failed to copy URL to clipboard', zh: '复制到剪贴板失败' },
{ en: 'Copied to clipboard', zh: '已复制到剪贴板' }
).fn
</script>

<style lang="scss" scoped>
.mobile-reminder {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 60px;

.guide-img {
margin-bottom: 48px;
img {
width: 240px;
height: auto;
}
}

.guide-text {
margin-bottom: 40px;
text-align: center;

h1 {
font-size: 20px;
font-weight: 600;
color: var(--ui-color-grey-1000);
margin-bottom: 12px;
}

p {
font-size: 14px;
line-height: 22px;
color: var(--ui-color-grey-900);
}
}
}
</style>
Binary file added spx-gui/src/components/ua/desktop-required.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
48 changes: 48 additions & 0 deletions spx-gui/src/utils/ua.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { compareVersions } from './ua'

describe('compareVersions', () => {
it('should compare major versions correctly', () => {
expect(compareVersions('2.0', '1.0')).toBeGreaterThan(0)
expect(compareVersions('1.0', '2.0')).toBeLessThan(0)
expect(compareVersions('111', '110')).toBeGreaterThan(0)
expect(compareVersions('110', '111')).toBeLessThan(0)
})

it('should compare minor versions correctly', () => {
expect(compareVersions('1.10', '1.4')).toBeGreaterThan(0)
expect(compareVersions('1.4', '1.10')).toBeLessThan(0)
expect(compareVersions('16.10', '16.4')).toBeGreaterThan(0)
expect(compareVersions('16.4', '16.10')).toBeLessThan(0)
})

it('should return 0 for equal versions', () => {
expect(compareVersions('1.0', '1.0')).toBe(0)
expect(compareVersions('1.10', '1.10')).toBe(0)
expect(compareVersions('111', '111')).toBe(0)
expect(compareVersions('16.4', '16.4')).toBe(0)
})

it('should handle versions without minor part', () => {
expect(compareVersions('111', '111.0')).toBe(0)
expect(compareVersions('111.0', '111')).toBe(0)
expect(compareVersions('112', '111')).toBeGreaterThan(0)
expect(compareVersions('111', '112')).toBeLessThan(0)
})

it('should handle real-world browser versions', () => {
expect(compareVersions('111.0', '111')).toBe(0)
expect(compareVersions('111.0.5563.146', '111')).toBe(0)
expect(compareVersions('113.0', '113')).toBe(0)
expect(compareVersions('16.4', '16.4')).toBe(0)
expect(compareVersions('120.0', '111')).toBeGreaterThan(0)
expect(compareVersions('110.0', '111')).toBeLessThan(0)
})

it('should handle edge cases', () => {
expect(compareVersions('0', '0')).toBe(0)
expect(compareVersions('0.0', '0.0')).toBe(0)
expect(compareVersions('1', '0')).toBeGreaterThan(0)
expect(compareVersions('0', '1')).toBeLessThan(0)
})
})
Loading