feat: initial MVP - FastAPI backend + Vue3 frontend + docker-compose

- 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
This commit is contained in:
Mavis
2026-06-07 21:51:01 +08:00
commit 60b062daf2
81 changed files with 5540 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import {
NCard, NDataTable, NButton, NTag, NSpace, NPopconfirm, useMessage, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, useDialog,
} from 'naive-ui'
import { adminApi, type Source } from '@/api/articles'
import { h } from 'vue'
const message = useMessage()
const dialog = useDialog()
const sources = ref<Source[]>([])
const showCreate = ref(false)
const form = ref({
name: '',
slug: '',
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel',
url: '',
region: '',
language_src: 'en',
priority: 50,
fetch_interval_min: 60,
translate_to: 'zh',
})
const kindOptions = [
{ label: 'RSS / Atom', value: 'rss' },
{ label: 'HTML 列表', value: 'html_list' },
{ label: 'Telegram', value: 'tg_channel' },
]
async function load() {
sources.value = await adminApi.listSources()
}
async function toggleEnabled(s: Source) {
await adminApi.updateSource(s.id, { enabled: !s.enabled })
await load()
message.success(s.enabled ? '已停用' : '已启用')
}
async function del(s: Source) {
dialog.warning({
title: '确认删除',
content: `删除源 "${s.name}" 及其全部文章?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await adminApi.deleteSource(s.id)
message.success('已删除')
await load()
},
})
}
async function refresh(s: Source) {
await adminApi.refresh(s.id)
message.success('已加入抓取队列')
}
async function create() {
if (!form.value.name || !form.value.slug || !form.value.url) {
message.error('请填写名称 / slug / url')
return
}
try {
await adminApi.createSource(form.value)
message.success('已创建,等下一轮抓取')
showCreate.value = false
form.value = {
name: '', slug: '', kind: 'rss', url: '',
region: '', language_src: 'en', priority: 50, fetch_interval_min: 60, translate_to: 'zh',
}
await load()
} catch (e: any) {
message.error(e?.response?.data?.title || '创建失败')
}
}
const columns = [
{ title: 'ID', key: 'id', width: 50 },
{ title: '名称', key: 'name' },
{ title: 'slug', key: 'slug' },
{ title: 'kind', key: 'kind' },
{ title: 'URL', key: 'url', render: (r: Source) => h('a', { href: r.url, target: '_blank', rel: 'noopener' }, r.url.slice(0, 60) + (r.url.length > 60 ? '…' : '')) },
{ title: '地区', key: 'region' },
{
title: '优先级/间隔', key: 'meta',
render: (r: Source) => `P${r.priority} / ${r.fetch_interval_min}m`,
},
{
title: '状态', key: 'enabled',
render: (r: Source) => h(NTag, { type: r.enabled ? 'success' : 'default', size: 'small' }, () => r.enabled ? '启用' : '停用'),
},
{
title: '操作', key: 'action', width: 280,
render: (row: Source) => h(NSpace, {}, () => [
h(NButton, { size: 'small', onClick: () => refresh(row) }, () => '抓取'),
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
h(NPopconfirm, { onPositiveClick: () => del(row) }, {
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'),
default: () => '确认删除?',
}),
]),
},
]
onMounted(load)
</script>
<template>
<NSpace vertical>
<NSpace justify="end">
<NButton type="primary" @click="showCreate = true">+ 新增源</NButton>
</NSpace>
<NCard title="源管理">
<NDataTable :columns="columns" :data="sources" :bordered="false" :pagination="{ pageSize: 20 }" />
</NCard>
<NModal v-model:show="showCreate" preset="card" title="新增采集源" style="width: 600px">
<NForm label-placement="left" label-width="100">
<NFormItem label="名称"><NInput v-model:value="form.name" /></NFormItem>
<NFormItem label="slug"><NInput v-model:value="form.slug" placeholder="小写字母+连字符" /></NFormItem>
<NFormItem label="类型"><NSelect v-model:value="form.kind" :options="kindOptions" /></NFormItem>
<NFormItem label="URL"><NInput v-model:value="form.url" placeholder="https://..." /></NFormItem>
<NFormItem label="地区"><NInput v-model:value="form.region" placeholder="global / eu / asia / mena" /></NFormItem>
<NFormItem label="源语种"><NInput v-model:value="form.language_src" placeholder="en" /></NFormItem>
<NFormItem label="优先级">
<NInputNumber v-model:value="form.priority" :min="1" :max="100" />
</NFormItem>
<NFormItem label="抓取间隔(分)">
<NInputNumber v-model:value="form.fetch_interval_min" :min="5" :max="1440" />
</NFormItem>
<NFormItem label="翻译目标"><NInput v-model:value="form.translate_to" /></NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="showCreate = false">取消</NButton>
<NButton type="primary" @click="create">创建</NButton>
</NSpace>
</template>
</NModal>
</NSpace>
</template>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
NCard, NSpace, NTag, NText, NButton, NSpin, NEmpty, NDivider, NAlert, NSkeleton, useMessage,
} from 'naive-ui'
import { articlesApi, type ArticleDetail } from '@/api/articles'
import { http } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
const route = useRoute()
const router = useRouter()
const message = useMessage()
const auth = useAuthStore()
const article = ref<ArticleDetail | null>(null)
const loading = ref(true)
const starred = ref(false)
const showOriginal = ref(true)
const showTranslation = ref(true)
async function load() {
loading.value = true
try {
const id = Number(route.params.id)
article.value = await articlesApi.get(id)
starred.value = article.value.is_starred
} catch (e: any) {
message.error(e?.response?.data?.title || '加载失败')
} finally {
loading.value = false
}
}
async function toggleStar() {
if (!article.value) return
const { bookmarksApi } = await import('@/api/articles')
try {
if (starred.value) {
await bookmarksApi.remove(article.value.id)
starred.value = false
message.info('已取消收藏')
} else {
await bookmarksApi.add(article.value.id)
starred.value = true
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 [UTC]')
}
const publishedAt = computed(() => article.value?.published_at || article.value?.fetched_at)
const isOwner = computed(() => auth.isOwner)
async function rerunTranslation() {
if (!article.value) return
if (!confirm('重新翻译会消耗配额,确认?')) return
try {
await http.post(`/admin/translation/rerun/${article.value.id}`)
message.success('已加入翻译队列,稍后刷新')
} catch (e: any) {
message.error(e?.response?.data?.title || '触发失败')
}
}
onMounted(load)
</script>
<template>
<NSpace vertical>
<NButton text @click="router.back()"> 返回</NButton>
<NSpin :show="loading">
<NSkeleton v-if="loading" :repeat="6" />
<NEmpty v-else-if="!article" description="文章不存在" />
<div v-else>
<NCard>
<NSpace vertical :size="8">
<NSpace align="center">
<NTag type="info">{{ article.source.name }}</NTag>
<NText depth="3" style="font-size: 12px">{{ fmtTime(publishedAt) }}</NText>
<NTag v-if="article.translation_status !== 'ok'" size="small" type="warning">
翻译: {{ article.translation_status }}
</NTag>
<NTag v-if="article.translation_engine" size="small">
{{ article.translation_engine }}
</NTag>
</NSpace>
<h1 style="margin: 0">{{ article.title }}</h1>
<h2 v-if="article.title_zh" style="margin: 0; color: #2080f0">{{ article.title_zh }}</h2>
<NSpace>
<NButton :type="starred ? 'warning' : 'default'" @click="toggleStar">
{{ starred ? ' 已收藏' : ' 收藏' }}
</NButton>
<NButton text @click="showOriginal = !showOriginal">
{{ showOriginal ? '隐藏原文' : '显示原文' }}
</NButton>
<NButton text @click="showTranslation = !showTranslation">
{{ showTranslation ? '隐藏译文' : '显示译文' }}
</NButton>
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation">
重译
</NButton>
<NButton tag="a" :href="article.url" target="_blank" rel="noopener">原文链接 </NButton>
</NSpace>
</NSpace>
</NCard>
<NAlert v-if="article.translation_status === 'failed'" type="warning" style="margin: 16px 0">
本条翻译失败,可点 "重译" 重试,或查看后端日志
</NAlert>
<div v-if="showOriginal" style="margin-top: 16px">
<NCard title="原文">
<div v-if="article.body_html" v-html="article.body_html" style="line-height: 1.8" />
<div v-else style="white-space: pre-wrap; line-height: 1.8">{{ article.body_text }}</div>
</NCard>
</div>
<div v-if="showTranslation" style="margin-top: 16px">
<NCard title="译文">
<div v-if="article.body_zh_html" v-html="article.body_zh_html" style="line-height: 1.8" />
<div v-else-if="article.body_zh_text" style="white-space: pre-wrap; line-height: 1.8">
{{ article.body_zh_text }}
</div>
<NText v-else depth="3">暂无译文</NText>
</NCard>
</div>
<NCard v-if="article.commentary || article.entities" style="margin-top: 16px" title="智能增强 (预留)">
<div v-if="article.commentary">
<NText strong>点评: </NText>
<span>{{ article.commentary }}</span>
</div>
<div v-if="article.entities" style="margin-top: 8px">
<NText strong>实体: </NText>
<code>{{ JSON.stringify(article.entities) }}</code>
</div>
</NCard>
</div>
</NSpin>
</NSpace>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NCard, NDataTable, NTag, NSpace, NButton, useMessage } from 'naive-ui'
import { bookmarksApi, articlesApi } from '@/api/articles'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
const router = useRouter()
const message = useMessage()
const items = ref<any[]>([])
const loading = ref(false)
async function load() {
loading.value = true
try {
const list = await bookmarksApi.list()
// 拉详情拿标题
const detailed = await Promise.all(
list.map(async (b) => {
try {
const a = await articlesApi.get(b.article_id)
return { bookmark: b, article: a }
} catch {
return { bookmark: b, article: null }
}
})
)
items.value = detailed
} finally {
loading.value = false
}
}
async function remove(article_id: number) {
try {
await bookmarksApi.remove(article_id)
message.success('已移除')
items.value = items.value.filter((x) => x.bookmark.article_id !== article_id)
} catch (e: any) {
message.error(e?.response?.data?.title || '失败')
}
}
const columns = [
{ title: '标题', key: 'title', render: (row: any) => row.article?.title || '(已删除)' },
{ title: '译文', key: 'title_zh', render: (row: any) => row.article?.title_zh || '—' },
{ title: '源', key: 'source', render: (row: any) => row.article?.source?.name || '—' },
{ title: '收藏时间', key: 'created_at', render: (row: any) => dayjs(row.bookmark.created_at).format('YYYY-MM-DD HH:mm') },
{
title: '操作', key: 'action', width: 200,
render: (row: any) => h(NSpace, {}, () => [
h(NButton, { size: 'small', onClick: () => row.article && router.push(`/article/${row.article.id}`) }, () => '查看'),
h(NButton, { size: 'small', type: 'error', ghost: true, onClick: () => remove(row.bookmark.article_id) }, () => '移除'),
]),
},
]
import { h } from 'vue'
onMounted(load)
</script>
<template>
<NCard title="我的收藏">
<NDataTable :columns="columns" :data="items" :bordered="false" :loading="loading" />
</NCard>
</template>

133
frontend/src/views/Feed.vue Normal file
View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
} from 'naive-ui'
import { articlesApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const router = useRouter()
const auth = useAuthStore()
const items = ref<ArticleListItem[]>([])
const sources = ref<Source[]>([])
const loading = ref(false)
const cursor = ref<string | null>(null)
const exhausted = ref(false)
const sourceFilter = ref<string[]>([])
const q = ref('')
const sourceOptions = ref<{ label: string; value: string }[]>([])
async function load() {
if (loading.value) return
loading.value = true
try {
const resp = await articlesApi.list({
source: sourceFilter.value.join(',') || undefined,
q: q.value || undefined,
cursor: cursor.value || undefined,
limit: 50,
})
items.value.push(...resp.items)
cursor.value = resp.next_cursor
if (!cursor.value) exhausted.value = true
} finally {
loading.value = false
}
}
async function loadSources() {
sources.value = await sourcesApi.list()
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
}
function refresh() {
items.value = []
cursor.value = null
exhausted.value = false
load()
}
function open(a: ArticleListItem) {
router.push(`/article/${a.id}`)
}
function star(a: ArticleListItem) {
// 简单:登录的 star 接口后续
a.is_starred = !a.is_starred
}
function fmtTime(s?: string | null) {
if (!s) return '—'
return dayjs(s).fromNow()
}
onMounted(async () => {
await loadSources()
await load()
})
</script>
<template>
<NSpace vertical>
<NSpace align="center" justify="space-between">
<NSpace>
<NSelect
v-model:value="sourceFilter"
multiple
clearable
placeholder="按源筛选"
:options="sourceOptions"
style="min-width: 240px"
@update:value="refresh"
/>
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 200px"
@keyup.enter="refresh" @clear="refresh" />
<NButton @click="refresh">刷新</NButton>
</NSpace>
<NText depth="3">{{ items.length }} </NText>
</NSpace>
<NSpin :show="loading && items.length === 0">
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
<div v-else>
<NCard
v-for="a in items"
:key="a.id"
class="article-card"
hoverable
@click="open(a)"
>
<NSpace vertical :size="4">
<NSpace align="center" :size="8">
<NTag size="small" type="info">{{ a.source.name }}</NTag>
<NTag v-if="a.lang_src" size="small">{{ a.lang_src }}</NTag>
<NTag v-if="a.translation_status !== 'ok'" size="small" type="warning">
{{ a.translation_status }}
</NTag>
<NText depth="3" style="font-size: 12px">{{ fmtTime(a.published_at || a.fetched_at) }}</NText>
</NSpace>
<div style="font-size: 16px; font-weight: 600; color: #333">{{ a.title }}</div>
<div v-if="a.title_zh" style="font-size: 15px; color: #2080f0; font-weight: 500;">
{{ a.title_zh }}
</div>
<div v-if="a.summary_zh" style="color: #666; font-size: 13px; margin-top: 4px">
{{ a.summary_zh.slice(0, 200) }}{{ a.summary_zh.length > 200 ? '…' : '' }}
</div>
</NSpace>
</NCard>
<NSpace v-if="!exhausted" justify="center" style="margin: 16px 0">
<NButton :loading="loading" @click="load">加载更多</NButton>
</NSpace>
<NText v-else depth="3" style="display:block; text-align:center; padding: 16px"> 到底了 </NText>
</div>
</NSpin>
</NSpace>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NCard, NForm, NFormItem, NInput, NButton, NSpace, NAlert, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const message = useMessage()
const username = ref('owner')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
error.value = ''
if (!username.value || !password.value) {
error.value = '请填写账号和密码'
return
}
loading.value = true
try {
await auth.login(username.value, password.value)
message.success('登录成功')
const next = (route.query.next as string) || '/'
router.push(next)
} catch (e: any) {
error.value = e?.response?.data?.title || '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);">
<NCard title="📚 Diary News" style="width: 380px;">
<NForm @submit.prevent="submit">
<NFormItem label="账号">
<NInput v-model:value="username" placeholder="owner" autofocus />
</NFormItem>
<NFormItem label="密码">
<NInput v-model:value="password" type="password" show-password-on="click" @keyup.enter="submit" />
</NFormItem>
<NAlert v-if="error" type="error" :show-icon="false" style="margin-bottom: 12px">
{{ error }}
</NAlert>
<NSpace vertical>
<NButton type="primary" block :loading="loading" @click="submit">登录</NButton>
<NText depth="3" style="font-size: 12px">
私人系统 · 仅授权用户
</NText>
</NSpace>
</NForm>
</NCard>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<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>