508 lines
20 KiB
Vue
508 lines
20 KiB
Vue
<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>
|