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:
151
frontend/src/views/ArticleDetail.vue
Normal file
151
frontend/src/views/ArticleDetail.vue
Normal 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>
|
||||
Reference in New Issue
Block a user