Compare commits

...
7 Commits
Author SHA1 Message Date
kevin 52c1b9de99 展开关闭标签特别卡的问题 2026-04-12 02:57:38 +08:00
kevin 5777561d9f 修复前端报错 2026-04-12 00:42:23 +08:00
kevin ded160083f 优化分页 2026-04-12 00:02:20 +08:00
kevin f7fa7faed1 前端显示缓存信息 2026-04-11 23:37:37 +08:00
kevin 558d91b0a4 实时显示发现的url数量 2026-04-11 22:59:10 +08:00
kevin 63c40d69d4 优化手动增加url 2026-04-11 21:37:56 +08:00
kevin 7ba2011ead feat: priority crawl UI to dashboard 2026-04-11 19:54:24 +08:00
6 changed files with 421 additions and 230 deletions
+4 -3
View File
@@ -2,16 +2,16 @@
import { ref } from 'vue'
import Dashboard from './views/Dashboard.vue'
import RecentCrawls from './views/RecentCrawls.vue'
import PriorityCrawl from './views/PriorityCrawl.vue'
import SearchView from './views/SearchView.vue'
import KeywordsCache from './views/KeywordsCache.vue'
const tab = ref('dashboard')
const nav = [
{ id: 'dashboard', label: '概览', icon: '📊' },
{ id: 'recent', label: '最近', icon: '🕷️' },
{ id: 'keywords', label: '缓存', icon: '💾' },
{ id: 'search', label: '搜索', icon: '🔍' },
{ id: 'priority', label: '插入', icon: '🚀' },
]
</script>
@@ -48,8 +48,9 @@ const nav = [
<main class="flex-1 overflow-y-auto pb-16 md:pb-0">
<Dashboard v-if="tab === 'dashboard'" />
<RecentCrawls v-else-if="tab === 'recent'" />
<KeywordsCache v-else-if="tab === 'keywords'" />
<SearchView v-else-if="tab === 'search'" />
<PriorityCrawl v-else-if="tab === 'priority'" />
</main>
<!-- Mobile Bottom Navigation -->
+10
View File
@@ -111,3 +111,13 @@ export async function fetchUrlKeywordsStats() {
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
}
+68 -3
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { fetchStats, flushIndex, fetchFlushStatus, fetchWorkers, setWorkers, fetchBacklink, triggerBacklink, fetchPriorityStatus, fetchCrawlStatus } from '../api.js'
import { fetchStats, flushIndex, fetchFlushStatus, fetchWorkers, setWorkers, fetchBacklink, triggerBacklink, fetchPriorityStatus, fetchCrawlStatus, addPriority } from '../api.js'
const stats = ref(null)
const loading = ref(true)
@@ -15,10 +15,14 @@ const workersInput = ref(0)
const workersSaving = ref(false)
// Priority 相关状态
const priorityStatus = ref(null) // { pending, active, max_workers, children_queue }
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)
onMounted(async () => {
await Promise.all([loadStats(), loadWorkers(), loadBacklink(), loadPriorityStatus(), loadCrawlStatus()])
@@ -172,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' }
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>
@@ -255,6 +297,22 @@ function langColor(lang) {
<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>
<!-- 爬取进度 -->
@@ -268,7 +326,7 @@ function langColor(lang) {
</span>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<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>
@@ -293,6 +351,13 @@ function langColor(lang) {
{{ 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>
+337
View File
@@ -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>
-184
View File
@@ -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>
+2 -40
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { fetchRecent, fetchUrlKeywords, fetchUrlKeywordsStats } from '../api.js'
import { fetchRecent, fetchUrlKeywords } from '../api.js'
const items = ref([])
const total = ref(0)
@@ -18,9 +18,6 @@ const expandedUrls = ref(new Set())
const urlKeywords = ref({})
const loadingKeywords = ref(new Set())
// 关键词缓存统计
const keywordsStats = ref({ size: 0, max_size: 10000, usage: 0 })
onMounted(async () => {
await load()
})
@@ -29,13 +26,9 @@ async function load() {
loading.value = true
error.value = null
try {
const [data, stats] = await Promise.all([
fetchRecent(limit.value),
fetchUrlKeywordsStats().catch(() => ({ size: 0, max_size: 10000, usage: 0 }))
])
const data = await fetchRecent(limit.value)
items.value = data.items || []
total.value = data.total || 0
keywordsStats.value = stats
} catch (e) {
error.value = '无法加载数据,可能人服务器未启动'
console.error(e)
@@ -128,37 +121,6 @@ async function toggleKeywords(url) {
<p class="text-sm text-gray-500"> {{ total.toLocaleString() }} 条记录</p>
</div>
<div class="flex items-center gap-2 md:gap-3">
<!-- 关键词缓存统计卡片 -->
<div class="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 flex items-center gap-2">
<div class="flex flex-col">
<span class="text-[10px] text-gray-500 uppercase">关键词缓存</span>
<span class="text-sm font-medium text-gray-300">
{{ keywordsStats.size.toLocaleString() }} / {{ keywordsStats.max_size.toLocaleString() }}
</span>
</div>
<div class="w-12 h-8 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="keywordsStats.usage > 0.9 ? 'text-red-500' : keywordsStats.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="`${(keywordsStats.usage * 100).toFixed(0)}, 100`"
/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[8px] font-medium text-gray-400">
{{ (keywordsStats.usage * 100).toFixed(0) }}%
</span>
</div>
</div>
<select
v-model="limit"
@change="changeLimit(limit)"