193 lines
6.6 KiB
Vue
193 lines
6.6 KiB
Vue
|
|
<template>
|
||
|
|
<div>
|
||
|
|
<h1 class="page-title">📊 仪表盘</h1>
|
||
|
|
|
||
|
|
<!-- 统计卡片 -->
|
||
|
|
<el-row :gutter="20" class="stats-row">
|
||
|
|
<el-col :span="6" v-for="stat in statsCards" :key="stat.key">
|
||
|
|
<el-card class="stat-card" shadow="hover">
|
||
|
|
<div class="stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
|
||
|
|
<div class="stat-label">{{ stat.label }}</div>
|
||
|
|
</el-card>
|
||
|
|
</el-col>
|
||
|
|
</el-row>
|
||
|
|
|
||
|
|
<!-- 健康度概览 + 最近活动 -->
|
||
|
|
<el-row :gutter="20" style="margin-top: 20px;">
|
||
|
|
<el-col :span="14">
|
||
|
|
<el-card shadow="hover">
|
||
|
|
<template #header>
|
||
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||
|
|
<span>🩺 RSS 源健康度</span>
|
||
|
|
<el-button text size="small" @click="$router.push('/feeds')">查看全部</el-button>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
<el-table :data="healthData" size="small" v-loading="loadingHealth">
|
||
|
|
<el-table-column prop="title" label="源名称" min-width="200" show-overflow-tooltip>
|
||
|
|
<template #default="scope">
|
||
|
|
<el-link :href="scope.row.url" target="_blank" type="primary">{{ scope.row.title }}</el-link>
|
||
|
|
</template>
|
||
|
|
</el-table-column>
|
||
|
|
<el-table-column prop="health_label" label="状态" width="80">
|
||
|
|
<template #default="scope">
|
||
|
|
<el-tag :type="healthTagType(scope.row.health_status)" size="small">
|
||
|
|
{{ scope.row.health_label }}
|
||
|
|
</el-tag>
|
||
|
|
</template>
|
||
|
|
</el-table-column>
|
||
|
|
<el-table-column prop="success_rate" label="成功率" width="90">
|
||
|
|
<template #default="scope">
|
||
|
|
{{ scope.row.success_rate }}%
|
||
|
|
</template>
|
||
|
|
</el-table-column>
|
||
|
|
<el-table-column prop="article_count" label="文章数" width="80" />
|
||
|
|
<el-table-column prop="days_since_fetch" label="未更新(天)" width="100">
|
||
|
|
<template #default="scope">
|
||
|
|
{{ scope.row.days_since_fetch !== null ? scope.row.days_since_fetch : '-' }}
|
||
|
|
</template>
|
||
|
|
</el-table-column>
|
||
|
|
</el-table>
|
||
|
|
</el-card>
|
||
|
|
</el-col>
|
||
|
|
|
||
|
|
<el-col :span="10">
|
||
|
|
<el-card shadow="hover">
|
||
|
|
<template #header>
|
||
|
|
<span>📋 最近抓取活动</span>
|
||
|
|
</template>
|
||
|
|
<el-timeline v-loading="loadingActivity">
|
||
|
|
<el-timeline-item
|
||
|
|
v-for="log in recentActivity"
|
||
|
|
:key="log.id"
|
||
|
|
:type="log.status === 'success' ? 'success' : 'danger'"
|
||
|
|
:icon="log.status === 'success' ? 'CircleCheck' : 'CircleClose'"
|
||
|
|
>
|
||
|
|
<div style="font-size: 13px;">
|
||
|
|
<strong>{{ log.feed_title }}</strong>
|
||
|
|
<el-tag :type="log.status === 'success' ? 'success' : 'danger'" size="small" style="margin-left: 8px;">
|
||
|
|
{{ log.status === 'success' ? '成功' : '失败' }}
|
||
|
|
</el-tag>
|
||
|
|
</div>
|
||
|
|
<div style="font-size: 12px; color: #a0aec0; margin-top: 4px;">
|
||
|
|
{{ log.status === 'success' ? `获取 ${log.articles_fetched} 篇文章` : log.error_message }}
|
||
|
|
· {{ formatTime(log.created_at) }}
|
||
|
|
</div>
|
||
|
|
</el-timeline-item>
|
||
|
|
</el-timeline>
|
||
|
|
</el-card>
|
||
|
|
</el-col>
|
||
|
|
</el-row>
|
||
|
|
|
||
|
|
<!-- 分类分布 -->
|
||
|
|
<el-row style="margin-top: 20px;">
|
||
|
|
<el-col :span="24">
|
||
|
|
<el-card shadow="hover">
|
||
|
|
<template #header>
|
||
|
|
<span>📂 分类分布</span>
|
||
|
|
</template>
|
||
|
|
<div v-if="categoryStats.length === 0" style="text-align: center; padding: 40px; color: #a0aec0;">
|
||
|
|
暂无数据
|
||
|
|
</div>
|
||
|
|
<el-row :gutter="20" v-else>
|
||
|
|
<el-col :span="4" v-for="cat in categoryStats" :key="cat.name">
|
||
|
|
<div style="text-align: center; padding: 16px;">
|
||
|
|
<div style="font-size: 24px; font-weight: bold; color: #63b3ed;">{{ cat.count }}</div>
|
||
|
|
<div style="font-size: 14px; color: #a0aec0; margin-top: 4px;">{{ cat.name || '未分类' }}</div>
|
||
|
|
</div>
|
||
|
|
</el-col>
|
||
|
|
</el-row>
|
||
|
|
</el-card>
|
||
|
|
</el-col>
|
||
|
|
</el-row>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup>
|
||
|
|
import { ref, onMounted, computed } from 'vue'
|
||
|
|
import { dashboardApi, feedsApi } from '../api/index.js'
|
||
|
|
|
||
|
|
const stats = ref({})
|
||
|
|
const healthData = ref([])
|
||
|
|
const recentActivity = ref([])
|
||
|
|
const loadingHealth = ref(false)
|
||
|
|
const loadingActivity = ref(false)
|
||
|
|
const categoryStats = ref([])
|
||
|
|
|
||
|
|
const statsCards = computed(() => [
|
||
|
|
{ key: 'feeds', label: 'RSS 源总数', value: stats.value.total_feeds || 0, color: '#63b3ed' },
|
||
|
|
{ key: 'articles', label: '文章总数', value: stats.value.total_articles || 0, color: '#68d391' },
|
||
|
|
{ key: 'healthy', label: '健康源数', value: stats.value.healthy_feeds || 0, color: '#48bb78' },
|
||
|
|
{ key: 'today', label: '今日抓取', value: stats.value.today_fetches || 0, color: '#f6ad55' },
|
||
|
|
])
|
||
|
|
|
||
|
|
const healthTagType = (status) => {
|
||
|
|
const map = { healthy: 'success', warning: 'warning', unhealthy: 'danger', unknown: 'info' }
|
||
|
|
return map[status] || 'info'
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatTime = (iso) => {
|
||
|
|
if (!iso) return '-'
|
||
|
|
const d = new Date(iso)
|
||
|
|
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||
|
|
}
|
||
|
|
|
||
|
|
const loadStats = async () => {
|
||
|
|
try {
|
||
|
|
stats.value = await dashboardApi.stats()
|
||
|
|
} catch (e) {
|
||
|
|
console.error('加载统计失败', e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const loadHealth = async () => {
|
||
|
|
loadingHealth.value = true
|
||
|
|
try {
|
||
|
|
const res = await dashboardApi.health({ limit: 10 })
|
||
|
|
healthData.value = res.items || []
|
||
|
|
} catch (e) {
|
||
|
|
console.error('加载健康度失败', e)
|
||
|
|
} finally {
|
||
|
|
loadingHealth.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const loadActivity = async () => {
|
||
|
|
loadingActivity.value = true
|
||
|
|
try {
|
||
|
|
const res = await dashboardApi.recentActivity()
|
||
|
|
recentActivity.value = res.items || []
|
||
|
|
} catch (e) {
|
||
|
|
console.error('加载活动失败', e)
|
||
|
|
} finally {
|
||
|
|
loadingActivity.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const loadCategories = async () => {
|
||
|
|
try {
|
||
|
|
const feeds = await feedsApi.list({ limit: 1000 })
|
||
|
|
const cats = {}
|
||
|
|
for (const f of feeds.items || []) {
|
||
|
|
const c = f.category || '未分类'
|
||
|
|
cats[c] = (cats[c] || 0) + 1
|
||
|
|
}
|
||
|
|
categoryStats.value = Object.entries(cats).map(([name, count]) => ({ name, count }))
|
||
|
|
} catch (e) {
|
||
|
|
console.error('加载分类失败', e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
loadStats()
|
||
|
|
loadHealth()
|
||
|
|
loadActivity()
|
||
|
|
loadCategories()
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.stats-row {
|
||
|
|
margin-bottom: 10px;
|
||
|
|
}
|
||
|
|
</style>
|