Files
sese-engine-ui/src/views/KeywordsCache.vue
T
2026-04-11 23:37:37 +08:00

352 lines
12 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, 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, items: [] })
const expandedUrls = ref(new Set())
const urlKeywords = ref({})
const loadingKeywords = ref(new Set())
const search = ref('')
// 分页
const currentPage = ref(1)
const pageSize = ref(50)
let statsInterval = null
onMounted(async () => {
await load()
// 容量卡片每 5 秒自动刷新
statsInterval = setInterval(loadStats, 5000)
})
onUnmounted(() => {
if (statsInterval) clearInterval(statsInterval)
})
// 只刷新 stats(容量数据),不刷新列表
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
currentPage.value = 1 // 重置到第一页
try {
const data = await fetchUrlKeywordsList()
stats.value.size = data.size || 0
stats.value.max_size = data.max_size || 10000
// items 数组:{ url, title, snippet, keywords },反转显示(最新在前)
stats.value.items = (data.items || []).reverse()
} catch (e) {
try {
const s = await fetchUrlKeywordsStats()
stats.value.size = s.size || 0
stats.value.max_size = s.max_size || 10000
stats.value.items = []
} catch {
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 filteredItems = computed(() => {
if (!search.value) return stats.value.items
const q = search.value.toLowerCase()
return stats.value.items.filter(item =>
item.url.toLowerCase().includes(q) ||
(item.title && item.title.toLowerCase().includes(q)) ||
(item.snippet && item.snippet.toLowerCase().includes(q))
)
})
const totalPages = computed(() => {
return Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value))
})
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredItems.value.slice(start, end)
})
function prevPage() {
if (currentPage.value > 1) currentPage.value--
}
function nextPage() {
if (currentPage.value < totalPages.value) currentPage.value++
}
function goToPage(p) {
if (p >= 1 && p <= totalPages.value) currentPage.value = p
}
// 搜索时回到第一页
function onSearch() {
currentPage.value = 1
}
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)
}
}
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="!stats.items.length && !search" 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 paginatedItems"
:key="item.url"
class="p-4 hover:bg-gray-800/40 transition-colors"
>
<!-- URL 标题行 -->
<div class="flex items-start justify-between gap-3 mb-1">
<a
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-400 hover:text-blue-300 break-all line-clamp-2 flex-1"
:title="item.url"
>
{{ truncateUrl(item.url) }}
</a>
<button
@click="toggleKeywords(item.url)"
class="shrink-0 text-xs text-gray-500 hover:text-blue-400 flex items-center gap-1"
>
<span>{{ expandedUrls.has(item.url) ? '▲' : '▼' }}</span>
<span v-if="loadingKeywords.has(item.url)">加载中</span>
<span v-else-if="urlKeywords[item.url] || item.keywords?.length">已缓存</span>
</button>
</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.has(item.url)" class="mt-2">
<template v-if="urlKeywords[item.url]?.length || item.keywords?.length">
<div class="flex flex-wrap gap-1.5">
<span
v-for="kw in (urlKeywords[item.url] || item.keywords || [])"
: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] || item.keywords || []).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 v-if="!paginatedItems.length" class="p-8 text-center text-gray-600">
没有找到匹配的记录
</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, filteredItems.length) }} /
{{ filteredItems.length }}
</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>