Files
diary-news/frontend/src/views/ArticleDetail.vue

214 lines
8.6 KiB
Vue
Raw Normal View History

<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, NImage, useMessage,
} from 'naive-ui'
import { articlesApi, adminApi, 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)
const showFormatted = ref(true)
const enriching = ref(false)
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)
const categories = computed(() => (article.value?.category || '').split(',').filter(Boolean))
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 || '触发失败')
}
}
async function triggerEnrich() {
if (!article.value) return
enriching.value = true
try {
const out = await adminApi.triggerEnrich(article.value.id)
const r = out.results || {}
const ok = Object.values(r).filter((v) => v === 'ok').length
const fail = Object.values(r).filter((v) => v.startsWith('failed')).length
message.info(`完成 ${ok} 项,失败 ${fail}`)
await load()
} catch (e: any) {
message.error(e?.response?.data?.title || '触发失败')
} finally {
enriching.value = false
}
}
function statusTagType(s?: string | null): 'success' | 'warning' | 'error' | 'default' {
if (s === 'ok') return 'success'
if (s === 'failed') return 'error'
if (s === 'pending') return 'warning'
return 'default'
}
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>
<NTag v-for="c in categories" :key="c" type="success" size="small">{{ c }}</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 v-if="isOwner" type="info" ghost :loading="enriching" @click="triggerEnrich">
LLM 增强
</NButton>
<NButton tag="a" :href="article.url" target="_blank" rel="noopener">原文链接 </NButton>
</NSpace>
<NSpace v-if="isOwner" size="small" align="center">
<NText depth="3" style="font-size: 12px">LLM 状态:</NText>
<NTag size="tiny" :type="statusTagType(article.format_status)">排版:{{ article.format_status || 'n/a' }}</NTag>
<NTag size="tiny" :type="statusTagType(article.classify_status)">分类:{{ article.classify_status || 'n/a' }}</NTag>
<NTag size="tiny" :type="statusTagType(article.image_ai_status)">插图:{{ article.image_ai_status || 'n/a' }}</NTag>
<NTag size="tiny" :type="statusTagType(article.commentary_status)">点评:{{ article.commentary_status || 'n/a' }}</NTag>
</NSpace>
</NSpace>
</NCard>
<NAlert v-if="article.translation_status === 'failed'" type="warning" style="margin: 16px 0">
本条翻译失败,可点 "重译" 重试,或查看后端日志
</NAlert>
<!-- 三段式:评论(顶部) / 译文() / 原文() -->
<!-- 1) 评论(LLM 点评) -->
<NCard v-if="article.commentary" style="margin-top: 16px" title="💬 评论">
<template #header-extra>
<NTag size="tiny" :type="statusTagType(article.commentary_status)">{{ article.commentary_status || 'n/a' }}</NTag>
</template>
<p style="white-space: pre-wrap; line-height: 1.8; margin: 0">{{ article.commentary }}</p>
</NCard>
<!-- 2) 译文(优先 LLM 排版版,fallback 原始译文) -->
<div v-if="showTranslation" style="margin-top: 16px">
<NCard v-if="article.body_zh_formatted" title="📖 文章译文">
<template #header-extra>
<NSpace align="center">
<NTag size="tiny" :type="statusTagType(article.format_status)">排版:{{ article.format_status || 'n/a' }}</NTag>
<NButton text size="tiny" @click="showFormatted = !showFormatted">
{{ showFormatted ? '隐藏排版' : '显示排版' }}
</NButton>
</NSpace>
</template>
<div v-if="showFormatted" v-html="article.body_zh_formatted" />
<NText v-else depth="3" style="font-size: 12px">已隐藏排版版(点击右上角显示)</NText>
</NCard>
<NCard v-else title="📖 文章译文(原始)" style="margin-top: 16px">
<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>
<!-- AI 插图(挂在译文卡片下,作附属) -->
<div v-if="article.image_ai_url" style="margin-top: 16px">
<NCard title="🎨 AI 插图">
<NImage :src="article.image_ai_url" object-fit="cover" style="max-width: 100%; border-radius: 6px" />
</NCard>
</div>
<!-- 3) 原文 -->
<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>
<NCard v-if="article.entities" style="margin-top: 16px" title="🔍 实体(预留)">
<code style="font-size: 12px">{{ JSON.stringify(article.entities) }}</code>
</NCard>
</div>
</NSpin>
</NSpace>
</template>