Files
diary-news/frontend/src/views/Feed.vue
xiaji 4f98fb8779 refactor(feed): 分类提示条 per-article 挂载,贴在对应卡片下方
旧实现: 提示条放在工具条下面(全局一个,堆所有 category)
       用户反馈:标第二篇时弹在最前,不符合视觉逻辑

新实现: categoryPromptsByArticle: Map<articleId, CategoryPromptItem[]>
       标第 N 篇已读,提示条只挂到第 N 篇下面(紧贴)
       hide_read 模式下,卡片 leave 时提示条跟着 leave(共享 v-for key)

变更:
- state 从 categoryPrompts: CategoryPromptItem[] 改为 Map
- 工具条下全局提示条块删除
- TransitionGroup 内部 NCard 后加独立 v-for 渲染提示条 wrapper
- v-for 共享 key 用 a.id(跟 NCard 一致),hide_read 时联动 leave
- 删 prompt-* 动画(改用 TransitionGroup 共享的 name=card 动画)
- 配套 confirmMarkCategory/dismissPrompt/clearPromptsForArticle 签名加 articleId
2026-06-15 21:24:06 +08:00

1106 lines
36 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 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())
// === 分类批量已读提示条(per-article) ===
// 触发:用户标某条为已读时,查询该文章前 2 个 category 的"24h 未读数";
// 大于 0 的 category 各显示一行提示,**紧贴该文章卡片下方**
// 行为:点击"全部已读"调 markCategory,前端拿到 article_ids 走乐观滑出
// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 移除该 article 的提示条)
// hide_read 模式下,卡片 leave 时该 article 对应的提示条也跟着 leave(共享 setTimeout)
type CategoryPromptItem = {
category: string
unreadCount: number
}
// Map<articleId, CategoryPromptItem[]> — 每篇文章独立挂自己的提示条
const categoryPromptsByArticle = ref<Map<number, CategoryPromptItem[]>>(new Map())
const pendingCategory = ref<string | null>(null) // 正在请求"全部已读"的分类(category 名)
let categoryPromptTimer: number | null = null
// 移除某篇文章的所有提示条(8 秒超时 / 确认后 / 过滤变化)
function clearPromptsForArticle(articleId: number) {
if (categoryPromptsByArticle.value.has(articleId)) {
const next = new Map(categoryPromptsByArticle.value)
next.delete(articleId)
categoryPromptsByArticle.value = next
}
}
// 全清
function clearAllPrompts() {
if (categoryPromptsByArticle.value.size > 0) {
categoryPromptsByArticle.value = new Map()
}
if (categoryPromptTimer !== null) {
clearTimeout(categoryPromptTimer)
categoryPromptTimer = 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,
}))
if (newPrompts.length === 0) return
// 把提示条挂到被标记的那篇文章下面(Map 替换触发响应式)
const next = new Map(categoryPromptsByArticle.value)
next.set(a.id, newPrompts)
categoryPromptsByArticle.value = next
// 8 秒后自动移除该文章的提示条(无操作就消失)
if (categoryPromptTimer !== null) clearTimeout(categoryPromptTimer)
categoryPromptTimer = window.setTimeout(() => {
clearPromptsForArticle(a.id)
categoryPromptTimer = null
}, 8000)
} catch (e: any) {
// 计数失败不影响主流程,静默
// eslint-disable-next-line no-console
console.debug('category count failed:', e?.message)
}
}
// 用户点提示条"全部已读":调 markCategory + 乐观滑出 + 关闭该条提示
async function confirmMarkCategory(articleId: number, 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)
}
// 关闭该 article 的整组提示(不只关 category — 既然"全部已读"了,这一片都不需要了)
clearPromptsForArticle(articleId)
message.success(`已将 ${resp.marked} 条「${category}」标记为已读`)
} catch (e: any) {
message.error(e?.response?.data?.title || '操作失败')
} finally {
pendingCategory.value = null
}
}
// 关闭某条提示(稍后再说 / × 按钮)— 输入是 articleId + category
function dismissPrompt(articleId: number, category: string) {
const cur = categoryPromptsByArticle.value.get(articleId)
if (!cur) return
const next = cur.filter((p) => p.category !== category)
const map = new Map(categoryPromptsByArticle.value)
if (next.length === 0) {
map.delete(articleId)
} else {
map.set(articleId, next)
}
categoryPromptsByArticle.value = map
// 如果整组提示都关了,清掉 timer
if (categoryPromptsByArticle.value.size === 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 (categoryPromptsByArticle.value.size > 0) {
clearAllPrompts()
}
}
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)
}
// === 搜索关键字高亮(用于标题/评论预览的渲染)===
// 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()
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'
}
// 正文摘要:长新闻截前 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
}
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>
<!--
分类批量已读提示条(per-article):
- 标某条已读后,提示条出现在该卡片下方(紧贴)
- 1-2 ( category 2 ,unread_count > 0)
- 每行独立"全部已读 / 稍后再说"
- 8 秒自动消失 / 过滤变化时清掉 / 卡片 leave 时跟随 leave
-->
<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"
: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>
<!-- 短新闻(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>
<!-- 中文标题() 搜索时高亮 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;
"
v-html="highlightHtml(a.title_zh, q)"
/>
<!-- 原标题(灰色,辅助) 搜索时高亮 q -->
<div
v-if="a.title"
style="font-size: 13px; color: var(--color-text-faint); line-height: 1.4;"
v-html="highlightHtml(a.title, q)"
/>
<!-- AI 插图(若有;短新闻不显示) -->
<img
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"
/>
<!--
正文摘要:
- 长新闻:body_zh_text 截前 200 (去多余空白)
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
- 搜索时高亮 q(escape + <mark> 包裹, XSS)
-->
<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);
font-size: 14px;
line-height: 1.75;
"
v-html="highlightHtml(
a.is_short_news
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
: 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"
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"
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>
<!--
该文章对应的分类提示条(per-article 挂载,卡片**下方**):
- 独立的 v-for 循环,key 用 a.id 跟 NCard 共享,这样 hide_read 模式下
items.splice 移除文章时,提示条会跟 NCard 同步触发 leave 动画
- 内部 NAlert v-for 用 ${a.id}-${category} 作为 key,确保多个 category 时各自独立
- 用 v-show 而非 v-if:v-for 始终渲染 wrapper,内部 NAlert 按需显示
-->
<div
v-for="a in items"
v-show="!!categoryPromptsByArticle.get(a.id)?.length"
:key="`prompt-wrapper-${a.id}`"
class="feed-category-prompt-wrapper"
>
<NAlert
v-for="p in (categoryPromptsByArticle.get(a.id) || [])"
:key="`${a.id}-${p.category}`"
type="success"
:show-icon="false"
closable
@close="dismissPrompt(a.id, 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(a.id, p.category)"
>
全部已读
</NButton>
<NButton size="small" round @click="dismissPrompt(a.id, p.category)">
稍后再说
</NButton>
</NSpace>
</NAlert>
</div>
</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);
}
/* === 短新闻(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;
}
/* === 搜索关键字高亮(<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;
}
/* === 分类批量已读提示条(per-article,紧贴卡片下方)===
- 浅绿渐变 + 左侧色条,跟已读视觉呼应但不抢戏
- wrapper 自身不带动画,内部 NAlert 走 card 动画(因为是 TransitionGroup 子节点)
- wrapper 与 NCard 之间有视觉分隔(margin-top),但因为它在 TransitionGroup 内
共享 name="card" 的 leave 动画,卡片滑出时 wrapper 同步滑出,看起来"提示条跟着卡片走"
*/
.feed-category-prompt-wrapper {
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;
}
</style>