Compare commits
17
Commits
60e897f110
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52c1b9de99 | ||
|
|
5777561d9f | ||
|
|
ded160083f | ||
|
|
f7fa7faed1 | ||
|
|
558d91b0a4 | ||
|
|
63c40d69d4 | ||
|
|
7ba2011ead | ||
|
|
67a5f7cfab | ||
|
|
5c944b7c90 | ||
|
|
422033fc6d | ||
|
|
d03644e69f | ||
|
|
01dcc396f5 | ||
|
|
9cc03ee749 | ||
|
|
e256b65f10 | ||
|
|
d19751e525 | ||
|
|
2407ce0d05 | ||
|
|
cdd11f80cb |
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.workbuddy
|
||||||
|
|||||||
Generated
+21
-3
@@ -64,6 +64,27 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/core": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.1",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||||
|
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
@@ -1462,7 +1483,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1602,7 +1622,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz",
|
||||||
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
|
"integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
@@ -1680,7 +1699,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
|
||||||
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.32",
|
"@vue/compiler-dom": "3.5.32",
|
||||||
"@vue/compiler-sfc": "3.5.32",
|
"@vue/compiler-sfc": "3.5.32",
|
||||||
|
|||||||
+4
-3
@@ -2,16 +2,16 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import Dashboard from './views/Dashboard.vue'
|
import Dashboard from './views/Dashboard.vue'
|
||||||
import RecentCrawls from './views/RecentCrawls.vue'
|
import RecentCrawls from './views/RecentCrawls.vue'
|
||||||
import PriorityCrawl from './views/PriorityCrawl.vue'
|
|
||||||
import SearchView from './views/SearchView.vue'
|
import SearchView from './views/SearchView.vue'
|
||||||
|
import KeywordsCache from './views/KeywordsCache.vue'
|
||||||
|
|
||||||
const tab = ref('dashboard')
|
const tab = ref('dashboard')
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ id: 'dashboard', label: '概览', icon: '📊' },
|
{ id: 'dashboard', label: '概览', icon: '📊' },
|
||||||
{ id: 'recent', label: '最近', icon: '🕷️' },
|
{ id: 'recent', label: '最近', icon: '🕷️' },
|
||||||
|
{ id: 'keywords', label: '缓存', icon: '💾' },
|
||||||
{ id: 'search', label: '搜索', icon: '🔍' },
|
{ id: 'search', label: '搜索', icon: '🔍' },
|
||||||
{ id: 'priority', label: '插入', icon: '🚀' },
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,8 +48,9 @@ const nav = [
|
|||||||
<main class="flex-1 overflow-y-auto pb-16 md:pb-0">
|
<main class="flex-1 overflow-y-auto pb-16 md:pb-0">
|
||||||
<Dashboard v-if="tab === 'dashboard'" />
|
<Dashboard v-if="tab === 'dashboard'" />
|
||||||
<RecentCrawls v-else-if="tab === 'recent'" />
|
<RecentCrawls v-else-if="tab === 'recent'" />
|
||||||
|
<KeywordsCache v-else-if="tab === 'keywords'" />
|
||||||
<SearchView v-else-if="tab === 'search'" />
|
<SearchView v-else-if="tab === 'search'" />
|
||||||
<PriorityCrawl v-else-if="tab === 'priority'" />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Mobile Bottom Navigation -->
|
<!-- Mobile Bottom Navigation -->
|
||||||
|
|||||||
+46
@@ -47,6 +47,13 @@ export async function flushIndex() {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchFlushStatus() {
|
||||||
|
const { data } = await axios.get(`${BASE}/admin/flush/status`, {
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWorkers() {
|
export async function fetchWorkers() {
|
||||||
const { data } = await axios.get(`${BASE}/admin/workers`, {
|
const { data } = await axios.get(`${BASE}/admin/workers`, {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
@@ -75,3 +82,42 @@ export async function triggerBacklink() {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPriorityStatus() {
|
||||||
|
const { data } = await axios.get(`${BASE}/admin/priority/status`, {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCrawlStatus() {
|
||||||
|
const { data } = await axios.get(`${BASE}/admin/crawl/status`, {
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlKeywords(url) {
|
||||||
|
const { data } = await axios.get(`${BASE}/admin/url/keywords`, {
|
||||||
|
params: { url },
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlKeywordsStats() {
|
||||||
|
const { data } = await axios.get(`${BASE}/admin/url/keywords/stats`, {
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUrlKeywordsList({ page = 1, page_size = 50 } = {}) {
|
||||||
|
const { data } = await axios.get(`${BASE}/admin/url/keywords/list`, {
|
||||||
|
params: { page, page_size },
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+189
-7
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { fetchStats, flushIndex, fetchWorkers, setWorkers, fetchBacklink, triggerBacklink } from '../api.js'
|
import { fetchStats, flushIndex, fetchFlushStatus, fetchWorkers, setWorkers, fetchBacklink, triggerBacklink, fetchPriorityStatus, fetchCrawlStatus, addPriority } from '../api.js'
|
||||||
|
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -14,16 +14,24 @@ const activeWorkers = ref(0) // 实际运行中的 goroutine 数
|
|||||||
const workersInput = ref(0)
|
const workersInput = ref(0)
|
||||||
const workersSaving = ref(false)
|
const workersSaving = ref(false)
|
||||||
|
|
||||||
// Backlink 相关状态
|
// Priority 相关状态
|
||||||
const backlinkStatus = ref(null) // { running, next_run, last_run?, last_error? }
|
const priorityStatus = ref(null) // { level1, level2_queue, level2_inflight, active, max_workers }
|
||||||
|
const batchUrls = ref('')
|
||||||
|
const batchAdding = ref(false)
|
||||||
|
|
||||||
|
// 爬取状态
|
||||||
|
const crawlStatus = ref(null) // { current_epoch, max_epoch, queue_length, completed_count, visited_total, is_running }
|
||||||
|
const backlinkStatus = ref(null)
|
||||||
const backlinkTriggering = ref(false)
|
const backlinkTriggering = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadStats(), loadWorkers(), loadBacklink()])
|
await Promise.all([loadStats(), loadWorkers(), loadBacklink(), loadPriorityStatus(), loadCrawlStatus()])
|
||||||
refreshInterval = setInterval(() => {
|
refreshInterval = setInterval(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
loadWorkers()
|
loadWorkers()
|
||||||
loadBacklink()
|
loadBacklink()
|
||||||
|
loadPriorityStatus()
|
||||||
|
loadCrawlStatus()
|
||||||
}, 5000)
|
}, 5000)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -82,6 +90,22 @@ async function loadBacklink() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPriorityStatus() {
|
||||||
|
try {
|
||||||
|
priorityStatus.value = await fetchPriorityStatus()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load priority status:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCrawlStatus() {
|
||||||
|
try {
|
||||||
|
crawlStatus.value = await fetchCrawlStatus()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load crawl status:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doTriggerBacklink() {
|
async function doTriggerBacklink() {
|
||||||
backlinkTriggering.value = true
|
backlinkTriggering.value = true
|
||||||
try {
|
try {
|
||||||
@@ -123,7 +147,13 @@ async function doFlush() {
|
|||||||
flushing.value = true
|
flushing.value = true
|
||||||
try {
|
try {
|
||||||
await flushIndex()
|
await flushIndex()
|
||||||
// 重新拉取 stats,pending 应该归零
|
// 轮询刷盘状态,直到完成
|
||||||
|
while (true) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000)) // 等待 1s
|
||||||
|
const status = await fetchFlushStatus()
|
||||||
|
if (!status.flushing) break
|
||||||
|
}
|
||||||
|
// 刷盘完成,重新拉取 stats
|
||||||
stats.value = await fetchStats()
|
stats.value = await fetchStats()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '刷盘失败: ' + e.message
|
error.value = '刷盘失败: ' + e.message
|
||||||
@@ -146,6 +176,44 @@ function langColor(lang) {
|
|||||||
const map = { zh: '#e53e3e', en: '#3182ce', ja: '#e53e3e', ko: '#3182ce', fr: '#38a169', de: '#d69e2e', es: '#38a169', ru: '#805ad5', other: '#718096' }
|
const map = { zh: '#e53e3e', en: '#3182ce', ja: '#e53e3e', ko: '#3182ce', fr: '#38a169', de: '#d69e2e', es: '#38a169', ru: '#805ad5', other: '#718096' }
|
||||||
return map[lang] || map.other
|
return map[lang] || map.other
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidUrl(s) {
|
||||||
|
try {
|
||||||
|
const u = new URL(s)
|
||||||
|
return u.protocol === 'http:' || u.protocol === 'https:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doBatchAdd() {
|
||||||
|
const lines = batchUrls.value.split('\n').map(l => l.trim()).filter(l => l)
|
||||||
|
const valid = lines.filter(isValidUrl)
|
||||||
|
if (valid.length === 0) {
|
||||||
|
error.value = '未检测到有效 URL'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchAdding.value = true
|
||||||
|
let ok = 0, fail = 0
|
||||||
|
try {
|
||||||
|
for (const url of valid) {
|
||||||
|
try {
|
||||||
|
await addPriority(url)
|
||||||
|
ok++
|
||||||
|
} catch {
|
||||||
|
fail++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batchUrls.value = ''
|
||||||
|
await loadPriorityStatus()
|
||||||
|
error.value = null
|
||||||
|
if (fail > 0) {
|
||||||
|
error.value = `添加完成:${ok} 成功,${fail} 失败`
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
batchAdding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -198,15 +266,127 @@ function langColor(lang) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 优先爬取队列 -->
|
||||||
|
<div v-if="priorityStatus" class="bg-gray-900 border border-gray-800 rounded-xl p-4 md:p-5 mb-6 md:mb-8">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">优先爬取队列</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<!-- 一级队列 -->
|
||||||
|
<div class="bg-gray-800/50 rounded-lg p-4 text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">一级队列</div>
|
||||||
|
<div class="text-2xl font-bold" :class="(priorityStatus.level1 || 0) > 0 ? 'text-yellow-400' : 'text-gray-500'">
|
||||||
|
{{ fmt(priorityStatus.level1 || 0) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 二级队列 -->
|
||||||
|
<div class="bg-gray-800/50 rounded-lg p-4 text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">二级队列</div>
|
||||||
|
<div class="text-2xl font-bold" :class="((priorityStatus.level2_queue || 0) + (priorityStatus.level2_inflight || 0)) > 0 ? 'text-yellow-400' : 'text-gray-500'">
|
||||||
|
{{ fmt((priorityStatus.level2_queue || 0) + (priorityStatus.level2_inflight || 0)) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 线程使用 -->
|
||||||
|
<div class="bg-gray-800/50 rounded-lg p-4 text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">线程使用</div>
|
||||||
|
<div class="text-2xl font-bold" :class="priorityStatus.active > 0 ? 'text-orange-400' : 'text-gray-500'">
|
||||||
|
{{ fmt(priorityStatus.active) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 线程总数 -->
|
||||||
|
<div class="bg-gray-800/50 rounded-lg p-4 text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">线程总数</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-400">{{ priorityStatus.max_workers }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 批量添加 -->
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="batchUrls"
|
||||||
|
rows="3"
|
||||||
|
placeholder="批量添加 URL(每行一个,仅发送有效链接)"
|
||||||
|
class="flex-1 bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded-lg px-3 py-2 resize-none focus:outline-none focus:border-blue-500 placeholder-gray-600"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors cursor-pointer whitespace-nowrap self-end"
|
||||||
|
:disabled="batchAdding || !batchUrls.trim()"
|
||||||
|
@click="doBatchAdd"
|
||||||
|
>
|
||||||
|
{{ batchAdding ? '添加中...' : '批量添加' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 爬取进度 -->
|
||||||
|
<div v-if="crawlStatus && crawlStatus.max_epoch > 0" class="bg-gray-900 border border-gray-800 rounded-xl p-4 md:p-5 mb-6 md:mb-8">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-4">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider">爬取进度</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xs text-gray-500">状态:</span>
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium" :class="crawlStatus.is_running ? 'bg-green-900/50 text-green-400' : 'bg-gray-800 text-gray-500'">
|
||||||
|
{{ crawlStatus.is_running ? '运行中' : '已停止' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<!-- 当前轮次 -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">当前轮次</div>
|
||||||
|
<div class="text-3xl font-bold text-white">
|
||||||
|
{{ crawlStatus.current_epoch }}<span class="text-lg text-gray-500">/{{ crawlStatus.max_epoch }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 本轮队列 -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">本轮队列</div>
|
||||||
|
<div class="text-3xl font-bold" :class="crawlStatus.queue_length > 0 ? 'text-blue-400' : 'text-gray-500'">
|
||||||
|
{{ fmt(crawlStatus.queue_length) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 已完成 -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">已完成</div>
|
||||||
|
<div class="text-3xl font-bold text-green-400">
|
||||||
|
{{ fmt(crawlStatus.completed_count) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">
|
||||||
|
{{ crawlStatus.queue_length > 0 ? Math.round(crawlStatus.completed_count / crawlStatus.queue_length * 100) + '%' : '--' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 下一轮链接池 -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">本轮新发现</div>
|
||||||
|
<div class="text-3xl font-bold" :class="crawlStatus.new_links_count > 0 ? 'text-purple-400' : 'text-gray-500'">
|
||||||
|
{{ fmt(crawlStatus.new_links_count) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 已收录总计 -->
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">已收录总计</div>
|
||||||
|
<div class="text-3xl font-bold text-white">{{ fmt(crawlStatus.visited_total) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 进度条 -->
|
||||||
|
<div v-if="crawlStatus.queue_length > 0" class="mt-4">
|
||||||
|
<div class="bg-gray-800 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full bg-gradient-to-r from-blue-600 to-green-500 rounded-full transition-all duration-500"
|
||||||
|
:style="{ width: `${(crawlStatus.completed_count / crawlStatus.queue_length) * 100}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Workers Control -->
|
<!-- Workers Control -->
|
||||||
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 md:p-5 mb-6 md:mb-8">
|
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 md:p-5 mb-6 md:mb-8">
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<!-- 左侧:主线程 -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-2">爬虫线程数</h2>
|
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-2">爬虫线程</h2>
|
||||||
<div class="flex items-baseline gap-4">
|
<div class="flex items-baseline gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500 mb-0.5">实际运行</div>
|
<div class="text-xs text-gray-500 mb-0.5">实际运行</div>
|
||||||
<div class="text-3xl font-bold" :class="activeWorkers > 0 ? 'text-green-400' : 'text-gray-500'">{{ activeWorkers }}</div>
|
<div class="text-3xl font-bold" :class="activeWorkers > 0 ? 'text-green-400' : 'text-gray-500'">
|
||||||
|
{{ activeWorkers }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700 text-xl">/</div>
|
<div class="text-gray-700 text-xl">/</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -215,6 +395,8 @@ function langColor(lang) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 调节按钮 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="w-9 h-9 flex items-center justify-center rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white text-lg font-bold transition-colors cursor-pointer"
|
class="w-9 h-9 flex items-center justify-center rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white text-lg font-bold transition-colors cursor-pointer"
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
|
import { fetchUrlKeywordsList, fetchUrlKeywords, fetchUrlKeywordsStats } from '../api.js'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(null)
|
||||||
|
const stats = ref({ size: 0, max_size: 10000 })
|
||||||
|
const items = ref([]) // 当前页数据
|
||||||
|
const expandedUrls = ref({}) // 使用普通对象存储展开状态,避免 Set 的响应式开销
|
||||||
|
const urlKeywords = ref({})
|
||||||
|
const loadingKeywords = ref(new Set())
|
||||||
|
const search = ref('')
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
|
||||||
|
let statsInterval = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await load()
|
||||||
|
statsInterval = setInterval(loadStats, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (statsInterval) clearInterval(statsInterval)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const s = await fetchUrlKeywordsStats()
|
||||||
|
stats.value.size = s.size || 0
|
||||||
|
stats.value.max_size = s.max_size || 10000
|
||||||
|
} catch {
|
||||||
|
// 静默
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await fetchUrlKeywordsList({ page: currentPage.value, page_size: pageSize.value })
|
||||||
|
stats.value.size = data.size || 0
|
||||||
|
stats.value.max_size = data.max_size || 10000
|
||||||
|
total.value = data.total || 0
|
||||||
|
items.value = data.items || []
|
||||||
|
|
||||||
|
// 默认全部展开
|
||||||
|
const expanded = {}
|
||||||
|
items.value.forEach(item => expanded[item.url] = true)
|
||||||
|
expandedUrls.value = expanded
|
||||||
|
// 预加载当前页关键词
|
||||||
|
items.value.forEach(item => {
|
||||||
|
if (item.keywords?.length) {
|
||||||
|
urlKeywords.value[item.url] = item.keywords
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '无法加载缓存数据'
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = computed(() => {
|
||||||
|
if (!stats.value.max_size) return 0
|
||||||
|
return stats.value.size / stats.value.max_size
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (currentPage.value > 1) {
|
||||||
|
currentPage.value--
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (currentPage.value < totalPages.value) {
|
||||||
|
currentPage.value++
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPage(p) {
|
||||||
|
if (p >= 1 && p <= totalPages.value && p !== currentPage.value) {
|
||||||
|
currentPage.value = p
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
currentPage.value = 1
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleKeywords(url) {
|
||||||
|
if (expandedUrls.value[url]) {
|
||||||
|
// 关闭:删除展开状态
|
||||||
|
const newExpanded = { ...expandedUrls.value }
|
||||||
|
delete newExpanded[url]
|
||||||
|
expandedUrls.value = newExpanded
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开
|
||||||
|
expandedUrls.value = { ...expandedUrls.value, [url]: true }
|
||||||
|
|
||||||
|
if (urlKeywords.value[url]) return
|
||||||
|
|
||||||
|
loadingKeywords.value.add(url)
|
||||||
|
try {
|
||||||
|
const data = await fetchUrlKeywords(url)
|
||||||
|
urlKeywords.value[url] = data.keywords || []
|
||||||
|
} catch (e) {
|
||||||
|
urlKeywords.value[url] = []
|
||||||
|
} finally {
|
||||||
|
loadingKeywords.value.delete(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateUrl(url, maxLen = 80) {
|
||||||
|
if (url.length <= maxLen) return url
|
||||||
|
return url.slice(0, maxLen) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateSnippet(text, maxLen = 200) {
|
||||||
|
if (!text) return ''
|
||||||
|
if (text.length <= maxLen) return text
|
||||||
|
return text.slice(0, maxLen) + '...'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-4 md:p-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between mb-4 md:mb-6 gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl md:text-2xl font-semibold text-white mb-1">关键词缓存</h1>
|
||||||
|
<p class="text-sm text-gray-500">LRU 缓存,共 {{ stats.size.toLocaleString() }} 条记录</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- 缓存状态卡片(自动刷新) -->
|
||||||
|
<div class="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 flex items-center gap-3">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-[10px] text-gray-500 uppercase">容量</span>
|
||||||
|
<span class="text-sm font-medium text-gray-300">
|
||||||
|
{{ stats.size.toLocaleString() }} / {{ stats.max_size.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 relative">
|
||||||
|
<svg viewBox="0 0 36 36" class="w-full h-full transform -rotate-90">
|
||||||
|
<path
|
||||||
|
class="text-gray-800"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
:class="usage > 0.9 ? 'text-red-500' : usage > 0.7 ? 'text-yellow-500' : 'text-green-500'"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
:stroke-dasharray="`${(usage * 100).toFixed(0)}, 100`"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="absolute inset-0 flex items-center justify-center text-[8px] font-medium text-gray-400">
|
||||||
|
{{ (usage * 100).toFixed(0) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="load"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="mb-4 md:mb-5">
|
||||||
|
<div class="relative max-w-sm">
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
@input="onSearch"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索 URL、标题或摘要..."
|
||||||
|
class="w-full bg-gray-900 border border-gray-700 text-gray-200 text-sm rounded-lg pl-10 pr-4 py-2 focus:border-blue-500 focus:outline-none placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex items-center justify-center h-48">
|
||||||
|
<div class="text-gray-400 animate-pulse">加载中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="bg-red-900/30 border border-red-800 rounded-lg p-4 text-red-300">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="!items.length" class="bg-gray-900 border border-gray-800 rounded-xl p-12 text-center">
|
||||||
|
<div class="text-4xl mb-3">📭</div>
|
||||||
|
<div class="text-gray-400">缓存为空</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">爬取页面时会自动填充此缓存</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL List with Pagination -->
|
||||||
|
<div v-else class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<div class="divide-y divide-gray-800">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.url"
|
||||||
|
class="p-4 hover:bg-gray-800/40 transition-colors cursor-pointer"
|
||||||
|
@click="toggleKeywords(item.url)"
|
||||||
|
>
|
||||||
|
<!-- URL 标题行 -->
|
||||||
|
<div class="flex items-start gap-2 mb-1">
|
||||||
|
<span class="shrink-0 text-gray-600 mt-0.5 text-xs select-none">
|
||||||
|
{{ expandedUrls[item.url] ? '▼' : '▶' }}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
:href="item.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm text-blue-400 hover:text-blue-300 break-all line-clamp-2"
|
||||||
|
:title="item.url"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
{{ truncateUrl(item.url) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div v-if="item.title" class="text-sm text-gray-300 font-medium mb-1">
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 摘要 -->
|
||||||
|
<div v-if="item.snippet" class="text-xs text-gray-500 leading-relaxed mb-2">
|
||||||
|
{{ truncateSnippet(item.snippet) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关键词 -->
|
||||||
|
<div v-if="expandedUrls[item.url]" class="mt-2">
|
||||||
|
<template v-if="urlKeywords[item.url]?.length">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="kw in urlKeywords[item.url]"
|
||||||
|
:key="kw.word"
|
||||||
|
class="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-300 border border-gray-700"
|
||||||
|
:title="`权重: ${kw.weight.toFixed(4)}`"
|
||||||
|
>
|
||||||
|
{{ kw.word }}
|
||||||
|
<span class="text-gray-500 text-[10px] ml-0.5">{{ kw.weight.toFixed(2) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-2">
|
||||||
|
共 {{ urlKeywords[item.url].length }} 个关键词
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="!loadingKeywords.has(item.url)" class="text-xs text-gray-600">
|
||||||
|
暂无关键词
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-gray-500">
|
||||||
|
加载中...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="px-4 py-3 border-t border-gray-800 flex items-center justify-between">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
第 {{ (currentPage - 1) * pageSize + 1 }}-{{ Math.min(currentPage * pageSize, total) }} 条 /
|
||||||
|
共 {{ total.toLocaleString() }} 条
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
@click="prevPage"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="p in totalPages <= 5 ? totalPages : (() => {
|
||||||
|
const pages = []
|
||||||
|
const start = Math.max(1, currentPage - 2)
|
||||||
|
const end = Math.min(totalPages, start + 4)
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i)
|
||||||
|
return pages
|
||||||
|
})()"
|
||||||
|
:key="p"
|
||||||
|
@click="goToPage(p)"
|
||||||
|
:class="[
|
||||||
|
'px-2 py-1 text-xs rounded',
|
||||||
|
p === currentPage ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ p }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="totalPages > 5 && currentPage < totalPages - 2"
|
||||||
|
class="px-1 py-1 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="totalPages > 5 && currentPage < totalPages - 2"
|
||||||
|
@click="goToPage(totalPages)"
|
||||||
|
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
{{ totalPages }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="nextPage"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
class="px-2 py-1 text-xs rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { fetchPriority, addPriority, removePriority } from '../api.js'
|
|
||||||
|
|
||||||
const items = ref([])
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref(null)
|
|
||||||
const submitting = ref(false)
|
|
||||||
const submitError = ref(null)
|
|
||||||
const submitSuccess = ref(false)
|
|
||||||
const inputUrl = ref('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
try {
|
|
||||||
const data = await fetchPriority()
|
|
||||||
items.value = data.items || []
|
|
||||||
} catch (e) {
|
|
||||||
error.value = '加载失败,请检查人服务器是否启动'
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const raw = inputUrl.value.trim()
|
|
||||||
if (!raw) return
|
|
||||||
submitting.value = true
|
|
||||||
submitError.value = null
|
|
||||||
submitSuccess.value = false
|
|
||||||
try {
|
|
||||||
await addPriority(raw)
|
|
||||||
inputUrl.value = ''
|
|
||||||
submitSuccess.value = true
|
|
||||||
setTimeout(() => { submitSuccess.value = false }, 3000)
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
submitError.value = e?.response?.data?.error || '添加失败'
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function del(url) {
|
|
||||||
try {
|
|
||||||
await removePriority(url)
|
|
||||||
await load()
|
|
||||||
} catch (e) {
|
|
||||||
error.value = '删除失败'
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTime(ts) {
|
|
||||||
if (!ts) return '-'
|
|
||||||
return new Date(ts * 1000).toLocaleString('zh-CN', {
|
|
||||||
month: '2-digit', day: '2-digit',
|
|
||||||
hour: '2-digit', minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="p-4 md:p-8">
|
|
||||||
<h1 class="text-xl md:text-2xl font-semibold text-white mb-2">插入爬取</h1>
|
|
||||||
<p class="text-sm text-gray-500 mb-6 md:mb-8">
|
|
||||||
添加 URL 或域名,下一轮爬取时会优先抓取。纯域名会自动补全为 https://www.域名/。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Input -->
|
|
||||||
<div class="bg-gray-900 rounded-xl p-4 md:p-6 mb-4 md:mb-6 border border-gray-800">
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3">
|
|
||||||
<input
|
|
||||||
v-model="inputUrl"
|
|
||||||
@keyup.enter="submit"
|
|
||||||
type="text"
|
|
||||||
placeholder="输入 URL 或域名,例如 https://example.com 或 example.com"
|
|
||||||
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2.5 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-sm"
|
|
||||||
:disabled="submitting"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="submit"
|
|
||||||
:disabled="submitting || !inputUrl.trim()"
|
|
||||||
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm font-medium rounded-lg transition-colors cursor-pointer whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ submitting ? '添加中...' : '插入队列' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<div v-if="submitError" class="mt-3 text-sm text-red-400">
|
|
||||||
{{ submitError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success -->
|
|
||||||
<div v-if="submitSuccess" class="mt-3 text-sm text-green-400">
|
|
||||||
已添加到优先队列,将在下一轮爬取时优先抓取
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- List -->
|
|
||||||
<div class="bg-gray-900 rounded-xl border border-gray-800">
|
|
||||||
<div class="px-4 md:px-6 py-3 md:py-4 border-b border-gray-800 flex items-center justify-between">
|
|
||||||
<span class="text-sm font-medium text-gray-300">待爬取队列</span>
|
|
||||||
<span class="text-xs text-gray-500">{{ items.length }} 条</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="loading" class="p-8 text-center text-gray-500 text-sm">
|
|
||||||
加载中...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error -->
|
|
||||||
<div v-else-if="error" class="p-8 text-center text-red-400 text-sm">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<div v-else-if="items.length === 0" class="p-8 text-center text-gray-500 text-sm">
|
|
||||||
暂无待爬取的优先 URL
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop Table -->
|
|
||||||
<table v-else class="hidden md:table w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-left text-gray-500 text-xs border-b border-gray-800">
|
|
||||||
<th class="px-6 py-3 font-medium">URL</th>
|
|
||||||
<th class="px-6 py-3 font-medium w-28">类型</th>
|
|
||||||
<th class="px-6 py-3 font-medium w-40">添加时间</th>
|
|
||||||
<th class="px-6 py-3 font-medium w-16">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-800">
|
|
||||||
<tr v-for="item in items" :key="item.url" class="hover:bg-gray-800/50">
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<span class="text-gray-300 break-all">{{ item.url }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<span v-if="item.domain" class="inline-block px-2 py-0.5 text-xs rounded bg-purple-900 text-purple-300">域名</span>
|
|
||||||
<span v-else class="inline-block px-2 py-0.5 text-xs rounded bg-blue-900 text-blue-300">URL</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 text-gray-500">{{ fmtTime(item.added_at) }}</td>
|
|
||||||
<td class="px-6 py-3">
|
|
||||||
<button
|
|
||||||
@click="del(item.url)"
|
|
||||||
class="text-red-400 hover:text-red-300 text-xs cursor-pointer"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Mobile Cards -->
|
|
||||||
<div class="md:hidden divide-y divide-gray-800">
|
|
||||||
<div
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.url"
|
|
||||||
class="p-4 hover:bg-gray-800/50 flex items-center justify-between gap-3"
|
|
||||||
>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="text-gray-300 text-sm break-all mb-1">{{ item.url }}</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span v-if="item.domain" class="inline-block px-2 py-0.5 text-xs rounded bg-purple-900 text-purple-300">域名</span>
|
|
||||||
<span v-else class="inline-block px-2 py-0.5 text-xs rounded bg-blue-900 text-blue-300">URL</span>
|
|
||||||
<span class="text-xs text-gray-500">{{ fmtTime(item.added_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="del(item.url)"
|
|
||||||
class="text-red-400 hover:text-red-300 text-xs cursor-pointer shrink-0 px-2 py-1"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { fetchRecent } from '../api.js'
|
import { fetchRecent, fetchUrlKeywords } from '../api.js'
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -13,6 +13,11 @@ let refreshInterval = null
|
|||||||
const limits = [20, 50, 100, 200]
|
const limits = [20, 50, 100, 200]
|
||||||
const limit = ref(50)
|
const limit = ref(50)
|
||||||
|
|
||||||
|
// 关键词展开状态和缓存
|
||||||
|
const expandedUrls = ref(new Set())
|
||||||
|
const urlKeywords = ref({})
|
||||||
|
const loadingKeywords = ref(new Set())
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await load()
|
await load()
|
||||||
})
|
})
|
||||||
@@ -81,6 +86,30 @@ function topLang(language) {
|
|||||||
const sorted = Object.entries(language).sort((a, b) => b[1] - a[1])
|
const sorted = Object.entries(language).sort((a, b) => b[1] - a[1])
|
||||||
return sorted[0]
|
return sorted[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleKeywords(url) {
|
||||||
|
if (expandedUrls.value.has(url)) {
|
||||||
|
expandedUrls.value.delete(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedUrls.value.add(url)
|
||||||
|
|
||||||
|
if (urlKeywords.value[url]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingKeywords.value.add(url)
|
||||||
|
try {
|
||||||
|
const data = await fetchUrlKeywords(url)
|
||||||
|
urlKeywords.value[url] = data.keywords || []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load keywords:', e)
|
||||||
|
urlKeywords.value[url] = []
|
||||||
|
} finally {
|
||||||
|
loadingKeywords.value.delete(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -170,6 +199,36 @@ function topLang(language) {
|
|||||||
<div class="text-xs text-gray-600 mt-0.5 break-all line-clamp-1">{{ item.url }}</div>
|
<div class="text-xs text-gray-600 mt-0.5 break-all line-clamp-1">{{ item.url }}</div>
|
||||||
</a>
|
</a>
|
||||||
<div v-if="item.description" class="text-xs text-gray-500 mt-1 line-clamp-1">{{ item.description }}</div>
|
<div v-if="item.description" class="text-xs text-gray-500 mt-1 line-clamp-1">{{ item.description }}</div>
|
||||||
|
<!-- 关键词展开/折叠 -->
|
||||||
|
<button
|
||||||
|
@click.prevent="toggleKeywords(item.url)"
|
||||||
|
class="mt-2 text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{{ expandedUrls.has(item.url) ? '▼' : '▶' }} 关键词</span>
|
||||||
|
<span v-if="loadingKeywords.has(item.url)" class="text-gray-500">加载中...</span>
|
||||||
|
</button>
|
||||||
|
<!-- 关键词列表 -->
|
||||||
|
<div v-if="expandedUrls.has(item.url)" class="mt-2">
|
||||||
|
<template v-if="urlKeywords[item.url]?.length">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="kw in urlKeywords[item.url]"
|
||||||
|
:key="kw.word"
|
||||||
|
class="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-300 border border-gray-700"
|
||||||
|
:title="`权重: ${kw.weight.toFixed(4)}`"
|
||||||
|
>
|
||||||
|
{{ kw.word }}
|
||||||
|
<span class="text-gray-500 text-[10px] ml-0.5">{{ kw.weight.toFixed(2) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">
|
||||||
|
共 {{ urlKeywords[item.url].length }} 个关键词
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="!loadingKeywords.has(item.url)" class="text-xs text-gray-600">
|
||||||
|
暂无关键词(服务重启后缓存已清空)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-3.5">
|
<td class="px-5 py-3.5">
|
||||||
<span class="text-gray-400 text-xs font-mono">{{ item.domain }}</span>
|
<span class="text-gray-400 text-xs font-mono">{{ item.domain }}</span>
|
||||||
@@ -202,17 +261,16 @@ function topLang(language) {
|
|||||||
|
|
||||||
<!-- Mobile Cards -->
|
<!-- Mobile Cards -->
|
||||||
<div class="md:hidden divide-y divide-gray-800">
|
<div class="md:hidden divide-y divide-gray-800">
|
||||||
<a
|
<div
|
||||||
v-for="item in filtered"
|
v-for="item in filtered"
|
||||||
:key="item.url"
|
:key="item.url"
|
||||||
:href="item.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="block p-4 hover:bg-gray-800/40 transition-colors"
|
class="block p-4 hover:bg-gray-800/40 transition-colors"
|
||||||
>
|
>
|
||||||
<div class="font-medium text-gray-200 text-sm line-clamp-2 mb-1">{{ item.title || '(无标题)' }}</div>
|
<a :href="item.url" target="_blank" rel="noopener noreferrer" class="block">
|
||||||
<div class="text-xs text-gray-500 break-all line-clamp-1 mb-2">{{ item.url }}</div>
|
<div class="font-medium text-gray-200 text-sm line-clamp-2 mb-1">{{ item.title || '(无标题)' }}</div>
|
||||||
<div class="flex items-center gap-2 text-xs">
|
<div class="text-xs text-gray-500 break-all line-clamp-1 mb-2">{{ item.url }}</div>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-2 text-xs mb-2">
|
||||||
<span class="text-gray-400 font-mono">{{ item.domain }}</span>
|
<span class="text-gray-400 font-mono">{{ item.domain }}</span>
|
||||||
<template v-if="topLang(item.language)">
|
<template v-if="topLang(item.language)">
|
||||||
<span
|
<span
|
||||||
@@ -223,7 +281,36 @@ function topLang(language) {
|
|||||||
</template>
|
</template>
|
||||||
<span class="text-gray-600 ml-auto">{{ fmtTime(item.crawled_at) }}</span>
|
<span class="text-gray-600 ml-auto">{{ fmtTime(item.crawled_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
<!-- 移动端关键词展开 -->
|
||||||
|
<button
|
||||||
|
@click.prevent="toggleKeywords(item.url)"
|
||||||
|
class="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 mb-2"
|
||||||
|
>
|
||||||
|
<span>{{ expandedUrls.has(item.url) ? '▼' : '▶' }} 关键词</span>
|
||||||
|
<span v-if="loadingKeywords.has(item.url)" class="text-gray-500">加载中...</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="expandedUrls.has(item.url)" class="mb-2">
|
||||||
|
<template v-if="urlKeywords[item.url]?.length">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="kw in urlKeywords[item.url]"
|
||||||
|
:key="kw.word"
|
||||||
|
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-800 text-gray-300 border border-gray-700"
|
||||||
|
:title="`权重: ${kw.weight.toFixed(4)}`"
|
||||||
|
>
|
||||||
|
{{ kw.word }}
|
||||||
|
<span class="text-gray-500 text-[9px] ml-0.5">{{ kw.weight.toFixed(2) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] text-gray-600 mt-1">
|
||||||
|
共 {{ urlKeywords[item.url].length }} 个
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="!loadingKeywords.has(item.url)" class="text-xs text-gray-600">
|
||||||
|
暂无关键词
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="!filtered.length" class="p-8 text-center text-gray-600">
|
<div v-if="!filtered.length" class="p-8 text-center text-gray-600">
|
||||||
没有找到匹配的记录
|
没有找到匹配的记录
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user