feat(ingest): API Push 前端层 + 文档 + 端到端联通
后端(支持 api_push source 创建/调度): - schemas/source.py:SourceIn.url 改成 str(允许 api_push 的 api-push:// 占位) - admin.py create_source 简化 url 传递 - workers/__main__.py:_rebuild_jobs 跳过 api_push 源(它是被动接收,不抓取) - workers/pipeline.py:run_once 也加同条件,api_push 不进抓取循环 前端: - api/articles.ts:ArticleListItem 加 is_short_news(required)/source_ref; ArticleDetail 加 external_id;导出 IngestTokenOut;adminApi 加 list/create/revoke ingest token 三个方法 - views/Feed.vue:卡片根 class 短新闻加 short-card(淡蓝底 #f6f9fc + 左侧 3px 蓝色色条 #4f9eff);元信息栏加 📰 短讯 角标;长新闻摘要 body_zh_text 截前 200 字,短新闻不截取保留换行(white-space: pre-wrap); 短新闻不显示 AI 插图 - views/ArticleDetail.vue:tag 行加 📰 短讯 + source_ref 角标;短新闻 路径下隐藏翻译状态/重译/原文链接按钮;正文区短新闻直接渲染 body_zh_text,跳过译文/原文/AI 配图卡片;Angel + 美团双评论卡片 都保留 - views/AdminSources.vue:kind 加 api_push 选项;api_push 源 URL 字段 变只读占位、隐藏抓取间隔;列表操作列加 🔑 Token 按钮; 弹窗支持生成(raw_token 一次性显示 + 复制)/列表/撤销 文档: - docs/api-push.md:调用方契约 + 三层去重 + 限速 + lifecycle + owner 操作手册 + curl/Python 示例 + 重试策略 + 故障排查 - README.md:关键特性加 API Push;API 概览加 /api/v1/ingest 和 3 个 /admin/.../ingest-tokens 端点
This commit is contained in:
@@ -41,6 +41,10 @@ export interface ArticleListItem {
|
||||
commentary_meituan_status?: string | null
|
||||
commentary_engine?: string | null // angel / meituan / "angel,meituan"
|
||||
image_ai_url?: string | null
|
||||
// === API Push 短新闻标识 ===
|
||||
// 短新闻(中文原生,由 /api/v1/ingest 推送)走差异化展示
|
||||
is_short_news: boolean
|
||||
source_ref?: string | null // 短新闻里再细分来源(wechat/rss-digest 等)
|
||||
is_starred: boolean
|
||||
is_read: boolean // 当前用户是否已读
|
||||
}
|
||||
@@ -79,6 +83,8 @@ export interface ArticleDetail extends ArticleListItem {
|
||||
entities?: Record<string, any> | null
|
||||
sentiment?: number | null
|
||||
duplicate_of?: number | null
|
||||
// === API Push 短新闻 ===
|
||||
external_id?: string | null // 调用方幂等 key
|
||||
}
|
||||
|
||||
export interface LlmSetting {
|
||||
@@ -209,4 +215,29 @@ export const adminApi = {
|
||||
`/admin/llm/enrich/${articleId}`
|
||||
).then((r) => r.data)
|
||||
},
|
||||
// === API Push ingest token 管理 ===
|
||||
listIngestTokens(sourceId: number) {
|
||||
return http.get<IngestTokenOut[]>(`/admin/sources/${sourceId}/ingest-tokens`).then((r) => r.data)
|
||||
},
|
||||
createIngestToken(sourceId: number, body: { name?: string; expires_days?: number }) {
|
||||
return http.post<IngestTokenOut>(`/admin/sources/${sourceId}/ingest-tokens`, body).then((r) => r.data)
|
||||
},
|
||||
revokeIngestToken(tokenId: number) {
|
||||
return http.delete<{ id: number; revoked_at: string; already_revoked: boolean }>(
|
||||
`/admin/ingest-tokens/${tokenId}`
|
||||
).then((r) => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export interface IngestTokenOut {
|
||||
id: number
|
||||
source_id: number
|
||||
name: string
|
||||
purpose: string
|
||||
created_at: string
|
||||
expires_at?: string | null
|
||||
revoked_at?: string | null
|
||||
last_used_at?: string | null
|
||||
// 仅 createIngestToken 返回时填充(raw_token 只一次性返给前端)
|
||||
raw_token?: string | null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed, h } from 'vue'
|
||||
import {
|
||||
NCard, NDataTable, NButton, NTag, NSpace, NPopconfirm, useMessage, NModal, NForm, NFormItem, NInput, NSelect, NInputNumber, useDialog,
|
||||
NCard, NDataTable, NButton, NTag, NSpace, NPopconfirm, useMessage, NModal, NForm, NFormItem,
|
||||
NInput, NSelect, NInputNumber, useDialog,
|
||||
} from 'naive-ui'
|
||||
import { adminApi, type Source } from '@/api/articles'
|
||||
import { h } from 'vue'
|
||||
import { adminApi, type Source, type IngestTokenOut } from '@/api/articles'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
@@ -13,7 +13,7 @@ const showCreate = ref(false)
|
||||
const form = ref({
|
||||
name: '',
|
||||
slug: '',
|
||||
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel',
|
||||
kind: 'rss' as 'rss' | 'html_list' | 'tg_channel' | 'api_push',
|
||||
url: '',
|
||||
region: '',
|
||||
language_src: 'en',
|
||||
@@ -26,8 +26,18 @@ const kindOptions = [
|
||||
{ label: 'RSS / Atom', value: 'rss' },
|
||||
{ label: 'HTML 列表', value: 'html_list' },
|
||||
{ label: 'Telegram', value: 'tg_channel' },
|
||||
{ label: 'API Push(短新闻推送)', value: 'api_push' },
|
||||
]
|
||||
|
||||
// === Ingest Token 管理(仅 api_push 源)===
|
||||
const showTokenModal = ref(false)
|
||||
const tokenSource = ref<Source | null>(null)
|
||||
const tokenList = ref<IngestTokenOut[]>([])
|
||||
const tokenLoading = ref(false)
|
||||
const newTokenName = ref('default')
|
||||
const newTokenExpiresDays = ref<number | null>(null)
|
||||
const lastIssuedRaw = ref<string | null>(null) // 创建后弹一次性 token
|
||||
|
||||
async function load() {
|
||||
sources.value = await adminApi.listSources()
|
||||
}
|
||||
@@ -58,13 +68,21 @@ async function refresh(s: Source) {
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!form.value.name || !form.value.slug || !form.value.url) {
|
||||
message.error('请填写名称 / slug / url')
|
||||
if (!form.value.name || !form.value.slug) {
|
||||
message.error('请填写名称 / slug')
|
||||
return
|
||||
}
|
||||
// api_push 类型的 url 不是必填(只是合成占位);其他类型必填
|
||||
if (form.value.kind !== 'api_push' && !form.value.url) {
|
||||
message.error('请填写 URL')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await adminApi.createSource(form.value)
|
||||
message.success('已创建,等下一轮抓取')
|
||||
await adminApi.createSource({
|
||||
...form.value,
|
||||
url: form.value.url || `api-push://${form.value.slug}`,
|
||||
})
|
||||
message.success('已创建,等下一轮抓取 / 推送')
|
||||
showCreate.value = false
|
||||
form.value = {
|
||||
name: '', slug: '', kind: 'rss', url: '',
|
||||
@@ -76,31 +94,166 @@ async function create() {
|
||||
}
|
||||
}
|
||||
|
||||
// === Ingest Token 弹窗 ===
|
||||
async function openTokenModal(s: Source) {
|
||||
tokenSource.value = s
|
||||
showTokenModal.value = true
|
||||
lastIssuedRaw.value = null
|
||||
newTokenName.value = 'default'
|
||||
newTokenExpiresDays.value = null
|
||||
await loadTokens()
|
||||
}
|
||||
|
||||
async function loadTokens() {
|
||||
if (!tokenSource.value) return
|
||||
tokenLoading.value = true
|
||||
try {
|
||||
tokenList.value = await adminApi.listIngestTokens(tokenSource.value.id)
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '加载 token 列表失败')
|
||||
} finally {
|
||||
tokenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
if (!tokenSource.value) return
|
||||
if (!newTokenName.value.trim()) {
|
||||
message.error('请填写 token 名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const out = await adminApi.createIngestToken(tokenSource.value.id, {
|
||||
name: newTokenName.value.trim(),
|
||||
expires_days: newTokenExpiresDays.value ?? undefined,
|
||||
})
|
||||
if (out.raw_token) {
|
||||
lastIssuedRaw.value = out.raw_token
|
||||
message.success('Token 已生成 — 请复制下方 raw_token,关闭后不再显示')
|
||||
} else {
|
||||
message.warning('Token 生成成功,但未返回 raw_token')
|
||||
}
|
||||
await loadTokens()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '生成失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeToken(id: number) {
|
||||
dialog.warning({
|
||||
title: '确认撤销',
|
||||
content: '撤销后此 token 立即失效,所有正在用它的调用方会拿到 401',
|
||||
positiveText: '撤销',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await adminApi.revokeIngestToken(id)
|
||||
message.success('已撤销')
|
||||
await loadTokens()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '撤销失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// raw token 复制到剪贴板
|
||||
async function copyRaw() {
|
||||
if (!lastIssuedRaw.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(lastIssuedRaw.value)
|
||||
message.success('已复制到剪贴板')
|
||||
} catch {
|
||||
message.warning('复制失败,请手动选中复制')
|
||||
}
|
||||
}
|
||||
|
||||
const originForCurl = computed(() => {
|
||||
if (typeof window !== 'undefined' && window.location) {
|
||||
return window.location.origin
|
||||
}
|
||||
return 'https://your-domain'
|
||||
})
|
||||
|
||||
const tokenColumns = [
|
||||
{ title: 'ID', key: 'id', width: 60 },
|
||||
{ title: '名称', key: 'name', width: 140 },
|
||||
{
|
||||
title: '状态', key: 'status', width: 100,
|
||||
render: (r: IngestTokenOut) => {
|
||||
if (r.revoked_at) return h(NTag, { type: 'default', size: 'small' }, () => '已撤销')
|
||||
if (r.expires_at && new Date(r.expires_at) < new Date()) {
|
||||
return h(NTag, { type: 'warning', size: 'small' }, () => '已过期')
|
||||
}
|
||||
return h(NTag, { type: 'success', size: 'small' }, () => '有效')
|
||||
},
|
||||
},
|
||||
{ title: '创建', key: 'created_at', width: 170 },
|
||||
{ title: '过期', key: 'expires_at', width: 170, render: (r: IngestTokenOut) => r.expires_at || '永不过期' },
|
||||
{ title: '最后使用', key: 'last_used_at', width: 170, render: (r: IngestTokenOut) => r.last_used_at || '—' },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: (r: IngestTokenOut) =>
|
||||
r.revoked_at
|
||||
? null
|
||||
: h(NButton, {
|
||||
size: 'tiny', type: 'error', ghost: true,
|
||||
onClick: () => revokeToken(r.id),
|
||||
}, () => '撤销'),
|
||||
},
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', key: 'id', width: 50 },
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: 'slug', key: 'slug' },
|
||||
{ title: 'kind', key: 'kind' },
|
||||
{ title: 'URL', key: 'url', render: (r: Source) => h('a', { href: r.url, target: '_blank', rel: 'noopener' }, r.url.slice(0, 60) + (r.url.length > 60 ? '…' : '')) },
|
||||
{ title: '地区', key: 'region' },
|
||||
{
|
||||
title: '优先级/间隔', key: 'meta',
|
||||
title: 'kind', key: 'kind', width: 120,
|
||||
render: (r: Source) => {
|
||||
const map: Record<string, { label: string; type: 'success' | 'info' }> = {
|
||||
rss: { label: 'RSS', type: 'success' },
|
||||
html_list: { label: 'HTML', type: 'success' },
|
||||
tg_channel: { label: 'Telegram', type: 'success' },
|
||||
api_push: { label: 'API Push', type: 'info' },
|
||||
}
|
||||
const cfg = map[r.kind] || { label: r.kind, type: 'success' as const }
|
||||
return h(NTag, { type: cfg.type, size: 'small' }, () => cfg.label)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'URL', key: 'url',
|
||||
render: (r: Source) => h('a', {
|
||||
href: r.url.startsWith('http') ? r.url : '#',
|
||||
target: '_blank', rel: 'noopener',
|
||||
style: r.url.startsWith('api-push://') ? 'color:#9ca3af' : '',
|
||||
}, r.url.slice(0, 60) + (r.url.length > 60 ? '…' : '')),
|
||||
},
|
||||
{ title: '地区', key: 'region', width: 90 },
|
||||
{
|
||||
title: '优先级/间隔', key: 'meta', width: 110,
|
||||
render: (r: Source) => `P${r.priority} / ${r.fetch_interval_min}m`,
|
||||
},
|
||||
{
|
||||
title: '状态', key: 'enabled',
|
||||
title: '状态', key: 'enabled', width: 80,
|
||||
render: (r: Source) => h(NTag, { type: r.enabled ? 'success' : 'default', size: 'small' }, () => r.enabled ? '启用' : '停用'),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 280,
|
||||
render: (row: Source) => h(NSpace, {}, () => [
|
||||
h(NButton, { size: 'small', onClick: () => refresh(row) }, () => '抓取'),
|
||||
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
|
||||
h(NPopconfirm, { onPositiveClick: () => del(row) }, {
|
||||
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'),
|
||||
default: () => '确认删除?',
|
||||
}),
|
||||
]),
|
||||
title: '操作', key: 'action', width: 360,
|
||||
render: (row: Source) => h(NSpace, {}, () => {
|
||||
const items: any[] = [
|
||||
h(NButton, { size: 'small', onClick: () => refresh(row), disabled: row.kind === 'api_push' }, () => '抓取'),
|
||||
h(NButton, { size: 'small', onClick: () => toggleEnabled(row) }, () => row.enabled ? '停用' : '启用'),
|
||||
h(NButton, {
|
||||
size: 'small', type: 'info', ghost: true,
|
||||
onClick: () => openTokenModal(row),
|
||||
}, () => '🔑 Token'),
|
||||
h(NPopconfirm, { onPositiveClick: () => del(row) }, {
|
||||
trigger: () => h(NButton, { size: 'small', type: 'error', ghost: true }, () => '删除'),
|
||||
default: () => '确认删除?',
|
||||
}),
|
||||
]
|
||||
return items
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -121,13 +274,25 @@ onMounted(load)
|
||||
<NFormItem label="名称"><NInput v-model:value="form.name" /></NFormItem>
|
||||
<NFormItem label="slug"><NInput v-model:value="form.slug" placeholder="小写字母+连字符" /></NFormItem>
|
||||
<NFormItem label="类型"><NSelect v-model:value="form.kind" :options="kindOptions" /></NFormItem>
|
||||
<NFormItem label="URL"><NInput v-model:value="form.url" placeholder="https://..." /></NFormItem>
|
||||
<NFormItem
|
||||
v-if="form.kind !== 'api_push'"
|
||||
label="URL"
|
||||
>
|
||||
<NInput v-model:value="form.url" placeholder="https://..." />
|
||||
</NFormItem>
|
||||
<NFormItem v-else label="占位 URL">
|
||||
<NInput
|
||||
:value="`api-push://${form.slug || '<slug>'}`"
|
||||
readonly
|
||||
placeholder="根据 slug 自动生成"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="地区"><NInput v-model:value="form.region" placeholder="global / eu / asia / mena" /></NFormItem>
|
||||
<NFormItem label="源语种"><NInput v-model:value="form.language_src" placeholder="en" /></NFormItem>
|
||||
<NFormItem label="源语种"><NInput v-model:value="form.language_src" placeholder="en / zh" /></NFormItem>
|
||||
<NFormItem label="优先级">
|
||||
<NInputNumber v-model:value="form.priority" :min="1" :max="100" />
|
||||
</NFormItem>
|
||||
<NFormItem label="抓取间隔(分)">
|
||||
<NFormItem v-if="form.kind !== 'api_push'" label="抓取间隔(分)">
|
||||
<NInputNumber v-model:value="form.fetch_interval_min" :min="5" :max="1440" />
|
||||
</NFormItem>
|
||||
<NFormItem label="翻译目标"><NInput v-model:value="form.translate_to" /></NFormItem>
|
||||
@@ -139,5 +304,70 @@ onMounted(load)
|
||||
</NSpace>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<!-- Ingest Token 管理弹窗 -->
|
||||
<NModal
|
||||
v-model:show="showTokenModal"
|
||||
preset="card"
|
||||
:title="`Ingest Tokens · ${tokenSource?.name || ''}`"
|
||||
style="width: 800px"
|
||||
>
|
||||
<NSpace vertical :size="14">
|
||||
<!-- 新建 token -->
|
||||
<NCard size="small" title="生成新 token">
|
||||
<NSpace align="center" :size="8" :wrap="true">
|
||||
<NInput
|
||||
v-model:value="newTokenName"
|
||||
placeholder="名称,如 'wechat-bot'"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<NInputNumber
|
||||
v-model:value="newTokenExpiresDays"
|
||||
placeholder="过期天数(留空=永不过期)"
|
||||
:min="1"
|
||||
:max="3650"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
/>
|
||||
<NButton type="primary" @click="createToken">生成</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<!-- 新生成的 raw token(只显示一次!)-->
|
||||
<NCard v-if="lastIssuedRaw" size="small" :bordered="true" content-style="background: #fef3c7;">
|
||||
<NSpace vertical :size="8">
|
||||
<strong style="color: #92400e">
|
||||
⚠️ 请立即复制下方 raw_token — 关闭弹窗后不再显示
|
||||
</strong>
|
||||
<NInput
|
||||
:value="lastIssuedRaw"
|
||||
readonly
|
||||
type="text"
|
||||
style="font-family: monospace; font-size: 12px"
|
||||
/>
|
||||
<NSpace>
|
||||
<NButton size="small" type="primary" @click="copyRaw">复制</NButton>
|
||||
<NButton size="small" @click="lastIssuedRaw = null">我已保存,关闭</NButton>
|
||||
</NSpace>
|
||||
<small style="color: #92400e">
|
||||
用法:curl -X POST {{ originForCurl }}/api/v1/ingest
|
||||
-H "X-Ingest-Token: <上方的 token>" -H "Content-Type: application/json"
|
||||
-d '{"title":"...","body":"..."}'
|
||||
</small>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<!-- 已有 token 列表 -->
|
||||
<NCard size="small" title="已有 token">
|
||||
<NDataTable
|
||||
:columns="tokenColumns"
|
||||
:data="tokenList"
|
||||
:loading="tokenLoading"
|
||||
:bordered="false"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
/>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
</NSpace>
|
||||
</template>
|
||||
</template>
|
||||
@@ -160,6 +160,20 @@ const originalBody = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
// 短新闻(API Push)判断。短新闻的 details 视图与长新闻不同:
|
||||
// - 不显示原文/译文 Tab(短新闻是中文原生,无原文)
|
||||
// - 不显示 AI 配图(用户明确不要)
|
||||
// - 隐藏"重译"按钮(translation_status=n/a,重译无意义)
|
||||
// - 隐藏"原文链接"(合成 url,打开无意义)
|
||||
const isShort = computed(() => !!article.value?.is_short_news)
|
||||
|
||||
// 短新闻正文:用 body_zh_text(ingest 时已 = body_text),按段落切分
|
||||
const shortBody = computed(() => {
|
||||
const a = article.value
|
||||
if (!a) return ''
|
||||
return splitIntoParagraphs(a.body_zh_text || a.body_text || '')
|
||||
})
|
||||
|
||||
async function rerunTranslation() {
|
||||
if (!article.value) return
|
||||
if (!confirm('重新翻译会消耗配额,确认?')) return
|
||||
@@ -213,10 +227,12 @@ onMounted(load)
|
||||
<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>
|
||||
<NTag v-if="isShort" type="info" :bordered="false" round>📰 短讯</NTag>
|
||||
<NTag v-if="article.source_ref" :bordered="false" round>{{ article.source_ref }}</NTag>
|
||||
<NTag v-if="!isShort && 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>
|
||||
<NTag v-if="!isShort && 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>
|
||||
@@ -267,13 +283,13 @@ onMounted(load)
|
||||
<NButton text @click="showTranslation = !showTranslation" round>
|
||||
{{ showTranslation ? '隐藏译文' : '显示译文' }}
|
||||
</NButton>
|
||||
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation" round>
|
||||
<NButton v-if="isOwner && !isShort" 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 v-if="!isShort" tag="a" :href="article.url" target="_blank" rel="noopener" ghost round>
|
||||
原文链接 ↗
|
||||
</NButton>
|
||||
</NSpace>
|
||||
@@ -393,8 +409,17 @@ onMounted(load)
|
||||
</NText>
|
||||
</NCard>
|
||||
|
||||
<!-- 2) 译文(优先 LLM 排版版) -->
|
||||
<div v-if="showTranslation" style="margin-top: 16px">
|
||||
<!-- 2) 短新闻(API Push):直接显示正文,跳过译文/原文 Tab -->
|
||||
<NCard v-if="isShort" class="detail-card" style="margin-top: 16px">
|
||||
<template #header>
|
||||
<span class="card-header-title">📝 短讯正文</span>
|
||||
</template>
|
||||
<div v-if="shortBody" class="article-body-fallback" v-html="shortBody" />
|
||||
<NText v-else :depth="3">暂无正文</NText>
|
||||
</NCard>
|
||||
|
||||
<!-- 2) 译文(长新闻,优先 LLM 排版版) -->
|
||||
<div v-else-if="showTranslation" style="margin-top: 16px">
|
||||
<NCard v-if="article.body_zh_formatted" class="detail-card">
|
||||
<template #header>
|
||||
<span class="card-header-title">📖 文章译文</span>
|
||||
@@ -422,16 +447,16 @@ onMounted(load)
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<!-- AI 插图 -->
|
||||
<NCard v-if="article.image_ai_url" class="detail-card" style="margin-top: 16px">
|
||||
<!-- AI 插图(长新闻) -->
|
||||
<NCard v-if="!isShort && 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">
|
||||
<!-- 3) 原文(长新闻) -->
|
||||
<div v-if="!isShort && showOriginal" style="margin-top: 16px">
|
||||
<NCard class="detail-card">
|
||||
<template #header>
|
||||
<span class="card-header-title">📄 文章原文</span>
|
||||
|
||||
@@ -156,9 +156,13 @@ function commentaryState(status?: string | null, content?: string | null): Comme
|
||||
return 'waiting'
|
||||
}
|
||||
|
||||
// 正文摘要(取 body_zh_text 前 N 字;没有就 fallback 到 summary_zh)
|
||||
function bodyExcerpt(text?: string | null, max = 200): string {
|
||||
// 正文摘要:长新闻截前 200 字(把多空白合并),短新闻保留原始换行不截取
|
||||
function bodyExcerpt(text?: string | null, max = 200, keepNewlines = false): string {
|
||||
if (!text) return ''
|
||||
if (keepNewlines) {
|
||||
// 短新闻:不去空白,保留 \n 让前端 white-space: pre-wrap 换行
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim()
|
||||
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
|
||||
}
|
||||
@@ -209,7 +213,10 @@ onMounted(async () => {
|
||||
v-for="a in items"
|
||||
:key="a.id"
|
||||
class="article-card"
|
||||
:class="{ 'article-card-read': a.is_read }"
|
||||
:class="{
|
||||
'article-card-read': a.is_read,
|
||||
'short-card': a.is_short_news,
|
||||
}"
|
||||
hoverable
|
||||
@click="open(a)"
|
||||
>
|
||||
@@ -235,6 +242,17 @@ onMounted(async () => {
|
||||
>
|
||||
{{ c }}
|
||||
</NTag>
|
||||
<!-- 短新闻(API Push)角标:固定显示 -->
|
||||
<NTag
|
||||
v-if="a.is_short_news"
|
||||
size="tiny"
|
||||
type="info"
|
||||
:bordered="false"
|
||||
round
|
||||
class="feed-short-tag"
|
||||
>
|
||||
📰 短讯
|
||||
</NTag>
|
||||
<!-- 已读/未读小标签 -->
|
||||
<NTag
|
||||
v-if="a.is_read"
|
||||
@@ -273,9 +291,9 @@ onMounted(async () => {
|
||||
{{ a.title }}
|
||||
</div>
|
||||
|
||||
<!-- AI 插图(若有) -->
|
||||
<!-- AI 插图(若有;短新闻不显示) -->
|
||||
<img
|
||||
v-if="a.image_ai_url || a.image_url"
|
||||
v-if="!a.is_short_news && (a.image_ai_url || a.image_url)"
|
||||
:src="a.image_ai_url || a.image_url || ''"
|
||||
style="
|
||||
display: block;
|
||||
@@ -290,9 +308,14 @@ onMounted(async () => {
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
<!-- 翻译后正文摘要 -->
|
||||
<!--
|
||||
正文摘要:
|
||||
- 长新闻:body_zh_text 截前 200 字(去多余空白)
|
||||
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
|
||||
-->
|
||||
<div
|
||||
v-if="a.body_zh_text || a.summary_zh"
|
||||
:class="{ 'short-body': a.is_short_news }"
|
||||
style="
|
||||
margin-top: 4px;
|
||||
color: var(--color-letter);
|
||||
@@ -300,7 +323,11 @@ onMounted(async () => {
|
||||
line-height: 1.75;
|
||||
"
|
||||
>
|
||||
{{ bodyExcerpt(a.body_zh_text || a.summary_zh, 220) }}
|
||||
{{
|
||||
a.is_short_news
|
||||
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
|
||||
: bodyExcerpt(a.body_zh_text || a.summary_zh, 200)
|
||||
}}
|
||||
</div>
|
||||
|
||||
<!-- 评论钩子(双 provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
|
||||
@@ -590,6 +617,36 @@ onMounted(async () => {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
/* === 短新闻(API Push)卡片差异化 ===
|
||||
长新闻:卡片色调不变
|
||||
短新闻:淡蓝底 + 左侧 3px 蓝色竖线条,便于一眼区分
|
||||
*/
|
||||
.article-card.short-card {
|
||||
background: #f6f9fc;
|
||||
border-left: 3px solid #4f9eff;
|
||||
}
|
||||
.article-card.short-card:hover {
|
||||
background: #eef4fb;
|
||||
}
|
||||
/* 已读 + 短新闻:已读底色优先,左边色条仍保留作为"短讯"标识 */
|
||||
.article-card.short-card.article-card-read {
|
||||
background: #fafafa;
|
||||
border-left-color: #4f9eff;
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
/* 短新闻正文:保留换行 */
|
||||
.short-body {
|
||||
white-space: pre-wrap;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* 短讯角标 */
|
||||
.feed-short-tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* === 底部操作栏(浮在卡片右下角)=== */
|
||||
.feed-actions {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user