feat: 代理支持、外部API增强、调度器修复、每日文章看板

- 添加 HTTP 代理支持(国内直连、外网走代理)
- 外部 API 新增全文搜索、源健康度/错误筛选、未读筛选
- 修复 APScheduler 线程静默崩溃(_safe_fetch 异常保护)
- 健康检查暴露调度器状态
- Dashboard 新增每日文章数柱状图(按 published_at)
- 文章列表 API 补上 content 字段,日期筛选修复时间范围
- 修复外部 API 双重 external 前缀
- User-Agent 改为 Chrome 标识缓解 403
- 添加完整 API 接口文档

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-06-12 09:58:32 +08:00
parent 68bba3d9e0
commit 4286731348
12 changed files with 1057 additions and 44 deletions
+2
View File
@@ -43,6 +43,7 @@ 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 } }),
searchFulltext: (params = {}) => api.get('/api/articles/search/fulltext', { params }),
markRead: (id) => api.put(`/api/articles/${id}/read`),
}
@@ -51,6 +52,7 @@ export const dashboardApi = {
stats: () => api.get('/api/dashboard/stats'),
health: (params = {}) => api.get('/api/dashboard/health', { params }),
recentActivity: () => api.get('/api/dashboard/recent-activity'),
articlesDaily: (params = {}) => api.get('/api/dashboard/articles-daily', { params }),
}
// 对外 API
+6 -3
View File
@@ -118,13 +118,16 @@ const loadArticles = async () => {
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]
params.since = dateRange.value[0] + 'T00:00:00'
params.until = dateRange.value[1] + 'T23:59:59'
}
// 如果有搜索词,使用全文搜索
if (searchQuery.value && searchQuery.value.trim()) {
const res = await articlesApi.search(searchQuery.value.trim())
const searchParams = { q: searchQuery.value.trim(), limit: pageSize.value, offset: (page.value - 1) * pageSize.value }
if (filterFeed.value) searchParams.feed_id = filterFeed.value
if (filterCategory.value) searchParams.category = filterCategory.value
const res = await articlesApi.searchFulltext(searchParams)
articles.value = res.items || []
stats.value = { total: res.total }
} else {
+90
View File
@@ -82,6 +82,26 @@
</el-col>
</el-row>
<!-- 每日文章统计 -->
<el-row style="margin-top: 20px;">
<el-col :span="24">
<div class="dark-card">
<div class="dark-card-header">
<span>📈 每日文章数按发布日期</span>
</div>
<div class="daily-chart" v-loading="loadingDaily">
<div v-for="d in dailyData" :key="d.date" class="daily-bar-wrap">
<div class="daily-bar" :style="{ height: barHeight(d.count) + 'px' }">
<span class="daily-count">{{ d.count }}</span>
</div>
<div class="daily-date">{{ shortDate(d.date) }}</div>
</div>
<div v-if="!dailyData.length && !loadingDaily" class="empty-row">暂无数据</div>
</div>
</div>
</el-col>
</el-row>
<!-- 分类分布 -->
<el-row style="margin-top: 20px;">
<el-col :span="24">
@@ -112,6 +132,8 @@ const recentActivity = ref([])
const loadingHealth = ref(false)
const loadingActivity = ref(false)
const categoryStats = ref([])
const dailyData = ref([])
const loadingDaily = ref(false)
const statsCards = computed(() => [
{ key: 'feeds', label: 'RSS 源总数', value: stats.value.total_feeds || 0, color: '#63b3ed' },
@@ -185,6 +207,29 @@ const loadActivity = async () => {
}
}
const loadDaily = async () => {
loadingDaily.value = true
try {
const res = await dashboardApi.articlesDaily({ days: 14 })
dailyData.value = (res.data || []).reverse()
} catch (e) {
console.error('加载每日统计失败', e)
} finally {
loadingDaily.value = false
}
}
const barHeight = (count) => {
const max = Math.max(...dailyData.value.map(d => d.count), 1)
return Math.max(4, Math.round((count / max) * 120))
}
const shortDate = (date) => {
if (!date) return ''
const d = new Date(date)
return `${d.getMonth() + 1}/${d.getDate()}`
}
const loadCategories = async () => {
try {
const feeds = await feedsApi.list({ limit: 1000 })
@@ -203,6 +248,7 @@ onMounted(() => {
loadStats()
loadHealth()
loadActivity()
loadDaily()
loadCategories()
})
</script>
@@ -250,6 +296,50 @@ onMounted(() => {
overflow: hidden;
}
/* 每日文章柱状图 */
.daily-chart {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 20px 16px 8px;
height: 200px;
overflow-x: auto;
}
.daily-bar-wrap {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 32px;
}
.daily-bar {
width: 100%;
max-width: 40px;
background: linear-gradient(180deg, #63b3ed, #3182ce);
border-radius: 3px 3px 0 0;
position: relative;
min-height: 4px;
}
.daily-count {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.daily-date {
font-size: 10px;
color: var(--text-secondary);
margin-top: 4px;
white-space: nowrap;
}
.dark-card-header {
display: flex;
justify-content: space-between;