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

1169 lines
38 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
// 只渲染"还在 items 里"且"有提示条"的 article 列表(给 TransitionGroup 用作 v-for)
// computed 保证响应式
// - items 没这条 → 不渲染(孤儿)
// - items 有 + Map 也有 → 渲染
// - items 有 + Map 没 → 不渲染(用户已 dismiss/确认/超时)
const articlesWithPrompts = computed(() =>
items.value.filter((a) => (categoryPromptsByArticle.value.get(a.id)?.length ?? 0) > 0)
)
// 移除某篇文章的所有提示条(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)
}
// === 关键:先 await 提示条 fetch,再决定是否 splice ===
// 原因:hide_read 模式下,如果 360ms 内就把 a 从 items 移除,
// articlesWithPrompts 跟着过滤,wrapper 就被切走 → 用户看不到提示条。
// 解决:如果 fetch 完发现有提示条 → 把 splice 推迟到 8s 提示条到期再 splice;
// 如果没有提示条 → 照旧 360ms splice。
if (!wasRead && a.category) {
try {
const fetchPromise = maybePromptCategoryRead(a)
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 1500))
await Promise.race([fetchPromise, timeout])
// eslint-disable-next-line no-console
console.debug('[category-prompt] mounted for article', a.id, 'cats=', categoryPromptsByArticle.value.get(a.id))
} catch {
// 静默失败
}
}
// hide_read 模式:卡片从列表里消失(用户预期行为)
// 但如果该文章有提示条,推迟 splice 到提示条消失后再做
if (!wasRead && hideRead.value) {
const hasPrompt = (categoryPromptsByArticle.value.get(a.id)?.length ?? 0) > 0
const delay = hasPrompt ? 8200 : 360 // 有提示:让提示条走完 8s;无提示:照旧
if (hasPrompt) {
// 监听 8s 计时器到期 / dismiss / confirm
// 用 watchEffect 监听 Map 变化,提示条消失后再 splice
const stopWatch = watch(
() => categoryPromptsByArticle.value.get(a.id),
(newVal) => {
if (!newVal || newVal.length === 0) {
stopWatch()
const idx = items.value.findIndex((x) => x.id === a.id)
if (idx >= 0) {
pendingRemoval.value.add(a.id)
items.value.splice(idx, 1)
pendingRemoval.value.delete(a.id)
if (total.value > 0) total.value -= 1
}
}
},
{ immediate: false },
)
// 兜底:8.2s 强制结束(防止 watch 漏掉)
setTimeout(() => {
stopWatch()
const idx = items.value.findIndex((x) => x.id === a.id)
if (idx >= 0) {
pendingRemoval.value.add(a.id)
items.value.splice(idx, 1)
pendingRemoval.value.delete(a.id)
if (total.value > 0) total.value -= 1
}
}, delay)
} else {
const idx = items.value.findIndex((x) => x.id === a.id)
if (idx >= 0) {
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
}, delay)
}
}
}
} 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 挂载,卡片**下方**):
- 用 articlesWithPrompts(过滤后只有有提示条的 article)做 v-for
- key 用 a.id 跟 NCard 共享(但 NCard 已经在自己的 v-for 里,key 不冲突,
因为这是独立的 v-for 节点),hide_read 模式下 items.splice 移除文章时,
对应的提示条 wrapper 会从 articlesWithPrompts 移除 → leave 动画
- 内部 NAlert v-for 用 ${a.id}-${category} 作为 key,确保多个 category 时各自独立
-->
<div
v-for="a in articlesWithPrompts"
:key="`prompt-wrapper-${a.id}`"
class="feed-category-prompt-wrapper"
>
<!-- 标题行:标记已读的确认 + 1~2 条分类提示的容器 -->
<div class="feed-category-prompt-header">
<NSpace align="center" :size="8" :wrap="true">
<NTag type="success" size="small" round :bordered="false">✓ 已将《{{ a.title_zh || a.title }}》标记为已读</NTag>
</NSpace>
</div>
<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">
<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;
}
/* 标题行(标已读的确认) 跟下面的 NAlert 视觉分层 */
.feed-category-prompt-header {
padding: 6px 4px 0;
font-size: 13px;
color: var(--color-text-faint);
}
.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>