refactor: simplify auth and subscription links
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: '设置' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -8,5 +8,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 404 page — shown when token is missing or invalid
|
||||
// 404 page
|
||||
</script>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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) || ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user