319 lines
11 KiB
Vue
319 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref, computed } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import {
|
|
NCard, NSpace, NTag, NText, NButton, NSpin, NEmpty, 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 :size="16">
|
|
<NButton text @click="router.back()" class="back-button">← 返回</NButton>
|
|
|
|
<NSpin :show="loading">
|
|
<NSkeleton v-if="loading" :repeat="6" />
|
|
<NEmpty v-else-if="!article" description="文章不存在" />
|
|
<div v-else>
|
|
<!-- 顶部信息卡 -->
|
|
<NCard class="article-detail-card">
|
|
<NSpace vertical :size="14">
|
|
<!-- tag 行 -->
|
|
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
|
|
<NTag type="primary" :bordered="false" round>{{ article.source.name }}</NTag>
|
|
<NTag v-if="article.lang_src" :bordered="false" round>{{ article.lang_src.toUpperCase() }}</NTag>
|
|
<NTag v-if="article.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
|
|
翻译:{{ article.translation_status }}
|
|
</NTag>
|
|
<NTag v-if="article.translation_engine" size="small" :bordered="false" round>
|
|
{{ article.translation_engine }}
|
|
</NTag>
|
|
<NTag v-for="c in categories" :key="c" type="success" size="small" :bordered="false" round>
|
|
{{ c }}
|
|
</NTag>
|
|
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="detail-time-label">
|
|
{{ fmtTime(publishedAt) }}
|
|
</NText>
|
|
</NSpace>
|
|
|
|
<!-- 主标题(中文) -->
|
|
<h1 v-if="article.title_zh" style="margin: 0; color: var(--color-letter)">
|
|
{{ article.title_zh }}
|
|
</h1>
|
|
<h2 v-else style="margin: 0; color: var(--color-letter)">
|
|
{{ article.title }}
|
|
</h2>
|
|
|
|
<!-- 原标题(英文/外文) -->
|
|
<div
|
|
v-if="article.title_zh && article.title"
|
|
style="font-size: 14px; color: var(--color-text-faint); line-height: 1.5;"
|
|
>
|
|
{{ article.title }}
|
|
</div>
|
|
|
|
<!-- 操作按钮行 -->
|
|
<NSpace :size="8" :wrap="true" style="row-gap: 8px">
|
|
<NButton
|
|
:type="starred ? 'warning' : 'primary'"
|
|
:ghost="!starred"
|
|
@click="toggleStar"
|
|
round
|
|
>
|
|
{{ starred ? '★ 已收藏' : '☆ 收藏' }}
|
|
</NButton>
|
|
<NButton text @click="showOriginal = !showOriginal" round>
|
|
{{ showOriginal ? '隐藏原文' : '显示原文' }}
|
|
</NButton>
|
|
<NButton text @click="showTranslation = !showTranslation" round>
|
|
{{ showTranslation ? '隐藏译文' : '显示译文' }}
|
|
</NButton>
|
|
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation" round>
|
|
重译
|
|
</NButton>
|
|
<NButton v-if="isOwner" type="info" ghost :loading="enriching" @click="triggerEnrich" round>
|
|
跑 LLM 增强
|
|
</NButton>
|
|
<NButton tag="a" :href="article.url" target="_blank" rel="noopener" ghost round>
|
|
原文链接 ↗
|
|
</NButton>
|
|
</NSpace>
|
|
|
|
<!-- owner 专属:LLM 状态 -->
|
|
<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)" :bordered="false" round>
|
|
排版:{{ article.format_status || 'n/a' }}
|
|
</NTag>
|
|
<NTag size="tiny" :type="statusTagType(article.classify_status)" :bordered="false" round>
|
|
分类:{{ article.classify_status || 'n/a' }}
|
|
</NTag>
|
|
<NTag size="tiny" :type="statusTagType(article.image_ai_status)" :bordered="false" round>
|
|
插图:{{ article.image_ai_status || 'n/a' }}
|
|
</NTag>
|
|
<NTag size="tiny" :type="statusTagType(article.commentary_status)" :bordered="false" round>
|
|
点评:{{ 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" class="detail-card" style="margin-top: 16px">
|
|
<template #header>
|
|
<span class="card-header-title">💬 评论</span>
|
|
</template>
|
|
<template #header-extra>
|
|
<NTag size="tiny" :type="statusTagType(article.commentary_status)" :bordered="false" round>
|
|
{{ article.commentary_status || 'n/a' }}
|
|
</NTag>
|
|
</template>
|
|
<p class="commentary-text-detail">{{ article.commentary }}</p>
|
|
</NCard>
|
|
|
|
<!-- 2) 译文(优先 LLM 排版版) -->
|
|
<div v-if="showTranslation" style="margin-top: 16px">
|
|
<NCard v-if="article.body_zh_formatted" class="detail-card">
|
|
<template #header>
|
|
<span class="card-header-title">📖 文章译文</span>
|
|
</template>
|
|
<template #header-extra>
|
|
<NSpace align="center" :size="4">
|
|
<NTag size="tiny" :type="statusTagType(article.format_status)" :bordered="false" round>
|
|
排版:{{ article.format_status || 'n/a' }}
|
|
</NTag>
|
|
<NButton text size="tiny" @click="showFormatted = !showFormatted" round>
|
|
{{ 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 class="detail-card">
|
|
<template #header>
|
|
<span class="card-header-title">📖 文章译文(原始)</span>
|
|
</template>
|
|
<div v-if="article.body_zh_html" v-html="article.body_zh_html" />
|
|
<div v-else-if="article.body_zh_text" class="article-body-fallback">{{ article.body_zh_text }}</div>
|
|
<NText v-else :depth="3">暂无译文</NText>
|
|
</NCard>
|
|
</div>
|
|
|
|
<!-- AI 插图 -->
|
|
<NCard v-if="article.image_ai_url" class="detail-card" style="margin-top: 16px">
|
|
<template #header>
|
|
<span class="card-header-title">🎨 AI 插图</span>
|
|
</template>
|
|
<NImage :src="article.image_ai_url" object-fit="cover" class="article-image" />
|
|
</NCard>
|
|
|
|
<!-- 3) 原文 -->
|
|
<div v-if="showOriginal" style="margin-top: 16px">
|
|
<NCard class="detail-card">
|
|
<template #header>
|
|
<span class="card-header-title">📄 文章原文</span>
|
|
</template>
|
|
<div v-if="article.body_html" v-html="article.body_html" class="article-body-fallback" />
|
|
<div v-else class="article-body-fallback">{{ article.body_text }}</div>
|
|
</NCard>
|
|
</div>
|
|
|
|
<NCard v-if="article.entities" class="detail-card" style="margin-top: 16px">
|
|
<template #header>
|
|
<span class="card-header-title">🔍 实体(预留)</span>
|
|
</template>
|
|
<code style="font-size: 12px">{{ JSON.stringify(article.entities) }}</code>
|
|
</NCard>
|
|
</div>
|
|
</NSpin>
|
|
</NSpace>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.back-button {
|
|
font-size: 14px;
|
|
color: var(--color-primary) !important;
|
|
}
|
|
|
|
.card-header-title {
|
|
font-family: var(--font-serif);
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
color: var(--color-letter);
|
|
}
|
|
|
|
.commentary-text-detail {
|
|
white-space: pre-wrap;
|
|
line-height: 1.85;
|
|
margin: 0;
|
|
color: var(--color-letter);
|
|
font-size: 15px;
|
|
}
|
|
|
|
.article-body-fallback {
|
|
white-space: pre-wrap;
|
|
line-height: 1.85;
|
|
color: var(--color-letter);
|
|
font-size: 16px;
|
|
font-family: var(--font-sans);
|
|
}
|
|
|
|
.article-image {
|
|
max-width: 100%;
|
|
border-radius: 8px;
|
|
background: var(--color-surface-variant);
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.detail-time-label {
|
|
margin-left: 0 !important;
|
|
width: 100%;
|
|
text-align: right;
|
|
}
|
|
/* 详情页头部操作按钮在手机上等宽更整齐 */
|
|
:deep(.n-card .n-space) {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style> |