Files
sese-engine-ui/src/views/Dashboard.vue
T
2026-04-11 21:37:56 +08:00

508 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { fetchStats, flushIndex, fetchFlushStatus, fetchWorkers, setWorkers, fetchBacklink, triggerBacklink, fetchPriorityStatus, fetchCrawlStatus, addPriority } from '../api.js'
const stats = ref(null)
const loading = ref(true)
const flushing = ref(false)
const error = ref(null)
let refreshInterval = null
// Workers 相关状态
const configuredWorkers = ref(0) // 设定线程数
const activeWorkers = ref(0) // 实际运行中的 goroutine 数
const workersInput = ref(0)
const workersSaving = ref(false)
// Priority 相关状态
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 }
onMounted(async () => {
await Promise.all([loadStats(), loadWorkers(), loadBacklink(), loadPriorityStatus(), loadCrawlStatus()])
refreshInterval = setInterval(() => {
loadStats()
loadWorkers()
loadBacklink()
loadPriorityStatus()
loadCrawlStatus()
}, 5000)
})
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
async function loadStats() {
try {
stats.value = await fetchStats()
error.value = null
} catch (e) {
error.value = '无法加载统计数据,可能人服务器未启动或端口不对'
console.error(e)
} finally {
loading.value = false
}
}
async function loadWorkers() {
try {
const res = await fetchWorkers()
configuredWorkers.value = res.configured
activeWorkers.value = res.active
workersInput.value = res.configured
} catch (e) {
console.error('Failed to load workers:', e)
}
}
async function saveWorkers() {
const n = parseInt(workersInput.value, 10)
if (isNaN(n) || n < 1 || n > 500) {
error.value = '线程数必须在 1~500 之间'
return
}
workersSaving.value = true
try {
await setWorkers(n)
configuredWorkers.value = n
error.value = null
} catch (e) {
error.value = '修改线程数失败: ' + e.message
} finally {
workersSaving.value = false
}
}
async function loadBacklink() {
try {
backlinkStatus.value = await fetchBacklink()
} catch (e) {
console.error('Failed to load backlink status:', e)
}
}
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() {
backlinkTriggering.value = true
try {
await triggerBacklink()
// 立即刷新状态,显示 running=true
backlinkStatus.value = await fetchBacklink()
error.value = null
} catch (e) {
error.value = '触发反链计算失败: ' + e.message
} finally {
backlinkTriggering.value = false
}
}
function formatBacklinkTime(isoStr) {
if (!isoStr) return '--'
const d = new Date(isoStr)
const now = new Date()
const diffMs = d - now
const diffMin = Math.floor(diffMs / 60000)
const diffH = Math.floor(diffMin / 60)
const diffD = Math.floor(diffH / 24)
const time = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
const date = d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
if (diffD > 0) return `${date} ${time} (${diffD}天后)`
if (diffH > 0) return `${date} ${time} (${diffH}小时后)`
if (diffMin > 0) return `${time} (${diffMin}分钟后)`
return `${time} (即将执行)`
}
function formatBacklinkLastRun(isoStr) {
if (!isoStr) return '从未执行'
const d = new Date(isoStr)
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
async function doFlush() {
flushing.value = true
try {
await flushIndex()
// 轮询刷盘状态,直到完成
while (true) {
await new Promise(r => setTimeout(r, 1000)) // 等待 1s
const status = await fetchFlushStatus()
if (!status.flushing) break
}
// 刷盘完成,重新拉取 stats
stats.value = await fetchStats()
} catch (e) {
error.value = '刷盘失败: ' + e.message
} finally {
flushing.value = false
}
}
function fmt(n) {
if (!n && n !== 0) return '0'
return Number(n).toLocaleString()
}
function topDomains(domains) {
if (!domains) return []
return Object.entries(domains).sort((a, b) => b[1] - a[1]).slice(0, 10)
}
function langColor(lang) {
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
}
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>
<template>
<div class="p-4 md:p-8">
<h1 class="text-xl md:text-2xl font-semibold text-white mb-6 md:mb-8">概览</h1>
<!-- Loading / Error -->
<div v-if="loading" class="flex items-center justify-center h-48">
<div class="text-gray-400 animate-pulse">加载中...</div>
</div>
<div v-else-if="error" class="bg-red-900/30 border border-red-800 rounded-lg p-4 text-red-300">
{{ error }}
</div>
<template v-else-if="stats">
<!-- Stat Cards -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 md:gap-5 mb-6 md:mb-8">
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div class="text-sm text-gray-500 mb-2">已爬取 URL</div>
<div class="text-3xl font-bold text-white">{{ fmt(stats.total_urls) }}</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div class="text-sm text-gray-500 mb-2">总词数</div>
<div class="text-3xl font-bold text-white">{{ fmt(stats.total_words) }}</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div class="text-sm text-gray-500 mb-2">域名数量</div>
<div class="text-3xl font-bold text-white">{{ fmt(stats.total_domains) }}</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5">
<div class="text-sm text-gray-500 mb-2">待重爬</div>
<div class="text-3xl font-bold" :class="stats.recrawl_eligible > 0 ? 'text-orange-400' : 'text-green-400'">
{{ fmt(stats.recrawl_eligible) }}
</div>
</div>
<div class="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between">
<div>
<div class="text-sm text-gray-500 mb-2">待刷盘</div>
<div class="text-3xl font-bold" :class="stats.pending > 0 ? 'text-yellow-400' : 'text-green-400'">
{{ fmt(stats.pending) }}
</div>
</div>
<button
class="mt-3 w-full bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm font-medium py-1.5 px-3 rounded transition-colors cursor-pointer"
:disabled="flushing || !stats.pending"
@click="doFlush"
>
{{ flushing ? '刷盘中...' : '立即刷盘' }}
</button>
</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.next_pool_size > 0 ? 'text-purple-400' : 'text-gray-500'">
{{ fmt(crawlStatus.next_pool_size) }}
</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 -->
<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>
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-2">爬虫线程</h2>
<div class="flex items-baseline gap-4">
<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>
<div class="text-gray-700 text-xl">/</div>
<div>
<div class="text-xs text-gray-500 mb-0.5">设定上限</div>
<div class="text-3xl font-bold text-white">{{ configuredWorkers }}</div>
</div>
</div>
</div>
<!-- 调节按钮 -->
<div class="flex items-center gap-2">
<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"
@click="workersInput = Math.max(1, workersInput - 1)"
:disabled="workersSaving"
></button>
<input
type="number"
v-model.number="workersInput"
min="1"
max="500"
class="w-20 h-9 text-center bg-gray-800 border border-gray-700 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500 transition-colors [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@keyup.enter="saveWorkers"
/>
<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"
@click="workersInput = Math.min(500, workersInput + 1)"
:disabled="workersSaving"
>+</button>
<button
class="h-9 px-4 bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm font-medium rounded-lg transition-colors cursor-pointer"
:disabled="workersSaving || workersInput === configuredWorkers"
@click="saveWorkers"
>
{{ workersSaving ? '保存中...' : '应用' }}
</button>
</div>
</div>
</div>
<!-- Backlink Control -->
<div v-if="backlinkStatus" 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-1">
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">反链计算PageRank</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<div class="text-xs text-gray-500 mb-0.5">下次自动执行</div>
<div class="text-lg font-bold" :class="backlinkStatus.running ? 'text-yellow-400' : 'text-white'">
{{ backlinkStatus.running ? '计算中...' : formatBacklinkTime(backlinkStatus.next_run) }}
</div>
</div>
<div>
<div class="text-xs text-gray-500 mb-0.5">上次完成</div>
<div class="text-lg font-bold text-white">{{ formatBacklinkLastRun(backlinkStatus.last_run) }}</div>
</div>
</div>
<div v-if="backlinkStatus.last_error" class="mt-2 text-xs text-red-400 truncate" :title="backlinkStatus.last_error">
上次错误{{ backlinkStatus.last_error }}
</div>
</div>
<button
class="h-9 px-4 bg-blue-700 hover:bg-blue-600 disabled:bg-gray-700 disabled:text-gray-500 text-white text-sm font-medium rounded-lg transition-colors cursor-pointer whitespace-nowrap"
:disabled="backlinkTriggering || backlinkStatus.running"
@click="doTriggerBacklink"
>
{{ backlinkTriggering ? '已触发...' : backlinkStatus.running ? '计算中...' : '立即执行' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-5">
<!-- Domain Distribution -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 md:p-5">
<h2 class="text-sm font-semibold text-gray-300 mb-3 md:mb-4 uppercase tracking-wider">域名分布 Top 10</h2>
<div class="space-y-2">
<div
v-for="[domain, count] in topDomains(stats.domains)"
:key="domain"
class="flex items-center gap-2 md:gap-3"
>
<div class="w-24 md:w-36 text-xs text-gray-400 truncate shrink-0" :title="domain">{{ domain }}</div>
<div class="flex-1 bg-gray-800 rounded-full h-4 md:h-5 overflow-hidden">
<div
class="h-full bg-blue-600 rounded-full transition-all duration-500"
:style="{ width: `${(count / stats.domains[Object.keys(stats.domains)[0]]) * 100}%` }"
></div>
</div>
<div class="w-12 md:w-16 text-xs text-gray-500 text-right shrink-0">{{ fmt(count) }}</div>
</div>
</div>
</div>
<!-- Language Distribution -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-4 md:p-5">
<h2 class="text-sm font-semibold text-gray-300 mb-3 md:mb-4 uppercase tracking-wider">语种分布</h2>
<div class="space-y-2">
<div
v-for="[lang, count] in Object.entries(stats.languages || {}).sort((a,b) => b[1]-a[1])"
:key="lang"
class="flex items-center gap-2 md:gap-3"
>
<div class="w-8 md:w-10 text-xs text-gray-400 shrink-0 font-mono">{{ lang }}</div>
<div class="flex-1 bg-gray-800 rounded-full h-4 md:h-5 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:style="{
width: `${(count / stats.total_urls) * 100}%`,
backgroundColor: langColor(lang)
}"
></div>
</div>
<div class="w-12 md:w-16 text-xs text-gray-500 text-right shrink-0">{{ fmt(count) }}</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>