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:
congsh
2026-06-11 14:03:36 +08:00
commit 54e7db0ef0
28 changed files with 2915 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
<template>
<el-container class="app-container">
<!-- 侧边栏 -->
<el-aside width="200px" class="sidebar">
<div class="logo">
<el-icon size="24"><Document /></el-icon>
<span>rssKeeper</span>
</div>
<el-menu
:default-active="$route.path"
router
class="sidebar-menu"
background-color="#1a1a2e"
text-color="#a0aec0"
active-text-color="#fff"
>
<el-menu-item index="/">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/feeds">
<el-icon><Collection /></el-icon>
<span>RSS 源管理</span>
</el-menu-item>
<el-menu-item index="/articles">
<el-icon><Document /></el-icon>
<span>文章列表</span>
</el-menu-item>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</template>
<script setup>
import { Document, Odometer, Collection } from '@element-plus/icons-vue'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f23;
color: #e2e8f0;
}
.app-container {
height: 100vh;
}
.sidebar {
background: #1a1a2e;
border-right: 1px solid #2d3748;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 18px;
font-weight: bold;
color: #63b3ed;
border-bottom: 1px solid #2d3748;
}
.sidebar-menu {
border-right: none !important;
}
.sidebar-menu .el-menu-item:hover {
background: #2d3748 !important;
}
.main-content {
background: #0f0f23;
padding: 20px;
overflow-y: auto;
}
/* Element Plus 暗色主题覆盖 */
.el-card {
background: #1a1a2e;
border: 1px solid #2d3748;
color: #e2e8f0;
}
.el-card__header {
border-bottom: 1px solid #2d3748;
color: #e2e8f0;
}
.el-table {
background: #1a1a2e;
--el-table-header-bg-color: #2d3748;
--el-table-row-hover-bg-color: #2d3748;
--el-table-border-color: #2d3748;
--el-table-text-color: #e2e8f0;
--el-table-header-text-color: #a0aec0;
}
.el-table th {
background: #2d3748 !important;
}
.el-input__wrapper {
background: #2d3748;
box-shadow: 0 0 0 1px #4a5568 inset;
}
.el-input__inner {
color: #e2e8f0;
}
.el-button--primary {
--el-button-bg-color: #3182ce;
--el-button-border-color: #3182ce;
--el-button-hover-bg-color: #2b6cb0;
--el-button-hover-border-color: #2b6cb0;
}
.el-pagination {
--el-pagination-button-bg-color: #1a1a2e;
--el-pagination-button-color: #a0aec0;
--el-pagination-hover-color: #63b3ed;
}
.el-dialog {
background: #1a1a2e;
}
.el-dialog__title {
color: #e2e8f0;
}
.el-tag {
border: none;
}
.el-empty__description {
color: #a0aec0;
}
.page-title {
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
color: #e2e8f0;
}
.stat-card {
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: bold;
color: #63b3ed;
}
.stat-label {
font-size: 14px;
color: #a0aec0;
margin-top: 8px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios'
const api = axios.create({
baseURL: '',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截
api.interceptors.request.use(
(config) => config,
(error) => Promise.reject(error)
)
// 响应拦截
api.interceptors.response.use(
(response) => response.data,
(error) => {
const msg = error.response?.data?.detail || error.message || '请求失败'
return Promise.reject(new Error(msg))
}
)
// RSS 源管理
export const feedsApi = {
list: (params = {}) => api.get('/api/feeds', { params }),
categories: () => api.get('/api/feeds/categories'),
get: (id) => api.get(`/api/feeds/${id}`),
create: (data) => api.post('/api/feeds', data),
update: (id, data) => api.put(`/api/feeds/${id}`, data),
remove: (id) => api.delete(`/api/feeds/${id}`),
fetch: (id) => api.post(`/api/feeds/${id}/fetch`),
discover: (url) => api.post('/api/feeds/discover', null, { params: { url } }),
importOpml: (content) => api.post('/api/feeds/import-opml', { opml_content: content }),
exportOpml: () => api.get('/api/feeds/export-opml'),
}
// 文章管理
export const articlesApi = {
list: (params = {}) => api.get('/api/articles', { params }),
get: (id) => api.get(`/api/articles/${id}`),
search: (q) => api.get('/api/articles/search/fulltext', { params: { q } }),
markRead: (id) => api.put(`/api/articles/${id}/read`),
}
// 仪表盘
export const dashboardApi = {
stats: () => api.get('/api/dashboard/stats'),
health: (params = {}) => api.get('/api/dashboard/health', { params }),
recentActivity: () => api.get('/api/dashboard/recent-activity'),
}
// 对外 API
export const externalApi = {
recent: (params = {}) => api.get('/api/v1/external/recent', { params }),
feeds: () => api.get('/api/v1/external/feeds'),
feedArticles: (id, params = {}) => api.get(`/api/v1/external/feeds/${id}/articles`, { params }),
summary: (date) => api.get('/api/v1/external/summary', { params: { date } }),
}
export default api
+35
View File
@@ -0,0 +1,35 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
// 页面组件
import Dashboard from './views/Dashboard.vue'
import Feeds from './views/Feeds.vue'
import Articles from './views/Articles.vue'
import ArticleDetail from './views/ArticleDetail.vue'
const routes = [
{ path: '/', component: Dashboard, name: 'Dashboard' },
{ path: '/feeds', component: Feeds, name: 'Feeds' },
{ path: '/articles', component: Articles, name: 'Articles' },
{ path: '/articles/:id', component: ArticleDetail, name: 'ArticleDetail' },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
const app = createApp(App)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus)
app.use(router)
app.mount('#app')
+120
View File
@@ -0,0 +1,120 @@
<template>
<div v-loading="loading">
<el-button text @click="$router.back()" style="margin-bottom: 16px;">
<el-icon><ArrowLeft /></el-icon> 返回
</el-button>
<el-card v-if="article.id" shadow="hover">
<template #header>
<div>
<h1 style="font-size: 22px; margin-bottom: 12px;">{{ article.title || '无标题' }}</h1>
<div style="display: flex; align-items: center; gap: 12px; font-size: 13px; color: #a0aec0;">
<el-tag size="small">{{ article.feed_title }}</el-tag>
<span v-if="article.author">作者: {{ article.author }}</span>
<span>发布时间: {{ formatTime(article.published_at) }}</span>
<span>抓取时间: {{ formatTime(article.created_at) }}</span>
<el-link v-if="article.link" :href="article.link" target="_blank" type="primary">
查看原文
</el-link>
</div>
</div>
</template>
<div class="article-content" v-html="formatContent(article.content)"></div>
</el-card>
<el-empty v-else description="文章不存在" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import { articlesApi } from '../api/index.js'
const route = useRoute()
const article = ref({})
const loading = ref(false)
const formatTime = (iso) => {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN')
}
const formatContent = (content) => {
if (!content) return '<p style="color: #718096;">暂无内容</p>'
// 将纯文本中的换行转为 HTML 换行
return content
.replace(/\n/g, '<br>')
.replace(/ /g, '&nbsp;&nbsp;')
}
const loadArticle = async () => {
const id = parseInt(route.params.id)
if (!id) return
loading.value = true
try {
article.value = await articlesApi.get(id)
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
onMounted(loadArticle)
</script>
<style scoped>
.article-content {
font-size: 15px;
line-height: 1.8;
color: #e2e8f0;
white-space: pre-wrap;
}
.article-content :deep(p) {
margin-bottom: 1em;
}
.article-content :deep(a) {
color: #63b3ed;
}
.article-content :deep(h1),
.article-content :deep(h2),
.article-content :deep(h3) {
color: #e2e8f0;
margin: 1.5em 0 0.5em;
}
.article-content :deep(code) {
background: #2d3748;
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.article-content :deep(pre) {
background: #2d3748;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
.article-content :deep(blockquote) {
border-left: 4px solid #4a5568;
padding-left: 16px;
margin-left: 0;
color: #a0aec0;
}
.article-content :deep(ul),
.article-content :deep(ol) {
padding-left: 2em;
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1 class="page-title">📄 文章列表</h1>
<el-tag v-if="stats.total" type="info"> {{ stats.total }} 篇文章</el-tag>
</div>
<!-- 筛选栏 -->
<el-row :gutter="10" style="margin-bottom: 16px;">
<el-col :span="8">
<el-input v-model="searchQuery" placeholder="全文搜索文章..." clearable @change="handleSearch" :prefix-icon="Search">
<template #append>
<el-button @click="handleSearch">搜索</el-button>
</template>
</el-input>
</el-col>
<el-col :span="4">
<el-select v-model="filterFeed" placeholder="RSS 源筛选" clearable @change="loadArticles">
<el-option
v-for="feed in feedOptions"
:key="feed.id"
:label="feed.title || feed.url"
:value="feed.id"
/>
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="filterCategory" placeholder="分类筛选" clearable @change="loadArticles">
<el-option v-for="cat in categories" :key="cat" :label="cat || '未分类'" :value="cat" />
</el-select>
</el-col>
<el-col :span="8">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="loadArticles"
style="width: 100%;"
/>
</el-col>
</el-row>
<!-- 文章列表 -->
<el-card shadow="hover" v-loading="loading">
<el-empty v-if="articles.length === 0" description="暂无文章" />
<div v-else>
<div
v-for="article in articles"
:key="article.id"
class="article-item"
@click="viewArticle(article.id)"
>
<div class="article-header">
<h3 class="article-title">{{ article.title || '无标题' }}</h3>
<div class="article-meta">
<el-tag size="small" type="info">{{ article.feed_title }}</el-tag>
<el-tag v-if="article.category" size="small" style="margin-left: 4px;">{{ article.category }}</el-tag>
<span class="article-time">{{ formatTime(article.published_at) }}</span>
<el-link v-if="article.link" :href="article.link" target="_blank" type="primary" @click.stop>
原文
</el-link>
</div>
</div>
<p class="article-summary">{{ article.summary || '暂无摘要' }}</p>
</div>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="stats.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="loadArticles"
style="margin-top: 20px; justify-content: flex-end;"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { articlesApi, feedsApi } from '../api/index.js'
const router = useRouter()
const articles = ref([])
const stats = ref({ total: 0 })
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const filterFeed = ref('')
const filterCategory = ref('')
const dateRange = ref(null)
const feedOptions = ref([])
const categories = ref([])
const formatTime = (iso) => {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const loadArticles = async () => {
loading.value = true
try {
const params = {
skip: (page.value - 1) * pageSize.value,
limit: pageSize.value,
}
if (filterFeed.value) params.feed_id = filterFeed.value
if (filterCategory.value) params.category = filterCategory.value
if (dateRange.value && dateRange.value[0]) {
params.since = dateRange.value[0]
params.until = dateRange.value[1]
}
// 如果有搜索词,使用全文搜索
if (searchQuery.value && searchQuery.value.trim()) {
const res = await articlesApi.search(searchQuery.value.trim())
articles.value = res.items || []
stats.value = { total: res.total }
} else {
const res = await articlesApi.list(params)
articles.value = res.items || []
stats.value = { total: res.total }
}
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const handleSearch = () => {
page.value = 1
loadArticles()
}
const viewArticle = (id) => {
router.push(`/articles/${id}`)
}
const loadFeedOptions = async () => {
try {
const res = await feedsApi.list({ limit: 1000 })
feedOptions.value = res.items || []
const cats = new Set()
for (const f of feedOptions.value) {
if (f.category) cats.add(f.category)
}
categories.value = Array.from(cats)
} catch (e) {
console.error(e)
}
}
onMounted(() => {
loadArticles()
loadFeedOptions()
})
</script>
<style scoped>
.article-item {
padding: 16px;
border-bottom: 1px solid #2d3748;
cursor: pointer;
transition: background 0.2s;
}
.article-item:hover {
background: #2d3748;
}
.article-item:last-child {
border-bottom: none;
}
.article-title {
font-size: 16px;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 8px;
line-height: 1.4;
}
.article-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
}
.article-time {
color: #718096;
}
.article-summary {
color: #a0aec0;
font-size: 14px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+192
View File
@@ -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>
+369
View File
@@ -0,0 +1,369 @@
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h1 class="page-title">📡 RSS 源管理</h1>
<div>
<el-button type="primary" @click="showAddDialog = true" :icon="Plus">添加源</el-button>
<el-button @click="showImportDialog = true" :icon="Upload">导入 OPML</el-button>
<el-button @click="handleExport" :icon="Download">导出 OPML</el-button>
</div>
</div>
<!-- 筛选栏 -->
<el-row :gutter="10" style="margin-bottom: 16px;">
<el-col :span="6">
<el-input v-model="searchQuery" placeholder="搜索源名称或 URL" clearable @change="loadFeeds" :prefix-icon="Search" />
</el-col>
<el-col :span="4">
<el-select v-model="filterCategory" placeholder="分类筛选" clearable @change="loadFeeds">
<el-option v-for="cat in categories" :key="cat" :label="cat || '未分类'" :value="cat" />
</el-select>
</el-col>
<el-col :span="4">
<el-select v-model="filterStatus" placeholder="状态筛选" clearable @change="loadFeeds">
<el-option label="启用" value="active" />
<el-option label="禁用" value="inactive" />
</el-select>
</el-col>
<el-col :span="6" style="display: flex; gap: 8px;">
<el-tag v-if="stats.total" type="info"> {{ stats.total }} 个源</el-tag>
</el-col>
</el-row>
<!-- 源列表 -->
<el-card shadow="hover">
<el-table :data="feeds" v-loading="loading" size="small">
<el-table-column type="index" width="50" />
<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 || scope.row.url }}</el-link>
</template>
</el-table-column>
<el-table-column prop="category" label="分类" width="120">
<template #default="scope">
<el-tag v-if="scope.row.category" size="small" type="info">{{ scope.row.category }}</el-tag>
<span v-else style="color: #718096;">-</span>
</template>
</el-table-column>
<el-table-column prop="health_status" label="健康度" width="100">
<template #default="scope">
<el-tag :type="healthTagType(scope.row.health_status)" size="small">
{{ healthLabel(scope.row.health_status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="article_count" label="文章数" width="80" />
<el-table-column prop="last_fetch_at" label="最后抓取" width="160">
<template #default="scope">
{{ formatTime(scope.row.last_fetch_at) }}
</template>
</el-table-column>
<el-table-column prop="fetch_interval_minutes" label="间隔(分)" width="90" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="triggerFetch(scope.row.id)" :icon="Refresh">抓取</el-button>
<el-button link type="primary" size="small" @click="editFeed(scope.row)" :icon="Edit">编辑</el-button>
<el-button link type="danger" size="small" @click="deleteFeed(scope.row.id)" :icon="Delete">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="stats.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="loadFeeds"
style="margin-top: 16px; justify-content: flex-end;"
/>
</el-card>
<!-- 添加源对话框 -->
<el-dialog v-model="showAddDialog" title="添加 RSS 源" width="600px">
<el-tabs v-model="addTab">
<el-tab-pane label="直接添加" name="direct">
<el-form :model="newFeed" label-width="100px">
<el-form-item label="RSS 地址">
<el-input v-model="newFeed.url" placeholder="https://example.com/feed.xml" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="newFeed.title" placeholder="自动抓取(可选)" />
</el-form-item>
<el-form-item label="分类">
<el-input v-model="newFeed.category" placeholder="如:科技、新闻" />
</el-form-item>
<el-form-item label="抓取间隔">
<el-input-number v-model="newFeed.fetch_interval_minutes" :min="15" :max="1440" />
<span style="margin-left: 8px; color: #a0aec0;">分钟</span>
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="自动发现" name="discover">
<el-input v-model="discoverUrl" placeholder="输入任意网页地址,自动发现 RSS 源">
<template #append>
<el-button @click="handleDiscover" :loading="discovering">发现</el-button>
</template>
</el-input>
<el-table :data="discoveredFeeds" v-if="discoveredFeeds.length" style="margin-top: 16px;" size="small">
<el-table-column prop="url" label="发现的 RSS 源" />
<el-table-column width="100">
<template #default="scope">
<el-button size="small" type="primary" @click="addDiscovered(scope.row.url)">添加</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="handleAdd" :loading="adding">添加</el-button>
</template>
</el-dialog>
<!-- 编辑对话框 -->
<el-dialog v-model="showEditDialog" title="编辑 RSS 源" width="500px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="名称">
<el-input v-model="editForm.title" />
</el-form-item>
<el-form-item label="分类">
<el-input v-model="editForm.category" />
</el-form-item>
<el-form-item label="抓取间隔">
<el-input-number v-model="editForm.fetch_interval_minutes" :min="15" :max="1440" />
<span style="margin-left: 8px; color: #a0aec0;">分钟</span>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="editForm.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" @click="handleEdit" :loading="editing">保存</el-button>
</template>
</el-dialog>
<!-- 导入 OPML -->
<el-dialog v-model="showImportDialog" title="导入 OPML" width="500px">
<el-input v-model="opmlContent" type="textarea" :rows="10" placeholder="粘贴 OPML 文件内容..." />
<template #footer>
<el-button @click="showImportDialog = false">取消</el-button>
<el-button type="primary" @click="handleImport" :loading="importing">导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Upload, Download, Search, Refresh, Edit, Delete } from '@element-plus/icons-vue'
import { feedsApi } from '../api/index.js'
const feeds = ref([])
const stats = ref({ total: 0 })
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const searchQuery = ref('')
const filterCategory = ref('')
const filterStatus = ref('')
const categories = ref([])
// 添加对话框
const showAddDialog = ref(false)
const addTab = ref('direct')
const adding = ref(false)
const newFeed = ref({ url: '', title: '', category: '', fetch_interval_minutes: 60 })
// 自动发现
const discoverUrl = ref('')
const discovering = ref(false)
const discoveredFeeds = ref([])
// 编辑
const showEditDialog = ref(false)
const editing = ref(false)
const editForm = ref({ id: null, title: '', category: '', fetch_interval_minutes: 60, is_active: true })
// 导入
const showImportDialog = ref(false)
const importing = ref(false)
const opmlContent = ref('')
const healthTagType = (status) => {
const map = { healthy: 'success', warning: 'warning', unhealthy: 'danger', unknown: 'info' }
return map[status] || 'info'
}
const healthLabel = (status) => {
const map = { healthy: '健康', warning: '警告', unhealthy: '异常', unknown: '未知' }
return map[status] || '未知'
}
const formatTime = (iso) => {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN')
}
const loadFeeds = async () => {
loading.value = true
try {
const params = {
skip: (page.value - 1) * pageSize.value,
limit: pageSize.value,
search: searchQuery.value || undefined,
category: filterCategory.value || undefined,
}
if (filterStatus.value === 'active') params.is_active = true
if (filterStatus.value === 'inactive') params.is_active = false
const res = await feedsApi.list(params)
feeds.value = res.items || []
stats.value = { total: res.total }
} catch (e) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
const loadCategories = async () => {
try {
categories.value = await feedsApi.categories()
} catch (e) {
console.error(e)
}
}
const handleAdd = async () => {
if (!newFeed.value.url) {
ElMessage.warning('请输入 RSS 地址')
return
}
adding.value = true
try {
await feedsApi.create(newFeed.value)
ElMessage.success('添加成功')
showAddDialog.value = false
newFeed.value = { url: '', title: '', category: '', fetch_interval_minutes: 60 }
loadFeeds()
loadCategories()
} catch (e) {
ElMessage.error(e.message)
} finally {
adding.value = false
}
}
const handleDiscover = async () => {
if (!discoverUrl.value) return
discovering.value = true
try {
const res = await feedsApi.discover(discoverUrl.value)
discoveredFeeds.value = (res.found_feeds || []).map(url => ({ url }))
if (discoveredFeeds.value.length === 0) {
ElMessage.info('未找到 RSS 源')
}
} catch (e) {
ElMessage.error(e.message)
} finally {
discovering.value = false
}
}
const addDiscovered = async (url) => {
try {
await feedsApi.create({ url, fetch_interval_minutes: 60 })
ElMessage.success('添加成功')
loadFeeds()
} catch (e) {
ElMessage.error(e.message)
}
}
const editFeed = (feed) => {
editForm.value = {
id: feed.id,
title: feed.title || '',
category: feed.category || '',
fetch_interval_minutes: feed.fetch_interval_minutes,
is_active: feed.is_active,
}
showEditDialog.value = true
}
const handleEdit = async () => {
editing.value = true
try {
await feedsApi.update(editForm.value.id, editForm.value)
ElMessage.success('更新成功')
showEditDialog.value = false
loadFeeds()
} catch (e) {
ElMessage.error(e.message)
} finally {
editing.value = false
}
}
const deleteFeed = async (id) => {
try {
await ElMessageBox.confirm('确定删除该 RSS 源吗?相关文章也会被删除。', '确认删除', { type: 'warning' })
await feedsApi.remove(id)
ElMessage.success('删除成功')
loadFeeds()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.message)
}
}
const triggerFetch = async (id) => {
try {
const res = await feedsApi.fetch(id)
ElMessage.success(`抓取完成,新增 ${res.articles_count || 0} 篇文章`)
loadFeeds()
} catch (e) {
ElMessage.error(e.message)
}
}
const handleImport = async () => {
if (!opmlContent.value.trim()) return
importing.value = true
try {
const res = await feedsApi.importOpml(opmlContent.value)
ElMessage.success(res.message)
showImportDialog.value = false
opmlContent.value = ''
loadFeeds()
loadCategories()
} catch (e) {
ElMessage.error(e.message)
} finally {
importing.value = false
}
}
const handleExport = async () => {
try {
const res = await feedsApi.exportOpml()
const blob = new Blob([res.opml], { type: 'text/xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'rsskeeper-feeds.opml'
a.click()
URL.revokeObjectURL(url)
} catch (e) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadFeeds()
loadCategories()
})
</script>