Files
AlphaPulse/web/src/pages/news/components/NewsDetail.vue
T
eezd 89c729092c
Development CI / check-and-build (push) Successful in 2m54s
GitHub Git mirror / mirror (push) Successful in 9s
Development CI / deploy-development (push) Failing after 4m24s
Add resumable US policy news collectors and provenance
Collect official crypto and US policy documents with deterministic screening, full bodies and attachments, bounded retries, and durable history cursors. Preserve publication precision and source identity while keeping new collectors disabled.

Add real Congress, GovInfo, DOJ and Federal Register fixtures, offline capture replay, isolated database validation, and rollout instructions. Validate 982 backend tests, frontend checks, and 57 deployment control tests.
2026-11-04 21:41:04 +08:00

166 lines
7.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import type { NewsDetail, Revision } from "@@/apis/news/type"
import { getNewsApi, listRevisionsApi } from "@@/apis/news"
import { useSourceNames } from "@@/composables/useSourceNames"
import { diffLines, formatTime, htmlToLines } from "@@/utils/text"
import { Link } from "@element-plus/icons-vue"
import NewsRisk from "./NewsRisk.vue"
const props = defineProps<{ newsId: number | null }>()
const sourceNames = useSourceNames()
const detail = ref<NewsDetail>()
const revisions = ref<Revision[]>([])
const activeTab = ref("content")
const summaryOnly = computed(() => detail.value?.extra.content_scope === "feed_summary"
|| detail.value?.extra.content_scope === "public_summary")
// 当前查看的修订序号,与上一版对比
const selectedRevision = ref<number>()
// 加载失败(错误提示已由 http 拦截器弹出):停止转圈,显示重试
const failed = ref(false)
// 连续点击不同新闻时,只采用最后一次请求的结果
let requestSeq = 0
async function load(id: number) {
const seq = ++requestSeq
failed.value = false
try {
const [news, revs] = await Promise.all([getNewsApi(id), listRevisionsApi(id)])
if (seq !== requestSeq)
return
detail.value = news
revisions.value = revs
selectedRevision.value = revs[0]?.revision_no
} catch (error) {
if (seq === requestSeq)
failed.value = true
throw error
}
}
watch(() => props.newsId, (id) => {
activeTab.value = "content"
detail.value = undefined
revisions.value = []
if (id == null)
requestSeq++
else
return load(id)
}, { immediate: true })
// 第三方正文放进无脚本权限的 sandbox iframe,避免其中的脚本访问本站
const srcdoc = computed(() => detail.value && `<!doctype html><html><head><meta charset="utf-8">
<meta name="referrer" content="no-referrer"><base target="_blank">
<style>body{font:15px/1.8 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#334155;margin:0 4px}img{max-width:100%;height:auto}</style>
</head><body>${detail.value.content_html}</body></html>`)
const diff = computed(() => {
const index = revisions.value.findIndex(r => r.revision_no === selectedRevision.value)
if (index < 0)
return []
const current = revisions.value[index]
const previous = revisions.value[index + 1]
const toLines = (r?: Revision) => r ? [r.title, r.summary, ...htmlToLines(r.content_html)].filter(Boolean) : []
return diffLines(toLines(previous), toLines(current))
})
const diffStats = computed(() => ({
add: diff.value.filter(line => line.type === "add").length,
del: diff.value.filter(line => line.type === "del").length
}))
</script>
<template>
<el-card shadow="never" class="flex flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3">
<el-empty v-if="newsId == null" description="点击左侧新闻查看详情" class="m-auto" />
<template v-else-if="detail">
<NewsRisk :news-id="detail.id" :revision="detail.revision_no" />
<div class="flex flex-wrap items-center gap-2">
<el-tag size="small">
{{ sourceNames[detail.source_code] ?? detail.source_code }}
</el-tag>
<el-tag v-if="detail.is_paywalled" size="small" type="warning">
付费(仅公开部分)
</el-tag>
</div>
<div class="text-xl text-slate-900 font-bold leading-snug">
{{ detail.title || detail.excerpt.slice(0, 60) }}
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 border border-slate-200 rounded-lg border-solid bg-slate-50 px-3 py-2 text-sm">
<div>
<span class="label">发布时间</span><span class="num">{{ detail.published_at_precision === "date" ? `${detail.published_date}(仅日期)` : formatTime(detail.published_at, "YYYY-MM-DD HH:mm:ss") }}</span>
</div>
<div>
<span class="label">作者</span>{{ detail.author || "-" }}
</div>
<div class="flex flex-wrap items-center gap-1">
<span class="label">标签</span>
<el-tag v-for="tag in detail.tags" :key="tag" size="small" type="info">
{{ tag }}
</el-tag>
<span v-if="!detail.tags.length">-</span>
</div>
<div class="text-right">
<el-link :href="detail.url" target="_blank" type="primary" :icon="Link">
原文链接
</el-link>
</div>
</div>
<el-alert
v-if="summaryOnly"
title="此条新闻仅采集公开摘要,完整内容请查看原文链接。"
type="info" :closable="false" show-icon
/>
<el-alert v-if="detail.extra.content_scope === 'partial'" title="正文或关键附件存在采集缺口,请核对官方原文。" type="warning" :closable="false" />
<div v-if="Array.isArray(detail.extra.attachments)" class="flex flex-wrap gap-2 text-xs">
<span v-for="attachment in (detail.extra.attachments as { url: string, title: string, status: string }[])" :key="attachment.url">
<el-link :href="attachment.url" target="_blank" type="primary">{{ attachment.title || '附件' }}</el-link>
({{ attachment.status === 'complete' ? '已采集' : '存在缺口' }})
</span>
</div>
<el-tabs v-model="activeTab" class="min-h-0 flex flex-1 flex-col">
<el-tab-pane :label="summaryOnly ? '摘要' : '正文'" name="content" class="h-full">
<iframe :srcdoc="srcdoc" sandbox="allow-popups allow-popups-to-escape-sandbox" class="h-full w-full border-0" />
</el-tab-pane>
<el-tab-pane :label="`修订(${revisions.length})`" name="revisions" class="h-full overflow-auto">
<div class="mb-3 flex flex-wrap items-center gap-3 border border-slate-200 rounded-lg border-solid bg-slate-50 px-3 py-2">
<span class="text-xs text-slate-500">比对版本</span>
<el-select v-model="selectedRevision" size="small" class="w-64">
<el-option
v-for="(r, i) in revisions" :key="r.revision_no" :value="r.revision_no"
:label="`v${r.revision_no} · ${formatTime(r.captured_at)}${revisions[i + 1] ? ` vs v${revisions[i + 1].revision_no}` : '(首版)'}`"
/>
</el-select>
<span class="num text-xs text-emerald-700">+{{ diffStats.add }} 行</span>
<span class="num text-xs text-rose-700">−{{ diffStats.del }} 行</span>
</div>
<div class="overflow-hidden border border-slate-200 rounded-lg border-solid text-sm leading-7">
<div
v-for="(line, i) in diff" :key="i" class="flex gap-2 px-3"
:class="{ 'bg-emerald-50 text-emerald-900': line.type === 'add', 'bg-rose-50 text-rose-900 line-through': line.type === 'del' }"
>
<span class="num w-3 shrink-0 text-slate-400">{{ line.type === "add" ? "+" : line.type === "del" ? "−" : "" }}</span>
<span class="min-w-0">{{ line.text }}</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
<el-empty v-else-if="failed" description="加载失败" class="m-auto">
<el-button type="primary" @click="load(newsId)">
重试
</el-button>
</el-empty>
<div v-else v-loading="true" class="h-full" />
</el-card>
</template>
<style scoped>
.label {
margin-right: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>