├── image.png ├── image-1.png ├── image-2.png ├── image-3.png ├── image-4.png ├── postcss.config.cjs ├── src ├── main.js ├── components │ ├── Footer.vue │ ├── SkeletonCard.vue │ ├── DashboardSkeleton.vue │ ├── Overview.vue │ ├── BulkImportModal.vue │ ├── Toast.vue │ ├── ThemeToggle.vue │ ├── Header.vue │ ├── ProfileCard.vue │ ├── Login.vue │ ├── Modal.vue │ ├── ManualNodeCard.vue │ ├── RightPanel.vue │ ├── SettingsModal.vue │ ├── ProfileModal.vue │ ├── Card.vue │ └── Dashboard.vue ├── assets │ └── main.css ├── lib │ ├── useAnimatedCounter.js │ ├── stores.js │ ├── api.js │ └── utils.js ├── composables │ ├── useManualNodes.js │ └── useSubscriptions.js └── App.vue ├── tailwind.config.cjs ├── .gitignore ├── vite.config.js ├── wrangler.toml ├── package.json ├── index.html ├── README.md └── functions └── [[path]].js /image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/cokoMiSub/main/image.png -------------------------------------------------------------------------------- /image-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/cokoMiSub/main/image-1.png -------------------------------------------------------------------------------- /image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/cokoMiSub/main/image-2.png -------------------------------------------------------------------------------- /image-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/cokoMiSub/main/image-3.png -------------------------------------------------------------------------------- /image-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coko8023/cokoMiSub/main/image-4.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import './assets/main.css' 3 | import App from './App.vue' 4 | 5 | createApp(App).mount('#app') -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{vue,js,ts,jsx,tsx}", 6 | ], 7 | darkMode: 'class', 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Build output 11 | dist 12 | dist-ssr 13 | 14 | # Dependencies 15 | /node_modules/ 16 | .wrangler/ 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | .vscode 26 | # Misc 27 | .DS_Store -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | // 我们移除了所有 alias 配置 7 | server: { 8 | proxy: { 9 | '/api': { 10 | target: 'http://127.0.0.1:8787', 11 | changeOrigin: true, 12 | }, 13 | '/sub': { 14 | target: 'http://127.0.0.1:8787', 15 | changeOrigin: true, 16 | } 17 | } 18 | } 19 | }) -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "misub" 2 | main = "functions/[[path]].js" 3 | compatibility_date = "2024-06-13" 4 | 5 | # KV 命名空间绑定 6 | # 这是 wrangler.toml 在Pages项目中最重要的作用 7 | [[kv_namespaces]] 8 | binding = "MISUB_KV" 9 | id = "9796c49ed5a3405aabbbc066d4686ce3" # 请确保这里是你正确的 KV ID 10 | preview_id = "db1e91b764d14234b38c0a233878b040" # 请确保这里是你正确的预览 KV ID 11 | 12 | # 你也可以在这里定义环境变量,但更推荐在Cloudflare控制面板设置 13 | [vars] 14 | ADMIN_PASSWORD = "admin" 15 | COOKIE_SECRET = "a_very_long_random_secret_string" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "misub-vue", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "clsx": "^2.1.1", 13 | "js-yaml": "^4.1.0", 14 | "vue": "^3.4.27", 15 | "vuedraggable": "^4.1.0" 16 | }, 17 | "devDependencies": { 18 | "@vitejs/plugin-vue": "^5.0.4", 19 | "autoprefixer": "^10.4.19", 20 | "miniflare": "^4.20250604.1", 21 | "postcss": "^8.4.38", 22 | "tailwindcss": "^3.4.3", 23 | "vite": "^5.2.11" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/SkeletonCard.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MISUB 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/DashboardSkeleton.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | /* 确保根元素充满整个视口高度 */ 2 | html, body, #app { 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | background-color: #F9FAFB; /* 浅色模式下的默认背景色 */ 7 | } 8 | 9 | /* 暗黑模式下的背景色 */ 10 | html.dark, html.dark body, html.dark #app { 11 | background-color: #030712; /* 深灰色 */ 12 | } 13 | 14 | @tailwind base; 15 | @tailwind components; 16 | @tailwind utilities; 17 | 18 | /* 全局字体渲染优化 */ 19 | body { 20 | -webkit-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | } 23 | 24 | /* 自定义滚动条样式,使其与主题匹配 */ 25 | ::-webkit-scrollbar { 26 | width: 6px; 27 | height: 6px; 28 | } 29 | ::-webkit-scrollbar-track { 30 | background-color: transparent; 31 | } 32 | ::-webkit-scrollbar-thumb { 33 | background-color: #4f46e5; /* 靛蓝色 */ 34 | border-radius: 3px; 35 | } 36 | ::-webkit-scrollbar-thumb:hover { 37 | background-color: #4338ca; /* 深一点的靛蓝色 */ 38 | } 39 | 40 | /* 为所有按钮增加一个更柔和的点击动效 */ 41 | button { 42 | /* 确保所有变换都有平滑过渡 */ 43 | transition: all 0.2s ease-in-out; 44 | } 45 | button:active { 46 | /* 点击时轻微缩放,提供物理反馈 */ 47 | transform: scale(0.95); 48 | } -------------------------------------------------------------------------------- /src/components/Overview.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/BulkImportModal.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/Toast.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/useAnimatedCounter.js: -------------------------------------------------------------------------------- 1 | import { ref, watch } from 'vue'; 2 | 3 | /** 4 | * 一个Vue组合式函数,接收一个响应式数字,返回一个带动画效果的响应式数字。 5 | * @param {import('vue').Ref} targetValue - 目标数字 (必须是一个ref或computed) 6 | * @returns {import('vue').Ref} - 一个会从旧值动画到新值的ref 7 | */ 8 | export function useAnimatedCounter(targetValue) { 9 | const displayValue = ref(targetValue.value || 0); 10 | let animationFrameId = null; 11 | 12 | const animate = (from, to) => { 13 | const duration = 700; // 动画持续时间 (毫秒) 14 | const start = performance.now(); 15 | 16 | const update = (now) => { 17 | const elapsed = now - start; 18 | const progress = Math.min(elapsed / duration, 1); 19 | 20 | // 使用缓动函数让动画效果更自然 (ease-out) 21 | const easedProgress = 1 - Math.pow(1 - progress, 3); 22 | 23 | displayValue.value = Math.round(from + (to - from) * easedProgress); 24 | 25 | if (progress < 1) { 26 | animationFrameId = requestAnimationFrame(update); 27 | } 28 | }; 29 | animationFrameId = requestAnimationFrame(update); 30 | }; 31 | 32 | // 监听目标值的变化,一旦变化就触发动画 33 | watch(targetValue, (newValue, oldValue) => { 34 | if (animationFrameId) { 35 | cancelAnimationFrame(animationFrameId); 36 | } 37 | animate(oldValue || 0, newValue || 0); 38 | }); 39 | 40 | return displayValue; 41 | } -------------------------------------------------------------------------------- /src/lib/stores.js: -------------------------------------------------------------------------------- 1 | import { reactive, readonly, ref } from 'vue'; 2 | 3 | // --- Theme Store --- 4 | const themeState = reactive({ current: 'dark' }); 5 | export const useTheme = () => { 6 | const toggleTheme = () => { 7 | const newTheme = themeState.current === 'dark' ? 'light' : 'dark'; 8 | themeState.current = newTheme; 9 | document.documentElement.classList.toggle('dark', newTheme === 'dark'); 10 | localStorage.setItem('theme', newTheme); 11 | }; 12 | const initTheme = () => { 13 | if (typeof window === 'undefined') return; 14 | const storedTheme = localStorage.getItem('theme'); 15 | const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; 16 | const initialTheme = storedTheme || systemTheme; 17 | themeState.current = initialTheme; 18 | document.documentElement.classList.toggle('dark', initialTheme === 'dark'); 19 | }; 20 | return { theme: readonly(themeState), toggleTheme, initTheme }; 21 | }; 22 | 23 | // --- Toast Store --- 24 | const toastState = reactive({ message: '', type: 'success', id: 0 }); 25 | let toastTimeout; 26 | export const useToast = () => { 27 | const showToast = (message, type = 'success', duration = 3000) => { 28 | toastState.message = message; 29 | toastState.type = type; 30 | toastState.id = Date.now(); 31 | clearTimeout(toastTimeout); 32 | toastTimeout = setTimeout(() => { toastState.message = ''; }, duration); 33 | }; 34 | return { toast: readonly(toastState), showToast }; 35 | }; 36 | 37 | // --- Settings Modal Store --- 38 | export const showSettingsModal = ref(false); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MiSub 2 | 3 | 一个完全靠AI生成的订阅转换器。 4 | 5 | # 重大更新 增加分组功能,修改布局 6 | 7 | * 增加了分组功能,更改了KV的结构,请各位已经fork了的朋友们,先同步至最新版本,然后在 域名 后面加上 /api/migrate 进行一次数据迁移。 8 | * 增加了分组功能,更改了KV的结构,请各位已经fork了的朋友们,先同步至最新版本,然后在 域名 后面加上 /api/migrate 进行一次数据迁移。 9 | * 增加了分组功能,更改了KV的结构,请各位已经fork了的朋友们,先同步至最新版本,然后在 域名 后面加上 /api/migrate 进行一次数据迁移。 10 | 11 | 重要的事情说三遍 12 | 13 | ### 致谢 14 | 本项目的是用CM大佬的项目 [CF-Workers-SUB](https://github.com/cmliu/CF-Workers-SUB) 丢给AI进化而来,感谢CM大佬。 15 | 16 | MiSub 应用截图 17 | 18 | ![登录界面](image-3.png) 19 | 20 | ![管理界面](image-4.png) 21 | 22 | --- 23 | 24 | ### ✨ 主要功能 25 | 26 | MiSub 不仅仅是一个简单的订阅转换工具,它经过了深度的架构优化和体验打磨,具备以下核心功能: 27 | 28 | * **订阅与节点分离管理**: 业内首创(可能)将“机场订阅”和“手动单节点”作为两种不同实体进行独立管理,逻辑清晰,互不干扰。 29 | * **强大的订阅生成**: 30 | * **自适应链接**: 一条链接,可智能识别客户端类型 (Clash, Surge 等) 并下发对应配置。 31 | * **专属客户端链接**: 为 Clash, Sing-Box, Surge, Loon, QuanX, Snell 等主流客户端提供专属格式的订阅链接。 32 | * **Base64 格式**: 支持为 V2RayN/V2RayNG 等客户端生成 Base64 编码的节点列表。 33 | * **智能批量导入**: 一键粘贴多个订阅链接或节点链接,系统会自动分类,分别存入“订阅”和“手动节点”列表。 34 | * **在线配置与持久化**: 所有订阅和节点数据、以及自定义设置(如输出文件名、`subconverter` 地址等)都通过 Cloudflare KV 在线持久化存储。 35 | * **精致的 UI/UX**: 36 | * 支持明亮/暗黑模式,并能跟随系统自动切换。 37 | * 带有“磨砂玻璃”质感的现代化卡片设计,风格统一。 38 | * 对长列表(订阅和手动节点)提供分页功能,保证大量数据下的性能和可用性。 39 | * 完善的交互逻辑,如“放弃更改”、模态框编辑、加载状态提示等。 40 | * **密码保护**: 管理界面由管理员密码进行保护。 41 | 42 | ### 🚀 技术栈 43 | 44 | * **前端**: Vue 3 + Vite + Tailwind CSS 45 | * **后端**: Cloudflare Pages Functions 46 | * **数据存储**: Cloudflare KV 47 | 48 | ### 🛠️ 部署指南 49 | 50 | 本项目天生为 Cloudflare Pages 设计,可以一键部署。 51 | 52 | #### 1. Fork 本仓库 53 | 将此项目 Fork 到你自己的 GitHub 账户下。 54 | 55 | #### 2. 创建 Cloudflare Pages 项目 56 | * 登录 Cloudflare 控制台,进入 `Pages`。 57 | * 选择“创建项目” -> “连接到 Git”。 58 | * 选择你刚刚 Fork 的仓库。 59 | * 在 **“设置构建和部署”** 页面,构建设置如下: 60 | * **框架预设**: `Vue` 61 | * **构建命令**: `npm run build` 62 | * **构建输出目录**: `dist` 63 | 64 | ![KV](image-1.png) 65 | 66 | #### 3. 绑定 KV 命名空间 67 | 在项目创建完成后,进入该项目的“设置” -> “函数” -> “KV 命名空间绑定”。 68 | * 点击“添加绑定”。 69 | * **变量名称 (Variable name)**: `MISUB_KV` 70 | * **KV 命名空间 (KV namespace)**: 选择或创建一个你自己的 KV 命名空间。 71 | 72 | #### 4. 设置环境变量 73 | 在项目的“设置” -> “环境变量”中,添加以下两个**生产环境**变量: 74 | * `ADMIN_PASSWORD` 75 | * **值**: 设置一个你自己的管理员登录密码。 76 | * `COOKIE_SECRET` 77 | * **值**: 设置一个用于加密 Cookie 的、足够长且随机的字符串(例如,你可以使用密码生成器生成一个64位的随机字符串)。 78 | 79 | ![变量](image.png) 80 | 81 | #### 5. 部署! 82 | 完成以上设置后,回到“部署”选项卡,重新部署一次你的项目。现在,你的 MiSub 就可以通过 Cloudflare 提供的域名访问了! 83 | 84 | ### License 85 | [MIT](./LICENSE) -------------------------------------------------------------------------------- /src/composables/useManualNodes.js: -------------------------------------------------------------------------------- 1 | // FILE: src/composables/useManualNodes.js 2 | import { ref, computed, watch } from 'vue'; 3 | 4 | export function useManualNodes(initialNodesRef, markDirty) { 5 | const manualNodes = ref([]); 6 | const manualNodesCurrentPage = ref(1); 7 | const manualNodesPerPage = 24; 8 | 9 | // 【关键修正】: 将内部所有辅助函数从 'const' 改为 'function' 声明 10 | 11 | function initializeManualNodes(nodesData) { 12 | manualNodes.value = (nodesData || []).map(node => ({ 13 | ...node, id: crypto.randomUUID(), enabled: node.enabled ?? true, 14 | })); 15 | } 16 | 17 | const manualNodesTotalPages = computed(() => Math.ceil(manualNodes.value.length / manualNodesPerPage)); 18 | const paginatedManualNodes = computed(() => { 19 | const start = (manualNodesCurrentPage.value - 1) * manualNodesPerPage; 20 | const end = start + manualNodesPerPage; 21 | return manualNodes.value.slice(start, end); 22 | }); 23 | const enabledManualNodes = computed(() => manualNodes.value.filter(n => n.enabled)); 24 | 25 | function changeManualNodesPage(page) { 26 | if (page < 1 || page > manualNodesTotalPages.value) return; 27 | manualNodesCurrentPage.value = page; 28 | } 29 | 30 | function addNode(node) { 31 | manualNodes.value.unshift(node); 32 | manualNodesCurrentPage.value = 1; 33 | markDirty(); 34 | } 35 | 36 | function updateNode(updatedNode) { 37 | const index = manualNodes.value.findIndex(n => n.id === updatedNode.id); 38 | if (index !== -1) { 39 | manualNodes.value[index] = updatedNode; 40 | markDirty(); 41 | } 42 | } 43 | 44 | function deleteNode(nodeId) { 45 | manualNodes.value = manualNodes.value.filter(n => n.id !== nodeId); 46 | if (paginatedManualNodes.value.length === 0 && manualNodesCurrentPage.value > 1) { 47 | manualNodesCurrentPage.value--; 48 | } 49 | markDirty(); 50 | } 51 | 52 | function deleteAllNodes() { 53 | manualNodes.value = []; 54 | manualNodesCurrentPage.value = 1; 55 | markDirty(); 56 | } 57 | 58 | function addNodesFromBulk(nodes) { 59 | manualNodes.value.unshift(...nodes); 60 | markDirty(); 61 | } 62 | 63 | watch(initialNodesRef, (newInitialNodes) => { 64 | initializeManualNodes(newInitialNodes); 65 | }, { immediate: true, deep: true }); 66 | 67 | return { 68 | manualNodes, manualNodesCurrentPage, manualNodesTotalPages, paginatedManualNodes, 69 | enabledManualNodesCount: computed(() => enabledManualNodes.value.length), 70 | changeManualNodesPage, addNode, updateNode, deleteNode, // <-- 【最终修正】在这里加上它 71 | deleteAllNodes, addNodesFromBulk, 72 | }; 73 | } -------------------------------------------------------------------------------- /src/lib/api.js: -------------------------------------------------------------------------------- 1 | // 2 | // src/lib/api.js 3 | // 4 | export async function fetchInitialData() { 5 | try { 6 | const response = await fetch('/api/data'); 7 | if (!response.ok) { 8 | console.error("Session invalid or API error, status:", response.status); 9 | return null; 10 | } 11 | // 后端已经更新,会返回 { misubs, profiles, config } 12 | return await response.json(); 13 | } catch (error) { 14 | console.error("Failed to fetch initial data:", error); 15 | return null; 16 | } 17 | } 18 | 19 | export async function login(password) { 20 | try { 21 | const response = await fetch('/api/login', { 22 | method: 'POST', 23 | headers: { 'Content-Type': 'application/json' }, 24 | body: JSON.stringify({ password }) 25 | }); 26 | return response; 27 | } catch (error) { 28 | console.error("Login request failed:", error); 29 | return { ok: false, error: '网络请求失败' }; 30 | } 31 | } 32 | 33 | // [核心修改] saveMisubs 现在接收并发送 profiles 34 | export async function saveMisubs(misubs, profiles) { 35 | try { 36 | const response = await fetch('/api/misubs', { 37 | method: 'POST', 38 | headers: { 'Content-Type': 'application/json' }, 39 | // 将 misubs 和 profiles 一起发送 40 | body: JSON.stringify({ misubs, profiles }) 41 | }); 42 | return await response.json(); 43 | } catch (error) { 44 | return { success: false, message: '网络请求失败' }; 45 | } 46 | } 47 | 48 | export async function fetchNodeCount(subUrl) { 49 | try { 50 | const res = await fetch('/api/node_count', { 51 | method: 'POST', 52 | headers: { 'Content-Type': 'application/json' }, 53 | body: JSON.stringify({ url: subUrl }) 54 | }); 55 | const data = await res.json(); 56 | return data; // [修正] 直接返回整个对象 { count, userInfo } 57 | } catch (e) { 58 | console.error('fetchNodeCount error:', e); 59 | return { count: 0, userInfo: null }; 60 | } 61 | } 62 | 63 | export async function fetchSettings() { 64 | try { 65 | const response = await fetch('/api/settings'); 66 | if (!response.ok) return {}; 67 | return await response.json(); 68 | } catch (error) { 69 | console.error("Failed to fetch settings:", error); 70 | return {}; 71 | } 72 | } 73 | 74 | export async function saveSettings(settings) { 75 | try { 76 | const response = await fetch('/api/settings', { 77 | method: 'POST', 78 | headers: { 'Content-Type': 'application/json' }, 79 | body: JSON.stringify(settings) 80 | }); 81 | return await response.json(); 82 | } catch (error) { 83 | return { success: false, message: '网络请求失败' }; 84 | } 85 | } -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 91 | 92 | -------------------------------------------------------------------------------- /src/components/Header.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | // 2 | // src/lib/utils.js 3 | // 4 | export function extractNodeName(url) { 5 | if (!url) return ''; 6 | url = url.trim(); 7 | try { 8 | const hashIndex = url.indexOf('#'); 9 | if (hashIndex !== -1 && hashIndex < url.length - 1) { 10 | return decodeURIComponent(url.substring(hashIndex + 1)).trim(); 11 | } 12 | const protocolIndex = url.indexOf('://'); 13 | if (protocolIndex === -1) return ''; 14 | const protocol = url.substring(0, protocolIndex); 15 | const mainPart = url.substring(protocolIndex + 3).split('#')[0]; 16 | switch (protocol) { 17 | case 'vmess': { 18 | // 修正:使用现代方法正确解码包含UTF-8字符的Base64 19 | let padded = mainPart.padEnd(mainPart.length + (4 - mainPart.length % 4) % 4, '='); 20 | let ps = ''; 21 | try { 22 | // 1. 使用 atob 将 Base64 解码为二进制字符串 23 | const binaryString = atob(padded); 24 | 25 | // 2. 将二进制字符串转换为 Uint8Array 字节数组 26 | const bytes = new Uint8Array(binaryString.length); 27 | for (let i = 0; i < binaryString.length; i++) { 28 | bytes[i] = binaryString.charCodeAt(i); 29 | } 30 | 31 | // 3. 使用 TextDecoder 将字节解码为正确的 UTF-8 字符串 32 | const jsonString = new TextDecoder('utf-8').decode(bytes); 33 | 34 | // 4. 解析 JSON 35 | const node = JSON.parse(jsonString); 36 | 37 | // 5. 直接获取节点名称,此时已是正确解码的字符串,无需再次处理 38 | ps = node.ps || ''; 39 | } catch (e) { 40 | // 如果解码失败,可以保留一个回退逻辑,或者直接返回空字符串 41 | console.error("Failed to decode vmess link:", e); 42 | } 43 | return ps; 44 | } 45 | case 'trojan': 46 | case 'vless': return mainPart.substring(mainPart.indexOf('@') + 1).split(':')[0] || ''; 47 | case 'ss': 48 | const atIndexSS = mainPart.indexOf('@'); 49 | if (atIndexSS !== -1) return mainPart.substring(atIndexSS + 1).split(':')[0] || ''; 50 | const decodedSS = atob(mainPart); 51 | const ssDecodedAtIndex = decodedSS.indexOf('@'); 52 | if (ssDecodedAtIndex !== -1) return decodedSS.substring(ssDecodedAtIndex + 1).split(':')[0] || ''; 53 | return ''; 54 | default: 55 | if(url.startsWith('http')) return new URL(url).hostname; 56 | return ''; 57 | } 58 | } catch (e) { return url.substring(0, 50); } 59 | } 60 | 61 | /** 62 | * 为节点链接添加名称前缀 63 | * @param {string} link - 原始节点链接 64 | * @param {string} prefix - 要添加的前缀 (通常是订阅名) 65 | * @returns {string} - 添加了前缀的新链接 66 | */ 67 | export function prependNodeName(link, prefix) { 68 | if (!prefix) return link; // 如果没有前缀,直接返回原链接 69 | 70 | const hashIndex = link.lastIndexOf('#'); 71 | 72 | // 如果链接没有 #fragment 73 | if (hashIndex === -1) { 74 | return `${link}#${encodeURIComponent(prefix)}`; 75 | } 76 | 77 | const baseLink = link.substring(0, hashIndex); 78 | const originalName = decodeURIComponent(link.substring(hashIndex + 1)); 79 | 80 | // 如果原始名称已经包含了前缀,则不再重复添加 81 | if (originalName.startsWith(prefix)) { 82 | return link; 83 | } 84 | 85 | const newName = `${prefix} - ${originalName}`; 86 | return `${baseLink}#${encodeURIComponent(newName)}`; 87 | } -------------------------------------------------------------------------------- /src/components/ProfileCard.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/Login.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | -------------------------------------------------------------------------------- /src/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 83 | 84 | -------------------------------------------------------------------------------- /src/composables/useSubscriptions.js: -------------------------------------------------------------------------------- 1 | // FILE: src/composables/useSubscriptions.js 2 | import { ref, computed, watch } from 'vue'; 3 | import { fetchNodeCount } from '../lib/api.js'; 4 | import { useToast } from '../lib/stores.js'; 5 | 6 | export function useSubscriptions(initialSubsRef, markDirty) { 7 | const { showToast } = useToast(); 8 | const subscriptions = ref([]); 9 | const subsCurrentPage = ref(1); 10 | const subsItemsPerPage = 3; 11 | 12 | // 【关键修正】: 将内部所有辅助函数从 'const' 改为 'function' 声明 13 | 14 | function initializeSubscriptions(subsData) { 15 | subscriptions.value = (subsData || []).map(sub => ({ 16 | ...sub, id: crypto.randomUUID(), enabled: sub.enabled ?? true, nodeCount: sub.nodeCount || 0, isUpdating: false, 17 | })); 18 | subscriptions.value.forEach(sub => handleUpdateNodeCount(sub.id, true)); 19 | } 20 | 21 | const enabledSubscriptions = computed(() => subscriptions.value.filter(s => s.enabled)); 22 | const nodesFromSubs = computed(() => 23 | enabledSubscriptions.value.reduce((acc, sub) => { 24 | const count = parseInt(sub.nodeCount, 10); 25 | return acc + (isNaN(count) ? 0 : count); 26 | }, 0) 27 | ); 28 | const subsTotalPages = computed(() => Math.ceil(subscriptions.value.length / subsItemsPerPage)); 29 | const paginatedSubscriptions = computed(() => { 30 | const start = (subsCurrentPage.value - 1) * subsItemsPerPage; 31 | const end = start + subsItemsPerPage; 32 | return subscriptions.value.slice(start, end); 33 | }); 34 | 35 | function changeSubsPage(page) { 36 | if (page < 1 || page > subsTotalPages.value) return; 37 | subsCurrentPage.value = page; 38 | } 39 | 40 | async function handleUpdateNodeCount(subId, isInitialLoad = false) { 41 | const subToUpdate = subscriptions.value.find(s => s.id === subId); 42 | if (!subToUpdate || !subToUpdate.url.startsWith('http')) return; 43 | subToUpdate.isUpdating = true; 44 | try { 45 | const count = await fetchNodeCount(subToUpdate.url); 46 | subToUpdate.nodeCount = typeof count === 'number' ? count : 0; 47 | if (!isInitialLoad) { 48 | showToast(`${subToUpdate.name || '订阅'} 更新成功!`, 'success'); 49 | markDirty(); 50 | } 51 | } catch (error) { 52 | if (!isInitialLoad) showToast(`${subToUpdate.name || '订阅'} 更新失败`, 'error'); 53 | } finally { 54 | subToUpdate.isUpdating = false; 55 | } 56 | } 57 | 58 | function addSubscription(sub) { 59 | subscriptions.value.unshift(sub); 60 | subsCurrentPage.value = 1; 61 | handleUpdateNodeCount(sub.id); 62 | markDirty(); 63 | } 64 | 65 | function updateSubscription(updatedSub) { 66 | const index = subscriptions.value.findIndex(s => s.id === updatedSub.id); 67 | if (index !== -1) { 68 | if (subscriptions.value[index].url !== updatedSub.url) { 69 | updatedSub.nodeCount = 0; 70 | handleUpdateNodeCount(updatedSub.id); 71 | } 72 | subscriptions.value[index] = updatedSub; 73 | markDirty(); 74 | } 75 | } 76 | 77 | function deleteSubscription(subId) { 78 | subscriptions.value = subscriptions.value.filter((s) => s.id !== subId); 79 | if (paginatedSubscriptions.value.length === 0 && subsCurrentPage.value > 1) { 80 | subsCurrentPage.value--; 81 | } 82 | markDirty(); 83 | } 84 | 85 | function deleteAllSubscriptions() { 86 | subscriptions.value = []; 87 | subsCurrentPage.value = 1; 88 | markDirty(); 89 | } 90 | 91 | watch(initialSubsRef, (newInitialSubs) => { 92 | initializeSubscriptions(newInitialSubs); 93 | }, { immediate: true, deep: true }); 94 | 95 | return { 96 | subscriptions, subsCurrentPage, subsTotalPages, paginatedSubscriptions, 97 | nodesFromSubs, enabledSubscriptionsCount: computed(() => enabledSubscriptions.value.length), 98 | changeSubsPage, addSubscription, updateSubscription, deleteSubscription, deleteAllSubscriptions, 99 | handleUpdateNodeCount, // <-- 【关键修正】在这里加上它 100 | }; 101 | } -------------------------------------------------------------------------------- /src/components/ManualNodeCard.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | -------------------------------------------------------------------------------- /src/components/RightPanel.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 124 | 125 | -------------------------------------------------------------------------------- /src/components/SettingsModal.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | -------------------------------------------------------------------------------- /src/components/ProfileModal.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | -------------------------------------------------------------------------------- /src/components/Card.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | -------------------------------------------------------------------------------- /functions/[[path]].js: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml'; 2 | 3 | const OLD_KV_KEY = 'misub_data_v1'; 4 | const KV_KEY_SUBS = 'misub_subscriptions_v1'; 5 | const KV_KEY_PROFILES = 'misub_profiles_v1'; 6 | const KV_KEY_SETTINGS = 'worker_settings_v1'; 7 | const COOKIE_NAME = 'auth_session'; 8 | const SESSION_DURATION = 8 * 60 * 60 * 1000; 9 | 10 | const defaultSettings = { 11 | FileName: 'MiSub', 12 | mytoken: 'auto', 13 | subConverter: 'url.v1.mk', 14 | subConfig: 'https://raw.githubusercontent.com/cmliu/ACL4SSR/main/Clash/config/ACL4SSR_Online_MultiCountry.ini', 15 | prependSubName: true 16 | }; 17 | 18 | // --- TG 通知函式 (无修改) --- 19 | async function sendTgNotification(settings, message) { 20 | if (!settings.BotToken || !settings.ChatID) { 21 | console.log("TG BotToken or ChatID not set, skipping notification."); 22 | return; 23 | } 24 | const url = `https://api.telegram.org/bot${settings.BotToken}/sendMessage`; 25 | const payload = { chat_id: settings.ChatID, text: message, parse_mode: 'Markdown' }; 26 | try { 27 | const response = await fetch(url, { 28 | method: 'POST', 29 | headers: { 'Content-Type': 'application/json' }, 30 | body: JSON.stringify(payload) 31 | }); 32 | if (response.ok) { 33 | console.log("TG notification sent successfully."); 34 | } else { 35 | const errorData = await response.json(); 36 | console.error("Failed to send TG notification:", errorData); 37 | } 38 | } catch (error) { 39 | console.error("Error sending TG notification:", error); 40 | } 41 | } 42 | 43 | // --- 认证与API处理的核心函数 (无修改) --- 44 | async function createSignedToken(key, data) { 45 | if (!key || !data) throw new Error("Key and data are required for signing."); 46 | const encoder = new TextEncoder(); 47 | const keyData = encoder.encode(key); 48 | const dataToSign = encoder.encode(data); 49 | const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); 50 | const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataToSign); 51 | return `${data}.${Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('')}`; 52 | } 53 | async function verifySignedToken(key, token) { 54 | if (!key || !token) return null; 55 | const parts = token.split('.'); 56 | if (parts.length !== 2) return null; 57 | const [data] = parts; 58 | const expectedToken = await createSignedToken(key, data); 59 | return token === expectedToken ? data : null; 60 | } 61 | async function authMiddleware(request, env) { 62 | if (!env.COOKIE_SECRET) return false; 63 | const cookie = request.headers.get('Cookie'); 64 | const sessionCookie = cookie?.split(';').find(c => c.trim().startsWith(`${COOKIE_NAME}=`)); 65 | if (!sessionCookie) return false; 66 | const token = sessionCookie.split('=')[1]; 67 | const verifiedData = await verifySignedToken(env.COOKIE_SECRET, token); 68 | return verifiedData && (Date.now() - parseInt(verifiedData, 10) < SESSION_DURATION); 69 | } 70 | 71 | // --- 主要 API 請求處理 --- 72 | async function handleApiRequest(request, env) { 73 | const url = new URL(request.url); 74 | const path = url.pathname.replace(/^\/api/, ''); 75 | // [新增] 安全的、可重复执行的迁移接口 76 | if (path === '/migrate') { 77 | if (!await authMiddleware(request, env)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); } 78 | try { 79 | const oldData = await env.MISUB_KV.get(OLD_KV_KEY, 'json'); 80 | const newDataExists = await env.MISUB_KV.get(KV_KEY_SUBS) !== null; 81 | 82 | if (newDataExists) { 83 | return new Response(JSON.stringify({ success: true, message: '无需迁移,数据已是最新结构。' }), { status: 200 }); 84 | } 85 | 86 | if (!oldData) { 87 | return new Response(JSON.stringify({ success: false, message: '未找到需要迁移的旧数据。' }), { status: 404 }); 88 | } 89 | 90 | await env.MISUB_KV.put(KV_KEY_SUBS, JSON.stringify(oldData)); 91 | await env.MISUB_KV.put(KV_KEY_PROFILES, JSON.stringify([])); 92 | 93 | // 将旧键重命名,防止重复迁移 94 | await env.MISUB_KV.put(OLD_KV_KEY + '_migrated_on_' + new Date().toISOString(), JSON.stringify(oldData)); 95 | await env.MISUB_KV.delete(OLD_KV_KEY); 96 | 97 | return new Response(JSON.stringify({ success: true, message: '数据迁移成功!' }), { status: 200 }); 98 | 99 | } catch (e) { 100 | return new Response(JSON.stringify({ success: false, message: `迁移失败: ${e.message}` }), { status: 500 }); 101 | } 102 | } 103 | 104 | 105 | if (path !== '/login') { 106 | if (!await authMiddleware(request, env)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); } 107 | } 108 | 109 | try { 110 | switch (path) { 111 | case '/login': { 112 | if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); 113 | const { password } = await request.json(); 114 | if (password === env.ADMIN_PASSWORD) { 115 | const token = await createSignedToken(env.COOKIE_SECRET, String(Date.now())); 116 | const headers = new Headers({ 'Content-Type': 'application/json' }); 117 | headers.append('Set-Cookie', `${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${SESSION_DURATION / 1000}`); 118 | return new Response(JSON.stringify({ success: true }), { headers }); 119 | } 120 | return new Response(JSON.stringify({ error: '密码错误' }), { status: 401 }); 121 | } 122 | case '/logout': { 123 | const headers = new Headers({ 'Content-Type': 'application/json' }); 124 | headers.append('Set-Cookie', `${COOKIE_NAME}=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0`); 125 | return new Response(JSON.stringify({ success: true }), { headers }); 126 | } 127 | // [修改] /data 接口,现在需要读取多个KV值 128 | case '/data': { 129 | // [最终修正] 如果 KV.get 返回 null (键不存在), 则使用 `|| []` 来确保得到的是一个空数组,防止崩溃 130 | const [misubs, profiles, settings] = await Promise.all([ 131 | env.MISUB_KV.get(KV_KEY_SUBS, 'json').then(res => res || []), 132 | env.MISUB_KV.get(KV_KEY_PROFILES, 'json').then(res => res || []), 133 | env.MISUB_KV.get(KV_KEY_SETTINGS, 'json').then(res => res || {}) 134 | ]); 135 | const config = { FileName: settings.FileName || 'MISUB', mytoken: settings.mytoken || 'auto' }; 136 | return new Response(JSON.stringify({ misubs, profiles, config }), { headers: { 'Content-Type': 'application/json' } }); 137 | } 138 | case '/misubs': { 139 | const { misubs, profiles } = await request.json(); 140 | if (typeof misubs === 'undefined' || typeof profiles === 'undefined') { 141 | return new Response(JSON.stringify({ success: false, message: '请求体中缺少 misubs 或 profiles 字段' }), { status: 400 }); 142 | } 143 | await Promise.all([ 144 | env.MISUB_KV.put(KV_KEY_SUBS, JSON.stringify(misubs)), 145 | env.MISUB_KV.put(KV_KEY_PROFILES, JSON.stringify(profiles)) 146 | ]); 147 | return new Response(JSON.stringify({ success: true, message: '订阅源及订阅组已保存' })); 148 | } 149 | case '/node_count': { 150 | if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); 151 | const { url: subUrl } = await request.json(); 152 | if (!subUrl || typeof subUrl !== 'string' || !/^https?:\/\//.test(subUrl)) { 153 | return new Response(JSON.stringify({ error: 'Invalid or missing url' }), { status: 400 }); 154 | } 155 | const result = { count: 0, userInfo: null }; 156 | try { 157 | const trafficRequest = fetch(new Request(subUrl, { headers: { 'User-Agent': 'Clash for Windows/0.20.39' }, redirect: "follow" })); 158 | const nodeCountRequest = fetch(new Request(subUrl, { headers: { 'User-Agent': 'MiSub-Node-Counter/2.0' }, redirect: "follow" })); 159 | const [trafficResponse, nodeCountResponse] = await Promise.all([trafficRequest, nodeCountRequest]); 160 | if (trafficResponse.ok) { 161 | const userInfoHeader = trafficResponse.headers.get('subscription-userinfo'); 162 | if (userInfoHeader) { 163 | const info = {}; 164 | userInfoHeader.split(';').forEach(part => { 165 | const [key, value] = part.trim().split('='); 166 | if (key && value) info[key] = /^\d+$/.test(value) ? Number(value) : value; 167 | }); 168 | result.userInfo = info; 169 | } 170 | } 171 | if (nodeCountResponse.ok) { 172 | const text = await nodeCountResponse.text(); 173 | let decoded = ''; 174 | try { 175 | decoded = atob(text.replace(/\s/g, '')); 176 | } catch { 177 | decoded = text; 178 | } 179 | // [更新] 支援 ssr, hy, hy2, tuic 180 | const lineMatches = decoded.match(/^(ss|ssr|vmess|vless|trojan|hysteria2?|hy|hy2|tuic):\/\//gm); 181 | if (lineMatches) { 182 | result.count = lineMatches.length; 183 | } 184 | } 185 | } catch (e) { 186 | console.error('Failed to fetch subscription with dual-request method:', e); 187 | } 188 | return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } }); 189 | } 190 | case '/settings': { 191 | if (request.method === 'GET') { 192 | const settings = await env.MISUB_KV.get(KV_KEY_SETTINGS, 'json') || {}; 193 | return new Response(JSON.stringify({ ...defaultSettings, ...settings }), { headers: { 'Content-Type': 'application/json' } }); 194 | } 195 | if (request.method === 'POST') { 196 | const newSettings = await request.json(); 197 | const oldSettings = await env.MISUB_KV.get(KV_KEY_SETTINGS, 'json') || {}; 198 | const finalSettings = { ...oldSettings, ...newSettings }; 199 | await env.MISUB_KV.put(KV_KEY_SETTINGS, JSON.stringify(finalSettings)); 200 | const message = `🎉 MiSub 設定已成功更新!`; 201 | await sendTgNotification(finalSettings, message); 202 | return new Response(JSON.stringify({ success: true, message: '设置已保存' })); 203 | } 204 | return new Response('Method Not Allowed', { status: 405 }); 205 | } 206 | } 207 | } catch (e) { return new Response(JSON.stringify({ error: 'Internal Server Error' }), { status: 500 }); } 208 | return new Response('API route not found', { status: 404 }); 209 | } 210 | 211 | // --- 名称前缀辅助函数 (无修改) --- 212 | function prependNodeName(link, prefix) { 213 | if (!prefix) return link; 214 | const appendToFragment = (baseLink, namePrefix) => { 215 | const hashIndex = baseLink.lastIndexOf('#'); 216 | const originalName = hashIndex !== -1 ? decodeURIComponent(baseLink.substring(hashIndex + 1)) : ''; 217 | const base = hashIndex !== -1 ? baseLink.substring(0, hashIndex) : baseLink; 218 | if (originalName.startsWith(namePrefix)) { 219 | return baseLink; 220 | } 221 | const newName = originalName ? `${namePrefix} - ${originalName}` : namePrefix; 222 | return `${base}#${encodeURIComponent(newName)}`; 223 | } 224 | if (link.startsWith('vmess://')) { 225 | try { 226 | const base64Part = link.substring('vmess://'.length); 227 | const binaryString = atob(base64Part); 228 | const bytes = new Uint8Array(binaryString.length); 229 | for (let i = 0; i < binaryString.length; i++) { 230 | bytes[i] = binaryString.charCodeAt(i); 231 | } 232 | const jsonString = new TextDecoder('utf-8').decode(bytes); 233 | const nodeConfig = JSON.parse(jsonString); 234 | const originalPs = nodeConfig.ps || ''; 235 | if (!originalPs.startsWith(prefix)) { 236 | nodeConfig.ps = originalPs ? `${prefix} - ${originalPs}` : prefix; 237 | } 238 | const newJsonString = JSON.stringify(nodeConfig); 239 | const newBase64Part = btoa(unescape(encodeURIComponent(newJsonString))); 240 | return 'vmess://' + newBase64Part; 241 | } catch (e) { 242 | console.error("为 vmess 节点添加名称前缀失败,将回退到通用方法。", e); 243 | return appendToFragment(link, prefix); 244 | } 245 | } 246 | return appendToFragment(link, prefix); 247 | } 248 | 249 | // --- 节点列表生成函数 --- 250 | async function generateCombinedNodeList(context, config, userAgent, misubs) { 251 | // [更新] 新增 anytls 支援 252 | const nodeRegex = /^(ss|ssr|vmess|vless|trojan|hysteria2?|hy|hy2|tuic|anytls):\/\//; 253 | let manualNodesContent = ''; 254 | const normalizeVmessLink = (link) => { 255 | if (!link.startsWith('vmess://')) { 256 | return link; 257 | } 258 | try { 259 | const base64Part = link.substring('vmess://'.length); 260 | const binaryString = atob(base64Part); 261 | const bytes = new Uint8Array(binaryString.length); 262 | for (let i = 0; i < binaryString.length; i++) { 263 | bytes[i] = binaryString.charCodeAt(i); 264 | } 265 | const jsonString = new TextDecoder('utf-8').decode(bytes); 266 | const compactJsonString = JSON.stringify(JSON.parse(jsonString)); 267 | const newBase64Part = btoa(unescape(encodeURIComponent(compactJsonString))); 268 | return 'vmess://' + newBase64Part; 269 | } catch (e) { 270 | console.error("标准化 vmess 链接失败,将使用原始链接:", link, e); 271 | return link; 272 | } 273 | }; 274 | const httpSubs = misubs.filter(sub => { 275 | if (sub.url.toLowerCase().startsWith('http')) return true; 276 | manualNodesContent += sub.url + '\n'; 277 | return false; 278 | }); 279 | const processedManualNodes = manualNodesContent.split('\n') 280 | .map(line => line.trim()) 281 | .filter(line => nodeRegex.test(line)) 282 | .map(normalizeVmessLink) 283 | .map(node => (config.prependSubName) ? prependNodeName(node, '手动节点') : node) 284 | .join('\n'); 285 | const subPromises = httpSubs.map(async (sub) => { 286 | try { 287 | const requestHeaders = { 'User-Agent': userAgent }; 288 | const response = await Promise.race([ 289 | fetch(new Request(sub.url, { headers: requestHeaders, redirect: "follow", cf: { insecureSkipVerify: true } })), 290 | new Promise((_, reject) => setTimeout(() => reject(new Error('Request timed out')), 10000)) 291 | ]); 292 | if (!response.ok) { 293 | console.error(`Failed to fetch sub: ${sub.url}, status: ${response.status}`); 294 | return ''; 295 | } 296 | let text = await response.text(); 297 | try { 298 | const cleanedText = text.replace(/\s/g, ''); 299 | if (cleanedText.length > 20 && /^[A-Za-z0-9+/=]+$/.test(cleanedText)) { 300 | const binaryString = atob(cleanedText); 301 | const bytes = new Uint8Array(binaryString.length); 302 | for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } 303 | text = new TextDecoder('utf-8').decode(bytes); 304 | } 305 | } catch (e) {} 306 | let validNodes = text.replace(/\r\n/g, '\n').split('\n') 307 | .map(line => line.trim()).filter(line => nodeRegex.test(line)); 308 | validNodes = validNodes.filter(nodeLink => { 309 | try { 310 | const hashIndex = nodeLink.lastIndexOf('#'); 311 | if (hashIndex === -1) return true; 312 | const nodeName = decodeURIComponent(nodeLink.substring(hashIndex + 1)); 313 | return !nodeName.includes('https://'); 314 | } catch (e) { 315 | console.error(`Failed to decode node name, filtering it out: ${nodeLink}`, e); 316 | return false; 317 | } 318 | }); 319 | return (config.prependSubName && sub.name) 320 | ? validNodes.map(node => prependNodeName(node, sub.name)).join('\n') 321 | : validNodes.join('\n'); 322 | } catch (e) { 323 | console.error(`Failed to fetch sub: ${sub.url}`, e); 324 | return ''; 325 | } 326 | }); 327 | const processedSubContents = await Promise.all(subPromises); 328 | const combinedContent = (processedManualNodes + '\n' + processedSubContents.join('\n')); 329 | return [...new Set(combinedContent.split('\n').map(line => line.trim()).filter(line => line))].join('\n'); 330 | } 331 | 332 | // --- [核心修改] 订阅处理函数 --- 333 | async function handleMisubRequest(context) { 334 | const { request, env } = context; 335 | const url = new URL(request.url); 336 | const userAgentHeader = request.headers.get('User-Agent') || "Unknown"; 337 | 338 | const [settingsData, misubsData, profilesData] = await Promise.all([ 339 | env.MISUB_KV.get(KV_KEY_SETTINGS, 'json'), 340 | env.MISUB_KV.get(KV_KEY_SUBS, 'json'), 341 | env.MISUB_KV.get(KV_KEY_PROFILES, 'json') 342 | ]); 343 | const settings = settingsData || {}; 344 | const allMisubs = misubsData || []; 345 | const allProfiles = profilesData || []; 346 | const config = { ...defaultSettings, ...settings }; 347 | 348 | // --- [核心修改] 新的 URL 解析邏輯 --- 349 | let token = ''; 350 | let profileIdentifier = null; 351 | 352 | const pathSegments = url.pathname.replace(/^\/sub\//, '/').split('/').filter(Boolean); 353 | 354 | if (pathSegments.length > 0) { 355 | token = pathSegments[0]; 356 | if (pathSegments.length > 1) { 357 | profileIdentifier = pathSegments[1]; 358 | } 359 | } else { 360 | // 從查詢參數中取得 token 作為備用 361 | token = url.searchParams.get('token'); 362 | } 363 | 364 | if (!token || token !== config.mytoken) { 365 | return new Response('Invalid token', { status: 403 }); 366 | } 367 | // --- URL 解析結束 --- 368 | 369 | let targetMisubs; 370 | let subName = config.FileName; 371 | 372 | // 如果有 profileIdentifier,則根據 profile 篩選節點 373 | if (profileIdentifier) { 374 | // [核心修改] 優先使用 customId 尋找,其次用 id 375 | const profile = allProfiles.find(p => (p.customId && p.customId === profileIdentifier) || p.id === profileIdentifier); 376 | 377 | if (profile && profile.enabled) { 378 | subName = profile.name; 379 | const profileSubIds = new Set(profile.subscriptions); 380 | const profileNodeIds = new Set(profile.manualNodes); 381 | targetMisubs = allMisubs.filter(item => { 382 | return (item.url.startsWith('http') ? profileSubIds.has(item.id) : profileNodeIds.has(item.id)); 383 | }); 384 | } else { 385 | return new Response('Profile not found or disabled', { status: 404 }); 386 | } 387 | } else { 388 | targetMisubs = allMisubs.filter(s => s.enabled); 389 | } 390 | 391 | let targetFormat = url.searchParams.get('target'); 392 | 393 | if (!targetFormat) { 394 | // 如果沒有 ?target=... ,則檢查 ?clash, ?singbox 這類參數 395 | const supportedFormats = ['clash', 'singbox', 'surge', 'loon', 'base64']; 396 | for (const format of supportedFormats) { 397 | if (url.searchParams.has(format)) { 398 | targetFormat = format; 399 | break; 400 | } 401 | } 402 | } 403 | 404 | if (!targetFormat) { 405 | // 如果還沒有,則透過 User-Agent 嗅探 406 | const ua = userAgentHeader.toLowerCase(); 407 | if (ua.includes('sing-box')) { 408 | targetFormat = 'singbox'; 409 | } else { 410 | // 所有其他情況(包括 clash 和未知 UA)都預設為 clash 411 | targetFormat = 'clash'; 412 | } 413 | } 414 | 415 | if (!url.searchParams.has('callback_token')) { 416 | const clientIp = request.headers.get('CF-Connecting-IP') || 'N/A'; 417 | const message = `🚀 *MiSub 訂閱被存取* 🚀\n\n*客戶端 (User-Agent):*\n\`${userAgentHeader}\`\n\n*請求 IP:*\n\`${clientIp}\`\n*請求格式:*\n\`${targetFormat}\`${profileIdentifier ? `\n*訂閱組:*\n\`${subName}\`` : ''}`; 418 | context.waitUntil(sendTgNotification(config, message)); 419 | } 420 | 421 | const combinedNodeList = await generateCombinedNodeList(context, config, userAgentHeader, targetMisubs); 422 | const base64Content = btoa(unescape(encodeURIComponent(combinedNodeList))); 423 | 424 | if (targetFormat === 'base64') { 425 | const headers = { "Content-Type": "text/plain; charset=utf-8", 'Cache-Control': 'no-store, no-cache' }; 426 | return new Response(base64Content, { headers }); 427 | } 428 | 429 | const callbackToken = await getCallbackToken(env); 430 | 431 | // [核心修改] 回調 URL 現在使用新的短路徑 432 | const callbackPath = profileIdentifier ? `/${token}/${profileIdentifier}` : `/${token}`; 433 | const callbackUrl = `${url.protocol}//${url.host}${callbackPath}?target=base64&callback_token=${callbackToken}`; 434 | 435 | if (url.searchParams.get('callback_token') === callbackToken) { 436 | const headers = { "Content-Type": "text/plain; charset=utf-8", 'Cache-Control': 'no-store, no-cache' }; 437 | return new Response(base64Content, { headers }); 438 | } 439 | 440 | const subconverterUrl = new URL(`https://${config.subConverter}/sub`); 441 | subconverterUrl.searchParams.set('target', targetFormat); 442 | subconverterUrl.searchParams.set('url', callbackUrl); 443 | subconverterUrl.searchParams.set('config', config.subConfig); 444 | subconverterUrl.searchParams.set('new_name', 'true'); 445 | 446 | try { 447 | const subconverterResponse = await fetch(subconverterUrl.toString(), { 448 | method: 'GET', 449 | headers: { 'User-Agent': 'Mozilla/5.0' }, 450 | }); 451 | 452 | if (!subconverterResponse.ok) { 453 | const errorBody = await subconverterResponse.text(); 454 | throw new Error(`Subconverter service returned status: ${subconverterResponse.status}. Body: ${errorBody}`); 455 | } 456 | 457 | const originalText = await subconverterResponse.text(); 458 | let correctedText; 459 | 460 | // --- [核心修改] 使用 YAML 解析和序列化來淨化設定檔 --- 461 | try { 462 | // 1. 將從 subconverter 收到的文字載入為 JS 物件 463 | const parsedYaml = yaml.load(originalText); 464 | 465 | // 2. (可選)在這裡可以對物件進行更細微的修正,但通常 js-yaml 已處理好相容性 466 | // 例如,手動修正關鍵字大小寫 467 | const keyMappings = { 468 | 'Proxy': 'proxies', 469 | 'Proxy Group': 'proxy-groups', 470 | 'Rule': 'rules' 471 | }; 472 | for (const oldKey in keyMappings) { 473 | if (parsedYaml[oldKey]) { 474 | const newKey = keyMappings[oldKey]; 475 | parsedYaml[newKey] = parsedYaml[oldKey]; 476 | delete parsedYaml[oldKey]; 477 | } 478 | } 479 | 480 | // 3. 將乾淨的 JS 物件重新序列化為標準格式的 YAML 字串 481 | correctedText = yaml.dump(parsedYaml, { 482 | indent: 2, // 標準縮排 483 | noArrayIndent: true // 陣列格式更美觀 484 | }); 485 | 486 | } catch (e) { 487 | console.error("YAML parsing/dumping failed, falling back to original text.", e); 488 | // 如果解析失敗(極端情況),則退回使用原始的文字和替換邏輯,確保服務不中斷 489 | correctedText = originalText 490 | .replace(/^Proxy:/m, 'proxies:') 491 | .replace(/^Proxy Group:/m, 'proxy-groups:') 492 | .replace(/^Rule:/m, 'rules:'); 493 | } 494 | // --- 修改結束 --- 495 | 496 | const responseHeaders = new Headers(subconverterResponse.headers); 497 | responseHeaders.set("Content-Disposition", `attachment; filename*=utf-8''${encodeURIComponent(subName)}`); 498 | responseHeaders.set('Content-Type', 'text/plain; charset=utf-8'); 499 | responseHeaders.set('Cache-Control', 'no-store, no-cache'); 500 | 501 | return new Response(correctedText, { 502 | status: subconverterResponse.status, 503 | statusText: subconverterResponse.statusText, 504 | headers: responseHeaders 505 | }); 506 | 507 | } catch (error) { 508 | console.error(`[MiSub Final Error] ${error.message}`); 509 | return new Response(`Error connecting to subconverter: ${error.message}`, { status: 502 }); 510 | } 511 | } 512 | 513 | // --- 回调Token辅助函数 (无修改) --- 514 | async function getCallbackToken(env) { 515 | const secret = env.COOKIE_SECRET || 'default-callback-secret'; 516 | const encoder = new TextEncoder(); 517 | const keyData = encoder.encode(secret); 518 | const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); 519 | const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode('callback-static-data')); 520 | return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16); 521 | } 522 | 523 | 524 | // --- [核心修改] Cloudflare Pages Functions 主入口 --- 525 | export async function onRequest(context) { 526 | const { request, env, next } = context; 527 | const url = new URL(request.url); 528 | 529 | // 1. 優先處理 API 請求 530 | if (url.pathname.startsWith('/api/')) { 531 | return handleApiRequest(request, env); 532 | } 533 | 534 | // 2. 檢查是否為靜態資源請求 (vite 在開發模式下會用 /@vite/ 等路徑) 535 | // 生产环境中,静态资源通常有 .js, .css, .ico 等扩展名 536 | const isStaticAsset = /^\/(assets|@vite|src)\//.test(url.pathname) || /\.\w+$/.test(url.pathname); 537 | 538 | // 3. 如果不是 API 也不是靜態資源,則視為訂閱請求 539 | if (!isStaticAsset && url.pathname !== '/') { 540 | return handleMisubRequest(context); 541 | } 542 | 543 | // 4. 其他情況 (如首頁 /) 交由 Pages 預設處理 544 | return next(); 545 | } -------------------------------------------------------------------------------- /src/components/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 267 | 268 | 458 | 459 | --------------------------------------------------------------------------------