refactor: simplify auth and subscription links

This commit is contained in:
2026-07-28 10:32:21 +08:00
parent b7a7cd9c71
commit fc8c23beee
41 changed files with 739 additions and 1468 deletions
-1
View File
@@ -84,7 +84,6 @@ npm run preview # 预览生产构建
| 订阅源 | `/sources` | CRUD + 过滤器编辑 + 预览 + 下载链接 |
| 合集 | `/collections` | CRUD + 源多选 + 模板选择 |
| 模板 | `/templates` | CRUD + JSON 配置编辑 + 内置模板查看 |
| 分享 | `/shares` | 创建 + 启用/禁用 + 删除 + 过期时间 |
| 回收站 | `/recycle-bin` | 恢复 + 彻底删除 |
| 工具 | `/tools` | 代理/规则转换 + 节点信息查询 + 数据导入导出 |
| 设置 | `/settings` | UA/超时/缓存/节点API/主题 |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sub-Store</title>
<script src="/config.js"></script>
<script type="module" crossorigin src="/assets/index-DuuGFjFx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CJWAL95F.css">
<script type="module" crossorigin src="/assets/index-CtUpN0Ye.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-2qQf5Bht.css">
</head>
<body class="bg-gray-50 text-gray-900">
<div id="app"></div>
+3 -11
View File
@@ -1,16 +1,13 @@
import axios from 'axios'
import { getEffectiveToken, clearToken } from '@/utils/token'
// Read runtime config (injected by config.js, defaults to same-origin)
const apiBaseUrl = (window.SUB_STORE_CONFIG?.apiBaseUrl) || ''
import { getApiBaseUrl, getEffectiveToken, clearToken } from '@/utils/token'
const api = axios.create({
baseURL: apiBaseUrl + '/api',
timeout: 30000,
})
// Request interceptor — attach admin token
api.interceptors.request.use((config) => {
config.baseURL = `${getApiBaseUrl()}/api`
const token = getEffectiveToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
@@ -68,12 +65,6 @@ export const getTemplate = (name) => api.get(`/templates/${name}`)
export const updateTemplate = (name, data) => api.patch(`/templates/${name}`, data)
export const deleteTemplate = (name) => api.delete(`/templates/${name}`)
// Shares
export const listShares = () => api.get('/shares')
export const createShare = (data) => api.post('/shares', data)
export const updateShare = (id, data) => api.patch(`/shares/${id}`, data)
export const deleteShare = (id) => api.delete(`/shares/${id}`)
// Recycle bin
export const listRecycleBin = () => api.get('/recycle-bin')
export const deleteRecycleEntry = (id) => api.delete(`/recycle-bin/${id}`)
@@ -93,6 +84,7 @@ export const getFlowInfo = (name) => api.get(`/source/flow/${name}`)
// Tools
export const parseProxy = (data) => api.post('/proxy/parse', data)
export const parseRule = (data) => api.post('/rule/parse', data)
export const exportProxyUri = (node) => api.post('/utils/proxy-uri', node)
export const getNodeInfo = (server) => api.post('/utils/node-info', { server })
export default api
-1
View File
@@ -47,7 +47,6 @@ const menu = [
{ to: '/sources', icon: '📡', label: '订阅源' },
{ to: '/collections', icon: '📁', label: '合集' },
{ to: '/templates', icon: '📋', label: '模板' },
{ to: '/shares', icon: '🔗', label: '分享' },
{ to: '/recycle-bin', icon: '♻️', label: '回收站' },
{ to: '/tools', icon: '🔧', label: '工具' },
{ to: '/settings', icon: '⚙️', label: '设置' },
+19 -2
View File
@@ -16,6 +16,8 @@
<span v-if="!col.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
</div>
<div class="flex gap-3 mt-1 text-xs text-gray-400">
<span>原始节点: {{ nodeCounts[col.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[col.id]?.processed ?? '-' }}</span>
<span>订阅源: {{ col.sourceIds?.length || 0 }}</span>
<span>过滤器: {{ col.filters?.length || 0 }}</span>
<span>模板: {{ col.templateId || 'default' }}</span>
@@ -97,7 +99,8 @@
import { ref, reactive, onMounted } from 'vue'
import Modal from '@/components/Modal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { listCollections, createCollection, updateCollection, deleteCollection, listSources, listTemplates, getLinkCollection } from '@/api'
import { listCollections, createCollection, updateCollection, deleteCollection, listSources, listTemplates, getLinkCollection, previewCollection } from '@/api'
import { copyText } from '@/utils/clipboard'
const collections = ref([])
const allSources = ref([])
@@ -106,6 +109,7 @@ const loading = ref(true)
const showModal = ref(false)
const editing = ref(false)
const toast = ref(null)
const nodeCounts = reactive({})
const confirmDialog = ref({ show: false, title: '', message: '', danger: false, action: null })
const filterTypes = ['include', 'exclude', 'rename', 'dedupe', 'sort', 'delete-field', 'flag', 'quick', 'resolve', 'custom']
@@ -173,7 +177,7 @@ async function remove(col) {
async function copyLink(col) {
try {
const res = await getLinkCollection(col.id)
await navigator.clipboard.writeText(res.data.url)
await copyText(res.data.url)
showToast('链接已复制')
} catch (e) {
showToast(e.message, 'error')
@@ -187,6 +191,7 @@ async function load() {
collections.value = colRes.data || []
allSources.value = srcRes.data || []
allTemplates.value = tplRes.data || []
for (const col of collections.value) loadNodeCount(col)
} catch (e) {
showToast(e.message, 'error')
} finally {
@@ -194,5 +199,17 @@ async function load() {
}
}
async function loadNodeCount(col) {
try {
const res = await previewCollection(col)
nodeCounts[col.id] = {
original: res.data.originalCount ?? res.data.nodes ?? 0,
processed: Array.isArray(res.data.processed) ? res.data.processed.length : 0,
}
} catch {
nodeCounts[col.id] = { original: '-', processed: '-' }
}
}
onMounted(load)
</script>
+3 -7
View File
@@ -37,26 +37,23 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getEnv, listSources, listCollections, listTemplates, listShares } from '@/api'
import { getEnv, listSources, listCollections, listTemplates } from '@/api'
const env = ref(null)
const sources = ref([])
const collections = ref([])
const templates = ref([])
const shares = ref([])
const stats = computed(() => [
{ label: '订阅源', value: sources.value.length, color: 'text-blue-600' },
{ label: '合集', value: collections.value.length, color: 'text-purple-600' },
{ label: '模板', value: templates.value.length, color: 'text-green-600' },
{ label: '分享链接', value: shares.value.length, color: 'text-orange-600' },
])
const featureLabels = {
buildTimeScripts: '脚本引擎',
proxyConversion: '代理转换',
ruleConversion: '规则转换',
scopedShares: '作用域分享',
recycleBin: '回收站',
nodeInfo: '节点信息',
surgeMac: 'Surge Mac',
@@ -64,14 +61,13 @@ const featureLabels = {
onMounted(async () => {
try {
const [envRes, srcRes, colRes, tplRes, shrRes] = await Promise.all([
getEnv(), listSources(), listCollections(), listTemplates(), listShares()
const [envRes, srcRes, colRes, tplRes] = await Promise.all([
getEnv(), listSources(), listCollections(), listTemplates()
])
env.value = envRes.data
sources.value = srcRes.data || []
collections.value = colRes.data || []
templates.value = tplRes.data || []
shares.value = shrRes.data || []
} catch (e) {
console.error('Failed to load dashboard', e)
}
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<form class="w-full max-w-sm bg-white border border-gray-200 rounded-lg p-6 shadow-sm" @submit.prevent="submit">
<label class="block text-sm font-medium text-gray-700" for="remote-uri">远程 URI</label>
<input
id="remote-uri"
v-model.trim="remoteUri"
type="url"
required
autocomplete="url"
placeholder="https://sub-store.example.com"
class="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<label class="block mt-4 text-sm font-medium text-gray-700" for="token">TOKEN</label>
<input
id="token"
v-model.trim="token"
type="password"
required
autocomplete="current-password"
class="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<p v-if="error" class="mt-4 text-sm text-red-600">{{ error }}</p>
<button
type="submit"
:disabled="loading"
class="mt-6 w-full px-4 py-2 rounded-lg bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 disabled:opacity-60 disabled:cursor-not-allowed"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getStoredRemoteUri, storeLogin, validateToken } from '@/utils/token'
const route = useRoute()
const router = useRouter()
const remoteUri = ref(getStoredRemoteUri() || window.location.origin)
const token = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
error.value = ''
loading.value = true
try {
const isValid = await validateToken(token.value, remoteUri.value)
if (!isValid) {
error.value = '远程 URI 或 TOKEN 无效'
return
}
storeLogin(remoteUri.value, token.value)
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/')
} catch {
error.value = '远程 URI 格式无效'
} finally {
loading.value = false
}
}
</script>
+1 -1
View File
@@ -8,5 +8,5 @@
</template>
<script setup>
// 404 page — shown when token is missing or invalid
// 404 page
</script>
-1
View File
@@ -60,7 +60,6 @@ function resourceTypeColor(type) {
source: 'bg-blue-100 text-blue-700',
collection: 'bg-purple-100 text-purple-700',
template: 'bg-green-100 text-green-700',
share: 'bg-orange-100 text-orange-700',
}
return map[type] || 'bg-gray-100 text-gray-600'
}
+1 -1
View File
@@ -3,7 +3,7 @@
<h2 class="text-xl font-bold mb-5">设置</h2>
<div v-if="loading" class="text-gray-400 text-sm">加载中...</div>
<div v-else class="space-y-4 max-w-2xl">
<div v-else class="space-y-4">
<!-- General settings -->
<div class="bg-white rounded-lg p-4 shadow-sm border">
<h3 class="font-semibold mb-3 text-sm">通用</h3>
-164
View File
@@ -1,164 +0,0 @@
<template>
<div>
<h2 class="text-xl font-bold mb-5">分享链接</h2>
<!-- Create share form -->
<div class="bg-white rounded-lg p-4 shadow-sm border mb-4">
<h3 class="font-semibold mb-3 text-sm">创建分享</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<select v-model="form.resourceType" class="px-3 py-2 border rounded-lg text-sm">
<option value="source">订阅源</option>
<option value="collection">合集</option>
</select>
<select v-model="form.resourceId" class="px-3 py-2 border rounded-lg text-sm">
<option value="">选择资源</option>
<option v-for="r in availableResources" :key="r.id" :value="r.id">{{ r.name }}</option>
</select>
<select v-model="form.target" class="px-3 py-2 border rounded-lg text-sm">
<option value="">自动</option>
<option v-for="t in targets" :key="t" :value="t">{{ t }}</option>
</select>
<input v-model="form.expiresHours" type="number" min="0" placeholder="有效小时(0=永久)"
class="px-3 py-2 border rounded-lg text-sm" />
</div>
<button class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700"
:disabled="!form.resourceId" @click="create">创建分享</button>
<div v-if="createdUrl" class="mt-3 p-3 bg-gray-50 rounded-lg break-all text-xs font-mono cursor-pointer hover:bg-gray-100" @click="copy(createdUrl)">
{{ createdUrl }} <span class="text-primary-600">[点击复制]</span>
</div>
</div>
<!-- Shares list -->
<div v-if="loading" class="text-gray-400 text-sm">加载中...</div>
<div v-else-if="shares.length === 0" class="text-gray-400 text-sm py-4 text-center">暂无分享</div>
<div v-else class="space-y-2">
<div v-for="share in shares" :key="share.id"
class="bg-white rounded-lg p-4 shadow-sm border flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="px-1.5 py-0.5 rounded text-xs"
:class="share.resourceType === 'source' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'">
{{ share.resourceType }}
</span>
<span class="font-medium text-gray-900">{{ share.resourceId }}</span>
<span v-if="share.target" class="text-xs text-gray-400">→ {{ share.target }}</span>
<span v-if="!share.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
</div>
<p class="text-xs text-gray-400 mt-1">
创建: {{ formatTime(share.createdAt) }}
<span v-if="share.expiresAt"> · 过期: {{ formatTime(share.expiresAt) }}</span>
<span v-else> · 永久有效</span>
</p>
</div>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click="toggle(share)">
{{ share.enabled ? '禁用' : '启用' }}
</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click="remove(share)">删除</button>
</div>
</div>
</div>
<ConfirmDialog v-model="confirmDialog.show" :title="confirmDialog.title" :message="confirmDialog.message"
:danger="confirmDialog.danger" @confirm="confirmDialog.action(); confirmDialog.show = false" />
<div v-if="toast" class="fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50"
:class="toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-green-500 text-white'">
{{ toast.msg }}
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { listShares, createShare, updateShare, deleteShare, listSources, listCollections } from '@/api'
const shares = ref([])
const sources = ref([])
const collections = ref([])
const loading = ref(true)
const createdUrl = ref('')
const toast = ref(null)
const confirmDialog = ref({ show: false, title: '', message: '', danger: false, action: null })
const targets = ['mihomo', 'stash', 'surge', 'surge-mac', 'surfboard', 'loon', 'egern', 'shadowrocket', 'qx', 'sing-box', 'v2ray', 'uri', 'json']
const form = reactive({
resourceType: 'source', resourceId: '', target: '', expiresHours: '0'
})
const availableResources = computed(() => form.resourceType === 'source' ? sources.value : collections.value)
function showToast(msg, type = 'success') {
toast.value = { msg, type }
setTimeout(() => toast.value = null, 2500)
}
function formatTime(ts) {
if (!ts) return ''
return new Date(ts).toLocaleString('zh-CN')
}
async function copy(text) {
await navigator.clipboard.writeText(text)
showToast('已复制')
}
async function create() {
try {
const payload = {
resourceType: form.resourceType,
resourceId: form.resourceId,
target: form.target || undefined,
expiresIn: Math.max(0, Number(form.expiresHours) || 0) * 3600,
}
const res = await createShare(payload)
createdUrl.value = res.data.url
await load()
showToast('分享已创建')
} catch (e) {
showToast(e.message, 'error')
}
}
async function toggle(share) {
try {
await updateShare(share.id, { enabled: !share.enabled })
await load()
} catch (e) {
showToast(e.message, 'error')
}
}
async function remove(share) {
confirmDialog.value = {
show: true, title: '删除分享', message: '确定删除此分享?', danger: true,
action: async () => {
try {
await deleteShare(share.id)
await load()
showToast('已删除')
} catch (e) {
showToast(e.message, 'error')
}
}
}
}
async function load() {
loading.value = true
try {
const [shrRes, srcRes, colRes] = await Promise.all([listShares(), listSources(), listCollections()])
shares.value = shrRes.data || []
sources.value = srcRes.data || []
collections.value = colRes.data || []
} catch (e) {
showToast(e.message, 'error')
} finally {
loading.value = false
}
}
onMounted(load)
</script>
+235 -53
View File
@@ -9,25 +9,67 @@
<div v-else-if="sources.length === 0" class="text-gray-400 text-sm py-8 text-center">暂无订阅源</div>
<div v-else class="space-y-2">
<div v-for="src in sources" :key="src.id"
class="bg-white rounded-lg p-4 shadow-sm border flex items-center justify-between hover:shadow-md transition-shadow">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ src.name }}</span>
<span class="px-1.5 py-0.5 rounded text-xs"
:class="src.type === 'remote' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'">{{ src.type }}</span>
<span v-if="!src.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
class="bg-white rounded-lg shadow-sm border overflow-hidden hover:shadow-md transition-shadow">
<div class="p-4 flex items-center justify-between cursor-pointer" @click="toggleExpand(src)">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ src.name }}</span>
<span class="px-1.5 py-0.5 rounded text-xs"
:class="src.type === 'remote' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'">{{ src.type }}</span>
<span v-if="!src.enabled" class="px-1.5 py-0.5 rounded text-xs bg-red-100 text-red-700">已禁用</span>
</div>
<p v-if="src.type === 'local'" class="text-xs text-gray-500 mt-1">(本地内容)</p>
<div class="flex gap-2 mt-1 text-xs text-gray-400">
<span>原始节点: {{ nodeCounts[src.id]?.original ?? '-' }}</span>
<span>处理后: {{ nodeCounts[src.id]?.processed ?? '-' }}</span>
<span>过滤器: {{ src.filters?.length || 0 }}</span>
<span v-if="src.createdAt">创建: {{ formatTime(src.createdAt) }}</span>
</div>
<p v-if="flowSummary(src)" class="text-xs text-gray-500 mt-1 truncate">{{ flowSummary(src) }}</p>
</div>
<p class="text-xs text-gray-500 mt-1 truncate">{{ src.type === 'local' ? '(本地内容)' : src.url }}</p>
<div class="flex gap-2 mt-1 text-xs text-gray-400">
<span>过滤器: {{ src.filters?.length || 0 }}</span>
<span v-if="src.createdAt">创建: {{ formatTime(src.createdAt) }}</span>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click.stop="copyLink(src)">复制链接</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600" @click.stop="editSource(src)">编辑</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click.stop="remove(src)">删除</button>
</div>
</div>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click="copyLink(src)">链接</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click="preview(src)">预览</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600" @click="editSource(src)">编辑</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click="remove(src)">删除</button>
<div v-if="expandedSourceId === src.id" class="border-t bg-gray-50" @click.stop>
<div v-if="expandedLoading" class="text-gray-400 text-sm py-4 text-center">解析中...</div>
<div v-else-if="expandedData">
<div class="overflow-x-auto rounded-lg bg-white">
<table class="w-full text-left text-xs">
<thead class="sticky top-0 bg-gray-100 text-gray-600">
<tr>
<th class="px-3 py-2 font-medium">Type</th>
<th class="px-3 py-2 font-medium">Name</th>
<th class="px-3 py-2 font-medium">Server</th>
<th class="px-3 py-2 font-medium">Port</th>
<th class="px-3 py-2 font-medium">Transport</th>
<th class="px-3 py-2 font-medium">Tls</th>
<th class="px-3 py-2 font-medium">Delay</th>
<th class="px-3 py-2 font-medium">Speed</th>
<th class="px-3 py-2 font-medium">Link</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="(node, i) in (expandedData.processed || expandedData.original || [])" :key="i">
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'type') }}</td>
<td class="px-3 py-2 text-blue-600">{{ nodeField(node, 'name', 'remarks') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'server', 'address') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'port') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'transport', 'network') }}</td>
<td class="px-3 py-2 text-gray-500">{{ formatTls(node) }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'delay', 'latency', 'ping') }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ nodeField(node, 'speed', 'downloadSpeed') }}</td>
<td class="px-3 py-2">
<button class="px-2 py-1 text-xs rounded hover:bg-gray-100 text-blue-600" @click="copyNodeLink(node)">复制</button>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="(expandedData.processed || expandedData.original || []).length === 0" class="text-xs text-gray-400 mt-2">暂无节点</p>
</div>
</div>
</div>
</div>
@@ -43,10 +85,18 @@
</div>
<div>
<label class="block text-sm font-medium mb-1">类型</label>
<select v-model="form.type" class="w-full px-3 py-2 border rounded-lg text-sm">
<option value="remote">远程 (URL)</option>
<option value="local">本地 (内容)</option>
</select>
<div class="flex gap-2">
<label class="flex-1 flex items-center gap-2 px-3 py-2 border rounded-lg text-sm cursor-pointer"
:class="form.type === 'remote' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-gray-200'">
<input type="radio" v-model="form.type" value="remote" class="text-primary-600" />
<span>远程 (URL)</span>
</label>
<label class="flex-1 flex items-center gap-2 px-3 py-2 border rounded-lg text-sm cursor-pointer"
:class="form.type === 'local' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-gray-200'">
<input type="radio" v-model="form.type" value="local" class="text-primary-600" />
<span>本地 (内容)</span>
</label>
</div>
</div>
<div v-if="form.type === 'remote'">
<label class="block text-sm font-medium mb-1">URL</label>
@@ -69,9 +119,29 @@
<label class="text-sm font-medium">过滤器</label>
<button class="text-xs text-primary-600 hover:underline" @click="addFilter">+ 添加</button>
</div>
<details class="mb-2 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-500">
<summary class="cursor-pointer text-gray-700">使用说明</summary>
<div class="mt-2">
<p>pattern 支持正则;field 留空默认匹配 name。常用字段:name、type、server、port。</p>
<table class="mt-2 w-full border-collapse overflow-hidden rounded-lg bg-white text-left">
<thead class="bg-gray-100 text-gray-700">
<tr>
<th class="border border-gray-200 px-2 py-1 font-medium">过滤器</th>
<th class="border border-gray-200 px-2 py-1 font-medium">说明</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filterGuides" :key="item.type">
<td class="border border-gray-200 px-2 py-1 text-gray-700">{{ item.label }}</td>
<td class="border border-gray-200 px-2 py-1">{{ item.help }}</td>
</tr>
</tbody>
</table>
</div>
</details>
<div v-for="(f, i) in form.filters" :key="i" class="flex gap-1 mb-1.5">
<select v-model="f.type" class="px-2 py-1 border rounded text-xs w-28">
<option v-for="t in filterTypes" :key="t" :value="t">{{ t }}</option>
<option v-for="t in filterTypes" :key="t" :value="t">{{ filterTypeLabels[t] || t }}</option>
</select>
<input v-model="f.pattern" placeholder="pattern" class="flex-1 px-2 py-1 border rounded text-xs" />
<input v-model="f.field" placeholder="field" class="w-24 px-2 py-1 border rounded text-xs" />
@@ -85,22 +155,6 @@
</template>
</Modal>
<!-- Preview Modal -->
<Modal v-model="showPreview" title="预览结果" >
<div v-if="previewLoading" class="text-gray-400 text-sm py-4 text-center">解析中...</div>
<div v-else-if="previewData">
<p class="text-xs text-gray-500 mb-2">原始节点: {{ previewData.original?.length || 0 }} · 处理后: {{ previewData.processed?.length || 0 }}</p>
<div class="max-h-96 overflow-y-auto text-xs font-mono bg-gray-50 p-3 rounded">
<div v-for="(node, i) in (previewData.processed || previewData.original || [])" :key="i" class="py-0.5">
<span class="text-gray-400">{{ i+1 }}.</span>
<span class="text-blue-600">{{ node.name || node.remarks || 'unnamed' }}</span>
<span class="text-gray-400 ml-2">{{ node.type }}</span>
<span v-if="node.server" class="text-gray-400 ml-2">{{ node.server }}:{{ node.port }}</span>
</div>
</div>
</div>
</Modal>
<!-- Confirm Dialog -->
<ConfirmDialog v-model="confirmDialog.show" :title="confirmDialog.title" :message="confirmDialog.message"
:danger="confirmDialog.danger" @confirm="confirmDialog.action(); confirmDialog.show = false" />
@@ -117,19 +171,46 @@
import { ref, reactive, onMounted } from 'vue'
import Modal from '@/components/Modal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { listSources, createSource, updateSource, deleteSource, previewSource, getLinkSource } from '@/api'
import { listSources, createSource, updateSource, deleteSource, previewSource, getLinkSource, getFlowInfo, exportProxyUri } from '@/api'
import { copyText } from '@/utils/clipboard'
const sources = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(false)
const showPreview = ref(false)
const previewLoading = ref(false)
const previewData = ref(null)
const expandedSourceId = ref('')
const expandedLoading = ref(false)
const expandedData = ref(null)
const nodeCounts = reactive({})
const flowInfos = reactive({})
const toast = ref(null)
const confirmDialog = ref({ show: false, title: '', message: '', danger: false, action: null })
const filterTypes = ['include', 'exclude', 'rename', 'dedupe', 'sort', 'delete-field', 'flag', 'quick', 'resolve', 'custom']
const filterTypeLabels = {
include: '包含(include)',
exclude: '排除(exclude)',
rename: '重命名(rename)',
dedupe: '去重(dedupe)',
sort: '排序(sort)',
'delete-field': '删除字段(delete-field)',
flag: '旗帜(flag)',
quick: '快捷规则(quick)',
resolve: '解析域名(resolve)',
custom: '自定义(custom)',
}
const filterGuides = [
{ type: 'include', label: filterTypeLabels.include, help: '只保留 field 匹配 pattern 的节点' },
{ type: 'exclude', label: filterTypeLabels.exclude, help: '删除 field 匹配 pattern 的节点' },
{ type: 'rename', label: filterTypeLabels.rename, help: '按 pattern 处理名称,简易表单适合清理固定前缀/后缀' },
{ type: 'dedupe', label: filterTypeLabels.dedupe, help: '按 field 去重,留空按 name 去重' },
{ type: 'sort', label: filterTypeLabels.sort, help: '按名称排序' },
{ type: 'delete-field', label: filterTypeLabels['delete-field'], help: '从 field 文本中删除匹配 pattern 的片段' },
{ type: 'flag', label: filterTypeLabels.flag, help: '给节点名补充或整理地区旗帜' },
{ type: 'quick', label: filterTypeLabels.quick, help: '应用快捷清理规则' },
{ type: 'resolve', label: filterTypeLabels.resolve, help: '把 server 域名解析为 IP' },
{ type: 'custom', label: filterTypeLabels.custom, help: '高级声明式规则链,适合 API/导入配置使用' },
]
const emptyForm = () => ({
name: '', type: 'remote', url: '', content: '', enabled: true, filters: []
@@ -146,6 +227,70 @@ function formatTime(ts) {
return new Date(ts).toLocaleString('zh-CN')
}
function formatDate(seconds) {
if (!seconds) return ''
return new Date(seconds * 1000).toLocaleDateString('zh-CN')
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return ''
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)}${units[unit]}`
}
function nodeField(node, ...keys) {
for (const key of keys) {
const value = node?.[key]
if (value !== undefined && value !== null && value !== '') return value
}
return '-'
}
function formatTls(node) {
const value = nodeField(node, 'tls', 'security')
if (value === true) return 'TLS'
if (value === false) return '-'
return value
}
function flowSummary(src) {
const flow = flowInfos[src.id]
if (!flow) return ''
const parts = []
if (flow.planName) parts.push(`套餐: ${flow.planName}`)
const used = Number(flow.usage?.upload || 0) + Number(flow.usage?.download || 0)
const total = Number(flow.total || 0)
if (total > 0) parts.push(`流量: ${formatBytes(used)} / ${formatBytes(total)}`)
if (total > used) parts.push(`剩余: ${formatBytes(total - used)}`)
if (flow.expires) parts.push(`到期: ${formatDate(flow.expires)}`)
if (src.meta?.price) parts.push(`计费: ${src.meta.price}${src.meta.billingCycle ? `/${src.meta.billingCycle}` : ''}`)
return parts.join(' · ')
}
function countsFromPreview(data) {
return {
original: data?.originalCount ?? (Array.isArray(data?.original) ? data.original.length : (data?.nodes ?? 0)),
processed: Array.isArray(data?.processed) ? data.processed.length : 0,
}
}
function sourcePreviewPayload(src) {
return {
id: src.id,
name: src.name,
type: src.type,
url: src.url,
content: src.content,
filters: src.filters,
}
}
function openCreate() {
Object.assign(form, emptyForm())
editing.value = false
@@ -207,39 +352,57 @@ async function remove(src) {
}
}
async function preview(src) {
showPreview.value = true
previewLoading.value = true
previewData.value = null
async function toggleExpand(src) {
if (expandedSourceId.value === src.id) {
expandedSourceId.value = ''
expandedData.value = null
return
}
expandedSourceId.value = src.id
expandedLoading.value = true
expandedData.value = null
try {
const res = await previewSource({
id: src.id, name: src.name, type: src.type, url: src.url, content: src.content,
filters: src.filters
})
previewData.value = res.data
const res = await previewSource(sourcePreviewPayload(src))
expandedData.value = res.data
nodeCounts[src.id] = countsFromPreview(res.data)
} catch (e) {
showToast(e.message, 'error')
showPreview.value = false
expandedSourceId.value = ''
} finally {
previewLoading.value = false
expandedLoading.value = false
}
}
async function copyLink(src) {
try {
const res = await getLinkSource(src.id)
await navigator.clipboard.writeText(res.data.url)
await copyText(res.data.url)
showToast('链接已复制')
} catch (e) {
showToast(e.message, 'error')
}
}
async function copyNodeLink(node) {
try {
const res = await exportProxyUri(node)
await copyText(res.data.uri)
showToast('节点链接已复制')
} catch (e) {
showToast(e.message, 'error')
}
}
async function load() {
loading.value = true
try {
const res = await listSources()
sources.value = res.data || []
for (const src of sources.value) {
loadNodeCount(src)
loadFlowInfo(src)
}
} catch (e) {
showToast(e.message, 'error')
} finally {
@@ -247,5 +410,24 @@ async function load() {
}
}
async function loadNodeCount(src) {
try {
const res = await previewSource(sourcePreviewPayload(src))
nodeCounts[src.id] = countsFromPreview(res.data)
} catch {
nodeCounts[src.id] = { original: '-', processed: '-' }
}
}
async function loadFlowInfo(src) {
if (src.type !== 'remote' && !src.meta?.subUserinfo) return
try {
const res = await getFlowInfo(src.id)
flowInfos[src.id] = res.data
} catch {
delete flowInfos[src.id]
}
}
onMounted(load)
</script>
+7 -2
View File
@@ -66,6 +66,7 @@
<script setup>
import { ref, computed } from 'vue'
import { parseProxy, parseRule, getNodeInfo, exportStorage, importStorage } from '@/api'
import { copyText } from '@/utils/clipboard'
const proxyTargets = ['mihomo', 'stash', 'surge', 'surge-mac', 'surfboard', 'loon', 'egern', 'shadowrocket', 'qx', 'sing-box', 'v2ray', 'uri', 'json']
const ruleTargets = ['mihomo', 'surge', 'loon', 'qx']
@@ -91,8 +92,12 @@ function showToast(msg, type = 'success') {
}
async function copy(text) {
await navigator.clipboard.writeText(text)
showToast('已复制')
try {
await copyText(text)
showToast('已复制')
} catch (e) {
showToast(e.message, 'error')
}
}
async function runConvert() {
+10 -42
View File
@@ -1,21 +1,22 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getTokenFromURL, getStoredToken, isTokenValidated, storeToken, clearToken, validateToken } from '@/utils/token'
import { getStoredRemoteUri, getStoredToken, isTokenValidated } from '@/utils/token'
// Layouts
import AdminLayout from '@/layouts/AdminLayout.vue'
// Pages
import Login from '@/pages/Login.vue'
import NotFound from '@/pages/NotFound.vue'
import Dashboard from '@/pages/Dashboard.vue'
import Sources from '@/pages/Sources.vue'
import Collections from '@/pages/Collections.vue'
import Templates from '@/pages/Templates.vue'
import Shares from '@/pages/Shares.vue'
import RecycleBin from '@/pages/RecycleBin.vue'
import Tools from '@/pages/Tools.vue'
import Settings from '@/pages/Settings.vue'
const routes = [
{ path: '/login', name: 'login', component: Login, meta: { title: '登录' } },
{
path: '/',
component: AdminLayout,
@@ -24,7 +25,6 @@ const routes = [
{ path: 'sources', name: 'sources', component: Sources, meta: { title: '订阅源' } },
{ path: 'collections', name: 'collections', component: Collections, meta: { title: '合集' } },
{ path: 'templates', name: 'templates', component: Templates, meta: { title: '模板' } },
{ path: 'shares', name: 'shares', component: Shares, meta: { title: '分享' } },
{ path: 'recycle-bin', name: 'recycle-bin', component: RecycleBin, meta: { title: '回收站' } },
{ path: 'tools', name: 'tools', component: Tools, meta: { title: '工具' } },
{ path: 'settings', name: 'settings', component: Settings, meta: { title: '设置' } },
@@ -39,53 +39,21 @@ const router = createRouter({
routes,
})
// Track if we're currently validating to avoid duplicate calls
let validatingPromise = null
/**
* Global async token guard.
* The page must be opened with ?token=xxx.
* When a token is provided in the URL, it's validated against the backend.
* If valid → stored and URL stripped. If invalid → 404.
* Subsequent navigations use the stored, already-validated token.
* Global login guard.
* Login is now remote URI + token, stored after /api/env validates.
*/
router.beforeEach(async (to, from, next) => {
// If navigating to 404 page, always allow (avoid infinite loop)
if (to.name === 'not-found') {
router.beforeEach((to, from, next) => {
if (to.name === 'login' || to.name === 'not-found') {
return next()
}
// Check token from Vue Router query (authoritative during navigation)
const routeToken = to.query.token
// If URL has a token, validate it before allowing access
if (routeToken) {
// Avoid duplicate validation
if (!validatingPromise) {
validatingPromise = validateToken(routeToken)
}
const isValid = await validatingPromise
validatingPromise = null
if (isValid) {
storeToken(routeToken)
// Navigate to same route without token in query
const cleanQuery = { ...to.query }
delete cleanQuery.token
return next({ ...to, query: cleanQuery, replace: true })
} else {
// Invalid token → clear storage, show 404
clearToken()
return next({ name: 'not-found' })
}
}
// No token in URL — check if we have a validated stored token
const storedRemoteUri = getStoredRemoteUri()
const storedToken = getStoredToken()
const validated = isTokenValidated()
if (!storedToken || !validated) {
return next({ name: 'not-found' })
if (!storedRemoteUri || !storedToken || !validated) {
return next({ name: 'login', query: { redirect: to.fullPath } })
}
next()
+22
View File
@@ -0,0 +1,22 @@
export async function copyText(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
try {
if (!document.execCommand('copy')) {
throw new Error('复制失败')
}
} finally {
document.body.removeChild(textarea)
}
}
+28 -26
View File
@@ -1,51 +1,55 @@
/**
* Token management — the page MUST be opened with ?token=xxx
* The token is validated against the backend /api/env endpoint.
* If invalid or missing, the router guard shows a 404 page.
* Login storage for remote Sub-Store backend URI + admin token.
*/
const REMOTE_URI_KEY = 'sub_store_remote_uri'
const TOKEN_KEY = 'sub_store_admin_token'
const VALIDATED_KEY = 'sub_store_token_validated'
/** Extract token from URL query string */
export function getTokenFromURL() {
const params = new URLSearchParams(window.location.search)
return params.get('token') || ''
export function normalizeRemoteUri(uri) {
const value = (uri || '').trim().replace(/\/+$/, '').replace(/\/api$/, '')
if (!value) return ''
const parsed = new URL(value)
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Remote URI must use http or https')
}
return parsed.toString().replace(/\/+$/, '')
}
/** Store token in localStorage (persists across page reloads) */
export function storeToken(token) {
if (token) {
export function storeLogin(remoteUri, token) {
const normalizedRemoteUri = normalizeRemoteUri(remoteUri)
if (normalizedRemoteUri && token) {
localStorage.setItem(REMOTE_URI_KEY, normalizedRemoteUri)
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(VALIDATED_KEY, 'true')
}
}
/** Get stored token */
export function getStoredRemoteUri() {
return localStorage.getItem(REMOTE_URI_KEY) || ''
}
export function getStoredToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
/** Check if token has been validated */
export function isTokenValidated() {
return localStorage.getItem(VALIDATED_KEY) === 'true'
}
/** Clear token (logout) */
export function clearToken() {
localStorage.removeItem(REMOTE_URI_KEY)
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(VALIDATED_KEY)
}
/**
* Validate token against backend by calling /api/env.
* Returns true if token is valid, false otherwise.
*/
export async function validateToken(token) {
export async function validateToken(token, remoteUri) {
if (!token) return false
try {
const base = (window.SUB_STORE_CONFIG?.apiBaseUrl) || ''
const resp = await fetch(`${base}/api/env?token=${encodeURIComponent(token)}`)
const base = normalizeRemoteUri(remoteUri)
const resp = await fetch(`${base}/api/env`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!resp.ok) return false
const data = await resp.json()
return data.status === 'success'
@@ -54,12 +58,10 @@ export async function validateToken(token) {
}
}
/**
* Get the effective token: from URL if present, otherwise from storage.
* This is used by the API layer to always send the token.
*/
export function getEffectiveToken() {
const urlToken = getTokenFromURL()
if (urlToken) return urlToken
return getStoredToken()
}
export function getApiBaseUrl() {
return getStoredRemoteUri() || (window.SUB_STORE_CONFIG?.apiBaseUrl) || ''
}
+2 -2
View File
@@ -14,11 +14,11 @@ export default defineConfig({
host: '0.0.0.0',
proxy: {
'/api': {
target: 'http://localhost:3001',
target: 'http://localhost:3000',
changeOrigin: true
},
'/download': {
target: 'http://localhost:3001',
target: 'http://localhost:3000',
changeOrigin: true
}
}