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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user