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

320 lines
9.2 KiB
Vue
Raw Normal View History

<script setup lang="ts">
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'
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 items = ref<ArticleListItem[]>([])
const sources = ref<Source[]>([])
const loading = 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('')
const sourceOptions = ref<{ label: string; value: string }[]>([])
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,
})
items.value = resp.items
total.value = resp.total
totalPages.value = resp.total_pages
} finally {
loading.value = false
}
}
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()
}
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)
}
// 评论预览:长文截断
function previewCommentary(c?: string | null, max = 120): string {
if (!c) return ''
const trimmed = c.replace(/\s+/g, ' ').trim()
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
}
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'
}
// 正文摘要(取 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()
})
</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"
/>
<NInput v-model:value="q" placeholder="关键词搜索" clearable class="feed-search-input"
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
<NButton type="primary" @click="resetToFirstPage" round>刷新</NButton>
</NSpace>
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
</NSpace>
<NSpin :show="loading && items.length === 0">
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
<div v-else>
<NCard
v-for="a in items"
:key="a.id"
class="article-card"
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>
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="feed-time-label">
{{ fmtTime(a.published_at || a.fetched_at) }}
</NText>
</NSpace>
<!-- 中文标题() -->
<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;
"
>
{{ a.title_zh }}
</div>
<!-- 原标题(灰色,辅助) -->
<div
v-if="a.title"
style="font-size: 13px; color: var(--color-text-faint); line-height: 1.4;"
>
{{ a.title }}
</div>
<!-- AI 插图(若有) -->
<img
v-if="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"
/>
<!-- 翻译后正文摘要 -->
<div
v-if="a.body_zh_text || a.summary_zh"
style="
margin-top: 4px;
color: var(--color-letter);
font-size: 14px;
line-height: 1.75;
"
>
{{ bodyExcerpt(a.body_zh_text || a.summary_zh, 220) }}
</div>
<!-- 评论钩子(淡木色背景 + 木色左边框, Android 对齐) -->
<div
v-if="a.commentary"
class="commentary-box"
>
<NSpace align="center" :size="6" style="margin-bottom: 6px">
<span class="commentary-label">💬 评论</span>
<NTag size="tiny" :type="commentaryStatusType(a.commentary_status)" round :bordered="false">
{{ a.commentary_status || 'n/a' }}
</NTag>
</NSpace>
<div class="commentary-text">
{{ previewCommentary(a.commentary, 140) }}
</div>
</div>
</NSpace>
</NCard>
<!-- 页码分页 -->
<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>
</div>
</NSpin>
</NSpace>
</template>
<style scoped>
/* 评论钩子(项目级统一样式,与 Android ArticleCard.kt 的 CommentaryBox 对齐) */
.commentary-box {
margin-top: 10px;
padding: 12px 14px;
background: var(--color-primary-soft);
border-left: 3px solid var(--color-primary);
border-radius: 6px;
color: var(--color-letter);
font-size: 13px;
line-height: 1.7;
}
.commentary-label {
font-size: 12px;
font-weight: 600;
color: var(--color-primary);
}
.commentary-text {
color: var(--color-letter);
font-size: 13px;
line-height: 1.7;
}
/* ===== 桌面端默认宽度 ===== */
.feed-source-select {
min-width: 240px;
}
.feed-search-input {
width: 220px;
}
/* ===== 移动端(<= 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;
}
}
</style>