- backend: FastAPI + SQLAlchemy 2.0(async) + asyncpg + Alembic - 7 API routes: auth/me/articles/sources/bookmarks/subscriptions/admin - models: User/Source/Article/Bookmark/Subscription/ApiToken - services: RSS fetcher (feedparser) + Tencent TMT translator with quota + cache + local NLLB fallback - workers: APScheduler + asyncio pipeline (fetch -> dedupe -> insert -> translate) - seed scripts: create_user, seed_sources (5 RSS: Reuters/BBC/Al Jazeera/NHK/DW) - frontend: Vue 3 + Vite + Naive UI + Pinia + vue-router - pages: Login, Feed (24h), ArticleDetail, Sources, Bookmarks, AdminSources - deploy: docker-compose (postgres/redis/api/worker/frontend/caddy) - docs: README, DEPLOY, architecture, acceptance
81 lines
2.6 KiB
Vue
81 lines
2.6 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref } from 'vue'
|
|
import { NCard, NDataTable, NTag, NSpace, NText, NButton, useMessage } from 'naive-ui'
|
|
import { sourcesApi, adminApi, type Source } from '@/api/articles'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import dayjs from 'dayjs'
|
|
|
|
const auth = useAuthStore()
|
|
const message = useMessage()
|
|
const sources = ref<Source[]>([])
|
|
const health = ref<any[]>([])
|
|
const loading = ref(false)
|
|
|
|
async function load() {
|
|
sources.value = await sourcesApi.list()
|
|
if (auth.isOwner) {
|
|
try { health.value = await adminApi.health() } catch { /* noop */ }
|
|
}
|
|
}
|
|
|
|
async function triggerRefresh(id: number) {
|
|
try {
|
|
await adminApi.refresh(id)
|
|
message.success('已加入抓取队列')
|
|
} catch (e: any) {
|
|
message.error(e?.response?.data?.title || '触发失败')
|
|
}
|
|
}
|
|
|
|
function fmtTime(s?: string | null) {
|
|
if (!s) return '—'
|
|
return dayjs(s).format('YYYY-MM-DD HH:mm')
|
|
}
|
|
|
|
const columns = [
|
|
{ title: '名称', key: 'name' },
|
|
{ title: '源', key: 'slug' },
|
|
{ title: '地区', key: 'region' },
|
|
{ title: '优先级', key: 'priority' },
|
|
{
|
|
title: '状态', key: 'enabled',
|
|
render: (row: Source) => h(NTag, { type: row.enabled ? 'success' : 'default', size: 'small' }, () => row.enabled ? '启用' : '停用'),
|
|
},
|
|
{ title: '抓取间隔', key: 'fetch_interval_min', render: (r: Source) => `${r.fetch_interval_min}m` },
|
|
{ title: '上次抓取', key: 'last_fetched_at', render: (r: Source) => fmtTime(r.last_fetched_at) },
|
|
{ title: '上次状态', key: 'last_status' },
|
|
{
|
|
title: '操作', key: 'action', width: 120,
|
|
render: (row: Source) => h(NSpace, {}, () => [
|
|
auth.isOwner && row.enabled
|
|
? h(NButton, { size: 'small', onClick: () => triggerRefresh(row.id) }, () => '立即抓取')
|
|
: null,
|
|
]),
|
|
},
|
|
]
|
|
|
|
import { h } from 'vue'
|
|
|
|
const healthColumns = [
|
|
{ title: '源', key: 'slug' },
|
|
{ title: '24h 新增', key: 'article_count_24h' },
|
|
{ title: '连续失败', key: 'consecutive_failures' },
|
|
{ title: '当前间隔', key: 'fetch_interval_min', render: (r: any) => `${r.fetch_interval_min}m` },
|
|
{ title: '上次', key: 'last_fetched_at', render: (r: any) => fmtTime(r.last_fetched_at) },
|
|
{ title: '状态', key: 'last_status' },
|
|
]
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<template>
|
|
<NSpace vertical>
|
|
<NCard title="采集源(只读)">
|
|
<NDataTable :columns="columns" :data="sources" :bordered="false" :pagination="false" />
|
|
</NCard>
|
|
<NCard v-if="auth.isOwner" title="源健康(Owner)">
|
|
<NDataTable :columns="healthColumns" :data="health" :bordered="false" :pagination="false" />
|
|
</NCard>
|
|
</NSpace>
|
|
</template>
|