2026-06-07 21:51:01 +08:00
|
|
|
<script setup lang="ts">
|
2026-06-10 12:07:04 +08:00
|
|
|
import { computed, onMounted, ref } from 'vue'
|
2026-06-07 21:51:01 +08:00
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
import {
|
|
|
|
|
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
|
2026-06-10 12:07:04 +08:00
|
|
|
NPagination,
|
2026-06-07 21:51:01 +08:00
|
|
|
} 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)
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
|
|
|
// === 页码分页(替代原来的 cursor 无限滚动)===
|
|
|
|
|
const page = ref(1)
|
|
|
|
|
const pageSize = ref(50)
|
|
|
|
|
const total = ref(0)
|
|
|
|
|
const totalPages = ref(1)
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
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,
|
2026-06-10 12:07:04 +08:00
|
|
|
page: page.value,
|
|
|
|
|
page_size: pageSize.value,
|
2026-06-07 21:51:01 +08:00
|
|
|
})
|
2026-06-10 12:07:04 +08:00
|
|
|
items.value = resp.items
|
|
|
|
|
total.value = resp.total
|
|
|
|
|
totalPages.value = resp.total_pages
|
2026-06-07 21:51:01 +08:00
|
|
|
} finally {
|
|
|
|
|
loading.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadSources() {
|
|
|
|
|
sources.value = await sourcesApi.list()
|
|
|
|
|
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 12:07:04 +08:00
|
|
|
// 切页 → 重新加载 + 滚到顶部
|
|
|
|
|
function onPageChange(p: number) {
|
|
|
|
|
page.value = p
|
|
|
|
|
load()
|
|
|
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 筛选/搜索变化 → 回到第 1 页
|
|
|
|
|
function resetToFirstPage() {
|
|
|
|
|
page.value = 1
|
2026-06-07 21:51:01 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:59:48 +08:00
|
|
|
// category 是逗号分隔字符串(LLM 输出),拆成多个 tag
|
|
|
|
|
function splitCategory(c?: string | null): string[] {
|
|
|
|
|
if (!c) return []
|
|
|
|
|
return c.split(',').map((s) => s.trim()).filter(Boolean)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 12:07:04 +08:00
|
|
|
// 评论预览:长文截断
|
2026-06-09 15:59:48 +08:00
|
|
|
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'
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 12:07:04 +08:00
|
|
|
// 正文摘要(取 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} 条`)
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
onMounted(async () => {
|
|
|
|
|
await loadSources()
|
|
|
|
|
await load()
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<NSpace vertical>
|
|
|
|
|
<NSpace align="center" justify="space-between">
|
|
|
|
|
<NSpace>
|
|
|
|
|
<NSelect
|
|
|
|
|
v-model:value="sourceFilter"
|
|
|
|
|
multiple
|
|
|
|
|
clearable
|
|
|
|
|
placeholder="按源筛选"
|
|
|
|
|
:options="sourceOptions"
|
|
|
|
|
style="min-width: 240px"
|
2026-06-10 12:07:04 +08:00
|
|
|
@update:value="resetToFirstPage"
|
2026-06-07 21:51:01 +08:00
|
|
|
/>
|
|
|
|
|
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 200px"
|
2026-06-10 12:07:04 +08:00
|
|
|
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
|
|
|
|
|
<NButton @click="resetToFirstPage">刷新</NButton>
|
2026-06-07 21:51:01 +08:00
|
|
|
</NSpace>
|
2026-06-10 12:07:04 +08:00
|
|
|
<NText depth="3">{{ itemsLabel }}</NText>
|
2026-06-07 21:51:01 +08:00
|
|
|
</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)"
|
|
|
|
|
>
|
2026-06-10 12:07:04 +08:00
|
|
|
<NSpace vertical :size="6">
|
2026-06-07 21:51:01 +08:00
|
|
|
<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>
|
2026-06-09 15:59:48 +08:00
|
|
|
<NTag
|
|
|
|
|
v-for="c in splitCategory(a.category)"
|
|
|
|
|
:key="c"
|
|
|
|
|
size="small"
|
|
|
|
|
type="success"
|
|
|
|
|
>
|
|
|
|
|
{{ c }}
|
|
|
|
|
</NTag>
|
2026-06-07 21:51:01 +08:00
|
|
|
<NText depth="3" style="font-size: 12px">{{ fmtTime(a.published_at || a.fetched_at) }}</NText>
|
|
|
|
|
</NSpace>
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
|
|
|
<!-- 原标题(灰色,辅助) -->
|
|
|
|
|
<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;">
|
2026-06-07 21:51:01 +08:00
|
|
|
{{ a.title_zh }}
|
|
|
|
|
</div>
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
|
|
|
<!-- 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) }}
|
2026-06-07 21:51:01 +08:00
|
|
|
</div>
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
|
|
|
<!-- 评论预览 -->
|
2026-06-09 15:59:48 +08:00
|
|
|
<div
|
|
|
|
|
v-if="a.commentary"
|
|
|
|
|
style="
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
padding: 8px 10px;
|
|
|
|
|
background: #f6f8ff;
|
|
|
|
|
border-left: 3px solid #2080f0;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
color: #444;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
"
|
|
|
|
|
>
|
|
|
|
|
<NSpace align="center" :size="6" style="margin-bottom: 4px">
|
|
|
|
|
<NText depth="2" style="font-size: 12px; font-weight: 600">💬 评论</NText>
|
|
|
|
|
<NTag size="tiny" :type="commentaryStatusType(a.commentary_status)">
|
|
|
|
|
{{ a.commentary_status || 'n/a' }}
|
|
|
|
|
</NTag>
|
|
|
|
|
</NSpace>
|
|
|
|
|
<span>{{ previewCommentary(a.commentary, 140) }}</span>
|
|
|
|
|
</div>
|
2026-06-07 21:51:01 +08:00
|
|
|
</NSpace>
|
|
|
|
|
</NCard>
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
|
|
|
<!-- 页码分页(替代无限滚动) -->
|
|
|
|
|
<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"
|
|
|
|
|
/>
|
2026-06-07 21:51:01 +08:00
|
|
|
</NSpace>
|
2026-06-10 12:07:04 +08:00
|
|
|
<NText v-else depth="3" style="display:block; text-align:center; padding: 16px">— 暂无数据 —</NText>
|
2026-06-07 21:51:01 +08:00
|
|
|
</div>
|
|
|
|
|
</NSpin>
|
|
|
|
|
</NSpace>
|
|
|
|
|
</template>
|