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,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
|
||||
NPagination,
|
||||
} from 'naive-ui'
|
||||
import { articlesApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -17,8 +18,13 @@ const auth = useAuthStore()
|
||||
const items = ref<ArticleListItem[]>([])
|
||||
const sources = ref<Source[]>([])
|
||||
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 q = ref('')
|
||||
|
||||
@@ -31,12 +37,12 @@ async function load() {
|
||||
const resp = await articlesApi.list({
|
||||
source: sourceFilter.value.join(',') || undefined,
|
||||
q: q.value || undefined,
|
||||
cursor: cursor.value || undefined,
|
||||
limit: 50,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
items.value.push(...resp.items)
|
||||
cursor.value = resp.next_cursor
|
||||
if (!cursor.value) exhausted.value = true
|
||||
items.value = resp.items
|
||||
total.value = resp.total
|
||||
totalPages.value = resp.total_pages
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -47,10 +53,16 @@ async function loadSources() {
|
||||
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
items.value = []
|
||||
cursor.value = null
|
||||
exhausted.value = false
|
||||
// 切页 → 重新加载 + 滚到顶部
|
||||
function onPageChange(p: number) {
|
||||
page.value = p
|
||||
load()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// 筛选/搜索变化 → 回到第 1 页
|
||||
function resetToFirstPage() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
@@ -59,7 +71,6 @@ function open(a: ArticleListItem) {
|
||||
}
|
||||
|
||||
function star(a: ArticleListItem) {
|
||||
// 简单:登录的 star 接口后续
|
||||
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)
|
||||
}
|
||||
|
||||
// 评论预览:长文截断,带状态点
|
||||
// 评论预览:长文截断
|
||||
function previewCommentary(c?: string | null, max = 120): string {
|
||||
if (!c) return ''
|
||||
const trimmed = c.replace(/\s+/g, ' ').trim()
|
||||
@@ -88,6 +99,15 @@ function commentaryStatusType(s?: string | null): 'success' | 'warning' | 'error
|
||||
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 () => {
|
||||
await loadSources()
|
||||
await load()
|
||||
@@ -105,13 +125,13 @@ onMounted(async () => {
|
||||
placeholder="按源筛选"
|
||||
:options="sourceOptions"
|
||||
style="min-width: 240px"
|
||||
@update:value="refresh"
|
||||
@update:value="resetToFirstPage"
|
||||
/>
|
||||
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 200px"
|
||||
@keyup.enter="refresh" @clear="refresh" />
|
||||
<NButton @click="refresh">刷新</NButton>
|
||||
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
|
||||
<NButton @click="resetToFirstPage">刷新</NButton>
|
||||
</NSpace>
|
||||
<NText depth="3">{{ items.length }} 条</NText>
|
||||
<NText depth="3">{{ itemsLabel }}</NText>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="loading && items.length === 0">
|
||||
@@ -125,14 +145,13 @@ onMounted(async () => {
|
||||
hoverable
|
||||
@click="open(a)"
|
||||
>
|
||||
<NSpace vertical :size="4">
|
||||
<NSpace vertical :size="6">
|
||||
<NSpace align="center" :size="8">
|
||||
<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.translation_status !== 'ok'" size="small" type="warning">
|
||||
{{ a.translation_status }}
|
||||
</NTag>
|
||||
<!-- 分类标签(LLM classify 输出,多分类逗号分隔) -->
|
||||
<NTag
|
||||
v-for="c in splitCategory(a.category)"
|
||||
:key="c"
|
||||
@@ -143,14 +162,46 @@ onMounted(async () => {
|
||||
</NTag>
|
||||
<NText depth="3" style="font-size: 12px">{{ fmtTime(a.published_at || a.fetched_at) }}</NText>
|
||||
</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 }}
|
||||
</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
|
||||
v-if="a.commentary"
|
||||
style="
|
||||
@@ -174,10 +225,18 @@ onMounted(async () => {
|
||||
</div>
|
||||
</NSpace>
|
||||
</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>
|
||||
<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>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
|
||||
Reference in New Issue
Block a user