feat(ui): 新增 LLM 智能增强设置页 + 路由/侧栏 + ArticleDetail 展示排版/分类/插图/点评
This commit is contained in:
@@ -44,15 +44,36 @@ export interface ArticleDetail extends ArticleListItem {
|
|||||||
body_text: string
|
body_text: string
|
||||||
body_zh_html?: string | null
|
body_zh_html?: string | null
|
||||||
body_zh_text?: string | null
|
body_zh_text?: string | null
|
||||||
|
body_zh_formatted?: string | null // LLM 排版后 HTML
|
||||||
author?: string | null
|
author?: string | null
|
||||||
|
image_ai_url?: string | null // LLM 生成的插图
|
||||||
translation_engine?: string | null
|
translation_engine?: string | null
|
||||||
translated_at?: string | null
|
translated_at?: string | null
|
||||||
|
// === LLM 增强状态 ===
|
||||||
|
format_status?: string | null
|
||||||
|
classify_status?: string | null
|
||||||
|
image_ai_status?: string | null
|
||||||
|
commentary_status?: string | null
|
||||||
|
// === LLM 内容 ===
|
||||||
commentary?: string | null
|
commentary?: string | null
|
||||||
entities?: Record<string, any> | null
|
entities?: Record<string, any> | null
|
||||||
sentiment?: number | null
|
sentiment?: number | null
|
||||||
duplicate_of?: number | null
|
duplicate_of?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LlmSetting {
|
||||||
|
format_prompt?: string | null
|
||||||
|
classify_prompt?: string | null
|
||||||
|
commentary_prompt?: string | null
|
||||||
|
image_prompt_template?: string | null
|
||||||
|
image_size: string
|
||||||
|
chat_model: string
|
||||||
|
image_model: string
|
||||||
|
interval_sec: number
|
||||||
|
enabled: boolean
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const articlesApi = {
|
export const articlesApi = {
|
||||||
list(params: Record<string, any> = {}) {
|
list(params: Record<string, any> = {}) {
|
||||||
return http.get<ArticleListResponse>('/articles', { params }).then((r) => r.data)
|
return http.get<ArticleListResponse>('/articles', { params }).then((r) => r.data)
|
||||||
@@ -108,4 +129,24 @@ export const adminApi = {
|
|||||||
health() {
|
health() {
|
||||||
return http.get('/admin/health').then((r) => r.data)
|
return http.get('/admin/health').then((r) => r.data)
|
||||||
},
|
},
|
||||||
|
// === LLM 设置 ===
|
||||||
|
getLlmSettings() {
|
||||||
|
return http.get<LlmSetting>('/admin/llm/settings').then((r) => r.data)
|
||||||
|
},
|
||||||
|
updateLlmSettings(body: Partial<LlmSetting>) {
|
||||||
|
return http.put<LlmSetting>('/admin/llm/settings', body).then((r) => r.data)
|
||||||
|
},
|
||||||
|
resetLlmSettings() {
|
||||||
|
return http.post<{ reset: boolean; detail: string }>('/admin/llm/settings/reset').then((r) => r.data)
|
||||||
|
},
|
||||||
|
testLlmConnection() {
|
||||||
|
return http.post<{ ok: boolean; detail: string; configured: boolean }>(
|
||||||
|
'/admin/llm/settings/test'
|
||||||
|
).then((r) => r.data)
|
||||||
|
},
|
||||||
|
triggerEnrich(articleId: number) {
|
||||||
|
return http.post<{ triggered: boolean; detail: string; results: Record<string, string> | null }>(
|
||||||
|
`/admin/llm/enrich/${articleId}`
|
||||||
|
).then((r) => r.data)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ const menu = computed(() => [
|
|||||||
{ key: '/', label: '24h 列表', icon: () => '📰' },
|
{ key: '/', label: '24h 列表', icon: () => '📰' },
|
||||||
{ key: '/sources', label: '采集源', icon: () => '📡' },
|
{ key: '/sources', label: '采集源', icon: () => '📡' },
|
||||||
{ key: '/bookmarks', label: '收藏', icon: () => '⭐' },
|
{ key: '/bookmarks', label: '收藏', icon: () => '⭐' },
|
||||||
...(auth.isOwner ? [{ key: '/admin/sources', label: '源管理(Admin)', icon: () => '🛠' }] : []),
|
...(auth.isOwner ? [
|
||||||
|
{ key: '/admin/sources', label: '源管理(Admin)', icon: () => '🛠' },
|
||||||
|
{ key: '/admin/llm', label: 'LLM 智能增强', icon: () => '🤖' },
|
||||||
|
] : []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const userMenu = computed(() => [
|
const userMenu = computed(() => [
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'sources', component: () => import('@/views/Sources.vue') },
|
{ path: 'sources', component: () => import('@/views/Sources.vue') },
|
||||||
{ path: 'bookmarks', component: () => import('@/views/Bookmarks.vue') },
|
{ path: 'bookmarks', component: () => import('@/views/Bookmarks.vue') },
|
||||||
{ path: 'admin/sources', component: () => import('@/views/AdminSources.vue'), meta: { ownerOnly: true } },
|
{ path: 'admin/sources', component: () => import('@/views/AdminSources.vue'), meta: { ownerOnly: true } },
|
||||||
|
{ path: 'admin/llm', component: () => import('@/views/AdminLlmSettings.vue'), meta: { ownerOnly: true } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
183
frontend/src/views/AdminLlmSettings.vue
Normal file
183
frontend/src/views/AdminLlmSettings.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
NCard, NSpace, NButton, NInput, NInputNumber, NSwitch, NAlert, useMessage, NSpin, NDivider, NText, NCode,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { adminApi, type LlmSetting } from '@/api/articles'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const setting = ref<LlmSetting>({
|
||||||
|
format_prompt: '',
|
||||||
|
classify_prompt: '',
|
||||||
|
commentary_prompt: '',
|
||||||
|
image_prompt_template: '',
|
||||||
|
image_size: '1024x768',
|
||||||
|
chat_model: 'agnes-2.0-flash',
|
||||||
|
image_model: 'agnes-image-2.1-flash',
|
||||||
|
interval_sec: 2.0,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const testResult = ref<{ ok: boolean; detail: string; configured: boolean } | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
setting.value = await adminApi.getLlmSettings()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.title || '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.updateLlmSettings(setting.value)
|
||||||
|
setting.value = updated
|
||||||
|
message.success('已保存')
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.title || '保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
if (!confirm('恢复所有提示词为默认值?(模型名 / 限速 / enabled 保留)')) return
|
||||||
|
try {
|
||||||
|
const out = await adminApi.resetLlmSettings()
|
||||||
|
message.success(out.detail || '已重置')
|
||||||
|
await load()
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.response?.data?.title || '重置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = null
|
||||||
|
try {
|
||||||
|
testResult.value = await adminApi.testLlmConnection()
|
||||||
|
if (testResult.value.ok) message.success('连接 OK')
|
||||||
|
else message.warning('连接失败')
|
||||||
|
} catch (e: any) {
|
||||||
|
testResult.value = { ok: false, detail: e?.message || '请求失败', configured: true }
|
||||||
|
message.error('测试失败')
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NSpace vertical>
|
||||||
|
<NSpace justify="space-between" align="center">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin: 0">🤖 LLM 智能增强设置</h2>
|
||||||
|
<NText depth="3" style="font-size: 12px">
|
||||||
|
翻译完成后,自动调用 Agnes LLM 跑 4 项任务:排版 / 分类 / 插图 / 点评
|
||||||
|
</NText>
|
||||||
|
</div>
|
||||||
|
<NSpace>
|
||||||
|
<NButton :loading="testing" @click="test">测连接</NButton>
|
||||||
|
<NButton @click="reset">重置默认提示词</NButton>
|
||||||
|
<NButton type="primary" :loading="saving" :disabled="loading" @click="save">保存</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NAlert v-if="testResult" :type="testResult.ok ? 'success' : 'warning'" :show-icon="false">
|
||||||
|
<NText strong>测连接结果:</NText>
|
||||||
|
<NText>{{ testResult.detail }}</NText>
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NSpin :show="loading">
|
||||||
|
<NCard title="总开关 + 模型">
|
||||||
|
<NSpace vertical>
|
||||||
|
<NSpace align="center">
|
||||||
|
<NText>启用 LLM 增强:</NText>
|
||||||
|
<NSwitch v-model:value="setting.enabled" />
|
||||||
|
<NText v-if="!setting.enabled" depth="3" style="font-size: 12px">(关闭后翻译后不再调 LLM)</NText>
|
||||||
|
</NSpace>
|
||||||
|
<NSpace>
|
||||||
|
<NText>文生文模型:</NText>
|
||||||
|
<NInput v-model:value="setting.chat_model" placeholder="agnes-2.0-flash" style="width: 240px" />
|
||||||
|
</NSpace>
|
||||||
|
<NSpace>
|
||||||
|
<NText>文生图模型:</NText>
|
||||||
|
<NInput v-model:value="setting.image_model" placeholder="agnes-image-2.1-flash" style="width: 240px" />
|
||||||
|
</NSpace>
|
||||||
|
<NSpace>
|
||||||
|
<NText>插图尺寸:</NText>
|
||||||
|
<NInput v-model:value="setting.image_size" placeholder="1024x768" style="width: 160px" />
|
||||||
|
<NText depth="3" style="font-size: 12px">(格式: WIDTHxHEIGHT,如 1024x768)</NText>
|
||||||
|
</NSpace>
|
||||||
|
<NSpace>
|
||||||
|
<NText>LLM 调用间隔(秒):</NText>
|
||||||
|
<NInputNumber v-model:value="setting.interval_sec" :min="0" :max="60" :step="0.5" />
|
||||||
|
<NText depth="3" style="font-size: 12px">(chat + image 各 1 个串行,每次调用后等这么久)</NText>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard title="排版提示词" style="margin-top: 16px">
|
||||||
|
<NText depth="3" style="font-size: 12px">
|
||||||
|
模板变量: <NCode>{body}</NCode> = 译文正文
|
||||||
|
</NText>
|
||||||
|
<NInput
|
||||||
|
v-model:value="setting.format_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 6, maxRows: 20 }"
|
||||||
|
placeholder="留空用默认"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard title="分类提示词" style="margin-top: 16px">
|
||||||
|
<NText depth="3" style="font-size: 12px">
|
||||||
|
模板变量: <NCode>{title}</NCode> = 译后标题, <NCode>{summary}</NCode> = 摘要。<br />
|
||||||
|
期望返回 JSON,形如 <NCode>{`{"categories": ["时政", "国际"]}`}</NCode>
|
||||||
|
</NText>
|
||||||
|
<NInput
|
||||||
|
v-model:value="setting.classify_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 12 }"
|
||||||
|
placeholder="留空用默认"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard title="点评提示词" style="margin-top: 16px">
|
||||||
|
<NText depth="3" style="font-size: 12px">
|
||||||
|
模板变量: <NCode>{title}</NCode> = 译后标题, <NCode>{body}</NCode> = 译文正文
|
||||||
|
</NText>
|
||||||
|
<NInput
|
||||||
|
v-model:value="setting.commentary_prompt"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 4, maxRows: 12 }"
|
||||||
|
placeholder="留空用默认"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard title="插图 prompt 模板" style="margin-top: 16px">
|
||||||
|
<NText depth="3" style="font-size: 12px">
|
||||||
|
模板变量: <NCode>{title}</NCode> = 标题(优先译后)。最终 prompt 会拼成英文描述发给文生图模型
|
||||||
|
</NText>
|
||||||
|
<NInput
|
||||||
|
v-model:value="setting.image_prompt_template"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||||
|
placeholder="留空用默认"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
</NCard>
|
||||||
|
</NSpin>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
NCard, NSpace, NTag, NText, NButton, NSpin, NEmpty, NDivider, NAlert, NSkeleton, useMessage,
|
NCard, NSpace, NTag, NText, NButton, NSpin, NEmpty, NDivider, NAlert, NSkeleton, NImage, useMessage,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { articlesApi, type ArticleDetail } from '@/api/articles'
|
import { articlesApi, adminApi, type ArticleDetail } from '@/api/articles'
|
||||||
import { http } from '@/api/client'
|
import { http } from '@/api/client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -21,6 +21,8 @@ const loading = ref(true)
|
|||||||
const starred = ref(false)
|
const starred = ref(false)
|
||||||
const showOriginal = ref(true)
|
const showOriginal = ref(true)
|
||||||
const showTranslation = ref(true)
|
const showTranslation = ref(true)
|
||||||
|
const showFormatted = ref(true)
|
||||||
|
const enriching = ref(false)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -60,6 +62,7 @@ function fmtTime(s?: string | null) {
|
|||||||
|
|
||||||
const publishedAt = computed(() => article.value?.published_at || article.value?.fetched_at)
|
const publishedAt = computed(() => article.value?.published_at || article.value?.fetched_at)
|
||||||
const isOwner = computed(() => auth.isOwner)
|
const isOwner = computed(() => auth.isOwner)
|
||||||
|
const categories = computed(() => (article.value?.category || '').split(',').filter(Boolean))
|
||||||
|
|
||||||
async function rerunTranslation() {
|
async function rerunTranslation() {
|
||||||
if (!article.value) return
|
if (!article.value) return
|
||||||
@@ -72,6 +75,30 @@ async function rerunTranslation() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -93,6 +120,7 @@ onMounted(load)
|
|||||||
<NTag v-if="article.translation_engine" size="small">
|
<NTag v-if="article.translation_engine" size="small">
|
||||||
{{ article.translation_engine }}
|
{{ article.translation_engine }}
|
||||||
</NTag>
|
</NTag>
|
||||||
|
<NTag v-for="c in categories" :key="c" type="success" size="small">{{ c }}</NTag>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
<h1 style="margin: 0">{{ article.title }}</h1>
|
<h1 style="margin: 0">{{ article.title }}</h1>
|
||||||
<h2 v-if="article.title_zh" style="margin: 0; color: #2080f0">{{ article.title_zh }}</h2>
|
<h2 v-if="article.title_zh" style="margin: 0; color: #2080f0">{{ article.title_zh }}</h2>
|
||||||
@@ -109,8 +137,18 @@ onMounted(load)
|
|||||||
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation">
|
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation">
|
||||||
重译
|
重译
|
||||||
</NButton>
|
</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>
|
<NButton tag="a" :href="article.url" target="_blank" rel="noopener">原文链接 ↗</NButton>
|
||||||
</NSpace>
|
</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>
|
</NSpace>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
@@ -118,15 +156,25 @@ onMounted(load)
|
|||||||
本条翻译失败,可点 "重译" 重试,或查看后端日志。
|
本条翻译失败,可点 "重译" 重试,或查看后端日志。
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<div v-if="showOriginal" style="margin-top: 16px">
|
<div v-if="article.image_ai_url" style="margin-top: 16px">
|
||||||
<NCard title="原文">
|
<NCard title="🎨 AI 插图">
|
||||||
<div v-if="article.body_html" v-html="article.body_html" style="line-height: 1.8" />
|
<NImage :src="article.image_ai_url" object-fit="cover" style="max-width: 100%; border-radius: 6px" />
|
||||||
<div v-else style="white-space: pre-wrap; line-height: 1.8">{{ article.body_text }}</div>
|
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showTranslation" style="margin-top: 16px">
|
<div v-if="showTranslation" style="margin-top: 16px">
|
||||||
<NCard title="译文">
|
<NCard v-if="article.body_zh_formatted" title="译文(LLM 排版版)">
|
||||||
|
<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" style="line-height: 1.8" />
|
||||||
|
</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-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">
|
<div v-else-if="article.body_zh_text" style="white-space: pre-wrap; line-height: 1.8">
|
||||||
{{ article.body_zh_text }}
|
{{ article.body_zh_text }}
|
||||||
@@ -135,15 +183,22 @@ onMounted(load)
|
|||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NCard v-if="article.commentary || article.entities" style="margin-top: 16px" title="智能增强 (预留)">
|
<div v-if="showOriginal" style="margin-top: 16px">
|
||||||
<div v-if="article.commentary">
|
<NCard title="原文">
|
||||||
<NText strong>点评: </NText>
|
<div v-if="article.body_html" v-html="article.body_html" style="line-height: 1.8" />
|
||||||
<span>{{ article.commentary }}</span>
|
<div v-else style="white-space: pre-wrap; line-height: 1.8">{{ article.body_text }}</div>
|
||||||
</div>
|
</NCard>
|
||||||
<div v-if="article.entities" style="margin-top: 8px">
|
|
||||||
<NText strong>实体: </NText>
|
|
||||||
<code>{{ JSON.stringify(article.entities) }}</code>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NCard v-if="article.commentary" style="margin-top: 16px" title="💬 AI 点评">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<NCard v-if="article.entities" style="margin-top: 16px" title="🔍 实体(预留)">
|
||||||
|
<code style="font-size: 12px">{{ JSON.stringify(article.entities) }}</code>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</NSpin>
|
</NSpin>
|
||||||
|
|||||||
Reference in New Issue
Block a user