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
+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) || ''
}