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
+54
View File
@@ -0,0 +1,54 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
.venv
# Database
data/*.db
!data/.gitkeep
# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Frontend build (will be built in Docker)
frontend/dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
.dockerignore
+36
View File
@@ -0,0 +1,36 @@
# rssKeeper - 多阶段构建
# Stage 1: 构建前端
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ .
RUN npm run build
# Stage 2: Python 后端
FROM python:3.12-slim
WORKDIR /app
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libxml2-dev \
libxslt1-dev \
&& rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制后端代码
COPY backend/ .
# 复制前端构建产物
COPY --from=frontend-builder /app/frontend/dist ./static
# 创建数据目录
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
+81
View File
@@ -0,0 +1,81 @@
# rssKeeper
RSS 抓取、管理与检索系统。支持 Docker 部署,包含 Web UI 和 REST API。
## 功能特性
- 📡 **RSS 源管理** — 添加、编辑、删除 RSS 源,支持自动发现 feed URL
- 📄 **文章管理** — 自动抓取、去重、分类,全文搜索
- 🩺 **健康度监控** — 实时展示每个 RSS 源的成功率、最后更新、文章数量
- 🔍 **全文检索** — 基于 SQLite FTS5 的全文搜索
- 🐳 **Docker 部署** — 单容器部署,数据持久化
- 🔌 **对外 API** — RESTful API 供 AI 或外部系统调用
## 快速开始
### Docker 部署(推荐)
```bash
# 克隆项目
git clone <repo-url>
cd rssKeeper
# 启动
docker-compose up -d --build
# 访问 http://localhost:8000
```
### 开发模式
```bash
# 后端
cd backend
pip install -r requirements.txt
uvicorn main:app --reload --port 8000
# 前端(另开终端)
cd frontend
npm install
npm run dev
```
## 对外 API
### 获取最近文章(供 AI 分析)
```bash
# 获取最近 24 小时的文章
curl "http://localhost:8000/api/v1/external/recent?hours=24&limit=50"
# 指定 RSS 源
curl "http://localhost:8000/api/v1/external/recent?feed_id=1&hours=48"
# 指定分类
curl "http://localhost:8000/api/v1/external/recent?category=科技&hours=24"
```
### 获取源列表
```bash
curl "http://localhost:8000/api/v1/external/feeds"
```
### 按源获取文章
```bash
curl "http://localhost:8000/api/v1/external/feeds/1/articles?limit=100"
```
### 获取每日摘要
```bash
curl "http://localhost:8000/api/v1/external/summary?date=2024-06-01"
```
## 技术栈
- **后端**: Python 3.12 + FastAPI + SQLAlchemy + APScheduler
- **数据库**: SQLiteFTS5 全文搜索)
- **前端**: Vue 3 + Element Plus + Vite
- **部署**: Docker + docker-compose
+26
View File
@@ -0,0 +1,26 @@
"""配置管理 - 环境变量 + 默认值"""
import os
from pathlib import Path
# 项目根目录
BASE_DIR = Path(__file__).parent
DATA_DIR = Path(os.getenv("DATA_DIR", "/app/data"))
DATA_DIR.mkdir(parents=True, exist_ok=True)
# 数据库
DATABASE_URL = os.getenv("DATABASE_URL", str(DATA_DIR / "rsskeeper.db"))
# RSS 抓取配置
FETCH_CONCURRENCY = int(os.getenv("FETCH_CONCURRENCY", "10"))
FETCH_TIMEOUT = int(os.getenv("FETCH_TIMEOUT", "30"))
DEFAULT_FETCH_INTERVAL = int(os.getenv("DEFAULT_FETCH_INTERVAL", "60")) # 分钟
MIN_FETCH_INTERVAL = int(os.getenv("MIN_FETCH_INTERVAL", "15")) # 最小间隔15分钟
# 内容处理
MAX_ARTICLE_CONTENT_LENGTH = int(os.getenv("MAX_ARTICLE_CONTENT_LENGTH", "50000"))
MAX_SUMMARY_LENGTH = int(os.getenv("MAX_SUMMARY_LENGTH", "500"))
ARTICLE_RETENTION_DAYS = int(os.getenv("ARTICLE_RETENTION_DAYS", "0")) # 0 = 永久保留
# API 配置
API_PREFIX = "/api"
EXTERNAL_API_PREFIX = "/api/v1/external"
+89
View File
@@ -0,0 +1,89 @@
"""数据库连接与初始化"""
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, declarative_base
from config import DATABASE_URL
# SQLite 连接
engine = create_engine(
f"sqlite:///{DATABASE_URL}",
connect_args={"check_same_thread": False},
echo=False,
)
# 启用 SQLite 外键约束
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""FastAPI 依赖注入用"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""初始化数据库表"""
from models import Feed, Article, FetchLog # noqa
Base.metadata.create_all(bind=engine)
init_fts5()
def init_fts5():
"""初始化 FTS5 全文搜索虚拟表"""
conn = engine.raw_connection()
cursor = conn.cursor()
# 检查 FTS5 扩展是否可用
try:
cursor.execute("SELECT sqlite_compileoption_used('ENABLE_FTS5')")
has_fts5 = cursor.fetchone()[0]
if not has_fts5:
print("警告: SQLite 未启用 FTS5 扩展,全文搜索将不可用")
return
except Exception:
pass
# 创建 FTS5 虚拟表
cursor.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, content,
content='articles',
content_rowid='id'
)
""")
# 创建触发器,自动同步 articles 表到 FTS5
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS articles_fts_insert AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, content)
VALUES (new.id, new.title, new.content);
END
""")
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS articles_fts_delete AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, content)
VALUES ('delete', old.id, old.title, old.content);
END
""")
cursor.execute("""
CREATE TRIGGER IF NOT EXISTS articles_fts_update AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, content)
VALUES ('delete', old.id, old.title, old.content);
INSERT INTO articles_fts(rowid, title, content)
VALUES (new.id, new.title, new.content);
END
""")
conn.commit()
cursor.close()
conn.close()
+81
View File
@@ -0,0 +1,81 @@
"""SQLite FTS5 全文搜索封装"""
from sqlalchemy import text
from database import engine
def search_articles(query: str, limit: int = 50, offset: int = 0):
"""全文搜索文章
返回 [(article_id, title, content_snippet, rank), ...]
"""
if not query or not query.strip():
return [], 0
# 转义 FTS5 特殊字符
query = query.replace('"', '""').strip()
conn = engine.raw_connection()
cursor = conn.cursor()
try:
# 使用 FTS5 查询
sql = """
SELECT a.id, a.title, a.summary, a.link, a.published_at, a.created_at,
f.id as feed_id, f.title as feed_title, f.category,
rank
FROM articles_fts
JOIN articles a ON articles_fts.rowid = a.id
JOIN feeds f ON a.feed_id = f.id
WHERE articles_fts MATCH ?
ORDER BY rank
LIMIT ? OFFSET ?
"""
cursor.execute(sql, (query, limit, offset))
rows = cursor.fetchall()
# 获取总数
count_sql = """
SELECT COUNT(*) FROM articles_fts WHERE articles_fts MATCH ?
"""
cursor.execute(count_sql, (query,))
total = cursor.fetchone()[0]
results = []
for row in rows:
results.append({
"id": row[0],
"title": row[1],
"summary": row[2],
"link": row[3],
"published_at": row[4],
"created_at": row[5],
"feed_id": row[6],
"feed_title": row[7],
"category": row[8],
})
return results, total
except Exception as e:
# FTS5 查询失败时返回空结果
return [], 0
finally:
cursor.close()
conn.close()
def rebuild_fts_index():
"""重建 FTS5 索引(数据不一致时使用)"""
conn = engine.raw_connection()
cursor = conn.cursor()
try:
cursor.execute("DELETE FROM articles_fts")
cursor.execute("""
INSERT INTO articles_fts(rowid, title, content)
SELECT id, title, content FROM articles
""")
conn.commit()
return True
except Exception:
return False
finally:
cursor.close()
conn.close()
+112
View File
@@ -0,0 +1,112 @@
"""RSS 源健康度检测"""
from datetime import datetime, timedelta
from typing import List, Dict
from sqlalchemy.orm import Session
from models import Feed, FetchLog
def get_feed_health(db: Session, feed_id: int = None) -> List[Dict]:
"""获取 RSS 源健康度信息
返回每个源的健康状态详情
"""
query = db.query(Feed)
if feed_id:
query = query.filter(Feed.id == feed_id)
feeds = query.all()
results = []
for feed in feeds:
total = feed.success_count + feed.fail_count
success_rate = round(feed.success_count / total * 100, 1) if total > 0 else 0
days_since_fetch = None
if feed.last_fetch_at:
days_since_fetch = (datetime.utcnow() - feed.last_fetch_at).days
# 获取最近 7 天抓取记录
recent_logs = db.query(FetchLog).filter(
FetchLog.feed_id == feed.id,
FetchLog.created_at >= datetime.utcnow() - timedelta(days=7)
).order_by(FetchLog.created_at.desc()).limit(10).all()
health = feed.health_status()
results.append({
"id": feed.id,
"title": feed.title or feed.url,
"url": feed.url,
"is_active": feed.is_active,
"health_status": health,
"health_label": _health_label(health),
"success_rate": success_rate,
"success_count": feed.success_count,
"fail_count": feed.fail_count,
"total_fetches": total,
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
"days_since_fetch": days_since_fetch,
"article_count": feed.article_count,
"last_error": feed.last_error,
"recent_logs": [
{
"status": log.status,
"articles_fetched": log.articles_fetched,
"response_time_ms": log.response_time_ms,
"created_at": log.created_at.isoformat(),
"error_message": log.error_message if log.status == "fail" else None,
}
for log in recent_logs
],
})
return results
def _health_label(status: str) -> str:
labels = {
"healthy": "健康",
"warning": "警告",
"unhealthy": "异常",
"unknown": "未知",
}
return labels.get(status, "未知")
def get_overall_stats(db: Session) -> Dict:
"""获取整体统计信息"""
total_feeds = db.query(Feed).count()
active_feeds = db.query(Feed).filter(Feed.is_active == True).count()
total_articles = db.query(Feed).with_entities(Feed.article_count).all()
total_articles_count = sum(a[0] for a in total_articles) if total_articles else 0
# 健康源统计
feeds = db.query(Feed).all()
healthy = warning = unhealthy = 0
for feed in feeds:
status = feed.health_status()
if status == "healthy":
healthy += 1
elif status == "warning":
warning += 1
elif status == "unhealthy":
unhealthy += 1
# 今日抓取
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
from models import FetchLog
today_fetches = db.query(FetchLog).filter(FetchLog.created_at >= today).count()
today_success = db.query(FetchLog).filter(
FetchLog.created_at >= today, FetchLog.status == "success"
).count()
return {
"total_feeds": total_feeds,
"active_feeds": active_feeds,
"total_articles": total_articles_count,
"healthy_feeds": healthy,
"warning_feeds": warning,
"unhealthy_feeds": unhealthy,
"today_fetches": today_fetches,
"today_success": today_success,
"today_success_rate": round(today_success / today_fetches * 100, 1) if today_fetches > 0 else 0,
}
+75
View File
@@ -0,0 +1,75 @@
"""rssKeeper - FastAPI 入口"""
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from starlette.middleware.cors import CORSMiddleware
from database import init_db, SessionLocal
from scheduler import init_feed_jobs, stop_scheduler
from routers import feeds, articles, dashboard, external_api
import config
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时:初始化数据库 + 注册定时任务
init_db()
db = SessionLocal()
try:
init_feed_jobs(db)
finally:
db.close()
yield
# 关闭时:停止调度器
stop_scheduler()
app = FastAPI(
title="rssKeeper",
description="RSS 抓取、管理与检索系统",
version="1.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API 路由
app.include_router(feeds.router, prefix=config.API_PREFIX)
app.include_router(articles.router, prefix=config.API_PREFIX)
app.include_router(dashboard.router, prefix=config.API_PREFIX)
app.include_router(external_api.router, prefix=config.EXTERNAL_API_PREFIX)
@app.get("/api/health")
def health_check():
"""健康检查"""
return {"status": "ok", "service": "rssKeeper"}
# 静态文件服务(前端构建产物)
static_dir = os.path.join(config.BASE_DIR, "static")
if os.path.exists(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Vue SPA 路由回退"""
# API 路由不走这里
if full_path.startswith("api/") or full_path.startswith("docs") or full_path.startswith("openapi.json"):
return {"detail": "Not found"}
index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path):
return FileResponse(index_path)
return {"detail": "Frontend not built"}
+90
View File
@@ -0,0 +1,90 @@
"""SQLAlchemy 数据模型"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from database import Base
class Feed(Base):
"""RSS 源"""
__tablename__ = "feeds"
id = Column(Integer, primary_key=True, index=True)
url = Column(String(2048), unique=True, nullable=False, index=True)
title = Column(String(512), default="")
description = Column(Text, default="")
category = Column(String(128), default="")
is_active = Column(Boolean, default=True, index=True)
fetch_interval_minutes = Column(Integer, default=60)
# 抓取统计
last_fetch_at = Column(DateTime, nullable=True)
last_fetch_status = Column(String(20), default="")
last_error = Column(Text, default="")
success_count = Column(Integer, default=0)
fail_count = Column(Integer, default=0)
article_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
# 关联
articles = relationship("Article", back_populates="feed", cascade="all, delete-orphan")
fetch_logs = relationship("FetchLog", back_populates="feed", cascade="all, delete-orphan")
def health_status(self):
"""计算健康度
🟢 健康: 成功率 >= 90%, 最近7天有更新
🟡 警告: 成功率 50%-90%, 或超过3天未更新
🔴 异常: 成功率 < 50%, 或超过7天未更新
"""
total = self.success_count + self.fail_count
if total == 0:
return "unknown"
success_rate = self.success_count / total
days_since_last_fetch = None
if self.last_fetch_at:
days_since_last_fetch = (datetime.utcnow() - self.last_fetch_at).days
if success_rate >= 0.9 and (days_since_last_fetch is None or days_since_last_fetch <= 7):
return "healthy"
elif success_rate >= 0.5 and (days_since_last_fetch is None or days_since_last_fetch <= 7):
return "warning"
else:
return "unhealthy"
class Article(Base):
"""RSS 文章"""
__tablename__ = "articles"
id = Column(Integer, primary_key=True, index=True)
feed_id = Column(Integer, ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True)
title = Column(String(1024), default="", index=True)
link = Column(String(2048), unique=True, nullable=False, index=True)
author = Column(String(256), default="")
published_at = Column(DateTime, nullable=True, index=True)
content = Column(Text, default="")
summary = Column(Text, default="")
is_read = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
# 关联
feed = relationship("Feed", back_populates="articles")
class FetchLog(Base):
"""抓取日志"""
__tablename__ = "fetch_logs"
id = Column(Integer, primary_key=True, index=True)
feed_id = Column(Integer, ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True)
status = Column(String(20), nullable=False) # success / fail
articles_fetched = Column(Integer, default=0)
error_message = Column(Text, default="")
response_time_ms = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
# 关联
feed = relationship("Feed", back_populates="fetch_logs")
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.110.0
uvicorn[standard]>=0.29.0
sqlalchemy>=2.0.0
pydantic>=2.6.0
feedparser>=6.0.11
requests>=2.31.0
beautifulsoup4>=4.12.0
apscheduler>=3.10.4
lxml>=5.1.0
+133
View File
@@ -0,0 +1,133 @@
"""文章管理 API"""
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import desc
from database import get_db
from models import Article, Feed
from fulltext_search import search_articles
router = APIRouter(prefix="/articles", tags=["articles"])
class ArticleOut(BaseModel):
id: int
feed_id: int
title: str
link: str
author: str
published_at: Optional[str]
summary: str
is_read: bool
created_at: str
feed_title: str
category: str
class Config:
from_attributes = True
@router.get("")
def list_articles(
skip: int = 0,
limit: int = 50,
feed_id: Optional[int] = None,
category: Optional[str] = None,
search: Optional[str] = None,
since: Optional[str] = None,
until: Optional[str] = None,
is_read: Optional[bool] = None,
db: Session = Depends(get_db),
):
"""获取文章列表,支持多种筛选条件"""
# 如果有搜索关键词,使用 FTS5 全文搜索
if search and search.strip():
results, total = search_articles(search.strip(), limit=limit, offset=skip)
return {"total": total, "items": results}
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
if feed_id:
query = query.filter(Article.feed_id == feed_id)
if category:
query = query.filter(Feed.category == category)
if is_read is not None:
query = query.filter(Article.is_read == is_read)
if since:
query = query.filter(Article.published_at >= since)
if until:
query = query.filter(Article.published_at <= until)
total = query.count()
rows = query.order_by(desc(Article.published_at)).offset(skip).limit(limit).all()
items = []
for article, feed_title, category in rows:
items.append({
"id": article.id,
"feed_id": article.feed_id,
"title": article.title or "",
"link": article.link,
"author": article.author or "",
"published_at": article.published_at.isoformat() if article.published_at else None,
"summary": article.summary or "",
"is_read": article.is_read,
"created_at": article.created_at.isoformat(),
"feed_title": feed_title or "",
"category": category or "",
})
return {"total": total, "items": items}
@router.get("/{article_id}")
def get_article(article_id: int, db: Session = Depends(get_db)):
"""获取文章详情"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="文章不存在")
feed = db.query(Feed).filter(Feed.id == article.feed_id).first()
return {
"id": article.id,
"feed_id": article.feed_id,
"title": article.title or "",
"link": article.link,
"author": article.author or "",
"published_at": article.published_at.isoformat() if article.published_at else None,
"content": article.content or "",
"summary": article.summary or "",
"is_read": article.is_read,
"created_at": article.created_at.isoformat(),
"feed_title": feed.title if feed else "",
"category": feed.category if feed else "",
}
@router.put("/{article_id}/read")
def mark_read(article_id: int, db: Session = Depends(get_db)):
"""标记文章为已读"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="文章不存在")
article.is_read = True
db.commit()
return {"message": "已标记为已读"}
@router.get("/search/fulltext")
def fulltext_search(
q: str,
skip: int = 0,
limit: int = 50,
):
"""全文搜索文章"""
results, total = search_articles(q, limit=limit, offset=skip)
return {"total": total, "items": results}
from fastapi import HTTPException
+58
View File
@@ -0,0 +1,58 @@
"""仪表盘统计 API"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from database import get_db
from health_checker import get_overall_stats, get_feed_health
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@router.get("/stats")
def dashboard_stats(db: Session = Depends(get_db)):
"""仪表盘统计数据"""
return get_overall_stats(db)
@router.get("/health")
def dashboard_health(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
):
"""RSS 源健康度列表"""
all_health = get_feed_health(db)
total = len(all_health)
# 按健康状态排序:异常在前
status_order = {"unhealthy": 0, "warning": 1, "unknown": 2, "healthy": 3}
all_health.sort(key=lambda x: status_order.get(x["health_status"], 2))
items = all_health[skip:skip + limit]
return {"total": total, "items": items}
@router.get("/recent-activity")
def recent_activity(limit: int = 20, db: Session = Depends(get_db)):
"""最近的抓取活动"""
from models import FetchLog, Feed
from sqlalchemy import desc
logs = db.query(FetchLog, Feed.title.label("feed_title")).join(Feed).order_by(
desc(FetchLog.created_at)
).limit(limit).all()
return {
"items": [
{
"id": log.id,
"feed_id": log.feed_id,
"feed_title": feed_title or "",
"status": log.status,
"articles_fetched": log.articles_fetched,
"response_time_ms": log.response_time_ms,
"error_message": log.error_message,
"created_at": log.created_at.isoformat(),
}
for log, feed_title in logs
]
}
+163
View File
@@ -0,0 +1,163 @@
"""对外 API(供 AI/外部系统调用)"""
from typing import Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import desc
from database import get_db
from models import Article, Feed
router = APIRouter(prefix="/external", tags=["external"])
@router.get("/recent")
def get_recent_articles(
hours: int = 24,
limit: int = 50,
feed_id: Optional[int] = None,
category: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取最近 N 小时的文章
这是对外提供给 AI 分析的主要接口
"""
since = datetime.utcnow() - timedelta(hours=hours)
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
query = query.filter(Article.created_at >= since)
if feed_id:
query = query.filter(Article.feed_id == feed_id)
if category:
query = query.filter(Feed.category == category)
rows = query.order_by(desc(Article.published_at)).limit(limit).all()
return {
"query": {
"hours": hours,
"limit": limit,
"feed_id": feed_id,
"category": category,
},
"count": len(rows),
"articles": [
{
"id": article.id,
"title": article.title or "",
"link": article.link,
"author": article.author or "",
"summary": article.summary or "",
"content": article.content or "" if len(article.content or "") < 10000 else article.summary or "",
"published_at": article.published_at.isoformat() if article.published_at else None,
"created_at": article.created_at.isoformat(),
"feed_title": feed_title or "",
"category": category or "",
}
for article, feed_title, category in rows
],
}
@router.get("/feeds")
def get_active_feeds(db: Session = Depends(get_db)):
"""获取所有活跃的 RSS 源列表"""
feeds = db.query(Feed).filter(Feed.is_active == True).all()
return {
"count": len(feeds),
"feeds": [
{
"id": feed.id,
"title": feed.title or feed.url,
"url": feed.url,
"category": feed.category or "",
"article_count": feed.article_count,
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
}
for feed in feeds
],
}
@router.get("/feeds/{feed_id}/articles")
def get_feed_articles(
feed_id: int,
limit: int = 100,
since: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取指定 RSS 源的文章"""
feed = db.query(Feed).filter(Feed.id == feed_id).first()
if not feed:
return {"error": "Feed not found"}
query = db.query(Article).filter(Article.feed_id == feed_id)
if since:
query = query.filter(Article.published_at >= since)
articles = query.order_by(desc(Article.published_at)).limit(limit).all()
return {
"feed": {
"id": feed.id,
"title": feed.title or feed.url,
"url": feed.url,
},
"count": len(articles),
"articles": [
{
"id": article.id,
"title": article.title or "",
"link": article.link,
"author": article.author or "",
"summary": article.summary or "",
"published_at": article.published_at.isoformat() if article.published_at else None,
}
for article in articles
],
}
@router.get("/summary")
def get_daily_summary(
date: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取指定日期的文章摘要统计
供 AI 快速了解某天的 RSS 内容概况
"""
if date:
try:
day = datetime.strptime(date, "%Y-%m-%d")
next_day = day + timedelta(days=1)
except ValueError:
return {"error": "Invalid date format, use YYYY-MM-DD"}
else:
day = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
next_day = day + timedelta(days=1)
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
query = query.filter(Article.created_at >= day, Article.created_at < next_day)
rows = query.order_by(desc(Article.published_at)).all()
# 按分类统计
by_category = {}
for article, feed_title, category in rows:
cat = category or "未分类"
if cat not in by_category:
by_category[cat] = []
by_category[cat].append({
"title": article.title or "",
"link": article.link,
"feed": feed_title or "",
"summary": article.summary or "",
})
return {
"date": day.strftime("%Y-%m-%d"),
"total_articles": len(rows),
"by_category": by_category,
}
+273
View File
@@ -0,0 +1,273 @@
"""RSS 源管理 API"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, HttpUrl
from sqlalchemy.orm import Session
from database import get_db
from models import Feed
from rss_fetcher import discover_feed_url, fetch_and_store_feed
from scheduler import add_feed_job, remove_feed_job
router = APIRouter(prefix="/feeds", tags=["feeds"])
class FeedCreate(BaseModel):
url: str
title: Optional[str] = ""
description: Optional[str] = ""
category: Optional[str] = ""
is_active: Optional[bool] = True
fetch_interval_minutes: Optional[int] = 60
class FeedUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
is_active: Optional[bool] = None
fetch_interval_minutes: Optional[int] = None
class FeedOut(BaseModel):
id: int
url: str
title: str
description: str
category: str
is_active: bool
fetch_interval_minutes: int
last_fetch_at: Optional[str] = None
last_fetch_status: str
success_count: int
fail_count: int
article_count: int
health_status: str
created_at: str
class Config:
from_attributes = True
@router.get("", response_model=dict)
def list_feeds(
skip: int = 0,
limit: int = 100,
category: Optional[str] = None,
search: Optional[str] = None,
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
):
"""获取 RSS 源列表,支持分页、分类筛选、搜索"""
query = db.query(Feed)
if category:
query = query.filter(Feed.category == category)
if is_active is not None:
query = query.filter(Feed.is_active == is_active)
if search:
query = query.filter(
Feed.title.contains(search) | Feed.url.contains(search) | Feed.description.contains(search)
)
total = query.count()
feeds = query.order_by(Feed.created_at.desc()).offset(skip).limit(limit).all()
results = []
for feed in feeds:
data = {
"id": feed.id,
"url": feed.url,
"title": feed.title or feed.url,
"description": feed.description or "",
"category": feed.category or "",
"is_active": feed.is_active,
"fetch_interval_minutes": feed.fetch_interval_minutes,
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
"last_fetch_status": feed.last_fetch_status,
"success_count": feed.success_count,
"fail_count": feed.fail_count,
"article_count": feed.article_count,
"health_status": feed.health_status(),
"created_at": feed.created_at.isoformat(),
}
results.append(data)
return {"total": total, "items": results}
@router.get("/categories")
def list_categories(db: Session = Depends(get_db)):
"""获取所有分类列表"""
categories = db.query(Feed.category).filter(Feed.category != "").distinct().all()
return [c[0] for c in categories if c[0]]
@router.post("", response_model=dict)
def create_feed(data: FeedCreate, db: Session = Depends(get_db)):
"""添加 RSS 源"""
# 检查是否已存在
existing = db.query(Feed).filter(Feed.url == data.url).first()
if existing:
raise HTTPException(status_code=409, detail="该 RSS 源已存在")
feed = Feed(
url=data.url,
title=data.title or "",
description=data.description or "",
category=data.category or "",
is_active=data.is_active,
fetch_interval_minutes=data.fetch_interval_minutes or 60,
)
db.add(feed)
db.commit()
db.refresh(feed)
# 注册定时任务
if feed.is_active:
add_feed_job(feed.id, feed.fetch_interval_minutes)
# 立即抓取一次
fetch_and_store_feed(feed.id)
return {"id": feed.id, "message": "RSS 源添加成功", "url": feed.url}
@router.post("/discover")
def discover_feed(url: str, db: Session = Depends(get_db)):
"""从网页自动发现 RSS feed URL"""
feed_urls = discover_feed_url(url)
return {"source_url": url, "found_feeds": feed_urls}
@router.get("/{feed_id}", response_model=dict)
def get_feed(feed_id: int, db: Session = Depends(get_db)):
"""获取 RSS 源详情"""
feed = db.query(Feed).filter(Feed.id == feed_id).first()
if not feed:
raise HTTPException(status_code=404, detail="RSS 源不存在")
return {
"id": feed.id,
"url": feed.url,
"title": feed.title or feed.url,
"description": feed.description or "",
"category": feed.category or "",
"is_active": feed.is_active,
"fetch_interval_minutes": feed.fetch_interval_minutes,
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
"last_fetch_status": feed.last_fetch_status,
"last_error": feed.last_error,
"success_count": feed.success_count,
"fail_count": feed.fail_count,
"article_count": feed.article_count,
"health_status": feed.health_status(),
"created_at": feed.created_at.isoformat(),
}
@router.put("/{feed_id}", response_model=dict)
def update_feed(feed_id: int, data: FeedUpdate, db: Session = Depends(get_db)):
"""更新 RSS 源"""
feed = db.query(Feed).filter(Feed.id == feed_id).first()
if not feed:
raise HTTPException(status_code=404, detail="RSS 源不存在")
if data.title is not None:
feed.title = data.title
if data.description is not None:
feed.description = data.description
if data.category is not None:
feed.category = data.category
if data.is_active is not None:
feed.is_active = data.is_active
if feed.is_active:
add_feed_job(feed.id, feed.fetch_interval_minutes)
else:
remove_feed_job(feed.id)
if data.fetch_interval_minutes is not None:
feed.fetch_interval_minutes = data.fetch_interval_minutes
if feed.is_active:
add_feed_job(feed.id, feed.fetch_interval_minutes)
db.commit()
return {"message": "RSS 源更新成功"}
@router.delete("/{feed_id}")
def delete_feed(feed_id: int, db: Session = Depends(get_db)):
"""删除 RSS 源(级联删除文章和日志)"""
feed = db.query(Feed).filter(Feed.id == feed_id).first()
if not feed:
raise HTTPException(status_code=404, detail="RSS 源不存在")
remove_feed_job(feed_id)
db.delete(feed)
db.commit()
return {"message": "RSS 源已删除"}
@router.post("/{feed_id}/fetch")
def trigger_fetch(feed_id: int, db: Session = Depends(get_db)):
"""手动触发抓取"""
feed = db.query(Feed).filter(Feed.id == feed_id).first()
if not feed:
raise HTTPException(status_code=404, detail="RSS 源不存在")
result = fetch_and_store_feed(feed_id)
return result
@router.post("/import-opml")
def import_opml(opml_content: str, db: Session = Depends(get_db)):
"""导入 OPML 文件内容"""
import xml.etree.ElementTree as ET
try:
root = ET.fromstring(opml_content)
except ET.ParseError:
raise HTTPException(status_code=400, detail="无效的 OPML 文件")
added = 0
skipped = 0
for outline in root.iter("outline"):
url = outline.get("xmlUrl") or outline.get("xmlurl")
if not url:
continue
existing = db.query(Feed).filter(Feed.url == url).first()
if existing:
skipped += 1
continue
feed = Feed(
url=url,
title=outline.get("title", "") or outline.get("text", ""),
description=outline.get("description", ""),
category=outline.get("category", ""),
is_active=True,
fetch_interval_minutes=60,
)
db.add(feed)
db.commit()
db.refresh(feed)
add_feed_job(feed.id, feed.fetch_interval_minutes)
added += 1
return {"added": added, "skipped": skipped, "message": f"成功导入 {added} 个 RSS 源"}
@router.get("/export-opml")
def export_opml(db: Session = Depends(get_db)):
"""导出 OPML 文件内容"""
feeds = db.query(Feed).all()
lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<opml version="2.0">', '<head><title>rssKeeper Feeds</title></head>', '<body>']
for feed in feeds:
title = (feed.title or feed.url).replace('"', '&quot;')
lines.append(f' <outline type="rss" text="{title}" xmlUrl="{feed.url}" />')
lines.append('</body>')
lines.append('</opml>')
return {"opml": "\n".join(lines)}
+298
View File
@@ -0,0 +1,298 @@
"""RSS 抓取核心逻辑"""
import time
import re
import html
import hashlib
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin
import requests
import feedparser
from bs4 import BeautifulSoup
from sqlalchemy.orm import Session
from models import Feed, Article, FetchLog
from database import SessionLocal
import config
def fetch_feed(url: str, timeout: int = config.FETCH_TIMEOUT) -> dict:
"""抓取单个 RSS 源
返回 {"success": bool, "feed_data": parsed, "error": str, "response_time_ms": int}
"""
start_time = time.time()
try:
headers = {
"User-Agent": "rssKeeper/1.0 (+https://github.com/rssKeeper)",
"Accept": "application/rss+xml, application/atom+xml, application/xml, text/xml, */*",
}
response = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
response.raise_for_status()
# 解析 RSS
parsed = feedparser.parse(response.content)
response_time_ms = int((time.time() - start_time) * 1000)
if parsed.bozo and hasattr(parsed, 'bozo_exception'):
# 有解析警告但可能仍然可用
pass
return {
"success": True,
"feed_data": parsed,
"error": None,
"response_time_ms": response_time_ms,
}
except requests.exceptions.RequestException as e:
return {"success": False, "feed_data": None, "error": str(e), "response_time_ms": None}
except Exception as e:
return {"success": False, "feed_data": None, "error": str(e), "response_time_ms": None}
def discover_feed_url(url: str, timeout: int = 15) -> list:
"""从任意网页自动发现 RSS/Atom feed URL
返回找到的 feed URL 列表
"""
try:
headers = {
"User-Agent": "rssKeeper/1.0 (+https://github.com/rssKeeper)",
}
response = requests.get(url, headers=headers, timeout=timeout, allow_redirects=True)
response.raise_for_status()
soup = BeautifulSoup(response.content, "html.parser")
feed_urls = []
# 查找 <link rel="alternate"> 标签
for link in soup.find_all("link", rel="alternate"):
link_type = link.get("type", "").lower()
href = link.get("href", "")
if href and any(t in link_type for t in ["rss", "atom", "xml"]):
full_url = urljoin(response.url, href)
feed_urls.append(full_url)
# 也查找常见的 RSS 链接
common_patterns = [
"/rss", "/feed", "/feeds", "/atom.xml", "/rss.xml",
"/index.xml", "/feed.xml", "/?feed=rss2",
]
for pattern in common_patterns:
candidate = urljoin(response.url, pattern)
if candidate not in feed_urls:
# 验证是否是有效的 feed
try:
resp = requests.head(candidate, headers=headers, timeout=5, allow_redirects=True)
content_type = resp.headers.get("Content-Type", "").lower()
if any(t in content_type for t in ["rss", "atom", "xml"]):
feed_urls.append(candidate)
except Exception:
pass
return list(dict.fromkeys(feed_urls)) # 去重保持顺序
except Exception:
return []
def parse_article(entry, feed_id: int) -> dict:
"""从 feedparser entry 解析文章数据"""
title = entry.get("title", "")
link = entry.get("link", "")
author = entry.get("author", "")
# 发布时间
published_at = None
if hasattr(entry, "published_parsed") and entry.published_parsed:
try:
published_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc).replace(tzinfo=None)
except (ValueError, TypeError):
pass
if not published_at and hasattr(entry, "updated_parsed") and entry.updated_parsed:
try:
published_at = datetime(*entry.updated_parsed[:6], tzinfo=timezone.utc).replace(tzinfo=None)
except (ValueError, TypeError):
pass
# 内容:优先 summary,其次 content
content = ""
if hasattr(entry, "content") and entry.content:
content = entry.content[0].value
elif hasattr(entry, "summary"):
content = entry.summary
# 清洗 HTML
content = clean_html(content)
# 生成摘要
summary = generate_summary(content)
return {
"feed_id": feed_id,
"title": title[:1024],
"link": link[:2048],
"author": author[:256],
"published_at": published_at,
"content": content[:config.MAX_ARTICLE_CONTENT_LENGTH],
"summary": summary[:config.MAX_SUMMARY_LENGTH],
}
def clean_html(html_text: str) -> str:
"""清洗 HTML,去除 script/style 标签,转为安全文本"""
if not html_text:
return ""
# 先解码 HTML 实体
text = html.unescape(html_text)
# 用 BeautifulSoup 清理
soup = BeautifulSoup(text, "html.parser")
# 移除 script 和 style
for tag in soup(["script", "style", "iframe", "object", "embed"]):
tag.decompose()
# 获取纯文本
cleaned = soup.get_text(separator="\n")
# 压缩空白行
cleaned = re.sub(r"\n\s*\n+", "\n\n", cleaned)
cleaned = cleaned.strip()
return cleaned
def generate_summary(content: str, max_length: int = 300) -> str:
"""从内容生成摘要"""
if not content:
return ""
# 去掉多余空白
text = re.sub(r"\s+", " ", content).strip()
if len(text) <= max_length:
return text
# 在句子边界截断
truncated = text[:max_length]
last_period = max(truncated.rfind(""), truncated.rfind(". "), truncated.rfind("! "), truncated.rfind("? "))
if last_period > max_length * 0.5:
return truncated[:last_period + 1]
return truncated + "..."
def fetch_and_store_feed(feed_id: int) -> dict:
"""抓取指定 RSS 源并存储文章
返回抓取结果统计
"""
db = SessionLocal()
try:
feed = db.query(Feed).filter(Feed.id == feed_id).first()
if not feed:
return {"success": False, "error": "Feed not found", "articles_count": 0}
result = fetch_feed(feed.url)
if not result["success"]:
# 记录失败
feed.last_fetch_at = datetime.utcnow()
feed.last_fetch_status = "fail"
feed.last_error = result["error"]
feed.fail_count += 1
log = FetchLog(
feed_id=feed_id,
status="fail",
error_message=result["error"],
response_time_ms=result.get("response_time_ms"),
)
db.add(log)
db.commit()
return {"success": False, "error": result["error"], "articles_count": 0}
parsed = result["feed_data"]
# 更新 feed 元信息
if hasattr(parsed.feed, "title"):
feed.title = parsed.feed.title[:512]
if hasattr(parsed.feed, "description"):
feed.description = parsed.feed.description[:1000]
# 存储文章
new_count = 0
for entry in parsed.entries:
article_data = parse_article(entry, feed_id)
if not article_data["link"]:
continue
# 检查是否已存在(基于 link
existing = db.query(Article).filter(Article.link == article_data["link"]).first()
if existing:
# 更新已有文章
existing.title = article_data["title"] or existing.title
existing.content = article_data["content"] or existing.content
existing.summary = article_data["summary"] or existing.summary
existing.author = article_data["author"] or existing.author
if article_data["published_at"]:
existing.published_at = article_data["published_at"]
else:
article = Article(**article_data)
db.add(article)
new_count += 1
# 更新 feed 统计
feed.last_fetch_at = datetime.utcnow()
feed.last_fetch_status = "success"
feed.last_error = ""
feed.success_count += 1
feed.article_count = db.query(Article).filter(Article.feed_id == feed_id).count()
log = FetchLog(
feed_id=feed_id,
status="success",
articles_fetched=new_count,
response_time_ms=result.get("response_time_ms"),
)
db.add(log)
db.commit()
return {
"success": True,
"articles_count": new_count,
"feed_title": feed.title,
}
except Exception as e:
db.rollback()
return {"success": False, "error": str(e), "articles_count": 0}
finally:
db.close()
def fetch_all_feeds(feed_ids: list = None) -> list:
"""并发抓取多个 RSS 源
返回每个源的抓取结果列表
"""
db = SessionLocal()
try:
query = db.query(Feed).filter(Feed.is_active == True)
if feed_ids:
query = query.filter(Feed.id.in_(feed_ids))
feeds = query.all()
finally:
db.close()
results = []
with ThreadPoolExecutor(max_workers=config.FETCH_CONCURRENCY) as executor:
future_to_feed = {
executor.submit(fetch_and_store_feed, feed.id): feed
for feed in feeds
}
for future in as_completed(future_to_feed):
feed = future_to_feed[future]
try:
result = future.result()
results.append({"feed_id": feed.id, **result})
except Exception as e:
results.append({"feed_id": feed.id, "success": False, "error": str(e), "articles_count": 0})
return results
+74
View File
@@ -0,0 +1,74 @@
"""APScheduler 定时任务管理"""
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from rss_fetcher import fetch_and_store_feed
import config
_scheduler = None
def get_scheduler():
"""获取或创建调度器实例"""
global _scheduler
if _scheduler is None:
_scheduler = BackgroundScheduler()
return _scheduler
def add_feed_job(feed_id: int, interval_minutes: int):
"""为指定 RSS 源添加定时抓取任务"""
scheduler = get_scheduler()
job_id = f"fetch_feed_{feed_id}"
# 确保间隔不低于最小值
interval = max(interval_minutes, config.MIN_FETCH_INTERVAL)
# 如果任务已存在则更新
existing = scheduler.get_job(job_id)
if existing:
existing.reschedule(trigger=IntervalTrigger(minutes=interval))
return
scheduler.add_job(
fetch_and_store_feed,
trigger=IntervalTrigger(minutes=interval),
id=job_id,
args=[feed_id],
replace_existing=True,
misfire_grace_time=300, # 5分钟容错
coalesce=True, # 合并错过的任务
)
def remove_feed_job(feed_id: int):
"""移除指定 RSS 源的定时任务"""
scheduler = get_scheduler()
job_id = f"fetch_feed_{feed_id}"
try:
scheduler.remove_job(job_id)
except Exception:
pass
def start_scheduler():
"""启动调度器"""
scheduler = get_scheduler()
if not scheduler.running:
scheduler.start()
def stop_scheduler():
"""停止调度器"""
global _scheduler
if _scheduler and _scheduler.running:
_scheduler.shutdown(wait=False)
_scheduler = None
def init_feed_jobs(db):
"""从数据库加载所有活跃 RSS 源并注册定时任务"""
from models import Feed
feeds = db.query(Feed).filter(Feed.is_active == True).all()
for feed in feeds:
add_feed_job(feed.id, feed.fetch_interval_minutes or config.DEFAULT_FETCH_INTERVAL)
start_scheduler()
View File
+28
View File
@@ -0,0 +1,28 @@
version: '3.8'
services:
rsskeeper:
build:
context: .
dockerfile: Dockerfile
container_name: rsskeeper
ports:
- "8000:8000"
volumes:
- ./data:/app/data
environment:
- DATA_DIR=/app/data
- DATABASE_URL=/app/data/rsskeeper.db
- FETCH_CONCURRENCY=10
- FETCH_TIMEOUT=30
- DEFAULT_FETCH_INTERVAL=60
- MIN_FETCH_INTERVAL=15
- MAX_ARTICLE_CONTENT_LENGTH=50000
- MAX_SUMMARY_LENGTH=500
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rssKeeper - RSS 管理与检索</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "rsskeeper-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"element-plus": "^2.6.3",
"axios": "^1.6.8",
"@element-plus/icons-vue": "^2.3.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.0"
}
}
+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>
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
base: '/',
build: {
outDir: 'dist',
assetsDir: 'assets',
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/api/v1': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})