feat: 深色主题UI、错误分类、批量抓取、健康度筛选

- 修复 datetime 时区不一致导致所有API 500错误的问题
- Feeds/Dashboard 页面改为深色表格主题,高对比度文字
- 添加错误类型自动分类(URL失效/被拒绝/超时/DNS失败/SSL错误等12种)
- 新增"下次抓取时间"列,从APScheduler获取
- 新增健康度筛选下拉,修复分页后过滤失效的bug
- "全部抓取"改为同步并发执行,基于当前筛选条件获取所有匹配源
- 新增数据库自动迁移机制,处理增量列变更

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-06-11 17:44:54 +08:00
parent c59dd304f7
commit 68bba3d9e0
12 changed files with 846 additions and 192 deletions
+6 -3
View File
@@ -3,7 +3,7 @@
FROM node:20-alpine AS frontend-builder FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package.json ./ COPY frontend/package.json ./
RUN npm install RUN npm config set registry https://registry.npmmirror.com && npm install
COPY frontend/ . COPY frontend/ .
RUN npm run build RUN npm run build
@@ -11,6 +11,9 @@ RUN npm run build
FROM python:3.12.7-slim FROM python:3.12.7-slim
WORKDIR /app WORKDIR /app
# 使用国内 apt 镜像源
RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources
# 安装系统依赖(构建时) # 安装系统依赖(构建时)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \ gcc \
@@ -18,9 +21,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxslt1-dev \ libxslt1-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 安装 Python 依赖 # 安装 Python 依赖(使用国内 pip 镜像)
COPY backend/requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
# 清理构建依赖 # 清理构建依赖
RUN apt-get purge -y gcc && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* RUN apt-get purge -y gcc && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
+39
View File
@@ -35,9 +35,48 @@ def init_db():
from models import Feed, Article, FetchLog # noqa from models import Feed, Article, FetchLog # noqa
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate(engine)
init_fts5() init_fts5()
def _migrate(engine):
"""处理数据库增量迁移(添加新列)"""
import logging
logger = logging.getLogger(__name__)
conn = engine.raw_connection()
cursor = conn.cursor()
# 获取 feeds 表现有列
cursor.execute("PRAGMA table_info(feeds)")
existing = {row[1] for row in cursor.fetchall()}
migrations = [
("feeds", "error_type", "VARCHAR(32) DEFAULT ''"),
]
for table, column, col_type in migrations:
if column not in existing:
logger.info(f"迁移: ALTER TABLE {table} ADD COLUMN {column} {col_type}")
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
conn.commit()
# 对已有错误数据分类
from rss_fetcher import classify_error
cursor.execute("SELECT id, last_error FROM feeds WHERE last_error != '' AND (error_type IS NULL OR error_type = '')")
rows = cursor.fetchall()
for row in rows:
feed_id, error = row
etype = classify_error(error)
if etype:
cursor.execute("UPDATE feeds SET error_type = ? WHERE id = ?", (etype, feed_id))
if rows:
conn.commit()
logger.info(f"迁移: 已分类 {len(rows)} 条历史错误")
cursor.close()
conn.close()
def init_fts5(): def init_fts5():
"""初始化 FTS5 全文搜索虚拟表""" """初始化 FTS5 全文搜索虚拟表"""
conn = engine.raw_connection() conn = engine.raw_connection()
+3 -3
View File
@@ -1,5 +1,5 @@
"""RSS 源健康度检测""" """RSS 源健康度检测"""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from typing import List, Dict from typing import List, Dict
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,7 +10,7 @@ def get_feed_health(db: Session, feed_id: int = None) -> List[Dict]:
"""获取 RSS 源健康度信息 """获取 RSS 源健康度信息
返回每个源的健康状态详情 返回每个源的健康状态详情
""" """
now = datetime.now(timezone.utc) now = datetime.utcnow()
query = db.query(Feed) query = db.query(Feed)
if feed_id: if feed_id:
query = query.filter(Feed.id == feed_id) query = query.filter(Feed.id == feed_id)
@@ -84,7 +84,7 @@ def get_overall_stats(db: Session) -> Dict:
# 健康源统计 # 健康源统计
feeds = db.query(Feed).all() feeds = db.query(Feed).all()
healthy = warning = unhealthy = 0 healthy = warning = unhealthy = 0
now = datetime.now(timezone.utc) now = datetime.utcnow()
for feed in feeds: for feed in feeds:
status = feed.health_status(now=now) status = feed.health_status(now=now)
if status == "healthy": if status == "healthy":
+2 -21
View File
@@ -3,7 +3,6 @@ import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from database import init_db, SessionLocal from database import init_db, SessionLocal
from scheduler import init_feed_jobs, stop_scheduler from scheduler import init_feed_jobs, stop_scheduler
@@ -61,25 +60,7 @@ def health_check():
return {"status": "ok", "service": "rssKeeper"} return {"status": "ok", "service": "rssKeeper"}
# 静态文件服务(前端构建产物) # 静态文件服务(前端构建产物)— 必须放在最后,API 路由优先匹配
static_dir = os.path.join(config.BASE_DIR, "static") static_dir = os.path.join(config.BASE_DIR, "static")
if os.path.exists(static_dir): if os.path.exists(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static") app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
# API 路径白名单 — 这些路径不应被 SPA 兜底
_API_PATHS = {
"api", "docs", "openapi.json", "redoc",
}
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Vue SPA 路由回退"""
# API/文档路由不走 SPA 兜底
first_seg = full_path.split("/")[0] if full_path else ""
if first_seg in _API_PATHS:
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"}
+7 -5
View File
@@ -1,5 +1,5 @@
"""SQLAlchemy 数据模型""" """SQLAlchemy 数据模型"""
from datetime import datetime, timezone from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from database import Base from database import Base
@@ -21,11 +21,12 @@ class Feed(Base):
last_fetch_at = Column(DateTime, nullable=True) last_fetch_at = Column(DateTime, nullable=True)
last_fetch_status = Column(String(20), default="") last_fetch_status = Column(String(20), default="")
last_error = Column(Text, default="") last_error = Column(Text, default="")
error_type = Column(String(32), default="")
success_count = Column(Integer, default=0) success_count = Column(Integer, default=0)
fail_count = Column(Integer, default=0) fail_count = Column(Integer, default=0)
article_count = Column(Integer, default=0) article_count = Column(Integer, default=0)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, default=datetime.utcnow)
# 关联 # 关联
articles = relationship("Article", back_populates="feed", cascade="all, delete-orphan") articles = relationship("Article", back_populates="feed", cascade="all, delete-orphan")
@@ -36,6 +37,7 @@ class Feed(Base):
🟢 健康: 成功率 >= 90%, 最近7天有更新 🟢 健康: 成功率 >= 90%, 最近7天有更新
🟡 警告: 成功率 50%-90%, 或超过3天未更新 🟡 警告: 成功率 50%-90%, 或超过3天未更新
🔴 异常: 成功率 < 50%, 或超过7天未更新 🔴 异常: 成功率 < 50%, 或超过7天未更新
⚪ 未知: 尚未进行过任何抓取
""" """
total = self.success_count + self.fail_count total = self.success_count + self.fail_count
if total == 0: if total == 0:
@@ -44,7 +46,7 @@ class Feed(Base):
success_rate = self.success_count / total success_rate = self.success_count / total
if now is None: if now is None:
now = datetime.now(timezone.utc) now = datetime.utcnow()
days_since_last_fetch = None days_since_last_fetch = None
if self.last_fetch_at: if self.last_fetch_at:
@@ -71,7 +73,7 @@ class Article(Base):
content = Column(Text, default="") content = Column(Text, default="")
summary = Column(Text, default="") summary = Column(Text, default="")
is_read = Column(Boolean, default=False) is_read = Column(Boolean, default=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), index=True) created_at = Column(DateTime, default=datetime.utcnow, index=True)
# 关联 # 关联
feed = relationship("Feed", back_populates="articles") feed = relationship("Feed", back_populates="articles")
@@ -87,7 +89,7 @@ class FetchLog(Base):
articles_fetched = Column(Integer, default=0) articles_fetched = Column(Integer, default=0)
error_message = Column(Text, default="") error_message = Column(Text, default="")
response_time_ms = Column(Integer, nullable=True) response_time_ms = Column(Integer, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), index=True) created_at = Column(DateTime, default=datetime.utcnow, index=True)
# 关联 # 关联
feed = relationship("Feed", back_populates="fetch_logs") feed = relationship("Feed", back_populates="fetch_logs")
+3 -3
View File
@@ -1,6 +1,6 @@
"""对外 API(供 AI/外部系统调用)""" """对外 API(供 AI/外部系统调用)"""
from typing import Optional from typing import Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import desc from sqlalchemy import desc
@@ -21,7 +21,7 @@ def get_recent_articles(
"""获取最近 N 小时的文章 """获取最近 N 小时的文章
这是对外提供给 AI 分析的主要接口 这是对外提供给 AI 分析的主要接口
""" """
since = datetime.now(timezone.utc) - timedelta(hours=hours) since = datetime.utcnow() - timedelta(hours=hours)
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed) query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
@@ -136,7 +136,7 @@ def get_daily_summary(
except ValueError: except ValueError:
return {"error": "Invalid date format, use YYYY-MM-DD"} return {"error": "Invalid date format, use YYYY-MM-DD"}
else: else:
day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) day = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
next_day = day + timedelta(days=1) next_day = day + timedelta(days=1)
query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed) query = db.query(Article, Feed.title.label("feed_title"), Feed.category.label("category")).join(Feed)
+38 -4
View File
@@ -5,8 +5,8 @@ from pydantic import BaseModel, HttpUrl
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from models import Feed from models import Feed
from rss_fetcher import discover_feed_url, fetch_and_store_feed from rss_fetcher import discover_feed_url, fetch_and_store_feed, fetch_all_feeds
from scheduler import add_feed_job, remove_feed_job from scheduler import add_feed_job, remove_feed_job, get_feed_next_run
router = APIRouter(prefix="/feeds", tags=["feeds"]) router = APIRouter(prefix="/feeds", tags=["feeds"])
@@ -55,9 +55,10 @@ def list_feeds(
category: Optional[str] = None, category: Optional[str] = None,
search: Optional[str] = None, search: Optional[str] = None,
is_active: Optional[bool] = None, is_active: Optional[bool] = None,
health_status: Optional[str] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""获取 RSS 源列表,支持分页、分类筛选、搜索""" """获取 RSS 源列表,支持分页、分类筛选、搜索、健康度筛选"""
query = db.query(Feed) query = db.query(Feed)
if category: if category:
@@ -70,10 +71,22 @@ def list_feeds(
) )
total = query.count() total = query.count()
feeds = query.order_by(Feed.created_at.desc()).offset(skip).limit(limit).all()
# 健康度是计算字段,需要在 Python 中过滤
if health_status:
all_feeds = query.order_by(Feed.created_at.desc()).all()
matched = []
for feed in all_feeds:
if feed.health_status() == health_status:
matched.append(feed)
total = len(matched)
feeds = matched[skip:skip + limit]
else:
feeds = query.order_by(Feed.created_at.desc()).offset(skip).limit(limit).all()
results = [] results = []
for feed in feeds: for feed in feeds:
next_run = get_feed_next_run(feed.id)
data = { data = {
"id": feed.id, "id": feed.id,
"url": feed.url, "url": feed.url,
@@ -84,10 +97,13 @@ def list_feeds(
"fetch_interval_minutes": feed.fetch_interval_minutes, "fetch_interval_minutes": feed.fetch_interval_minutes,
"last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None, "last_fetch_at": feed.last_fetch_at.isoformat() if feed.last_fetch_at else None,
"last_fetch_status": feed.last_fetch_status, "last_fetch_status": feed.last_fetch_status,
"last_error": feed.last_error,
"error_type": feed.error_type,
"success_count": feed.success_count, "success_count": feed.success_count,
"fail_count": feed.fail_count, "fail_count": feed.fail_count,
"article_count": feed.article_count, "article_count": feed.article_count,
"health_status": feed.health_status(), "health_status": feed.health_status(),
"next_fetch_time": next_run.isoformat() if next_run else None,
"created_at": feed.created_at.isoformat(), "created_at": feed.created_at.isoformat(),
} }
results.append(data) results.append(data)
@@ -210,6 +226,24 @@ def delete_feed(feed_id: int, db: Session = Depends(get_db)):
return {"message": "RSS 源已删除"} return {"message": "RSS 源已删除"}
class BatchFetchRequest(BaseModel):
feed_ids: List[int]
@router.post("/batch-fetch")
def batch_fetch(data: BatchFetchRequest):
"""批量抓取(并发同步执行,等待结果返回)"""
results = fetch_all_feeds(data.feed_ids)
success = sum(1 for r in results if r.get("success"))
fail = len(results) - success
return {
"message": f"完成:{success} 个成功,{fail} 个失败",
"total": len(results),
"success": success,
"fail": fail,
}
@router.post("/{feed_id}/fetch") @router.post("/{feed_id}/fetch")
def trigger_fetch(feed_id: int, db: Session = Depends(get_db)): def trigger_fetch(feed_id: int, db: Session = Depends(get_db)):
"""手动触发抓取""" """手动触发抓取"""
+40 -5
View File
@@ -2,7 +2,7 @@
import time import time
import re import re
import html import html
from datetime import datetime, timezone from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin from urllib.parse import urljoin
import requests import requests
@@ -14,6 +14,39 @@ from database import SessionLocal
import config import config
def classify_error(error: str) -> str:
"""根据错误信息分类错误类型"""
if not error:
return ""
err = error.lower()
if "404" in error or "not found" in err:
return "url_invalid"
if "403" in error or "forbidden" in err:
return "forbidden"
if "429" in error or "too many request" in err:
return "rate_limited"
if "timeout" in err or "timed out" in err:
return "timeout"
if "connecttimeout" in err or "connectiontimeout" in err:
return "timeout"
if "could not resolve" in err or "name or service not known" in err or "nodename nor servname" in err:
return "dns_failure"
if "connection refused" in err:
return "connection_refused"
if "connection aborted" in err or "remotedisconnected" in err or "remote end closed" in err:
return "connection_reset"
if "ssl" in err or "certificate" in err or "certifi" in err:
return "ssl_error"
if "max retries" in err or "newconnectionerror" in err:
return "unreachable"
if "invalid url" in err or "no host" in err or "missing scheme" in err:
return "url_malformed"
if "5" in error and "server error" in err:
return "server_error"
return "unknown"
def fetch_feed(url: str, timeout: int = config.FETCH_TIMEOUT) -> dict: def fetch_feed(url: str, timeout: int = config.FETCH_TIMEOUT) -> dict:
"""抓取单个 RSS 源 """抓取单个 RSS 源
返回 {"success": bool, "feed_data": parsed, "error": str, "response_time_ms": int} 返回 {"success": bool, "feed_data": parsed, "error": str, "response_time_ms": int}
@@ -102,12 +135,12 @@ def parse_article(entry, feed_id: int) -> dict:
published_at = None published_at = None
if hasattr(entry, "published_parsed") and entry.published_parsed: if hasattr(entry, "published_parsed") and entry.published_parsed:
try: try:
published_at = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc) published_at = datetime(*entry.published_parsed[:6])
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
if not published_at and hasattr(entry, "updated_parsed") and entry.updated_parsed: if not published_at and hasattr(entry, "updated_parsed") and entry.updated_parsed:
try: try:
published_at = datetime(*entry.updated_parsed[:6], tzinfo=timezone.utc) published_at = datetime(*entry.updated_parsed[:6])
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -199,9 +232,10 @@ def fetch_and_store_feed(feed_id: int) -> dict:
if not result["success"]: if not result["success"]:
# 记录失败 # 记录失败
feed.last_fetch_at = datetime.now(timezone.utc) feed.last_fetch_at = datetime.utcnow()
feed.last_fetch_status = "fail" feed.last_fetch_status = "fail"
feed.last_error = result["error"] feed.last_error = result["error"]
feed.error_type = classify_error(result["error"])
feed.fail_count += 1 feed.fail_count += 1
log = FetchLog( log = FetchLog(
@@ -264,9 +298,10 @@ def fetch_and_store_feed(feed_id: int) -> dict:
existing.published_at = article_data["published_at"] existing.published_at = article_data["published_at"]
# 更新 feed 统计 # 更新 feed 统计
feed.last_fetch_at = datetime.now(timezone.utc) feed.last_fetch_at = datetime.utcnow()
feed.last_fetch_status = "success" feed.last_fetch_status = "success"
feed.last_error = "" feed.last_error = ""
feed.error_type = ""
feed.success_count += 1 feed.success_count += 1
feed.article_count += new_count feed.article_count += new_count
+9
View File
@@ -65,6 +65,15 @@ def stop_scheduler():
_scheduler = None _scheduler = None
def get_feed_next_run(feed_id: int):
"""获取指定 RSS 源的下一次抓取时间"""
scheduler = get_scheduler()
if not scheduler.running:
return None
job = scheduler.get_job(f"fetch_feed_{feed_id}")
return job.next_run_time if job else None
def init_feed_jobs(db): def init_feed_jobs(db):
"""从数据库加载所有活跃 RSS 源并注册定时任务""" """从数据库加载所有活跃 RSS 源并注册定时任务"""
from models import Feed from models import Feed
+1
View File
@@ -32,6 +32,7 @@ export const feedsApi = {
update: (id, data) => api.put(`/api/feeds/${id}`, data), update: (id, data) => api.put(`/api/feeds/${id}`, data),
remove: (id) => api.delete(`/api/feeds/${id}`), remove: (id) => api.delete(`/api/feeds/${id}`),
fetch: (id) => api.post(`/api/feeds/${id}/fetch`), fetch: (id) => api.post(`/api/feeds/${id}/fetch`),
batchFetch: (ids) => api.post('/api/feeds/batch-fetch', { feed_ids: ids }, { timeout: 300000 }),
discover: (url) => api.post('/api/feeds/discover', null, { params: { url } }), discover: (url) => api.post('/api/feeds/discover', null, { params: { url } }),
importOpml: (content) => api.post('/api/feeds/import-opml', { opml_content: content }), importOpml: (content) => api.post('/api/feeds/import-opml', { opml_content: content }),
exportOpml: () => api.get('/api/feeds/export-opml'), exportOpml: () => api.get('/api/feeds/export-opml'),
+331 -71
View File
@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="dashboard-page">
<h1 class="page-title">📊 仪表盘</h1> <h1 class="page-title">📊 仪表盘</h1>
<!-- 统计卡片 --> <!-- 统计卡片 -->
@@ -15,88 +15,88 @@
<!-- 健康度概览 + 最近活动 --> <!-- 健康度概览 + 最近活动 -->
<el-row :gutter="20" style="margin-top: 20px;"> <el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="14"> <el-col :span="14">
<el-card shadow="hover"> <div class="dark-card">
<template #header> <div class="dark-card-header">
<div style="display: flex; justify-content: space-between; align-items: center;"> <span>🩺 RSS 源健康度</span>
<span>🩺 RSS 源健康度</span> <router-link to="/feeds" class="view-all-link">查看全部</router-link>
<el-button text size="small" @click="$router.push('/feeds')">查看全部</el-button> </div>
</div> <div class="dark-table-wrap" v-loading="loadingHealth">
</template> <table class="dark-table">
<el-table :data="healthData" size="small" v-loading="loadingHealth"> <thead>
<el-table-column prop="title" label="源名称" min-width="200" show-overflow-tooltip> <tr>
<template #default="scope"> <th>源名称</th>
<el-link :href="scope.row.url" target="_blank" type="primary">{{ scope.row.title }}</el-link> <th>状态</th>
</template> <th>错误</th>
</el-table-column> <th>成功率</th>
<el-table-column prop="health_label" label="状态" width="80"> <th>文章</th>
<template #default="scope"> <th>未更新</th>
<el-tag :type="healthTagType(scope.row.health_status)" size="small"> </tr>
{{ scope.row.health_label }} </thead>
</el-tag> <tbody>
</template> <tr v-for="feed in healthData" :key="feed.id">
</el-table-column> <td>
<el-table-column prop="success_rate" label="成功率" width="90"> <a :href="feed.url" target="_blank" class="feed-link">{{ feed.title }}</a>
<template #default="scope"> </td>
{{ scope.row.success_rate }}% <td>
</template> <span :class="['health-dot', `health-${feed.health_status}`]"></span>
</el-table-column> {{ feed.health_label }}
<el-table-column prop="article_count" label="文章数" width="80" /> </td>
<el-table-column prop="days_since_fetch" label="未更新(天)" width="100"> <td>
<template #default="scope"> <span v-if="feed.last_error" class="err-tag" :class="'err-' + classifyError(feed.last_error)">{{ errorLabel(classifyError(feed.last_error)) }}</span>
{{ scope.row.days_since_fetch !== null ? scope.row.days_since_fetch : '-' }} <span v-else class="muted">-</span>
</template> </td>
</el-table-column> <td class="td-center">{{ feed.success_rate }}%</td>
</el-table> <td class="td-center">{{ feed.article_count }}</td>
</el-card> <td class="td-center">{{ feed.days_since_fetch !== null ? feed.days_since_fetch + '天' : '-' }}</td>
</tr>
<tr v-if="!healthData.length && !loadingHealth">
<td colspan="6" class="empty-row">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</el-col> </el-col>
<el-col :span="10"> <el-col :span="10">
<el-card shadow="hover"> <div class="dark-card">
<template #header> <div class="dark-card-header">
<span>📋 最近抓取活动</span> <span>📋 最近抓取活动</span>
</template> </div>
<el-timeline v-loading="loadingActivity"> <div class="activity-list" v-loading="loadingActivity">
<el-timeline-item <div v-for="log in recentActivity" :key="log.id" class="activity-item">
v-for="log in recentActivity" <div class="activity-row">
:key="log.id" <strong class="activity-title">{{ log.feed_title }}</strong>
:type="log.status === 'success' ? 'success' : 'danger'" <span :class="['status-badge', log.status === 'success' ? 'status-success' : 'status-fail']">
: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' ? '成功' : '失败' }} {{ log.status === 'success' ? '成功' : '失败' }}
</el-tag> </span>
</div> </div>
<div style="font-size: 12px; color: #a0aec0; margin-top: 4px;"> <div class="activity-detail">
{{ log.status === 'success' ? `获取 ${log.articles_fetched} 篇文章` : log.error_message }} {{ log.status === 'success' ? `获取 ${log.articles_fetched} 篇文章` : log.error_message }}
· {{ formatTime(log.created_at) }} · {{ formatTime(log.created_at) }}
</div> </div>
</el-timeline-item> </div>
</el-timeline> <div v-if="!recentActivity.length && !loadingActivity" class="empty-row">暂无数据</div>
</el-card> </div>
</div>
</el-col> </el-col>
</el-row> </el-row>
<!-- 分类分布 --> <!-- 分类分布 -->
<el-row style="margin-top: 20px;"> <el-row style="margin-top: 20px;">
<el-col :span="24"> <el-col :span="24">
<el-card shadow="hover"> <div class="dark-card">
<template #header> <div class="dark-card-header">
<span>📂 分类分布</span> <span>📂 分类分布</span>
</template>
<div v-if="categoryStats.length === 0" style="text-align: center; padding: 40px; color: #a0aec0;">
暂无数据
</div> </div>
<el-row :gutter="20" v-else> <div class="category-grid" v-if="categoryStats.length">
<el-col :span="4" v-for="cat in categoryStats" :key="cat.name"> <div v-for="cat in categoryStats" :key="cat.name" class="category-item">
<div style="text-align: center; padding: 16px;"> <div class="category-count">{{ cat.count }}</div>
<div style="font-size: 24px; font-weight: bold; color: #63b3ed;">{{ cat.count }}</div> <div class="category-name">{{ cat.name || '未分类' }}</div>
<div style="font-size: 14px; color: #a0aec0; margin-top: 4px;">{{ cat.name || '未分类' }}</div> </div>
</div> </div>
</el-col> <div v-else class="empty-row">暂无数据</div>
</el-row> </div>
</el-card>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
@@ -120,17 +120,39 @@ const statsCards = computed(() => [
{ key: 'today', label: '今日抓取', value: stats.value.today_fetches || 0, color: '#f6ad55' }, { 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) => { const formatTime = (iso) => {
if (!iso) return '-' if (!iso) return '-'
const d = new Date(iso) const d = new Date(iso)
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
} }
const classifyError = (error) => {
if (!error) return ''
const e = error.toLowerCase()
if (e.includes('404') || e.includes('not found')) return 'url_invalid'
if (e.includes('403') || e.includes('forbidden')) return 'forbidden'
if (e.includes('429') || e.includes('too many')) return 'rate_limited'
if (e.includes('timeout') || e.includes('timed out')) return 'timeout'
if (e.includes('connecttimeout')) return 'timeout'
if (e.includes('could not resolve') || e.includes('name or service')) return 'dns_failure'
if (e.includes('connection refused')) return 'connection_refused'
if (e.includes('connection aborted') || e.includes('remotedisconnected')) return 'connection_reset'
if (e.includes('ssl') || e.includes('certificate')) return 'ssl_error'
if (e.includes('max retries') || e.includes('newconnectionerror')) return 'unreachable'
if (e.includes('invalid url') || e.includes('missing scheme')) return 'url_malformed'
return 'unknown'
}
const errorLabel = (type) => {
const map = {
url_invalid: 'URL失效', forbidden: '被拒绝', rate_limited: '频率限制',
timeout: '超时', dns_failure: 'DNS失败', connection_refused: '连接拒绝',
connection_reset: '连接中断', ssl_error: 'SSL错误', unreachable: '不可达',
url_malformed: 'URL错误', unknown: '其他',
}
return map[type] || type
}
const loadStats = async () => { const loadStats = async () => {
try { try {
stats.value = await dashboardApi.stats() stats.value = await dashboardApi.stats()
@@ -186,7 +208,245 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.dashboard-page {
--dark-bg: #1a1f2e;
--dark-bg-alt: #161b28;
--dark-bg-header: #12162a;
--dark-hover: #222840;
--text-primary: #e8ecf4;
--text-secondary: #8b95a8;
--accent: #4f8ff7;
--border-color: #2a3048;
--health-healthy: #34d399;
--health-warning: #fbbf24;
--health-unhealthy: #f87171;
--health-unknown: #6b7280;
}
.stats-row { .stats-row {
margin-bottom: 10px; margin-bottom: 10px;
} }
.stat-card {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
}
.stat-label {
font-size: 13px;
color: #a0aec0;
margin-top: 4px;
}
/* 深色卡片 */
.dark-card {
background: var(--dark-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.dark-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
background: var(--dark-bg-header);
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.view-all-link {
color: var(--accent);
font-size: 12px;
text-decoration: none;
font-weight: 400;
}
.view-all-link:hover {
text-decoration: underline;
}
/* 深色表格 */
.dark-table {
width: 100%;
border-collapse: collapse;
}
.dark-table thead tr {
background: var(--dark-bg-header);
}
.dark-table th {
padding: 10px 14px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border-color);
}
.dark-table td {
padding: 9px 14px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid rgba(42, 48, 72, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dark-table tbody tr {
background: var(--dark-bg);
transition: background 0.15s;
}
.dark-table tbody tr:nth-child(even) {
background: var(--dark-bg-alt);
}
.dark-table tbody tr:hover {
background: var(--dark-hover) !important;
}
.td-center {
text-align: center;
}
.feed-link {
color: var(--accent);
text-decoration: none;
font-weight: 500;
}
.feed-link:hover {
color: #6fa1ff;
text-decoration: underline;
}
/* 健康度指示点 */
.health-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.health-dot.health-healthy { background: var(--health-healthy); box-shadow: 0 0 6px rgba(52, 211, 153, 0.4); }
.health-dot.health-warning { background: var(--health-warning); box-shadow: 0 0 6px rgba(251, 191, 36, 0.4); }
.health-dot.health-unhealthy { background: var(--health-unhealthy); box-shadow: 0 0 6px rgba(248, 113, 113, 0.4); }
.health-dot.health-unknown { background: var(--health-unknown); }
/* 活动列表 */
.activity-list {
padding: 8px 0;
max-height: 380px;
overflow-y: auto;
}
.activity-item {
padding: 10px 18px;
border-bottom: 1px solid rgba(42, 48, 72, 0.4);
}
.activity-item:last-child {
border-bottom: none;
}
.activity-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.activity-title {
color: var(--text-primary);
font-size: 13px;
}
.status-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
}
.status-success {
color: var(--health-healthy);
background: rgba(52, 211, 153, 0.12);
}
.status-fail {
color: var(--health-unhealthy);
background: rgba(248, 113, 113, 0.12);
}
.activity-detail {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
/* 分类分布 */
.category-grid {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 16px;
}
.category-item {
text-align: center;
padding: 16px 20px;
min-width: 100px;
flex: 1;
}
.category-count {
font-size: 24px;
font-weight: 700;
color: #63b3ed;
}
.category-name {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.empty-row {
text-align: center;
color: var(--text-secondary);
padding: 40px;
font-size: 14px;
}
/* 错误标签 */
.err-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
}
.err-url_invalid { color: #f87171; background: rgba(248,113,113,0.12); }
.err-forbidden { color: #fb923c; background: rgba(251,146,60,0.12); }
.err-rate_limited { color: #fbbf24; background: rgba(251,191,36,0.12); }
.err-timeout { color: #a78bfa; background: rgba(167,139,250,0.12); }
.err-dns_failure { color: #f87171; background: rgba(248,113,113,0.12); }
.err-connection_refused { color: #fb923c; background: rgba(251,146,60,0.12); }
.err-connection_reset { color: #fb923c; background: rgba(251,146,60,0.12); }
.err-ssl_error { color: #f87171; background: rgba(248,113,113,0.12); }
.err-unreachable { color: #a78bfa; background: rgba(167,139,250,0.12); }
.err-url_malformed { color: #f87171; background: rgba(248,113,113,0.12); }
.err-unknown { color: #6b7280; background: rgba(107,114,128,0.12); }
</style> </style>
+367 -77
View File
@@ -1,8 +1,8 @@
<template> <template>
<div> <div class="feeds-page">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div class="feeds-header">
<h1 class="page-title">📡 RSS 源管理</h1> <h1 class="page-title">📡 RSS 源管理</h1>
<div> <div class="header-actions">
<el-button type="primary" @click="showAddDialog = true" :icon="Plus">添加源</el-button> <el-button type="primary" @click="showAddDialog = true" :icon="Plus">添加源</el-button>
<el-button @click="showImportDialog = true" :icon="Upload">导入 OPML</el-button> <el-button @click="showImportDialog = true" :icon="Upload">导入 OPML</el-button>
<el-button @click="handleExport" :icon="Download">导出 OPML</el-button> <el-button @click="handleExport" :icon="Download">导出 OPML</el-button>
@@ -10,74 +10,87 @@
</div> </div>
<!-- 筛选栏 --> <!-- 筛选栏 -->
<el-row :gutter="10" style="margin-bottom: 16px;"> <div class="filter-bar">
<el-col :span="6"> <el-input v-model="searchQuery" placeholder="搜索源名称或 URL" clearable @change="loadFeeds" :prefix-icon="Search" class="filter-input" />
<el-input v-model="searchQuery" placeholder="搜索源名称或 URL" clearable @change="loadFeeds" :prefix-icon="Search" /> <el-select v-model="filterCategory" placeholder="分类" clearable @change="loadFeeds" class="filter-select">
</el-col> <el-option v-for="cat in categories" :key="cat" :label="cat || '未分类'" :value="cat" />
<el-col :span="4"> </el-select>
<el-select v-model="filterCategory" placeholder="分类筛选" clearable @change="loadFeeds"> <el-select v-model="filterStatus" placeholder="状态" clearable @change="loadFeeds" class="filter-select">
<el-option v-for="cat in categories" :key="cat" :label="cat || '未分类'" :value="cat" /> <el-option label="启用" value="active" />
</el-select> <el-option label="禁用" value="inactive" />
</el-col> </el-select>
<el-col :span="4"> <el-select v-model="filterHealth" placeholder="健康度" clearable @change="loadFeeds" class="filter-select">
<el-select v-model="filterStatus" placeholder="状态筛选" clearable @change="loadFeeds"> <el-option label="健康" value="healthy" />
<el-option label="启用" value="active" /> <el-option label="警告" value="warning" />
<el-option label="禁用" value="inactive" /> <el-option label="异常" value="unhealthy" />
</el-select> <el-option label="未知" value="unknown" />
</el-col> </el-select>
<el-col :span="6" style="display: flex; gap: 8px;"> <span class="total-badge" v-if="stats.total">{{ stats.total }} 个源</span>
<el-tag v-if="stats.total" type="info"> {{ stats.total }} 个源</el-tag> <el-button type="warning" @click="handleBatchFetch" :loading="batchFetching" :icon="Refresh">全部抓取</el-button>
</el-col> </div>
</el-row>
<!-- 源列表 --> <!-- 源列表 -->
<el-card shadow="hover"> <div class="feeds-table-wrap">
<el-table :data="feeds" v-loading="loading" size="small"> <table class="feeds-table" v-loading="loading">
<el-table-column type="index" width="50" /> <thead>
<el-table-column prop="title" label="源名称" min-width="200" show-overflow-tooltip> <tr>
<template #default="scope"> <th class="col-idx">#</th>
<el-link :href="scope.row.url" target="_blank" type="primary">{{ scope.row.title || scope.row.url }}</el-link> <th class="col-title">源名称</th>
</template> <th class="col-cat">分类</th>
</el-table-column> <th class="col-health">健康度</th>
<el-table-column prop="category" label="分类" width="120"> <th class="col-err">错误类型</th>
<template #default="scope"> <th class="col-num">文章</th>
<el-tag v-if="scope.row.category" size="small" type="info">{{ scope.row.category }}</el-tag> <th class="col-time">最后抓取</th>
<span v-else style="color: #718096;">-</span> <th class="col-time">下次抓取</th>
</template> <th class="col-num">间隔</th>
</el-table-column> <th class="col-actions">操作</th>
<el-table-column prop="health_status" label="健康度" width="100"> </tr>
<template #default="scope"> </thead>
<el-tag :type="healthTagType(scope.row.health_status)" size="small"> <tbody>
{{ healthLabel(scope.row.health_status) }} <tr v-for="(feed, idx) in feeds" :key="feed.id" class="feed-row">
</el-tag> <td class="col-idx">{{ (page - 1) * pageSize + idx + 1 }}</td>
</template> <td class="col-title">
</el-table-column> <a :href="feed.url" target="_blank" class="feed-link">{{ feed.title || feed.url }}</a>
<el-table-column prop="article_count" label="文章数" width="80" /> </td>
<el-table-column prop="last_fetch_at" label="最后抓取" width="160"> <td class="col-cat">
<template #default="scope"> <span class="cat-tag" v-if="feed.category">{{ feed.category }}</span>
{{ formatTime(scope.row.last_fetch_at) }} <span v-else class="muted">-</span>
</template> </td>
</el-table-column> <td class="col-health">
<el-table-column prop="fetch_interval_minutes" label="间隔(分)" width="90" /> <span :class="['health-dot', `health-${feed.health_status}`]"></span>
<el-table-column label="操作" width="200" fixed="right"> {{ healthLabel(feed.health_status) }}
<template #default="scope"> </td>
<el-button link type="primary" size="small" @click="triggerFetch(scope.row.id)" :icon="Refresh">抓取</el-button> <td class="col-err">
<el-button link type="primary" size="small" @click="editFeed(scope.row)" :icon="Edit">编辑</el-button> <span v-if="feed.error_type" class="err-tag" :class="'err-' + feed.error_type">{{ errorLabel(feed.error_type) }}</span>
<el-button link type="danger" size="small" @click="deleteFeed(scope.row.id)" :icon="Delete">删除</el-button> <span v-else class="muted">-</span>
</template> </td>
</el-table-column> <td class="col-num">{{ feed.article_count }}</td>
</el-table> <td class="col-time">{{ formatTime(feed.last_fetch_at) }}</td>
<td class="col-time">{{ formatTime(feed.next_fetch_time) }}</td>
<td class="col-num">{{ feed.fetch_interval_minutes }}</td>
<td class="col-actions">
<button class="action-btn action-fetch" @click="triggerFetch(feed.id)" title="抓取">抓取</button>
<button class="action-btn action-edit" @click="editFeed(feed)" title="编辑">编辑</button>
<button class="action-btn action-delete" @click="deleteFeed(feed.id)" title="删除">删除</button>
</td>
</tr>
<tr v-if="!feeds.length && !loading">
<td colspan="10" class="empty-row">暂无数据</td>
</tr>
</tbody>
</table>
<el-pagination <div class="pagination-wrap">
v-model:current-page="page" <el-pagination
v-model:page-size="pageSize" v-model:current-page="page"
:total="stats.total" v-model:page-size="pageSize"
:page-sizes="[20, 50, 100]" :total="stats.total"
layout="total, sizes, prev, pager, next" :page-sizes="[20, 50, 100]"
@change="loadFeeds" layout="total, sizes, prev, pager, next"
style="margin-top: 16px; justify-content: flex-end;" @change="loadFeeds"
/> />
</el-card> </div>
</div>
<!-- 添加源对话框 --> <!-- 添加源对话框 -->
<el-dialog v-model="showAddDialog" title="添加 RSS 源" width="600px"> <el-dialog v-model="showAddDialog" title="添加 RSS 源" width="600px">
@@ -169,39 +182,50 @@ const pageSize = ref(20)
const searchQuery = ref('') const searchQuery = ref('')
const filterCategory = ref('') const filterCategory = ref('')
const filterStatus = ref('') const filterStatus = ref('')
const filterHealth = ref('')
const categories = ref([]) const categories = ref([])
// 添加对话框
const showAddDialog = ref(false) const showAddDialog = ref(false)
const addTab = ref('direct') const addTab = ref('direct')
const adding = ref(false) const adding = ref(false)
const newFeed = ref({ url: '', title: '', category: '', fetch_interval_minutes: 60 }) const newFeed = ref({ url: '', title: '', category: '', fetch_interval_minutes: 60 })
// 自动发现
const discoverUrl = ref('') const discoverUrl = ref('')
const discovering = ref(false) const discovering = ref(false)
const discoveredFeeds = ref([]) const discoveredFeeds = ref([])
// 编辑
const showEditDialog = ref(false) const showEditDialog = ref(false)
const editing = ref(false) const editing = ref(false)
const editForm = ref({ id: null, title: '', category: '', fetch_interval_minutes: 60, is_active: true }) const editForm = ref({ id: null, title: '', category: '', fetch_interval_minutes: 60, is_active: true })
// 导入
const showImportDialog = ref(false) const showImportDialog = ref(false)
const importing = ref(false) const importing = ref(false)
const opmlContent = ref('') const opmlContent = ref('')
const batchFetching = ref(false)
const healthTagType = (status) => {
const map = { healthy: 'success', warning: 'warning', unhealthy: 'danger', unknown: 'info' }
return map[status] || 'info'
}
const healthLabel = (status) => { const healthLabel = (status) => {
const map = { healthy: '健康', warning: '警告', unhealthy: '异常', unknown: '未知' } const map = { healthy: '健康', warning: '警告', unhealthy: '异常', unknown: '未知' }
return map[status] || '未知' return map[status] || '未知'
} }
const errorLabel = (type) => {
const map = {
url_invalid: 'URL失效',
forbidden: '被拒绝',
rate_limited: '频率限制',
timeout: '超时',
dns_failure: 'DNS失败',
connection_refused: '连接拒绝',
connection_reset: '连接中断',
ssl_error: 'SSL错误',
unreachable: '不可达',
url_malformed: 'URL错误',
server_error: '服务器错误',
unknown: '其他错误',
}
return map[type] || type
}
const formatTime = (iso) => { const formatTime = (iso) => {
if (!iso) return '-' if (!iso) return '-'
const d = new Date(iso) const d = new Date(iso)
@@ -216,6 +240,7 @@ const loadFeeds = async () => {
limit: pageSize.value, limit: pageSize.value,
search: searchQuery.value || undefined, search: searchQuery.value || undefined,
category: filterCategory.value || undefined, category: filterCategory.value || undefined,
health_status: filterHealth.value || undefined,
} }
if (filterStatus.value === 'active') params.is_active = true if (filterStatus.value === 'active') params.is_active = true
if (filterStatus.value === 'inactive') params.is_active = false if (filterStatus.value === 'inactive') params.is_active = false
@@ -330,6 +355,38 @@ const triggerFetch = async (id) => {
} }
} }
const handleBatchFetch = async () => {
try {
// 用当前筛选条件获取全部匹配源的 ID
const filterParams = {
skip: 0,
limit: 10000,
search: searchQuery.value || undefined,
category: filterCategory.value || undefined,
health_status: filterHealth.value || undefined,
}
if (filterStatus.value === 'active') filterParams.is_active = true
if (filterStatus.value === 'inactive') filterParams.is_active = false
const allRes = await feedsApi.list(filterParams)
const ids = (allRes.items || []).map(f => f.id)
if (!ids.length) {
ElMessage.warning('当前筛选条件下无源可抓取')
return
}
await ElMessageBox.confirm(`将对筛选出的 ${ids.length} 个源发起抓取,抓取过程中请耐心等待。`, '批量抓取', { type: 'info' })
batchFetching.value = true
const res = await feedsApi.batchFetch(ids)
ElMessage.success(res.message)
loadFeeds()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e.message)
} finally {
batchFetching.value = false
}
}
const handleImport = async () => { const handleImport = async () => {
if (!opmlContent.value.trim()) return if (!opmlContent.value.trim()) return
importing.value = true importing.value = true
@@ -367,3 +424,236 @@ onMounted(() => {
loadCategories() loadCategories()
}) })
</script> </script>
<style scoped>
.feeds-page {
--row-bg: #1a1f2e;
--row-bg-alt: #161b28;
--row-hover: #222840;
--text-primary: #e8ecf4;
--text-secondary: #8b95a8;
--accent: #4f8ff7;
--accent-hover: #6fa1ff;
--border-color: #2a3048;
--health-healthy: #34d399;
--health-warning: #fbbf24;
--health-unhealthy: #f87171;
--health-unknown: #6b7280;
}
.feeds-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-actions {
display: flex;
gap: 8px;
}
/* 筛选栏 */
.filter-bar {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-input {
width: 240px;
}
.filter-select {
width: 120px;
}
.total-badge {
color: var(--text-secondary);
font-size: 13px;
background: rgba(79, 143, 247, 0.12);
padding: 4px 12px;
border-radius: 12px;
white-space: nowrap;
}
/* 表格容器 */
.feeds-table-wrap {
background: var(--row-bg);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
/* 表格 */
.feeds-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.feeds-table thead tr {
background: #12162a;
}
.feeds-table th {
padding: 12px 14px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border-color);
}
.feeds-table td {
padding: 10px 14px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid rgba(42, 48, 72, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-row {
background: var(--row-bg);
transition: background 0.15s ease;
}
.feed-row:nth-child(even) {
background: var(--row-bg-alt);
}
.feed-row:hover {
background: var(--row-hover) !important;
}
/* 列宽 */
.col-idx { width: 44px; text-align: center; }
.col-title { width: auto; }
.col-cat { width: 100px; }
.col-health { width: 90px; }
.col-err { width: 90px; }
.col-num { width: 65px; text-align: center; }
.col-time { width: 150px; }
.col-actions { width: 160px; text-align: center; }
/* 源名称链接 */
.feed-link {
color: var(--accent);
text-decoration: none;
font-weight: 500;
transition: color 0.15s;
}
.feed-link:hover {
color: var(--accent-hover);
text-decoration: underline;
}
/* 分类标签 */
.cat-tag {
display: inline-block;
background: rgba(79, 143, 247, 0.15);
color: #93b5f5;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
}
.muted {
color: var(--text-secondary);
}
/* 健康度指示 */
.health-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.health-dot.health-healthy { background: var(--health-healthy); box-shadow: 0 0 6px rgba(52, 211, 153, 0.4); }
.health-dot.health-warning { background: var(--health-warning); box-shadow: 0 0 6px rgba(251, 191, 36, 0.4); }
.health-dot.health-unhealthy { background: var(--health-unhealthy); box-shadow: 0 0 6px rgba(248, 113, 113, 0.4); }
.health-dot.health-unknown { background: var(--health-unknown); }
/* 错误类型标签 */
.err-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
}
.err-url_invalid { color: #f87171; background: rgba(248, 113, 113, 0.12); }
.err-forbidden { color: #fb923c; background: rgba(251, 146, 60, 0.12); }
.err-rate_limited { color: #fbbf24; background: rgba(251, 191, 36, 0.12); }
.err-timeout { color: #a78bfa; background: rgba(167, 139, 250, 0.12); }
.err-dns_failure { color: #f87171; background: rgba(248, 113, 113, 0.12); }
.err-connection_refused { color: #fb923c; background: rgba(251, 146, 60, 0.12); }
.err-connection_reset { color: #fb923c; background: rgba(251, 146, 60, 0.12); }
.err-ssl_error { color: #f87171; background: rgba(248, 113, 113, 0.12); }
.err-unreachable { color: #a78bfa; background: rgba(167, 139, 250, 0.12); }
.err-url_malformed { color: #f87171; background: rgba(248, 113, 113, 0.12); }
.err-server_error { color: #fb923c; background: rgba(251, 146, 60, 0.12); }
.err-unknown { color: #6b7280; background: rgba(107, 114, 128, 0.12); }
/* 操作按钮 */
.action-btn {
background: none;
border: 1px solid transparent;
color: var(--text-secondary);
padding: 3px 10px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
border-color: var(--border-color);
}
.action-fetch:hover {
color: var(--accent);
background: rgba(79, 143, 247, 0.1);
border-color: rgba(79, 143, 247, 0.3);
}
.action-edit:hover {
color: #a78bfa;
background: rgba(167, 139, 250, 0.1);
border-color: rgba(167, 139, 250, 0.3);
}
.action-delete:hover {
color: var(--health-unhealthy);
background: rgba(248, 113, 113, 0.1);
border-color: rgba(248, 113, 113, 0.3);
}
/* 空数据 */
.empty-row {
text-align: center;
color: var(--text-secondary);
padding: 40px !important;
font-size: 14px;
}
/* 分页 */
.pagination-wrap {
padding: 14px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--border-color);
background: #12162a;
}
</style>