From 06ecdf66b6515b03780dcf2705405ac67cbb2e6b Mon Sep 17 00:00:00 2001 From: imzyb Date: Wed, 21 Jan 2026 00:03:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=95=B4=E7=90=86=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/modules/utils/node-cleaner.js | 43 ++++- public/manifest.json | 4 +- public/offline.html | 172 ++++++++++++----- src/App.vue | 84 +++++--- src/assets/main.css | 63 +++--- src/components/features/AnnouncementCard.vue | 8 +- .../features/Dashboard/Dashboard.vue | 12 +- .../features/Dashboard/DashboardBanner.vue | 11 +- .../features/Dashboard/DashboardHeader.vue | 8 +- .../features/Dashboard/SaveIndicator.vue | 4 +- src/components/features/PWADevTools.vue | 10 + src/components/features/PWAInstallPrompt.vue | 4 + src/components/features/PWAUpdatePrompt.vue | 6 +- src/components/features/ThemeToggle.vue | 5 +- src/components/forms/Modal.vue | 11 +- src/components/forms/SmartSearch.vue | 11 +- src/components/forms/SubConverterSelector.vue | 16 +- src/components/layout/Header.vue | 40 +++- src/components/layout/MobileBottomNav.vue | 2 + src/components/layout/NavBar.vue | 13 +- src/components/modals/BulkImportModal.vue | 8 +- src/components/modals/GuestbookModal.vue | 50 +++-- src/components/modals/LogModal.vue | 14 +- src/components/modals/Login.vue | 3 +- src/components/modals/ManualNodeEditModal.vue | 8 + src/components/modals/MigrationModal.vue | 4 +- .../NodePreview/NodePreviewContainer.vue | 2 + .../modals/NodePreview/NodePreviewHeader.vue | 4 +- .../modals/NodePreview/NodePreviewModal.vue | 4 + .../NodePreview/components/NodeCard.vue | 4 + .../NodePreview/components/NodeFilters.vue | 14 ++ .../NodePreview/components/NodeList.vue | 2 + .../NodePreview/components/NodePagination.vue | 10 + .../modals/ProfileModal/NodeSelector.vue | 11 +- .../modals/ProfileModal/ProfileForm.vue | 1 + .../ProfileModal/SubscriptionSelector.vue | 5 +- src/components/modals/QuickImportModal.vue | 14 +- .../SubscriptionEditModal/AdvancedOptions.vue | 4 +- .../SubscriptionEditModal/RuleSection.vue | 74 ++++--- .../modals/SubscriptionImport/ImportForm.vue | 1 + src/components/nodes/ManualNodeCard.vue | 18 +- src/components/nodes/ManualNodeList.vue | 16 +- src/components/nodes/ManualNodePanel.vue | 182 ++---------------- .../nodes/ManualNodePanel/BulkOperations.vue | 25 ++- .../nodes/ManualNodePanel/NodeActions.vue | 56 +++++- .../nodes/ManualNodePanel/NodeTable.vue | 135 ++++++++++--- src/components/profiles/ProfileCard.vue | 57 +++++- src/components/profiles/ProfilePanel.vue | 40 +++- src/components/profiles/RightPanel.vue | 18 +- src/components/public/HeroProfileCard.vue | 34 +++- src/components/public/ProfileCard.vue | 16 +- .../NodeTransformSettings/RuleEditor.vue | 50 +++-- .../NodeTransformSettings/RulePreview.vue | 1 + .../NodeTransformSettings/TagBuilder.vue | 21 +- src/components/settings/SettingsSidebar.vue | 7 +- .../settings/sections/BasicSettings.vue | 19 +- .../settings/sections/GuestbookManagement.vue | 36 ++-- .../sections/ServiceSettings/CronCard.vue | 24 ++- .../ServiceSettings/SubConverterCard.vue | 9 +- .../sections/ServiceSettings/TelegramCard.vue | 49 +++-- src/components/shared/DataGrid.vue | 14 +- src/components/shared/DragDropList.vue | 3 +- src/components/shared/FilterPanel.vue | 26 ++- src/components/shared/FormModal.vue | 4 +- .../subscriptions/SubscriptionPanel.vue | 55 +++++- src/components/ui/Card.vue | 56 ++++-- src/components/ui/EmptyState.vue | 6 +- src/components/ui/FluidButton.vue | 3 +- src/components/ui/Input.vue | 4 +- src/components/ui/ProgressiveDisclosure.vue | 5 +- src/components/ui/Switch.vue | 1 + src/components/ui/Toast.vue | 4 +- src/composables/manual-nodes/filters.js | 1 + src/composables/useManualNodes.js | 23 ++- src/composables/useSubscriptions.js | 11 +- src/stores/session.js | 4 +- src/utils/protocols/converters/vless.js | 2 +- src/utils/protocols/converters/vmess.js | 2 +- src/views/Entrance.vue | 35 ++-- src/views/HomeView.vue | 5 +- src/views/PublicProfilesView.vue | 108 ++++++++++- vite.config.js | 18 +- 82 files changed, 1385 insertions(+), 582 deletions(-) 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', From 39fc3e6e0892622927ed6a15f0408bd9ada44e0b Mon Sep 17 00:00:00 2001 From: imzyb Date: Wed, 21 Jan 2026 00:23:23 +0800 Subject: [PATCH 2/2] fix: ensure login waits for session check --- src/stores/session.js | 62 ++++++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/src/stores/session.js b/src/stores/session.js index 3a81cc50..bc95b6c0 100644 --- a/src/stores/session.js +++ b/src/stores/session.js @@ -13,30 +13,33 @@ export const useSessionStore = defineStore('session', () => { const isConfigReady = ref(false); async function checkSession() { - // Parallel fetch of initial data (auth check) and public config - const [dataResult, pConfigResult] = await Promise.all([ - fetchInitialData(), - fetchPublicConfig() - ]); + try { + // Parallel fetch of initial data (auth check) and public config + const [dataResult, pConfigResult] = await Promise.all([ + fetchInitialData(), + fetchPublicConfig() + ]); - // Update public config - if (pConfigResult.success) { - publicConfig.value = pConfigResult.data; - } else { - // Fallback to default if fetch fails - publicConfig.value = { enablePublicPage: false }; - } - isConfigReady.value = true; + // Update public config + if (pConfigResult.success) { + publicConfig.value = pConfigResult.data; + } else { + // Fallback to default if fetch fails + publicConfig.value = { enablePublicPage: false }; + } + isConfigReady.value = true; - if (dataResult.success) { - initialData.value = dataResult.data; + if (dataResult.success) { + initialData.value = dataResult.data; - // 直接注入数据到 dataStore,避免 Dashboard 重复请求 - const dataStore = useDataStore(); - dataStore.hydrateFromData(dataResult.data); + // 直接注入数据到 dataStore,避免 Dashboard 重复请求 + const dataStore = useDataStore(); + dataStore.hydrateFromData(dataResult.data); + + sessionState.value = 'loggedIn'; + return true; + } - sessionState.value = 'loggedIn'; - } else { // Auth failed or other error if (dataResult.errorType === 'auth') { sessionState.value = 'loggedOut'; @@ -45,23 +48,32 @@ export const useSessionStore = defineStore('session', () => { console.error("Session check failed:", dataResult.error); sessionState.value = 'loggedOut'; } + return false; + } catch (error) { + console.error('Session check failed unexpectedly:', error); + sessionState.value = 'loggedOut'; + return false; } } async function login(password) { const result = await apiLogin(password); if (result.success) { - handleLoginSuccess(); - // 登录成功后跳转到首页 (HomeView will show Dashboard) - router.push({ path: '/' }); + const success = await handleLoginSuccess(); + if (success) { + // 登录成功后跳转到首页 (HomeView will show Dashboard) + await router.push({ path: '/' }); + } else { + throw new Error('登录后校验失败,请稍后重试'); + } } else { throw new Error(result.error || '登录失败'); } } - function handleLoginSuccess() { + async function handleLoginSuccess() { sessionState.value = 'loading'; - checkSession(); + return checkSession(); } async function logout() {