<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>