Files
diary-news/frontend/src/views/Feed.vue

1086 lines
34 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
NPagination, NAutoComplete, NAlert, useMessage,
} from 'naive-ui'
import { articlesApi, readsApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
import { searchApi, type SearchKeyword } from '@/api/search'
import { useDebounce } from '@/composables/useDebounce'
import { useAuthStore } from '@/stores/auth'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const router = useRouter()
const auth = useAuthStore()
const message = useMessage()
const items = ref<ArticleListItem[]>([])
const sources = ref<Source[]>([])
const loading = ref(false)
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
const pendingRemoval = ref<Set<number>>(new Set())
// === 分类批量已读提示条 ===
// 触发:用户标某条为已读时,查询该文章前 2 个 category 的"24h 未读数";
// 大于 0 的 category 各显示一行提示,带独立"全部已读 / 稍后再说"按钮
// 行为:点击"全部已读"调 markCategory,前端拿到 article_ids 走乐观滑出
// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 重置或清空)
type CategoryPromptItem = {
category: string
unreadCount: number
triggeredById: number
}
const categoryPrompts = ref<CategoryPromptItem[]>([])
const pendingCategory = ref<string | null>(null) // 正在请求"全部已读"的分类
let categoryPromptTimer: number | null = null
// === 页码分页(替代原来的 cursor 无限滚动)===
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const totalPages = ref(1)
const sourceFilter = ref<string[]>([])
const q = ref('')
// 已读过滤:hide_read = true → 默认隐藏已读;切换显示
const hideRead = ref(true)
const sourceOptions = ref<{ label: string; value: string }[]>([])
// === 搜索建议(autocomplete) — 纯 keyword 续接词 ===
// 触发:q 变化(用户输入)→ 250ms debounce → 调 /api/v1/search/suggestions
// 取消:每次新输入前 abort 上一次未完成的请求,避免旧响应覆盖新结果
// 选词:@select → 填入 q + 触发搜索(不再等回车)
const suggestKeywords = ref<SearchKeyword[]>([])
let suggestAbort: AbortController | null = null
async function fetchSuggestions(prefix: string) {
const p = prefix.trim()
if (!p) {
suggestKeywords.value = []
return
}
// 取消上一次未完成的
suggestAbort?.abort()
const ctrl = new AbortController()
suggestAbort = ctrl
try {
const resp = await searchApi.suggestions(p, 10, { signal: ctrl.signal })
// race condition 防护 — 只采纳最新请求的响应
if (suggestAbort === ctrl) {
suggestKeywords.value = resp.keywords
}
} catch (e: any) {
// 被 abort 的请求静默忽略(用户继续输入中)
if (e?.code !== 'ERR_CANCELED' && e?.name !== 'CanceledError') {
// 静默失败:不弹错误(autocomplete 失败不应干扰用户)
// eslint-disable-next-line no-console
console.debug('search suggestions failed:', e?.message)
if (suggestAbort === ctrl) {
suggestKeywords.value = []
}
}
}
}
const debouncedFetchSuggestions = useDebounce(fetchSuggestions, 250)
watch(q, (v) => {
debouncedFetchSuggestions(v)
})
// === NAutoComplete options ===
// 只用 keyword 续接词,扁平结构。
type SuggestOption = {
label: string
value: string
meta: SearchKeyword
}
const suggestOptions = computed<SuggestOption[]>(() => {
return suggestKeywords.value.map((k) => ({
label: k.word,
value: k.word,
meta: k,
}))
})
// 自定义 render:只显示词文本(干净,不挂标签/数字)
function renderSuggestion(opt: SuggestOption) {
return h('span', { class: 'feed-suggest-text' }, opt.label)
}
// 选完候选词:naive-ui 把 value 写回 v-model,我们从 suggestOptions 找 meta
function onSelectSuggestion(value: string) {
const matched = suggestOptions.value.find((o) => o.value === value)
if (matched) {
q.value = matched.meta.word
resetToFirstPage()
} else {
// 兜底:value 就是用户要的关键词
q.value = value
resetToFirstPage()
}
}
async function load() {
if (loading.value) return
loading.value = true
try {
const resp = await articlesApi.list({
source: sourceFilter.value.join(',') || undefined,
q: q.value || undefined,
page: page.value,
page_size: pageSize.value,
hide_read: hideRead.value ? 'true' : 'false',
})
items.value = resp.items
total.value = resp.total
totalPages.value = resp.total_pages
} finally {
loading.value = false
}
}
// === 已读操作(乐观更新,失败回滚;hide_read 模式下用滑出动画)===
// leave 动画起点(测出卡片真实高度,写到 style 让 max-height 能 transition 到 0)
function beforeCardLeave(el: Element) {
const h = (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.maxHeight = h + 'px'
}
async function toggleRead(a: ArticleListItem) {
const wasRead = a.is_read
a.is_read = !wasRead // 乐观更新
try {
if (wasRead) {
await readsApi.unmark(a.id)
} else {
await readsApi.mark(a.id)
}
// 标记为已读后,如果当前在 hide_read 模式,卡片要从列表里消失
if (!wasRead && hideRead.value) {
// 等 leave 动画跑完再从 items 数组里移除(TransitionGroup 才能触发动画)
const idx = items.value.findIndex((x) => x.id === a.id)
if (idx >= 0) {
// 触发 leave 动画:Vue 会保留 DOM 元素直到 transition 结束
// 但 splice(items, idx, 1) 会立即从 v-for 移除 → 用 markPending 标记 → 350ms 后再真正移除
pendingRemoval.value.add(a.id)
setTimeout(() => {
const i = items.value.findIndex((x) => x.id === a.id)
if (i >= 0) items.value.splice(i, 1)
pendingRemoval.value.delete(a.id)
if (total.value > 0) total.value -= 1
}, 360)
}
}
// === 新增:刚标为已读 → 查该文章前 2 个 category 的 24h 未读数 ===
// unmark 路径不触发(用户反悔了,不该再骚扰);hide_read 模式仍可触发
// (滑出动画只影响当前这一条,提示条展示的是"这个分类下还有别的未读")
if (!wasRead && a.category) {
await maybePromptCategoryRead(a)
}
} catch (e: any) {
// 失败回滚
a.is_read = wasRead
message.error(e?.response?.data?.title || '操作失败')
}
}
// 查 a 的前 2 个 category 的 24h 未读数,有未读就追加 / 替换提示条
async function maybePromptCategoryRead(a: ArticleListItem) {
const cats = splitCategory(a.category).slice(0, 2)
if (cats.length === 0) return
try {
const counts = await readsApi.countByCategories(cats, {
window_hours: 24,
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
q: q.value || undefined,
})
// 只保留 unread_count > 0 的
const newPrompts: CategoryPromptItem[] = counts
.filter((c) => c.unread_count > 0)
.map((c) => ({
category: c.category,
unreadCount: c.unread_count,
triggeredById: a.id,
}))
if (newPrompts.length === 0) return
// 同一 category 已存在则替换(更新计数),不重复
const map = new Map(categoryPrompts.value.map((p) => [p.category, p]))
for (const p of newPrompts) map.set(p.category, p)
categoryPrompts.value = Array.from(map.values())
// 重置 8 秒自动消失
if (categoryPromptTimer !== null) clearTimeout(categoryPromptTimer)
categoryPromptTimer = window.setTimeout(() => {
categoryPrompts.value = []
categoryPromptTimer = null
}, 8000)
} catch (e: any) {
// 计数失败不影响主流程,静默
// eslint-disable-next-line no-console
console.debug('category count failed:', e?.message)
}
}
// 用户点头部"全部已读":调 markCategory + 乐观滑出 + 关闭该行提示
async function confirmMarkCategory(category: string) {
if (pendingCategory.value) return // 防重入
pendingCategory.value = category
try {
const resp = await readsApi.markCategory({
category,
scope: 'filtered_unread',
window_hours: 24,
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
q: q.value || undefined,
})
// 把命中的 article 在 items 里全部 is_read=true
const ids = new Set(resp.article_ids)
for (const item of items.value) {
if (ids.has(item.id)) item.is_read = true
}
// 走 hide_read 模式下的滑出(累计 delay,逐个错开,避免 30 个 setTimeout 同时触发视觉抖)
let i = 0
for (const id of resp.article_ids) {
const idx = items.value.findIndex((x) => x.id === id)
if (idx < 0) continue
pendingRemoval.value.add(id)
const delay = 360 + i * 20
i++
setTimeout(() => {
const k = items.value.findIndex((x) => x.id === id)
if (k >= 0) items.value.splice(k, 1)
pendingRemoval.value.delete(id)
if (total.value > 0) total.value -= 1
}, delay)
}
dismissPrompt(category)
message.success(`已将 ${resp.marked} 条「${category}」标记为已读`)
} catch (e: any) {
message.error(e?.response?.data?.title || '操作失败')
} finally {
pendingCategory.value = null
}
}
// 关闭某一行(稍后再说 / 自动消失 / 已被 confirm 后调用)
function dismissPrompt(category: string) {
categoryPrompts.value = categoryPrompts.value.filter((p) => p.category !== category)
if (categoryPrompts.value.length === 0 && categoryPromptTimer !== null) {
clearTimeout(categoryPromptTimer)
categoryPromptTimer = null
}
}
async function loadSources() {
sources.value = await sourcesApi.list()
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
}
// 切页 → 重新加载 + 滚到顶部
function onPageChange(p: number) {
page.value = p
load()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 筛选/搜索变化 → 回到第 1 页
function resetToFirstPage() {
page.value = 1
load()
// 过滤上下文变了,旧分类提示的"24h 未读"数已不准,清掉
if (categoryPrompts.value.length > 0) {
categoryPrompts.value = []
if (categoryPromptTimer !== null) {
clearTimeout(categoryPromptTimer)
categoryPromptTimer = null
}
}
}
function open(a: ArticleListItem) {
router.push(`/article/${a.id}`)
}
function star(a: ArticleListItem) {
a.is_starred = !a.is_starred
}
function fmtTime(s?: string | null) {
if (!s) return '—'
return dayjs(s).fromNow()
}
// category 是逗号分隔字符串(LLM 输出),拆成多个 tag
function splitCategory(c?: string | null): string[] {
if (!c) return []
return c.split(',').map((s) => s.trim()).filter(Boolean)
}
2026-06-15 07:32:39 +08:00
// === 搜索关键字高亮(用于标题/评论预览的渲染)===
// 1) HTML escape — 防止 XSS(content 来自外部 RSS/ingest,不可信)
// 2) 不区分大小写匹配 q
// 3) 用 <mark> 包裹匹配项(全局 mark 标签由 .commentary-text / 标题区样式接管)
// 返回 HTML 字符串,供 v-html 使用。
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function highlightHtml(text: string | null | undefined, q: string): string {
const safe = escapeHtml(text || '')
if (!q || !q.trim()) return safe
// 转义 q 里可能的正则元字符(用户搜 "*.x" 等不应该当 regex)
const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(escapedQ, 'gi')
return safe.replace(re, (m) => `<mark>${m}</mark>`)
}
// 评论预览:长文截断 + 搜索时高亮
// 返回 HTML 字符串(供 v-html 渲染)。q 为空时等价于纯文本(无高亮标签)。
function previewCommentary(c?: string | null, max = 120, q = ''): string {
if (!c) return ''
const trimmed = c.replace(/\s+/g, ' ').trim()
2026-06-15 07:32:39 +08:00
const text = trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
return highlightHtml(text, q)
}
function commentaryStatusType(s?: string | null): 'success' | 'warning' | 'error' | 'default' {
if (s === 'ok') return 'success'
if (s === 'failed') return 'error'
if (s === 'pending') return 'warning'
return 'default'
}
// === 评论三态语义 ===
// status 'ok' + 有内容 → 显示评论
// status 'ok' + 无内容 → 视为等待(防御性,正常不会触发)
// status 'pending' / 'n/a' / null → 等待中
// status 'failed' → 显示失败提示
type CommentaryState = 'ok' | 'waiting' | 'failed'
function commentaryState(status?: string | null, content?: string | null): CommentaryState {
if (status === 'failed') return 'failed'
if (status === 'ok' && content) return 'ok'
return 'waiting'
}
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 端点
2026-06-14 16:15:21 +08:00
// 正文摘要:长新闻截前 200 字(把多空白合并),短新闻保留原始换行不截取
function bodyExcerpt(text?: string | null, max = 200, keepNewlines = false): string {
if (!text) return ''
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 端点
2026-06-14 16:15:21 +08:00
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
}
const itemsLabel = computed(() => `${total.value}`)
onMounted(async () => {
await loadSources()
await load()
})
</script>
<template>
<NSpace vertical :size="16">
<NSpace align="center" justify="space-between" :wrap="true" :size="[10, 10]" class="feed-toolbar">
<NSpace :size="10" :wrap="true" class="feed-toolbar-left">
<NSelect
v-model:value="sourceFilter"
multiple
clearable
placeholder="按源筛选"
:options="sourceOptions"
class="feed-source-select"
@update:value="resetToFirstPage"
/>
<NAutoComplete
v-model:value="q"
placeholder="关键词搜索"
clearable
class="feed-search-input"
:options="suggestOptions"
:render-label="renderSuggestion"
@select="onSelectSuggestion"
@keyup.enter="resetToFirstPage"
@clear="resetToFirstPage"
/>
<NSpace align="center" :size="6" class="feed-hideread-toggle">
<NText style="font-size: 13px">隐藏已读</NText>
<NSwitch v-model:value="hideRead" @update:value="resetToFirstPage" />
</NSpace>
<NButton type="primary" @click="resetToFirstPage" round>刷新</NButton>
</NSpace>
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
</NSpace>
<!--
分类批量已读提示条:
- 一篇文章可拆出 1-2 ( category 2 )
- 每行独立"全部已读 / 稍后再说"
- 8 秒自动消失 / 过滤变化时立即清掉
-->
<Transition name="prompt">
<div v-if="categoryPrompts.length > 0" class="feed-category-prompts">
<NAlert
v-for="p in categoryPrompts"
:key="p.category"
type="success"
:show-icon="false"
closable
@close="dismissPrompt(p.category)"
class="feed-category-prompt"
>
<template #header>
<NSpace align="center" :size="8" :wrap="true">
<NTag type="success" size="small" round :bordered="false"> 已读</NTag>
<NText>{{ p.category }}分类下还有 {{ p.unreadCount }} 24 小时未读</NText>
</NSpace>
</template>
<NSpace :size="8" style="margin-top: 8px">
<NButton
type="primary"
size="small"
round
:loading="pendingCategory === p.category"
:disabled="pendingCategory !== null"
@click="confirmMarkCategory(p.category)"
>
全部已读
</NButton>
<NButton size="small" round @click="dismissPrompt(p.category)">
稍后再说
</NButton>
</NSpace>
</NAlert>
</div>
</Transition>
<NSpin :show="loading && items.length === 0">
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
<TransitionGroup
v-else
name="card"
tag="div"
class="feed-list"
@before-leave="beforeCardLeave"
>
<NCard
v-for="a in items"
:key="a.id"
class="article-card"
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 端点
2026-06-14 16:15:21 +08:00
:class="{
'article-card-read': a.is_read,
'short-card': a.is_short_news,
}"
hoverable
@click="open(a)"
>
<NSpace vertical :size="10">
<!-- 顶行: / 语言 / 分类 tag / 时间 -->
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
<NTag size="small" type="primary" :bordered="false" round>
{{ a.source.name }}
</NTag>
<NTag v-if="a.lang_src" size="small" round :bordered="false">
{{ a.lang_src.toUpperCase() }}
</NTag>
<NTag v-if="a.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
{{ a.translation_status }}
</NTag>
<NTag
v-for="c in splitCategory(a.category)"
:key="c"
size="small"
type="success"
:bordered="false"
round
>
{{ c }}
</NTag>
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 端点
2026-06-14 16:15:21 +08:00
<!-- 短新闻(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"
size="tiny"
:bordered="false"
round
type="default"
class="feed-read-tag"
>
已读
</NTag>
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="feed-time-label">
{{ fmtTime(a.published_at || a.fetched_at) }}
</NText>
</NSpace>
2026-06-15 07:32:39 +08:00
<!-- 中文标题() 搜索时高亮 q -->
<div
v-if="a.title_zh"
style="
font-family: var(--font-serif);
font-size: 20px;
font-weight: 700;
color: var(--color-letter);
line-height: 1.4;
"
2026-06-15 07:32:39 +08:00
v-html="highlightHtml(a.title_zh, q)"
/>
2026-06-15 07:32:39 +08:00
<!-- 原标题(灰色,辅助) 搜索时高亮 q -->
<div
v-if="a.title"
style="font-size: 13px; color: var(--color-text-faint); line-height: 1.4;"
2026-06-15 07:32:39 +08:00
v-html="highlightHtml(a.title, q)"
/>
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 端点
2026-06-14 16:15:21 +08:00
<!-- AI 插图(若有;短新闻不显示) -->
<img
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 端点
2026-06-14 16:15:21 +08:00
v-if="!a.is_short_news && (a.image_ai_url || a.image_url)"
:src="a.image_ai_url || a.image_url || ''"
style="
display: block;
width: 100%;
max-height: 280px;
object-fit: cover;
border-radius: 8px;
margin: 4px 0;
background: var(--color-surface-variant);
"
referrerpolicy="no-referrer"
loading="lazy"
/>
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 端点
2026-06-14 16:15:21 +08:00
<!--
正文摘要:
- 长新闻:body_zh_text 截前 200 (去多余空白)
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
2026-06-15 07:32:39 +08:00
- 搜索时高亮 q(escape + <mark> 包裹, XSS)
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 端点
2026-06-14 16:15:21 +08:00
-->
<div
v-if="a.body_zh_text || a.summary_zh"
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 端点
2026-06-14 16:15:21 +08:00
:class="{ 'short-body': a.is_short_news }"
style="
margin-top: 4px;
color: var(--color-letter);
font-size: 14px;
line-height: 1.75;
"
2026-06-15 07:32:39 +08:00
v-html="highlightHtml(
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 端点
2026-06-14 16:15:21 +08:00
a.is_short_news
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
2026-06-15 07:32:39 +08:00
: bodyExcerpt(a.body_zh_text || a.summary_zh, 200),
q
)"
/>
<!-- 评论钩子( provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
<div class="commentary-stack">
<!-- Angel 评论 -->
<div
class="commentary-item"
:class="{
'commentary-item-waiting': commentaryState(a.commentary_status, a.commentary) === 'waiting',
'commentary-item-failed': commentaryState(a.commentary_status, a.commentary) === 'failed',
'commentary-item-ok': commentaryState(a.commentary_status, a.commentary) === 'ok',
}"
>
<div class="commentary-head">
<span class="commentary-label">💬 Angel 评论</span>
<span
v-if="commentaryState(a.commentary_status, a.commentary) !== 'ok'"
class="commentary-badge"
:class="`commentary-badge-${commentaryState(a.commentary_status, a.commentary)}`"
>
<span
v-if="commentaryState(a.commentary_status, a.commentary) === 'waiting'"
class="commentary-spinner"
/>
{{
commentaryState(a.commentary_status, a.commentary) === 'failed' ? '失败' : '生成中'
}}
</span>
</div>
<div
v-if="commentaryState(a.commentary_status, a.commentary) === 'ok'"
class="commentary-text"
2026-06-15 07:32:39 +08:00
v-html="previewCommentary(a.commentary, 140, q)"
/>
<div v-else-if="commentaryState(a.commentary_status, a.commentary) === 'failed'" class="commentary-text commentary-text-failed">
评论生成失败,后台 enrichment_loop 会重试
</div>
<div v-else class="commentary-text commentary-text-waiting">
正在生成评论
</div>
</div>
<!-- 美团评论 -->
<div
class="commentary-item"
:class="{
'commentary-item-waiting': commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'waiting',
'commentary-item-failed': commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed',
'commentary-item-ok': commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'ok',
}"
>
<div class="commentary-head">
<span class="commentary-label commentary-label-meituan">🐱 美团评论</span>
<span
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) !== 'ok'"
class="commentary-badge"
:class="`commentary-badge-${commentaryState(a.commentary_meituan_status, a.commentary_meituan)}`"
>
<span
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'waiting'"
class="commentary-spinner"
/>
{{
commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed' ? '失败' : '生成中'
}}
</span>
</div>
<div
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'ok'"
class="commentary-text"
2026-06-15 07:32:39 +08:00
v-html="previewCommentary(a.commentary_meituan, 140, q)"
/>
<div v-else-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed'" class="commentary-text commentary-text-failed">
评论生成失败,后台 enrichment_loop 会重试
</div>
<div v-else class="commentary-text commentary-text-waiting">
正在生成评论
</div>
</div>
</div>
<!-- 底部操作栏:已读/未读切换(浮在卡片右下角) -->
<div class="feed-actions" @click.stop>
<NButton
size="small"
:type="a.is_read ? 'tertiary' : 'primary'"
:ghost="!a.is_read"
round
class="feed-read-btn"
:class="{ 'feed-read-btn-read': a.is_read }"
@click.stop="toggleRead(a)"
>
<template #icon>
<span class="feed-read-icon" :class="{ 'feed-read-icon-checked': a.is_read }">
{{ a.is_read ? '✓' : '○' }}
</span>
</template>
{{ a.is_read ? '已读' : '标为已读' }}
</NButton>
</div>
</NSpace>
</NCard>
</TransitionGroup>
<!-- 页码分页 -->
<NSpace v-if="total > 0" justify="center" style="margin: 32px 0 24px">
<NPagination
v-model:page="page"
:page-count="totalPages"
:page-size="pageSize"
show-quick-jumper
@update:page="onPageChange"
/>
</NSpace>
<NText v-else :depth="3" style="display:block; text-align:center; padding: 16px"> 暂无数据 </NText>
</NSpin>
</NSpace>
</template>
<style scoped>
/* === 评论钩子(双 provider,三态视觉化) === */
.commentary-stack {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.commentary-item {
position: relative;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
line-height: 1.65;
min-height: 56px; /* 避免占位卡片太矮导致视觉跳 */
}
/* === OK 状态(有内容)== */
.commentary-item-ok {
background: var(--color-primary-soft);
border-left: 3px solid var(--color-primary);
color: var(--color-letter);
}
.commentary-item-ok .commentary-label {
color: var(--color-primary);
}
.commentary-item-ok .commentary-text {
color: var(--color-letter);
font-size: 13px;
line-height: 1.7;
}
/* === 等待中(生成中)== */
.commentary-item-waiting {
background: #f0f4fa; /* 浅蓝底 */
border: 1.5px dashed #94a3b8; /* 虚线边框,提示"未完成" */
border-left: 4px solid #64748b; /* 左侧稍粗的灰条,跟 ok 状态一致位置 */
color: var(--color-text-faint);
display: flex;
flex-direction: column;
justify-content: center;
}
.commentary-item-waiting .commentary-label {
color: #475569;
}
.commentary-item-waiting .commentary-text {
color: #64748b;
font-size: 13px;
font-style: italic;
}
/* === 失败 === */
.commentary-item-failed {
background: #fef2f2; /* 浅红底 */
border: 1.5px solid #fca5a5;
border-left: 4px solid #ef4444;
color: #991b1b;
display: flex;
flex-direction: column;
justify-content: center;
}
.commentary-item-failed .commentary-label {
color: #dc2626;
}
.commentary-item-failed .commentary-text {
color: #b91c1c;
font-size: 12px;
font-style: italic;
}
.commentary-label {
font-size: 12px;
font-weight: 600;
}
.commentary-label-meituan {
color: #c2410c; /* 橙色,与 Angel 区分 */
}
.commentary-text-failed,
.commentary-text-waiting {
font-size: 12px;
font-style: italic;
}
.commentary-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.commentary-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
.commentary-badge-waiting {
background: #e2e8f0;
color: #334155;
}
.commentary-badge-failed {
background: #fee2e2;
color: #991b1b;
}
/* 旋转的 spinner(等待中) */
.commentary-spinner {
display: inline-block;
width: 10px;
height: 10px;
border: 1.5px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: commentary-spin 0.8s linear infinite;
}
@keyframes commentary-spin {
to { transform: rotate(360deg); }
}
/* ===== 桌面端默认宽度 ===== */
.feed-source-select {
min-width: 240px;
}
.feed-search-input {
width: 220px;
}
/* NAutoComplete 外层 div 不默认撑满,需要让内部 n-input 占满 */
.feed-search-input.n-auto-complete {
display: inline-block;
}
.feed-search-input.n-auto-complete > .n-input {
width: 100%;
}
/* === 搜索建议下拉项(干净版,只显示词本身) === */
.feed-suggest-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== 移动端(<= 768px):过滤条全宽,允许换行 ===== */
@media (max-width: 768px) {
.feed-source-select {
min-width: 0;
width: 100%;
}
.feed-search-input {
width: 100%;
}
.feed-toolbar-left > * {
width: 100%;
}
.feed-count-label {
display: block;
width: 100%;
margin-top: 4px;
}
.feed-toolbar {
align-items: stretch !important;
}
.feed-time-label {
margin-left: 0 !important;
width: 100%;
text-align: right;
}
}
/* === 已读卡片视觉降级 === */
.article-card-read {
opacity: 0.7;
background: #fafafa;
}
.article-card-read :deep(.n-card-header) {
color: var(--color-text-faint);
}
.article-card-read .commentary-text {
color: var(--color-text-faint);
}
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 端点
2026-06-14 16:15:21 +08:00
/* === (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;
}
2026-06-15 07:32:39 +08:00
/* === 搜索关键字高亮(<mark> 标签)=== */
.feed-list :deep(mark) {
background: #fff3a0; /* 暖黄底,跟 Naive UI 主题协调 */
color: inherit; /* 保持父级文字色,不被 <mark> 默认色影响 */
padding: 0 2px;
border-radius: 2px;
font-weight: 600; /* 命中后略加粗,视觉强调 */
}
/* === 底部操作栏(浮在卡片右下角)=== */
.feed-actions {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--color-primary-soft);
}
.feed-read-btn {
font-size: 12px;
transition: all 0.2s ease;
}
.feed-read-btn:not(.feed-read-btn-read) {
background: linear-gradient(135deg, var(--color-primary) 0%, #4f7fd1 100%);
color: white;
box-shadow: 0 2px 6px rgba(91, 134, 229, 0.25);
}
.feed-read-btn:not(.feed-read-btn-read):hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(91, 134, 229, 0.35);
}
.feed-read-btn-read {
opacity: 0.7;
}
.feed-read-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
margin-right: 2px;
}
.feed-read-icon-checked {
color: #16a34a;
}
.feed-read-tag {
font-size: 11px;
}
.feed-hideread-toggle {
margin-left: 4px;
}
/* === Feed 列表容器(给 TransitionGroup 用)== */
.feed-list {
display: flex;
flex-direction: column;
gap: 16px;
}
/* === 卡片进入/离开动画(标为已读后 hide_read 模式下滑出)== */
.card-enter-active,
.card-leave-active {
transition: opacity 0.3s ease, transform 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
max-height 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
margin 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
padding 0.35s cubic-bezier(0.55, 0, 0.55, 0.2);
}
.card-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.card-leave-from {
/* 由 JS 在 before-leave 钩子注入 maxHeight 起点 */
opacity: 1;
transform: translateX(0) scale(1);
}
.card-leave-to {
opacity: 0;
transform: translateX(40px) scale(0.96);
max-height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
border-width: 0 !important;
overflow: hidden;
}
.card-move {
transition: transform 0.35s ease;
}
.card-enter-from {
opacity: 0;
transform: translateY(-12px);
}
.card-leave-to {
opacity: 0;
transform: translateX(40px) scale(0.96);
max-height: 0;
margin: 0;
padding: 0;
border-width: 0;
overflow: hidden;
}
.card-leave-active {
/* max-height transition, 配合 transform 一起 */
max-height: 800px;
}
.card-move {
transition: transform 0.35s ease;
}
/* === ===
- 浅绿渐变 + 左侧色条,跟已读视觉呼应但不抢戏
- 出现/消失:opacity + max-height 配合,跟卡片滑出同节奏
*/
.feed-category-prompts {
display: flex;
flex-direction: column;
gap: 8px;
}
.feed-category-prompt {
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
border-left: 3px solid var(--color-primary, #5b86e5);
border-radius: 6px;
padding: 10px 12px;
}
.feed-category-prompt :deep(.n-alert__header) {
font-size: 13px;
font-weight: 500;
}
/* 提示条进入/离开(整组) */
.prompt-enter-active,
.prompt-leave-active {
transition: opacity 0.25s ease, transform 0.3s cubic-bezier(0.55, 0, 0.55, 0.2),
max-height 0.3s ease;
overflow: hidden;
}
.prompt-enter-from,
.prompt-leave-to {
opacity: 0;
transform: translateY(-6px);
max-height: 0 !important;
}
.prompt-enter-to,
.prompt-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 200px;
}
</style>