├── 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 |
8 |
21 |
--------------------------------------------------------------------------------
/src/components/SkeletonCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | MISUB
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/components/DashboardSkeleton.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
13 |
14 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/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 |
20 |
21 |
23 |
{{ stat.name }}
24 |
{{ stat.value }}
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/components/BulkImportModal.vue:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 | 批量导入
23 |
24 | 每行一个订阅链接或分享节点。将自动识别节点名称。
25 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/components/Toast.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
28 | {{ toast.message }}
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/components/ThemeToggle.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
24 |
--------------------------------------------------------------------------------
/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 | 
19 |
20 | 
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 | 
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 | 
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 |
65 |
69 |
70 |
71 |
78 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/src/components/Header.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
25 |
MISUB
26 |
27 |
28 |
29 |
32 |
33 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/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 |
16 |
20 |
21 |
22 |
23 | {{ profile.name }}
24 |
25 |
26 | 包含 {{ profile.subscriptions.length }} 个订阅,{{ profile.manualNodes.length }} 个节点
27 |
28 |
29 |
30 |
31 |
34 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
48 |
49 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/components/Login.vue:
--------------------------------------------------------------------------------
1 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
41 |
42 |
MISUB
43 |
请输入管理员密码
44 |
45 |
46 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/src/components/Modal.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
36 |
46 |
47 |
56 |
57 |
58 | 确认操作
59 |
60 |
61 |
62 |
63 | 你确定要继续吗?
64 |
65 |
66 |
67 |
请输入 "{{ confirmKeyword }}" 以确认:
68 |
69 |
70 |
71 |
72 |
77 |
78 |
79 |
80 |
81 |
82 |
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 |
67 |
71 |
72 |
76 | {{ protocolStyle.text }}
77 |
78 |
79 | {{ node.name || '未命名节点' }}
80 |
81 |
82 |
83 |
84 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/src/components/RightPanel.vue:
--------------------------------------------------------------------------------
1 |
64 |
65 |
66 |
67 |
68 |
生成订阅链接
69 |
70 |
71 |
72 |
78 |
79 |
80 |
81 |
82 |
83 |
96 |
97 |
98 |
99 |
100 |
106 |
116 |
117 |
118 |
119 | 提示:当前为自动Token,链接可能会变化。为确保链接稳定,推荐在 "设置" 中配置一个固定Token。
120 |
121 |
122 |
123 |
124 |
125 |
--------------------------------------------------------------------------------
/src/components/SettingsModal.vue:
--------------------------------------------------------------------------------
1 |
62 |
63 |
64 |
65 | 设置
66 |
67 |
70 |
71 |
72 |
73 |
77 |
78 |
79 |
80 |
84 |
85 |
86 |
87 |
91 |
92 |
93 |
94 |
98 |
99 |
100 |
101 |
105 |
106 |
107 |
108 |
112 |
113 |
114 |
115 |
116 |
自动将订阅名添加为节点名的前缀
117 |
121 |
122 |
123 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/src/components/ProfileModal.vue:
--------------------------------------------------------------------------------
1 |
47 |
48 |
49 |
50 |
51 |
52 | {{ isNew ? '新增订阅组' : '编辑订阅组' }}
53 |
54 |
55 |
56 |
57 |
58 |
59 |
62 |
69 |
70 |
71 |
74 |
81 |
设置后,订阅链接会更短,如 /token/home
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
选择机场订阅
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
106 |
107 |
108 |
109 |
110 | 没有可用的机场订阅
111 |
112 |
113 |
114 |
115 |
选择手动节点
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
132 |
133 |
134 |
135 |
136 | 没有可用的手动节点
137 |
138 |
139 |
140 |
141 |
142 |
143 |
--------------------------------------------------------------------------------
/src/components/Card.vue:
--------------------------------------------------------------------------------
1 |
78 |
79 |
80 |
84 |
85 |
86 |
87 |
88 | {{ protocolStyle.text }}
89 |
90 |
91 |
92 | {{ misub.name || '未命名订阅' }}
93 |
94 |
95 |
96 |
100 |
101 |
102 |
103 |
104 |
105 |
{{ trafficInfo.used }}{{ trafficInfo.total }}
106 |
107 |
108 |
109 |
110 |
111 |
112 |
116 |
{{ expiryInfo.daysRemaining }}
117 |
118 |
119 |
{{ misub.isUpdating ? '更新中...' : `${misub.nodeCount} Nodes` }}
120 |
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/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 |
269 |
270 | 正在加载...
271 |
272 |
273 |
274 |
仪表盘
275 |
276 |
277 |
278 |
279 |
280 |
281 |
您有未保存的更改
282 |
283 |
284 |
289 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
机场订阅
301 | {{ subscriptions.length }}
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
311 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 |
328 |
329 |
330 |
331 |
332 | 第 {{ subsCurrentPage }} / {{ subsTotalPages }} 页
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
手动节点
343 | {{ manualNodes.length }}
344 |
345 |
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
357 |
358 |
359 |
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 |
372 |
373 |
374 |
375 |
376 | 第 {{ manualNodesCurrentPage }} / {{ manualNodesTotalPages }} 页
377 |
378 |
379 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 |
391 |
我的订阅组
392 | {{ profiles.length }}
393 |
394 |
395 |
396 |
397 |
398 |
401 |
402 |
403 |
404 |
405 |
406 |
407 |
408 |
409 |
420 |
421 |
422 |
没有订阅组
423 |
创建一个订阅组来组合你的节点吧!
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 | 确认清空订阅
您确定要删除所有**订阅**吗?此操作将标记为待保存,不会影响手动节点。
432 | 确认清空节点
您确定要删除所有**手动节点**吗?此操作将标记为待保存,不会影响订阅。
433 | 确认清空订阅组
您确定要删除所有**订阅组**吗?此操作不可逆。
434 |
435 |
436 |
437 | {{ isNewNode ? '新增手动节点' : '编辑手动节点' }}
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 | {{ isNewSubscription ? '新增订阅' : '编辑订阅' }}
448 |
449 |
453 |
454 |
455 |
456 |
457 |
458 |
459 |
--------------------------------------------------------------------------------