feat: init rssKeeper - RSS 抓取、管理与检索系统
完整功能包括: - FastAPI 后端 + SQLite + FTS5 全文搜索 - RSS 源管理、自动发现、OPML 导入导出 - 文章抓取、去重、分类、全文检索 - RSS 源健康度监控 - Vue 3 + Element Plus 暗色主题 Web UI - 对外 REST API 供 AI 分析调用 - Docker + docker-compose 部署
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user