feat(feed): 列表展示翻译正文摘要 + 页码分页
首页 Feed.vue 改造: - 卡片在中文标题下直接展示 body_zh_text(前 220 字) 用户不进详情就能看到译文正文,提升阅读效率 - 配图(image_ai_url 或 image_url)也直接显示在卡片中 - 把原标题作为副标题(灰色,辅助参考) 分页从 cursor 无限滚动换成 page + page_size: - 后端 /articles 加 page/page_size 参数,返回 total/total_pages - 干掉 _encode_cursor/_decode_cursor - 前端用 n-pagination,显示 1,2,3,4,5 + 快速跳转 - 筛选/搜索变化自动回到第 1 页 - 切页自动滚到顶部
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
"""/articles 列表与详情。"""
|
"""/articles 列表与详情。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -26,19 +24,6 @@ from app.schemas.article import (
|
|||||||
router = APIRouter(prefix="/articles", tags=["articles"])
|
router = APIRouter(prefix="/articles", tags=["articles"])
|
||||||
|
|
||||||
|
|
||||||
def _encode_cursor(article: Article) -> str:
|
|
||||||
payload = {"id": article.id, "ts": int(article.fetched_at.timestamp())}
|
|
||||||
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_cursor(cur: str) -> tuple[int, datetime]:
|
|
||||||
try:
|
|
||||||
data = json.loads(base64.urlsafe_b64decode(cur.encode()).decode())
|
|
||||||
return int(data["id"]), datetime.fromtimestamp(int(data["ts"]))
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid cursor")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ArticleListResponse)
|
@router.get("", response_model=ArticleListResponse)
|
||||||
async def list_articles(
|
async def list_articles(
|
||||||
since: datetime | None = Query(default=None, description="起时间 UTC"),
|
since: datetime | None = Query(default=None, description="起时间 UTC"),
|
||||||
@@ -47,58 +32,70 @@ async def list_articles(
|
|||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
q: str | None = Query(default=None, description="标题/正文搜索"),
|
q: str | None = Query(default=None, description="标题/正文搜索"),
|
||||||
lang: Annotated[str, Query(pattern=r"^(src|zh|both)$")] = "both",
|
lang: Annotated[str, Query(pattern=r"^(src|zh|both)$")] = "both",
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
page: int = Query(default=1, ge=1, description="页码(从 1 开始)"),
|
||||||
cursor: str | None = None,
|
page_size: int = Query(default=50, ge=1, le=200, description="每页条数"),
|
||||||
starred_only: bool = False,
|
starred_only: bool = False,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
stmt = (
|
# 公共筛选条件(用于 list + count)
|
||||||
select(Article, Source)
|
filters = [Article.duplicate_of.is_(None)]
|
||||||
.join(Source, Source.id == Article.source_id)
|
|
||||||
.where(Article.duplicate_of.is_(None))
|
|
||||||
)
|
|
||||||
|
|
||||||
# 默认过去 24h
|
# 默认过去 24h
|
||||||
if since is None and until is None and cursor is None:
|
if since is None and until is None:
|
||||||
since = _default_since_24h()
|
since = _default_since_24h()
|
||||||
|
|
||||||
if since:
|
if since:
|
||||||
stmt = stmt.where(Article.published_at >= since)
|
filters.append(Article.published_at >= since)
|
||||||
if until:
|
if until:
|
||||||
stmt = stmt.where(Article.published_at <= until)
|
filters.append(Article.published_at <= until)
|
||||||
if category:
|
if category:
|
||||||
stmt = stmt.where(Article.category == category)
|
filters.append(Article.category == category)
|
||||||
|
|
||||||
if source:
|
if source:
|
||||||
slugs = [s.strip() for s in source.split(",") if s.strip()]
|
slugs = [s.strip() for s in source.split(",") if s.strip()]
|
||||||
if slugs:
|
if slugs:
|
||||||
stmt = stmt.where(Source.slug.in_(slugs))
|
filters.append(Source.slug.in_(slugs))
|
||||||
|
|
||||||
if q:
|
if q:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
stmt = stmt.where(or_(Article.title.ilike(like), Article.body_text.ilike(like)))
|
filters.append(or_(Article.title.ilike(like), Article.body_text.ilike(like)))
|
||||||
|
|
||||||
# 语言过滤
|
|
||||||
if lang == "zh":
|
if lang == "zh":
|
||||||
stmt = stmt.where(Article.title_zh.is_not(None))
|
filters.append(Article.title_zh.is_not(None))
|
||||||
elif lang == "src":
|
|
||||||
# 只要原文已有
|
|
||||||
pass
|
|
||||||
|
|
||||||
if cursor:
|
# ===== count 总数 =====
|
||||||
last_id, _ = _decode_cursor(cursor)
|
count_stmt = select(func.count(Article.id)).join(Source, Source.id == Article.source_id)
|
||||||
stmt = stmt.where(Article.id < last_id)
|
for f in filters:
|
||||||
|
count_stmt = count_stmt.where(f)
|
||||||
|
if starred_only:
|
||||||
|
count_stmt = count_stmt.join(
|
||||||
|
Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id)
|
||||||
|
)
|
||||||
|
total: int = (await session.execute(count_stmt)).scalar_one()
|
||||||
|
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
# 越界保护:page 超出范围时返回空数组,total 仍真实
|
||||||
|
if page > total_pages:
|
||||||
|
return ArticleListResponse(
|
||||||
|
items=[], page=page, page_size=page_size, total=total, total_pages=total_pages
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===== 当前页数据 =====
|
||||||
|
stmt = select(Article, Source).join(Source, Source.id == Article.source_id)
|
||||||
|
for f in filters:
|
||||||
|
stmt = stmt.where(f)
|
||||||
if starred_only:
|
if starred_only:
|
||||||
stmt = stmt.join(Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id))
|
stmt = stmt.join(Bookmark, and_(Bookmark.article_id == Article.id, Bookmark.user_id == user.id))
|
||||||
|
|
||||||
stmt = stmt.order_by(desc(Article.published_at), desc(Article.id)).limit(limit + 1)
|
offset = (page - 1) * page_size
|
||||||
|
stmt = (
|
||||||
|
stmt.order_by(desc(Article.published_at), desc(Article.id))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
|
||||||
result = await session.execute(stmt)
|
rows = (await session.execute(stmt)).all()
|
||||||
rows = result.all()
|
|
||||||
has_more = len(rows) > limit
|
|
||||||
rows = rows[:limit]
|
|
||||||
|
|
||||||
# 标记 is_starred(批量)
|
# 标记 is_starred(批量)
|
||||||
ids = [a.id for a, _ in rows]
|
ids = [a.id for a, _ in rows]
|
||||||
@@ -115,11 +112,13 @@ async def list_articles(
|
|||||||
|
|
||||||
items = []
|
items = []
|
||||||
for art, src in rows:
|
for art, src in rows:
|
||||||
item = ArticleListItem(
|
items.append(
|
||||||
|
ArticleListItem(
|
||||||
id=art.id,
|
id=art.id,
|
||||||
source=SourceBrief.model_validate(src),
|
source=SourceBrief.model_validate(src),
|
||||||
title=art.title,
|
title=art.title,
|
||||||
title_zh=art.title_zh,
|
title_zh=art.title_zh,
|
||||||
|
body_zh_text=art.body_zh_text,
|
||||||
summary_zh=art.summary_zh,
|
summary_zh=art.summary_zh,
|
||||||
lang_src=art.lang_src,
|
lang_src=art.lang_src,
|
||||||
translation_status=art.translation_status,
|
translation_status=art.translation_status,
|
||||||
@@ -133,10 +132,15 @@ async def list_articles(
|
|||||||
image_ai_url=art.image_ai_url,
|
image_ai_url=art.image_ai_url,
|
||||||
is_starred=art.id in starred_ids,
|
is_starred=art.id in starred_ids,
|
||||||
)
|
)
|
||||||
items.append(item)
|
)
|
||||||
|
|
||||||
next_cursor = _encode_cursor(rows[-1][0]) if has_more and rows else None
|
return ArticleListResponse(
|
||||||
return ArticleListResponse(items=items, next_cursor=next_cursor, total=None)
|
items=items,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{article_id}", response_model=ArticleDetail)
|
@router.get("/{article_id}", response_model=ArticleDetail)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class SourceBrief(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ArticleListItem(BaseModel):
|
class ArticleListItem(BaseModel):
|
||||||
"""列表项:精简字段(首页只露钩子,详细阅读进详情页)。"""
|
"""列表项:首页展示标题/译标/正文摘要/分类/插图,详细阅读进详情页。"""
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ class ArticleListItem(BaseModel):
|
|||||||
source: SourceBrief
|
source: SourceBrief
|
||||||
title: str
|
title: str
|
||||||
title_zh: str | None = None
|
title_zh: str | None = None
|
||||||
|
# 翻译后的正文(纯文本);列表里截断显示,详情页展示完整
|
||||||
|
body_zh_text: str | None = None
|
||||||
summary_zh: str | None = None
|
summary_zh: str | None = None
|
||||||
lang_src: str | None = None
|
lang_src: str | None = None
|
||||||
translation_status: str
|
translation_status: str
|
||||||
@@ -76,8 +78,11 @@ class ArticleDetail(BaseModel):
|
|||||||
|
|
||||||
class ArticleListResponse(BaseModel):
|
class ArticleListResponse(BaseModel):
|
||||||
items: list[ArticleListItem]
|
items: list[ArticleListItem]
|
||||||
next_cursor: str | None = None
|
# 页码分页
|
||||||
total: int | None = None
|
page: int = 1
|
||||||
|
page_size: int = 50
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
class ArticleQuery(BaseModel):
|
class ArticleQuery(BaseModel):
|
||||||
@@ -89,6 +94,6 @@ class ArticleQuery(BaseModel):
|
|||||||
category: str | None = None
|
category: str | None = None
|
||||||
q: str | None = None
|
q: str | None = None
|
||||||
lang: str = Field(default="both", pattern=r"^(src|zh|both)$")
|
lang: str = Field(default="both", pattern=r"^(src|zh|both)$")
|
||||||
limit: int = Field(default=50, ge=1, le=200)
|
page: int = Field(default=1, ge=1)
|
||||||
cursor: str | None = None
|
page_size: int = Field(default=50, ge=1, le=200)
|
||||||
starred_only: bool = False
|
starred_only: bool = False
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface ArticleListItem {
|
|||||||
source: { id: number; name: string; slug: string; region?: string | null }
|
source: { id: number; name: string; slug: string; region?: string | null }
|
||||||
title: string
|
title: string
|
||||||
title_zh?: string | null
|
title_zh?: string | null
|
||||||
|
// 翻译后的正文(纯文本);列表里截断显示
|
||||||
|
body_zh_text?: string | null
|
||||||
summary_zh?: string | null
|
summary_zh?: string | null
|
||||||
lang_src?: string | null
|
lang_src?: string | null
|
||||||
translation_status: string
|
translation_status: string
|
||||||
@@ -40,8 +42,10 @@ export interface ArticleListItem {
|
|||||||
|
|
||||||
export interface ArticleListResponse {
|
export interface ArticleListResponse {
|
||||||
items: ArticleListItem[]
|
items: ArticleListItem[]
|
||||||
next_cursor: string | null
|
page: number
|
||||||
total: number | null
|
page_size: number
|
||||||
|
total: number
|
||||||
|
total_pages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleDetail extends ArticleListItem {
|
export interface ArticleDetail extends ArticleListItem {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
|
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
|
||||||
|
NPagination,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { articlesApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
|
import { articlesApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -17,8 +18,13 @@ const auth = useAuthStore()
|
|||||||
const items = ref<ArticleListItem[]>([])
|
const items = ref<ArticleListItem[]>([])
|
||||||
const sources = ref<Source[]>([])
|
const sources = ref<Source[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const cursor = ref<string | null>(null)
|
|
||||||
const exhausted = ref(false)
|
// === 页码分页(替代原来的 cursor 无限滚动)===
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
const total = ref(0)
|
||||||
|
const totalPages = ref(1)
|
||||||
|
|
||||||
const sourceFilter = ref<string[]>([])
|
const sourceFilter = ref<string[]>([])
|
||||||
const q = ref('')
|
const q = ref('')
|
||||||
|
|
||||||
@@ -31,12 +37,12 @@ async function load() {
|
|||||||
const resp = await articlesApi.list({
|
const resp = await articlesApi.list({
|
||||||
source: sourceFilter.value.join(',') || undefined,
|
source: sourceFilter.value.join(',') || undefined,
|
||||||
q: q.value || undefined,
|
q: q.value || undefined,
|
||||||
cursor: cursor.value || undefined,
|
page: page.value,
|
||||||
limit: 50,
|
page_size: pageSize.value,
|
||||||
})
|
})
|
||||||
items.value.push(...resp.items)
|
items.value = resp.items
|
||||||
cursor.value = resp.next_cursor
|
total.value = resp.total
|
||||||
if (!cursor.value) exhausted.value = true
|
totalPages.value = resp.total_pages
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -47,10 +53,16 @@ async function loadSources() {
|
|||||||
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
|
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
// 切页 → 重新加载 + 滚到顶部
|
||||||
items.value = []
|
function onPageChange(p: number) {
|
||||||
cursor.value = null
|
page.value = p
|
||||||
exhausted.value = false
|
load()
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选/搜索变化 → 回到第 1 页
|
||||||
|
function resetToFirstPage() {
|
||||||
|
page.value = 1
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +71,6 @@ function open(a: ArticleListItem) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function star(a: ArticleListItem) {
|
function star(a: ArticleListItem) {
|
||||||
// 简单:登录的 star 接口后续
|
|
||||||
a.is_starred = !a.is_starred
|
a.is_starred = !a.is_starred
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +85,7 @@ function splitCategory(c?: string | null): string[] {
|
|||||||
return c.split(',').map((s) => s.trim()).filter(Boolean)
|
return c.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 评论预览:长文截断,带状态点
|
// 评论预览:长文截断
|
||||||
function previewCommentary(c?: string | null, max = 120): string {
|
function previewCommentary(c?: string | null, max = 120): string {
|
||||||
if (!c) return ''
|
if (!c) return ''
|
||||||
const trimmed = c.replace(/\s+/g, ' ').trim()
|
const trimmed = c.replace(/\s+/g, ' ').trim()
|
||||||
@@ -88,6 +99,15 @@ function commentaryStatusType(s?: string | null): 'success' | 'warning' | 'error
|
|||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 正文摘要(取 body_zh_text 前 N 字;没有就 fallback 到 summary_zh)
|
||||||
|
function bodyExcerpt(text?: string | null, max = 200): string {
|
||||||
|
if (!text) return ''
|
||||||
|
const trimmed = text.replace(/\s+/g, ' ').trim()
|
||||||
|
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsLabel = computed(() => `共 ${total.value} 条`)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSources()
|
await loadSources()
|
||||||
await load()
|
await load()
|
||||||
@@ -105,13 +125,13 @@ onMounted(async () => {
|
|||||||
placeholder="按源筛选"
|
placeholder="按源筛选"
|
||||||
:options="sourceOptions"
|
:options="sourceOptions"
|
||||||
style="min-width: 240px"
|
style="min-width: 240px"
|
||||||
@update:value="refresh"
|
@update:value="resetToFirstPage"
|
||||||
/>
|
/>
|
||||||
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 200px"
|
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 200px"
|
||||||
@keyup.enter="refresh" @clear="refresh" />
|
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
|
||||||
<NButton @click="refresh">刷新</NButton>
|
<NButton @click="resetToFirstPage">刷新</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
<NText depth="3">{{ items.length }} 条</NText>
|
<NText depth="3">{{ itemsLabel }}</NText>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<NSpin :show="loading && items.length === 0">
|
<NSpin :show="loading && items.length === 0">
|
||||||
@@ -125,14 +145,13 @@ onMounted(async () => {
|
|||||||
hoverable
|
hoverable
|
||||||
@click="open(a)"
|
@click="open(a)"
|
||||||
>
|
>
|
||||||
<NSpace vertical :size="4">
|
<NSpace vertical :size="6">
|
||||||
<NSpace align="center" :size="8">
|
<NSpace align="center" :size="8">
|
||||||
<NTag size="small" type="info">{{ a.source.name }}</NTag>
|
<NTag size="small" type="info">{{ a.source.name }}</NTag>
|
||||||
<NTag v-if="a.lang_src" size="small">{{ a.lang_src }}</NTag>
|
<NTag v-if="a.lang_src" size="small">{{ a.lang_src }}</NTag>
|
||||||
<NTag v-if="a.translation_status !== 'ok'" size="small" type="warning">
|
<NTag v-if="a.translation_status !== 'ok'" size="small" type="warning">
|
||||||
{{ a.translation_status }}
|
{{ a.translation_status }}
|
||||||
</NTag>
|
</NTag>
|
||||||
<!-- 分类标签(LLM classify 输出,多分类逗号分隔) -->
|
|
||||||
<NTag
|
<NTag
|
||||||
v-for="c in splitCategory(a.category)"
|
v-for="c in splitCategory(a.category)"
|
||||||
:key="c"
|
:key="c"
|
||||||
@@ -143,14 +162,46 @@ onMounted(async () => {
|
|||||||
</NTag>
|
</NTag>
|
||||||
<NText depth="3" style="font-size: 12px">{{ fmtTime(a.published_at || a.fetched_at) }}</NText>
|
<NText depth="3" style="font-size: 12px">{{ fmtTime(a.published_at || a.fetched_at) }}</NText>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
<div style="font-size: 16px; font-weight: 600; color: #333">{{ a.title }}</div>
|
|
||||||
<div v-if="a.title_zh" style="font-size: 15px; color: #2080f0; font-weight: 500;">
|
<!-- 原标题(灰色,辅助) -->
|
||||||
|
<div style="font-size: 13px; color: #999; line-height: 1.4;">
|
||||||
|
{{ a.title }}
|
||||||
|
</div>
|
||||||
|
<!-- 中文标题(主标题) -->
|
||||||
|
<div v-if="a.title_zh" style="font-size: 18px; font-weight: 600; color: #333; line-height: 1.4;">
|
||||||
{{ a.title_zh }}
|
{{ a.title_zh }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="a.summary_zh" style="color: #666; font-size: 13px; margin-top: 4px">
|
|
||||||
{{ a.summary_zh.slice(0, 200) }}{{ a.summary_zh.length > 200 ? '…' : '' }}
|
<!-- AI 插图(若有) -->
|
||||||
|
<img
|
||||||
|
v-if="a.image_ai_url || a.image_url"
|
||||||
|
:src="a.image_ai_url || a.image_url || ''"
|
||||||
|
style="
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 280px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 4px 0;
|
||||||
|
"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 翻译后正文摘要(列表钩子,详情页有完整版) -->
|
||||||
|
<div
|
||||||
|
v-if="a.body_zh_text || a.summary_zh"
|
||||||
|
style="
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #444;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ bodyExcerpt(a.body_zh_text || a.summary_zh, 220) }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 评论预览(列表钩子,详情页有完整版) -->
|
|
||||||
|
<!-- 评论预览 -->
|
||||||
<div
|
<div
|
||||||
v-if="a.commentary"
|
v-if="a.commentary"
|
||||||
style="
|
style="
|
||||||
@@ -174,10 +225,18 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
</NCard>
|
</NCard>
|
||||||
<NSpace v-if="!exhausted" justify="center" style="margin: 16px 0">
|
|
||||||
<NButton :loading="loading" @click="load">加载更多</NButton>
|
<!-- 页码分页(替代无限滚动) -->
|
||||||
|
<NSpace v-if="total > 0" justify="center" style="margin: 24px 0 16px">
|
||||||
|
<NPagination
|
||||||
|
v-model:page="page"
|
||||||
|
:page-count="totalPages"
|
||||||
|
:page-size="pageSize"
|
||||||
|
show-quick-jumper
|
||||||
|
@update:page="onPageChange"
|
||||||
|
/>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
<NText v-else depth="3" style="display:block; text-align:center; padding: 16px">— 到底了 —</NText>
|
<NText v-else depth="3" style="display:block; text-align:center; padding: 16px">— 暂无数据 —</NText>
|
||||||
</div>
|
</div>
|
||||||
</NSpin>
|
</NSpin>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|||||||
Reference in New Issue
Block a user