diff --git a/spx-gui/package-lock.json b/spx-gui/package-lock.json index 1cc7cce1a..4aef4f8a7 100644 --- a/spx-gui/package-lock.json +++ b/spx-gui/package-lock.json @@ -22,6 +22,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", @@ -58,6 +59,7 @@ "shiki": "^3.2.2", "sortablejs": "^1.15.6", "typescript": "^5.9.3", + "ua-parser-js": "^1.0.41", "vite": "^6.4.1", "vite-plugin-ejs": "^1.7.0", "vite-plugin-vercel": "^9.0.8", @@ -3325,6 +3327,12 @@ "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", "license": "MIT" }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.39", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", + "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -10345,6 +10353,32 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/spx-gui/package.json b/spx-gui/package.json index 7bb772f6b..e7f3d8c4f 100644 --- a/spx-gui/package.json +++ b/spx-gui/package.json @@ -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", @@ -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" }, diff --git a/spx-gui/src/App.vue b/spx-gui/src/App.vue index 21f047d20..40bcdf9e0 100644 --- a/spx-gui/src/App.vue +++ b/spx-gui/src/App.vue @@ -1,7 +1,9 @@ - + + + @@ -17,7 +19,7 @@ + + diff --git a/spx-gui/src/components/ua/MobileReminder.vue b/spx-gui/src/components/ua/MobileReminder.vue new file mode 100644 index 000000000..6ac6c9c1a --- /dev/null +++ b/spx-gui/src/components/ua/MobileReminder.vue @@ -0,0 +1,81 @@ + + + + + + + {{ t({ en: 'Desktop Required', zh: '访问限制' }) }} + + {{ + t({ + en: 'This application is not supported on mobile devices. Please access it from a desktop.', + zh: '该应用暂不支持移动端,请使用电脑访问' + }) + }} + + + + {{ t({ en: 'Copy Access URL', zh: '复制访问地址' }) }} + + + + + + + diff --git a/spx-gui/src/components/ua/desktop-required.png b/spx-gui/src/components/ua/desktop-required.png new file mode 100644 index 000000000..392530b76 Binary files /dev/null and b/spx-gui/src/components/ua/desktop-required.png differ diff --git a/spx-gui/src/utils/ua.test.ts b/spx-gui/src/utils/ua.test.ts new file mode 100644 index 000000000..f09e21f83 --- /dev/null +++ b/spx-gui/src/utils/ua.test.ts @@ -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) + }) +}) diff --git a/spx-gui/src/utils/ua.ts b/spx-gui/src/utils/ua.ts new file mode 100644 index 000000000..0fa5d547d --- /dev/null +++ b/spx-gui/src/utils/ua.ts @@ -0,0 +1,77 @@ +import uaParser from 'ua-parser-js' + +const ua = uaParser(navigator.userAgent) + +export default ua + +export function isMobile() { + return ua.device.type === 'mobile' || ua.device.type === 'tablet' +} + +export const BrowserName = { + CHROME: 'Chrome', + EDGE: 'Edge', + FIREFOX: 'Firefox', + SAFARI: 'Safari' +} as const + +// Minimum recommended browser versions, derived from `browserslist` in package.json +const recommendedBrowserVersions: Record = { + [BrowserName.CHROME]: '111', + [BrowserName.EDGE]: '111', + [BrowserName.FIREFOX]: '113', + [BrowserName.SAFARI]: '16.4' +} + +export type BrowserCheckResult = + | { ok: true } + | { ok: false; browserName: string; recommendedVersion: string } + | { ok: false; browserName: null; recommendedBrowser: string; recommendedVersion: string } + +/** + * Checks if the current browser meets minimum version requirements. + * + * @returns An object indicating the check result: + * - `{ ok: true }` if the browser version meets requirements + * - `{ ok: false, browserName, recommendedVersion }` if a known browser is outdated + * - `{ ok: false, browserName: null, recommendedBrowser, recommendedVersion }` if the browser is unknown/unsupported + */ +export function checkBrowserVersion(): BrowserCheckResult { + const browserName = ua.browser.name + if (!browserName || !(browserName in recommendedBrowserVersions)) { + return { + ok: false, + browserName: null, + recommendedBrowser: BrowserName.CHROME, + recommendedVersion: recommendedBrowserVersions[BrowserName.CHROME] + } + } + const recommendedVersion = recommendedBrowserVersions[browserName] + const browserVersion = ua.browser.version || '0' + if (compareVersions(browserVersion, recommendedVersion) >= 0) { + return { ok: true } + } + return { ok: false, browserName, recommendedVersion } +} + +/** + * Compares two version strings (e.g., "16.10" vs "16.4"). + * + * @param version - The version string to compare (e.g., browser version) + * @param target - The target version string to compare against (e.g., recommended version) + * @returns A positive number if `version` is greater than `target`, negative if less, or 0 if equal + */ +export function compareVersions(version: string, target: string): number { + const parts = version.split('.').map((part) => parseInt(part, 10) || 0) + const major = parts[0] || 0 + const minor = parts[1] || 0 + + const targetParts = target.split('.').map((part) => parseInt(part, 10) || 0) + const targetMajor = targetParts[0] || 0 + const targetMinor = targetParts[1] || 0 + + if (major !== targetMajor) { + return major - targetMajor + } + return minor - targetMinor +}
+ {{ + t({ + en: 'This application is not supported on mobile devices. Please access it from a desktop.', + zh: '该应用暂不支持移动端,请使用电脑访问' + }) + }} +