This commit is contained in:
2026-07-28 10:32:21 +08:00
parent fc8c23beee
commit 389c0d172a
17 changed files with 678 additions and 157 deletions
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-CtUpN0Ye.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-2qQf5Bht.css">
<script type="module" crossorigin src="/assets/index-CPGYuQco.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkTyHILH.css">
</head>
<body class="bg-gray-50 text-gray-900">
<div id="app"></div>
+1
View File
@@ -86,5 +86,6 @@ 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 const getEgressInfo = (node) => api.post('/utils/egress-info', node)
export default api
+1 -18
View File
@@ -5,9 +5,8 @@
<div class="px-5 py-4 border-b border-gray-700">
<h1 class="text-lg font-bold text-white flex items-center gap-2">
<span class="inline-block w-2 h-2 bg-primary-500 rounded-full"></span>
Sub-Store
SubStore
</h1>
<p class="text-xs text-gray-500 mt-0.5" v-if="env">{{ env.backend }} · {{ env.version }}</p>
</div>
<nav class="flex-1 py-3 overflow-y-auto">
<router-link
@@ -23,9 +22,6 @@
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="px-5 py-3 border-t border-gray-700 text-xs text-gray-500">
<p>SQLite · Go</p>
</div>
</aside>
<!-- Main content -->
@@ -38,10 +34,6 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getEnv } from '@/api'
const env = ref(null)
const menu = [
{ to: '/', icon: '📊', label: '概览' },
{ to: '/sources', icon: '📡', label: '订阅源' },
@@ -51,13 +43,4 @@ const menu = [
{ to: '/tools', icon: '🔧', label: '工具' },
{ to: '/settings', icon: '⚙️', label: '设置' },
]
onMounted(async () => {
try {
const res = await getEnv()
env.value = res.data
} catch {
// ignore
}
})
</script>
+168 -21
View File
@@ -9,25 +9,75 @@
<div v-else-if="collections.length === 0" class="text-gray-400 text-sm py-8 text-center">暂无合集</div>
<div v-else class="space-y-2">
<div v-for="col in collections" :key="col.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">{{ col.name }}</span>
<span v-if="!col.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(col)">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900">{{ col.name }}</span>
<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>
<span v-if="col.ignoreFailed">忽略失败</span>
</div>
</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>
<span v-if="col.ignoreFailed">忽略失败</span>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600 whitespace-nowrap"
:disabled="!!egressLoadingId" @click.stop="probeCollectionEgress(col)">
{{ egressLoadingId === col.id ? `探测 ${egressProgress}` : '探测出口' }}
</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-gray-100" @click.stop="copyLink(col)">复制链接</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600" @click.stop="editCol(col)">编辑</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click.stop="remove(col)">删除</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(col)">链接</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600" @click="editCol(col)">编辑</button>
<button class="px-2.5 py-1 text-xs rounded hover:bg-red-50 text-red-600" @click="remove(col)">删除</button>
<div v-if="expandedCollectionId === col.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">Egress IP</th>
<th class="px-3 py-2 font-medium">Country</th>
<th class="px-3 py-2 font-medium">Region</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">{{ formatTransport(node) }}</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, 'latencyMs', '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 font-mono text-gray-500">{{ nodeField(node, 'egressIp') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressCountry', 'country') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressRegion', 'region') }}</td>
<td class="px-3 py-2">
<button class="px-2 py-1 text-xs rounded hover:bg-gray-100 text-blue-600 whitespace-nowrap" @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>
@@ -99,7 +149,7 @@
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, previewCollection } from '@/api'
import { listCollections, createCollection, updateCollection, deleteCollection, listSources, listTemplates, getLinkCollection, previewCollection, exportProxyUri, getEgressInfo } from '@/api'
import { copyText } from '@/utils/clipboard'
const collections = ref([])
@@ -108,6 +158,11 @@ const allTemplates = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(false)
const expandedCollectionId = ref('')
const expandedLoading = ref(false)
const expandedData = ref(null)
const egressLoadingId = ref('')
const egressProgress = ref('')
const toast = ref(null)
const nodeCounts = reactive({})
const confirmDialog = ref({ show: false, title: '', message: '', danger: false, action: null })
@@ -124,6 +179,37 @@ function showToast(msg, type = 'success') {
setTimeout(() => toast.value = null, 2500)
}
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) {
if (node?.['reality-opts'] || node?.security === 'reality') return 'reality'
const value = nodeField(node, 'tls', 'security')
if (value === true) return 'TLS'
if (value === false) return '-'
if (value === 'none') return '-'
return value
}
function formatTransport(node) {
const transport = node?.transport
if (typeof transport === 'string') return transport
if (transport?.type) return transport.type
return nodeField(node, 'network', 'net')
}
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 openCreate() {
Object.assign(form, emptyForm())
editing.value = false
@@ -184,6 +270,70 @@ async function copyLink(col) {
}
}
async function toggleExpand(col) {
if (expandedCollectionId.value === col.id) {
expandedCollectionId.value = ''
expandedData.value = null
return
}
expandedCollectionId.value = col.id
expandedLoading.value = true
expandedData.value = null
try {
const res = await previewCollection(col)
expandedData.value = res.data
nodeCounts[col.id] = countsFromPreview(res.data)
} catch (e) {
showToast(e.message, 'error')
expandedCollectionId.value = ''
} finally {
expandedLoading.value = false
}
}
async function copyNodeLink(node) {
try {
const res = await exportProxyUri(node)
await copyText(res.data.uri)
showToast('节点链接已复制')
} catch (e) {
showToast(e.message, 'error')
}
}
async function probeCollectionEgress(col) {
if (egressLoadingId.value) return
egressLoadingId.value = col.id
egressProgress.value = ''
try {
const preview = await previewCollection(col)
const nodes = preview.data?.processed || preview.data?.original || []
nodeCounts[col.id] = countsFromPreview(preview.data)
if (expandedCollectionId.value === col.id) {
expandedData.value = preview.data
}
for (let i = 0; i < nodes.length; i += 1) {
egressProgress.value = `${i + 1}/${nodes.length}`
try {
const res = await getEgressInfo(nodes[i])
Object.assign(nodes[i], {
egressIp: res.data.egressIp,
egressCountry: res.data.country,
egressRegion: res.data.region,
latencyMs: res.data.latencyMs,
})
} catch {
Object.assign(nodes[i], { egressIp: '-', egressCountry: '-', egressRegion: '-' })
}
}
showToast('出口探测完成')
} finally {
egressLoadingId.value = ''
egressProgress.value = ''
}
}
async function load() {
loading.value = true
try {
@@ -202,10 +352,7 @@ 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,
}
nodeCounts[col.id] = countsFromPreview(res.data)
} catch {
nodeCounts[col.id] = { original: '-', processed: '-' }
}
-28
View File
@@ -43,34 +43,6 @@
<p class="text-xs text-gray-400 mt-1">URL 中 {ip} 会被替换为查询的 IP</p>
</div>
<!-- Theme -->
<div class="bg-white rounded-lg p-4 shadow-sm border">
<h3 class="font-semibold mb-3 text-sm">主题</h3>
<div class="flex items-center gap-2 mb-2">
<input type="checkbox" id="theme-auto" v-model="settings.theme.auto" class="rounded" />
<label for="theme-auto" class="text-sm">自动跟随系统</label>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-500">亮色主题</label>
<input v-model="settings.theme.light" class="w-full px-3 py-2 border rounded-lg text-sm" />
</div>
<div>
<label class="text-xs text-gray-500">暗色主题</label>
<input v-model="settings.theme.dark" class="w-full px-3 py-2 border rounded-lg text-sm" />
</div>
</div>
</div>
<!-- App name -->
<div class="bg-white rounded-lg p-4 shadow-sm border">
<h3 class="font-semibold mb-3 text-sm">应用</h3>
<div>
<label class="block text-sm mb-1">应用名称</label>
<input v-model="settings.appName" class="w-full px-3 py-2 border rounded-lg text-sm" />
</div>
</div>
<button class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700" @click="save">保存设置</button>
</div>
+57 -4
View File
@@ -28,6 +28,10 @@
<p v-if="flowSummary(src)" class="text-xs text-gray-500 mt-1 truncate">{{ flowSummary(src) }}</p>
</div>
<div class="flex gap-1 ml-3">
<button class="px-2.5 py-1 text-xs rounded hover:bg-blue-50 text-blue-600 whitespace-nowrap"
:disabled="!!egressLoadingId" @click.stop="probeSourceEgress(src)">
{{ egressLoadingId === src.id ? `探测 ${egressProgress}` : '探测出口' }}
</button>
<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>
@@ -48,6 +52,9 @@
<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">Egress IP</th>
<th class="px-3 py-2 font-medium">Country</th>
<th class="px-3 py-2 font-medium">Region</th>
<th class="px-3 py-2 font-medium">Link</th>
</tr>
</thead>
@@ -57,12 +64,15 @@
<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">{{ formatTransport(node) }}</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, 'latencyMs', '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 font-mono text-gray-500">{{ nodeField(node, 'egressIp') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressCountry', 'country') }}</td>
<td class="px-3 py-2 text-gray-500">{{ nodeField(node, 'egressRegion', 'region') }}</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>
<button class="px-2 py-1 text-xs rounded hover:bg-gray-100 text-blue-600 whitespace-nowrap" @click="copyNodeLink(node)">复制</button>
</td>
</tr>
</tbody>
@@ -171,7 +181,7 @@
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, getFlowInfo, exportProxyUri } from '@/api'
import { listSources, createSource, updateSource, deleteSource, previewSource, getLinkSource, getFlowInfo, exportProxyUri, getEgressInfo } from '@/api'
import { copyText } from '@/utils/clipboard'
const sources = ref([])
@@ -181,6 +191,8 @@ const editing = ref(false)
const expandedSourceId = ref('')
const expandedLoading = ref(false)
const expandedData = ref(null)
const egressLoadingId = ref('')
const egressProgress = ref('')
const nodeCounts = reactive({})
const flowInfos = reactive({})
const toast = ref(null)
@@ -253,12 +265,21 @@ function nodeField(node, ...keys) {
}
function formatTls(node) {
if (node?.['reality-opts'] || node?.security === 'reality') return 'reality'
const value = nodeField(node, 'tls', 'security')
if (value === true) return 'TLS'
if (value === false) return '-'
if (value === 'none') return '-'
return value
}
function formatTransport(node) {
const transport = node?.transport
if (typeof transport === 'string') return transport
if (transport?.type) return transport.type
return nodeField(node, 'network', 'net')
}
function flowSummary(src) {
const flow = flowInfos[src.id]
if (!flow) return ''
@@ -394,6 +415,38 @@ async function copyNodeLink(node) {
}
}
async function probeSourceEgress(src) {
if (egressLoadingId.value) return
egressLoadingId.value = src.id
egressProgress.value = ''
try {
const preview = await previewSource(sourcePreviewPayload(src))
const nodes = preview.data?.processed || preview.data?.original || []
nodeCounts[src.id] = countsFromPreview(preview.data)
if (expandedSourceId.value === src.id) {
expandedData.value = preview.data
}
for (let i = 0; i < nodes.length; i += 1) {
egressProgress.value = `${i + 1}/${nodes.length}`
try {
const res = await getEgressInfo(nodes[i])
Object.assign(nodes[i], {
egressIp: res.data.egressIp,
egressCountry: res.data.country,
egressRegion: res.data.region,
latencyMs: res.data.latencyMs,
})
} catch {
Object.assign(nodes[i], { egressIp: '-', egressCountry: '-', egressRegion: '-' })
}
}
showToast('出口探测完成')
} finally {
egressLoadingId.value = ''
egressProgress.value = ''
}
}
async function load() {
loading.value = true
try {