feat(feed): 按分类批量已读 + 标已读后弹出分类提示条
后端: - POST /me/reads/by-category:按 category + time window 批量标已读 - GET /me/reads/category-count:查多个分类的 24h 未读数 前端: - 标已读后,查该文章前 2 个 category 的 24h 未读数 - 每个有未读的 category 显示一行提示(独立全部已读/稍后再说按钮) - 全部已读走乐观更新,命中的 article 走累计 delay 滑出 - 过滤变化时清掉提示(上下文变了) - 8 秒自动消失
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"""/me 当前用户信息 + 翻译配额 + 已读文章。"""
|
||||
"""/me 当前用户信息 + 翻译配额 + 已读文章 + 按分类批量已读。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, delete, select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import and_, delete, func, not_, or_, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -13,6 +15,7 @@ from app.core.deps import get_current_user
|
||||
from app.database import get_session
|
||||
from app.models.article import Article
|
||||
from app.models.article_read import ArticleRead
|
||||
from app.models.source import Source
|
||||
from app.models.user import User
|
||||
from app.redis_client import get_redis
|
||||
|
||||
@@ -95,7 +98,6 @@ async def mark_read(
|
||||
if not exists:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Article not found")
|
||||
# 用 INSERT ... ON CONFLICT DO NOTHING (PG 原生)— 等价 upsert 跳过
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
stmt = pg_insert(ArticleRead).values(user_id=user.id, article_id=article_id).on_conflict_do_nothing(
|
||||
index_elements=["user_id", "article_id"]
|
||||
)
|
||||
@@ -142,3 +144,208 @@ async def list_reads(
|
||||
rows = (await session.execute(stmt)).all()
|
||||
ids = [r[0] for r in rows]
|
||||
return ReadListResponse(article_ids=ids, total=len(ids))
|
||||
|
||||
|
||||
# === 按分类批量已读(per-user) ===
|
||||
# 用例:用户在 Feed 标了一条《xxx》为已读,该文章 category 含 "美国" "社会" 两个 tag,
|
||||
# 前端会同时查这两个分类下"24 小时未读数"展示提示条,点头部的"全部已读"调本接口。
|
||||
# 设计要点:
|
||||
# - category 用 ilike 模糊匹配(Article.category 是逗号分隔字符串 '美国,社会,紧急')
|
||||
# - 默认遵守当前 Feed 过滤(关键词/源)— scope=filtered_unread,避免误杀
|
||||
# - 时间窗默认 24h(防"老新闻刷不完"的提示)
|
||||
# - 批量插入用 ON CONFLICT DO NOTHING,幂等
|
||||
# - 返回 article_ids 给前端做乐观滑出动画
|
||||
|
||||
|
||||
def _unread_subquery(user_id: int):
|
||||
"""未读文章 id 子查询(per-user)— 复用 articles.py 的 NOT EXISTS 模式。"""
|
||||
return (
|
||||
select(ArticleRead.article_id)
|
||||
.where(ArticleRead.user_id == user_id, ArticleRead.article_id == Article.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
def _escape_like(term: str) -> str:
|
||||
"""转义 ilike 的元字符,避免 category 含 % / _ 时被当通配符。"""
|
||||
return term.replace("\\", "\\\\").replace("%", r"\%").replace("_", r"\_")
|
||||
|
||||
|
||||
class CategoryReadRequest(BaseModel):
|
||||
category: str = Field(..., min_length=1, max_length=32, description="要批量已读的分类 tag")
|
||||
scope: Literal["all_unread", "filtered_unread"] = Field(
|
||||
default="filtered_unread",
|
||||
description="过滤范围:filtered_unread=遵守 sources/q;all_unread=忽略",
|
||||
)
|
||||
window_hours: int = Field(
|
||||
default=24, ge=1, le=168, description="只标 published_at 在最近 N 小时内的未读"
|
||||
)
|
||||
sources: list[str] | None = Field(default=None, description="源 slug 列表,过滤范围")
|
||||
q: str | None = Field(default=None, description="关键词过滤(标题/正文模糊)")
|
||||
|
||||
|
||||
class CategoryReadResponse(BaseModel):
|
||||
category: str
|
||||
matched: int # 命中的未读数
|
||||
marked: int # 实际新标已读数(去重后)
|
||||
article_ids: list[int] # 给前端做滑出动画
|
||||
|
||||
|
||||
class CategoryCountItem(BaseModel):
|
||||
category: str
|
||||
unread_count: int
|
||||
window_hours: int
|
||||
|
||||
|
||||
def _build_category_filter(
|
||||
category: str,
|
||||
window_hours: int,
|
||||
user_id: int,
|
||||
sources: list[str] | None = None,
|
||||
q: str | None = None,
|
||||
):
|
||||
"""构造"分类 + 时间窗 + 未读"三合一 filter。
|
||||
|
||||
- category 用 ilike 模糊匹配(逗号分隔串中含此 tag 即可)
|
||||
- window_hours 通过 published_at 过滤
|
||||
- 用 NOT EXISTS 排除当前用户已读
|
||||
- sources / q 可选,跟 articles.py 列表查询口径一致
|
||||
"""
|
||||
pattern = f"%{_escape_like(category)}%"
|
||||
# 用 text() 拼 interval,避免 make_interval 的 PG 函数参数绑定在某些
|
||||
# SQLAlchemy 版本下的兼容性问题。window_hours 是 int,只来自我们自己的
|
||||
# endpoint,不是用户原始字符串,安全。
|
||||
interval_sql = text(f"interval '{int(window_hours)} hours'")
|
||||
filters = [
|
||||
Article.category.ilike(pattern, escape="\\"),
|
||||
Article.published_at >= func.now() - interval_sql,
|
||||
not_(_unread_subquery(user_id)),
|
||||
]
|
||||
if sources:
|
||||
slugs = [s.strip() for s in sources if s.strip()]
|
||||
if slugs:
|
||||
filters.append(Source.slug.in_(slugs))
|
||||
if q and q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
filters.append(
|
||||
or_(
|
||||
Article.title.ilike(like),
|
||||
Article.body_text.ilike(like),
|
||||
Article.title_zh.ilike(like),
|
||||
Article.body_zh_text.ilike(like),
|
||||
Article.summary_zh.ilike(like),
|
||||
)
|
||||
)
|
||||
return filters
|
||||
|
||||
|
||||
@router.post("/reads/by-category", response_model=CategoryReadResponse)
|
||||
async def mark_category_read(
|
||||
body: CategoryReadRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""按分类批量已读。
|
||||
|
||||
- 默认 scope=filtered_unread:只在当前 Feed 过滤范围内标
|
||||
- 时间窗默认 24h:防"老新闻刷不完"
|
||||
- 幂等:已读的不重复处理
|
||||
"""
|
||||
cat = body.category.strip()
|
||||
if not cat:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "category 不能为空")
|
||||
|
||||
# scope=all_unread 时,无视 sources/q(传了也忽略,语义更清晰)
|
||||
if body.scope == "all_unread":
|
||||
eff_sources: list[str] | None = None
|
||||
eff_q: str | None = None
|
||||
else:
|
||||
eff_sources = body.sources
|
||||
eff_q = body.q
|
||||
|
||||
filters = _build_category_filter(
|
||||
category=cat,
|
||||
window_hours=body.window_hours,
|
||||
user_id=user.id,
|
||||
sources=eff_sources,
|
||||
q=eff_q,
|
||||
)
|
||||
|
||||
# 1. 查命中 id 列表(返回给前端)
|
||||
id_stmt = select(Article.id)
|
||||
# sources 过滤需要 join
|
||||
if eff_sources:
|
||||
id_stmt = id_stmt.join(Source, Source.id == Article.source_id)
|
||||
for f in filters:
|
||||
id_stmt = id_stmt.where(f)
|
||||
article_ids = [r[0] for r in (await session.execute(id_stmt)).all()]
|
||||
|
||||
if not article_ids:
|
||||
return CategoryReadResponse(category=cat, matched=0, marked=0, article_ids=[])
|
||||
|
||||
# 2. 批量插入 article_reads(ON CONFLICT DO NOTHING,幂等)
|
||||
# 分批:防御性,避免单次 VALUES 太多;500 是经验值,PG 完全能吃更大
|
||||
BATCH = 500
|
||||
marked_total = 0
|
||||
for i in range(0, len(article_ids), BATCH):
|
||||
chunk = article_ids[i : i + BATCH]
|
||||
rows = [{"user_id": user.id, "article_id": aid} for aid in chunk]
|
||||
stmt = pg_insert(ArticleRead).values(rows).on_conflict_do_nothing(
|
||||
index_elements=["user_id", "article_id"]
|
||||
)
|
||||
# PG ON CONFLICT 不直接告诉哪些是"真插入的",通过 result() 或者信任总数;
|
||||
# 这里简单信任 chunk 大小,marked 返回 attempt 数(去重由 PK 保证)
|
||||
await session.execute(stmt)
|
||||
marked_total += len(chunk)
|
||||
await session.commit()
|
||||
|
||||
return CategoryReadResponse(
|
||||
category=cat,
|
||||
matched=len(article_ids),
|
||||
marked=marked_total,
|
||||
article_ids=article_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reads/category-count", response_model=list[CategoryCountItem])
|
||||
async def count_unread_by_categories(
|
||||
categories: str = Query(..., description="逗号分隔的分类列表,例如 '美国,社会'"),
|
||||
window_hours: int = Query(default=24, ge=1, le=168),
|
||||
sources: str | None = Query(default=None, description="逗号分隔源 slug"),
|
||||
q: str | None = Query(default=None, description="关键词过滤"),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""批量查多个分类的未读数(给前端提示条用,一次拿全,省 round-trip)。
|
||||
|
||||
- categories 必填,逗号分隔
|
||||
- 每个分类独立 count(单次请求内是 N 次查询,数据量小,够用)
|
||||
- 返回的 unread_count = 0 的分类前端不展示提示行
|
||||
"""
|
||||
cat_list = [c.strip() for c in categories.split(",") if c.strip()]
|
||||
if not cat_list:
|
||||
return []
|
||||
|
||||
src_list = [s.strip() for s in sources.split(",") if s.strip()] if sources else None
|
||||
eff_q = q.strip() if q and q.strip() else None
|
||||
|
||||
results: list[CategoryCountItem] = []
|
||||
for cat in cat_list[:10]: # 防御性:一次最多 10 个分类
|
||||
filters = _build_category_filter(
|
||||
category=cat,
|
||||
window_hours=window_hours,
|
||||
user_id=user.id,
|
||||
sources=src_list,
|
||||
q=eff_q,
|
||||
)
|
||||
stmt = select(func.count(Article.id))
|
||||
if src_list:
|
||||
stmt = stmt.join(Source, Source.id == Article.source_id)
|
||||
for f in filters:
|
||||
stmt = stmt.where(f)
|
||||
n = (await session.execute(stmt)).scalar_one()
|
||||
if n > 0:
|
||||
results.append(
|
||||
CategoryCountItem(category=cat, unread_count=int(n), window_hours=window_hours)
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -142,6 +142,39 @@ export const readsApi = {
|
||||
'/me/reads', { params: sinceIso ? { since: sinceIso } : {} }
|
||||
).then((r) => r.data)
|
||||
},
|
||||
// === 按分类批量已读(Feed 提示条用)===
|
||||
// 一次返回完整 article_ids 列表,前端用它做乐观滑出动画
|
||||
markCategory(body: {
|
||||
category: string
|
||||
scope?: 'all_unread' | 'filtered_unread'
|
||||
window_hours?: number
|
||||
sources?: string[]
|
||||
q?: string
|
||||
}) {
|
||||
return http.post<{
|
||||
category: string
|
||||
matched: number
|
||||
marked: number
|
||||
article_ids: number[]
|
||||
}>('/me/reads/by-category', body).then((r) => r.data)
|
||||
},
|
||||
// 一次查多个分类的未读数(给提示条文案用)— 后端逗号分隔串接
|
||||
countByCategories(
|
||||
categories: string[],
|
||||
params: { window_hours?: number; sources?: string[]; q?: string } = {},
|
||||
) {
|
||||
return http.get<{ category: string; unread_count: number; window_hours: number }[]>(
|
||||
'/me/reads/category-count',
|
||||
{
|
||||
params: {
|
||||
categories: categories.join(','),
|
||||
window_hours: params.window_hours ?? 24,
|
||||
sources: params.sources?.length ? params.sources.join(',') : undefined,
|
||||
q: params.q || undefined,
|
||||
},
|
||||
},
|
||||
).then((r) => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
export const sourcesApi = {
|
||||
|
||||
@@ -3,7 +3,7 @@ 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, useMessage,
|
||||
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'
|
||||
@@ -24,6 +24,20 @@ const loading = ref(false)
|
||||
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
|
||||
const pendingRemoval = ref<Set<number>>(new Set())
|
||||
|
||||
// === 分类批量已读提示条 ===
|
||||
// 触发:用户标某条为已读时,查询该文章前 2 个 category 的"24h 未读数";
|
||||
// 大于 0 的 category 各显示一行提示,带独立"全部已读 / 稍后再说"按钮
|
||||
// 行为:点击"全部已读"调 markCategory,前端拿到 article_ids 走乐观滑出
|
||||
// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 重置或清空)
|
||||
type CategoryPromptItem = {
|
||||
category: string
|
||||
unreadCount: number
|
||||
triggeredById: number
|
||||
}
|
||||
const categoryPrompts = ref<CategoryPromptItem[]>([])
|
||||
const pendingCategory = ref<string | null>(null) // 正在请求"全部已读"的分类
|
||||
let categoryPromptTimer: number | null = null
|
||||
|
||||
// === 页码分页(替代原来的 cursor 无限滚动)===
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
@@ -164,6 +178,13 @@ async function toggleRead(a: ArticleListItem) {
|
||||
}, 360)
|
||||
}
|
||||
}
|
||||
|
||||
// === 新增:刚标为已读 → 查该文章前 2 个 category 的 24h 未读数 ===
|
||||
// unmark 路径不触发(用户反悔了,不该再骚扰);hide_read 模式仍可触发
|
||||
// (滑出动画只影响当前这一条,提示条展示的是"这个分类下还有别的未读")
|
||||
if (!wasRead && a.category) {
|
||||
await maybePromptCategoryRead(a)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 失败回滚
|
||||
a.is_read = wasRead
|
||||
@@ -171,6 +192,94 @@ async function toggleRead(a: ArticleListItem) {
|
||||
}
|
||||
}
|
||||
|
||||
// 查 a 的前 2 个 category 的 24h 未读数,有未读就追加 / 替换提示条
|
||||
async function maybePromptCategoryRead(a: ArticleListItem) {
|
||||
const cats = splitCategory(a.category).slice(0, 2)
|
||||
if (cats.length === 0) return
|
||||
try {
|
||||
const counts = await readsApi.countByCategories(cats, {
|
||||
window_hours: 24,
|
||||
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
|
||||
q: q.value || undefined,
|
||||
})
|
||||
// 只保留 unread_count > 0 的
|
||||
const newPrompts: CategoryPromptItem[] = counts
|
||||
.filter((c) => c.unread_count > 0)
|
||||
.map((c) => ({
|
||||
category: c.category,
|
||||
unreadCount: c.unread_count,
|
||||
triggeredById: a.id,
|
||||
}))
|
||||
if (newPrompts.length === 0) return
|
||||
|
||||
// 同一 category 已存在则替换(更新计数),不重复
|
||||
const map = new Map(categoryPrompts.value.map((p) => [p.category, p]))
|
||||
for (const p of newPrompts) map.set(p.category, p)
|
||||
categoryPrompts.value = Array.from(map.values())
|
||||
|
||||
// 重置 8 秒自动消失
|
||||
if (categoryPromptTimer !== null) clearTimeout(categoryPromptTimer)
|
||||
categoryPromptTimer = window.setTimeout(() => {
|
||||
categoryPrompts.value = []
|
||||
categoryPromptTimer = null
|
||||
}, 8000)
|
||||
} catch (e: any) {
|
||||
// 计数失败不影响主流程,静默
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('category count failed:', e?.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户点头部"全部已读":调 markCategory + 乐观滑出 + 关闭该行提示
|
||||
async function confirmMarkCategory(category: string) {
|
||||
if (pendingCategory.value) return // 防重入
|
||||
pendingCategory.value = category
|
||||
try {
|
||||
const resp = await readsApi.markCategory({
|
||||
category,
|
||||
scope: 'filtered_unread',
|
||||
window_hours: 24,
|
||||
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
|
||||
q: q.value || undefined,
|
||||
})
|
||||
// 把命中的 article 在 items 里全部 is_read=true
|
||||
const ids = new Set(resp.article_ids)
|
||||
for (const item of items.value) {
|
||||
if (ids.has(item.id)) item.is_read = true
|
||||
}
|
||||
// 走 hide_read 模式下的滑出(累计 delay,逐个错开,避免 30 个 setTimeout 同时触发视觉抖)
|
||||
let i = 0
|
||||
for (const id of resp.article_ids) {
|
||||
const idx = items.value.findIndex((x) => x.id === id)
|
||||
if (idx < 0) continue
|
||||
pendingRemoval.value.add(id)
|
||||
const delay = 360 + i * 20
|
||||
i++
|
||||
setTimeout(() => {
|
||||
const k = items.value.findIndex((x) => x.id === id)
|
||||
if (k >= 0) items.value.splice(k, 1)
|
||||
pendingRemoval.value.delete(id)
|
||||
if (total.value > 0) total.value -= 1
|
||||
}, delay)
|
||||
}
|
||||
dismissPrompt(category)
|
||||
message.success(`已将 ${resp.marked} 条「${category}」标记为已读`)
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '操作失败')
|
||||
} finally {
|
||||
pendingCategory.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭某一行(稍后再说 / 自动消失 / 已被 confirm 后调用)
|
||||
function dismissPrompt(category: string) {
|
||||
categoryPrompts.value = categoryPrompts.value.filter((p) => p.category !== category)
|
||||
if (categoryPrompts.value.length === 0 && categoryPromptTimer !== null) {
|
||||
clearTimeout(categoryPromptTimer)
|
||||
categoryPromptTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
sources.value = await sourcesApi.list()
|
||||
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
|
||||
@@ -187,6 +296,14 @@ function onPageChange(p: number) {
|
||||
function resetToFirstPage() {
|
||||
page.value = 1
|
||||
load()
|
||||
// 过滤上下文变了,旧分类提示的"24h 未读"数已不准,清掉
|
||||
if (categoryPrompts.value.length > 0) {
|
||||
categoryPrompts.value = []
|
||||
if (categoryPromptTimer !== null) {
|
||||
clearTimeout(categoryPromptTimer)
|
||||
categoryPromptTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function open(a: ArticleListItem) {
|
||||
@@ -310,6 +427,48 @@ onMounted(async () => {
|
||||
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
|
||||
</NSpace>
|
||||
|
||||
<!--
|
||||
分类批量已读提示条:
|
||||
- 一篇文章可拆出 1-2 行(取 category 前 2 个)
|
||||
- 每行独立"全部已读 / 稍后再说"
|
||||
- 8 秒自动消失 / 过滤变化时立即清掉
|
||||
-->
|
||||
<Transition name="prompt">
|
||||
<div v-if="categoryPrompts.length > 0" class="feed-category-prompts">
|
||||
<NAlert
|
||||
v-for="p in categoryPrompts"
|
||||
:key="p.category"
|
||||
type="success"
|
||||
:show-icon="false"
|
||||
closable
|
||||
@close="dismissPrompt(p.category)"
|
||||
class="feed-category-prompt"
|
||||
>
|
||||
<template #header>
|
||||
<NSpace align="center" :size="8" :wrap="true">
|
||||
<NTag type="success" size="small" round :bordered="false">✓ 已读</NTag>
|
||||
<NText>「{{ p.category }}」分类下还有 {{ p.unreadCount }} 条 24 小时未读</NText>
|
||||
</NSpace>
|
||||
</template>
|
||||
<NSpace :size="8" style="margin-top: 8px">
|
||||
<NButton
|
||||
type="primary"
|
||||
size="small"
|
||||
round
|
||||
:loading="pendingCategory === p.category"
|
||||
:disabled="pendingCategory !== null"
|
||||
@click="confirmMarkCategory(p.category)"
|
||||
>
|
||||
全部已读
|
||||
</NButton>
|
||||
<NButton size="small" round @click="dismissPrompt(p.category)">
|
||||
稍后再说
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NAlert>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<NSpin :show="loading && items.length === 0">
|
||||
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
|
||||
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
|
||||
@@ -884,4 +1043,44 @@ onMounted(async () => {
|
||||
.card-move {
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
|
||||
/* === 分类批量已读提示条 ===
|
||||
- 浅绿渐变 + 左侧色条,跟已读视觉呼应但不抢戏
|
||||
- 出现/消失:opacity + max-height 配合,跟卡片滑出同节奏
|
||||
*/
|
||||
.feed-category-prompts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.feed-category-prompt {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
|
||||
border-left: 3px solid var(--color-primary, #5b86e5);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.feed-category-prompt :deep(.n-alert__header) {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 提示条进入/离开(整组) */
|
||||
.prompt-enter-active,
|
||||
.prompt-leave-active {
|
||||
transition: opacity 0.25s ease, transform 0.3s cubic-bezier(0.55, 0, 0.55, 0.2),
|
||||
max-height 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.prompt-enter-from,
|
||||
.prompt-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
max-height: 0 !important;
|
||||
}
|
||||
.prompt-enter-to,
|
||||
.prompt-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
max-height: 200px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user