diff --git a/functions/modules/utils/node-cleaner.js b/functions/modules/utils/node-cleaner.js index 2b4aff08..47bbb409 100644 --- a/functions/modules/utils/node-cleaner.js +++ b/functions/modules/utils/node-cleaner.js @@ -20,9 +20,20 @@ export function fixNodeUrlEncoding(nodeUrl) { } }; + const decodeRepeated = (value) => { + let decoded = safeDecode(value); + if (decoded.includes('%')) { + const decodedTwice = safeDecode(decoded); + if (decodedTwice !== decoded) { + decoded = decodedTwice; + } + } + return decoded; + }; + // 辅助函数:判断是否需要保持原样(即解码后出现乱码) const shouldKeepRaw = (decoded) => { - return decoded.includes(''); + return decoded.includes('�'); }; let fixedUrl = nodeUrl; @@ -42,7 +53,7 @@ export function fixNodeUrlEncoding(nodeUrl) { // 修复 hash (节点名称) if (urlObj.hash) { const rawHash = urlObj.hash.substring(1); - const decodedHash = safeDecode(rawHash); + const decodedHash = decodeRepeated(rawHash); if (!shouldKeepRaw(decodedHash)) { urlObj.hash = '#' + encodeURIComponent(decodedHash); } @@ -68,13 +79,29 @@ export function fixSSEncoding(nodeUrl) { if (!nodeUrl.startsWith('ss://')) return nodeUrl; try { - const urlObj = new URL(nodeUrl); - if (urlObj.hash) { - try { - urlObj.hash = '#' + encodeURIComponent(decodeURIComponent(urlObj.hash.substring(1))); - } catch (e) { } + const hashIndex = nodeUrl.indexOf('#'); + const baseUrl = hashIndex === -1 ? nodeUrl : nodeUrl.substring(0, hashIndex); + const hashPart = hashIndex === -1 ? '' : nodeUrl.substring(hashIndex + 1); + + let fixedBase = baseUrl; + const prefix = 'ss://'; + if (baseUrl.startsWith(prefix)) { + const afterScheme = baseUrl.substring(prefix.length); + const atIndex = afterScheme.indexOf('@'); + if (atIndex !== -1) { + const base64Part = afterScheme.substring(0, atIndex); + const rest = afterScheme.substring(atIndex); + const decodedBase64 = base64Part.includes('%') ? decodeURIComponent(base64Part) : base64Part; + fixedBase = `${prefix}${decodedBase64}${rest}`; + } } - return urlObj.toString(); + + if (!hashPart) { + return fixedBase; + } + + const decodedName = decodeURIComponent(hashPart); + return `${fixedBase}#${encodeURIComponent(decodedName)}`; } catch (e) { const parts = nodeUrl.split('#'); if (parts.length > 1) { diff --git a/public/manifest.json b/public/manifest.json index 24705373..0381e26d 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,12 +2,14 @@ "name": "MiSub - 订阅转换器", "short_name": "MiSub", "description": "基于 Cloudflare 的订阅转换和管理工具", - "start_url": "/", + "start_url": "/?source=pwa", "display": "standalone", + "display_override": ["standalone", "minimal-ui", "browser"], "background_color": "#0f172a", "theme_color": "#4f46e5", "orientation": "portrait-primary", "scope": "/", + "id": "/?source=pwa", "lang": "zh-CN", "categories": ["productivity", "utilities"], "icons": [ diff --git a/public/offline.html b/public/offline.html index 1db570df..b75229cb 100644 --- a/public/offline.html +++ b/public/offline.html @@ -3,127 +3,207 @@ + MiSub - 离线模式
- - + +
- -

您当前处于离线状态

-

网络连接似乎出现了问题,但您仍然可以查看已缓存的内容。

- - - + +

当前离线

+

网络暂不可用,但已缓存的页面仍可浏览。恢复连接后将自动同步最新数据。

