Files
AlphaPulse/web/src/pages/news/components/NewsDetail.vue
T
eezd 4f43ca8b57 Add AI news risk analysis and dashboard
Analyze news revisions with independent model settings, evidence-backed risk ratings, event grouping, and rolling market outlooks. Add a default homepage, archival history, pending-result refresh, and isolated regression coverage.

Keep analysis disabled until explicitly enabled. Require a separately authorized schema migration before deployment and preserve compatibility with the previous six-service instance.
2026-10-30 18:35:49 +08:00

159 lines
6.6 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">{{ 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-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>