+ +
+ + 返回主页 +
+ +
+ + 离线状态 +
+
- 离线时可查看已缓存的订阅 + 已缓存的订阅和配置可继续查看
- 数据会在连接恢复后同步 + 恢复网络后自动尝试同步
- PWA应用离线体验优化 + 建议保持应用常驻以加速更新
- \ No newline at end of file + diff --git a/src/App.vue b/src/App.vue index 2a818aa2..b3a00547 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,13 +1,14 @@ @@ -101,7 +121,9 @@ const handleDiscard = async () => { class="grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6" :class="{ 'flex items-center justify-center': shouldCenterMain }" > -
Loading...
+
+ +
- + \ No newline at end of file + diff --git a/src/components/features/PWADevTools.vue b/src/components/features/PWADevTools.vue index f7933eb0..009d30a1 100644 --- a/src/components/features/PWADevTools.vue +++ b/src/components/features/PWADevTools.vue @@ -67,8 +67,10 @@ onMounted(() => {

PWA 开发工具

@@ -110,9 +118,11 @@ onMounted(() => { +
@@ -125,4 +132,4 @@ onUnmounted(() => window.removeEventListener('keydown', handleKeydown)); transform: scale(0.95); } } - \ No newline at end of file + diff --git a/src/components/forms/SmartSearch.vue b/src/components/forms/SmartSearch.vue index d99dbeb4..37543bb8 100644 --- a/src/components/forms/SmartSearch.vue +++ b/src/components/forms/SmartSearch.vue @@ -120,6 +120,7 @@ watch([searchResults, searchQuery], () => { v-model="searchQuery" type="text" :placeholder="placeholder" + :aria-label="placeholder" class="w-full pl-10 pr-12 py-3 bg-white/90 dark:bg-gray-900/80 backdrop-blur-md border border-gray-200 dark:border-gray-700 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-transparent smooth-all text-gray-900 dark:text-gray-100" @keyup.enter="addToHistory(searchQuery)" > @@ -127,8 +128,10 @@ watch([searchResults, searchQuery], () => { @@ -183,12 +190,14 @@ watch([searchResults, searchQuery], () => { - \ No newline at end of file + diff --git a/src/components/forms/SubConverterSelector.vue b/src/components/forms/SubConverterSelector.vue index ee0cd2b0..76efc4c9 100644 --- a/src/components/forms/SubConverterSelector.vue +++ b/src/components/forms/SubConverterSelector.vue @@ -78,6 +78,7 @@ const switchToSelect = () => { + type="text" + :value="modelValue" + @input="handleCustomInput" + :aria-label="type === 'backend' ? '自定义 SubConverter 后端' : '自定义远程配置'" + :placeholder="type === 'backend' ? '输入 SubConverter 后端地址 (不带 https://)' : '输入远程配置 URL'" + class="block w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl shadow-xs focus:outline-hidden focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm dark:text-white" + /> diff --git a/src/components/layout/Header.vue b/src/components/layout/Header.vue index ec3a8961..561fa597 100644 --- a/src/components/layout/Header.vue +++ b/src/components/layout/Header.vue @@ -1,16 +1,14 @@ @@ -249,9 +295,35 @@ onMounted(async () => { -

+

{{ heroConfig.description }}

+ +
+ + +
+ +
+
+

公开订阅

+

{{ profileCount }}

+
+
+

推荐客户端

+

{{ clientCount }}

+
+
+

导入方式

+

QR / Link

+
+
@@ -338,13 +410,14 @@ onMounted(async () => { - -
+
-
+
+
+
+
+
+
+
+
+ +
@@ -436,6 +519,15 @@ onMounted(async () => { animation-delay: 2s; } +@keyframes fade-rise { + 0% { opacity: 0; transform: translateY(12px); } + 100% { opacity: 1; transform: translateY(0); } +} + +.fade-rise-in { + animation: fade-rise 0.6s ease-out; +} + /* Custom Scrollbar for nicer feel */ ::-webkit-scrollbar { width: 6px; diff --git a/vite.config.js b/vite.config.js index 71f7ac83..34a3766f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -9,9 +9,10 @@ export default defineConfig({ vue(), tailwindcss(), VitePWA({ - registerType: 'autoUpdate', + registerType: 'prompt', workbox: { globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], + cleanupOutdatedCaches: true, // 使用离线回退页面,并显式忽略订阅路径 navigateFallback: '/index.html', navigateFallbackDenylist: [ @@ -20,6 +21,21 @@ export default defineConfig({ /^\/[^/]+\/[^/]+(\?.*)?$/ // Two-segment paths like /test1/work, optionally with query params ], runtimeCaching: [ + { + urlPattern: ({ request }) => request.mode === 'navigate', + handler: 'NetworkFirst', + options: { + cacheName: 'page-cache', + networkTimeoutSeconds: 5, + cacheableResponse: { + statuses: [0, 200] + }, + expiration: { + maxEntries: 30, + maxAgeSeconds: 24 * 60 * 60 + } + } + }, { urlPattern: /^\/cdn-cgi\/.*/, handler: 'NetworkOnly',