AI-News/frontend/app/pages/favorites.vue
2025-12-04 10:04:21 +08:00

341 lines
7.6 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="fav-page">
<div class="fav-shell">
<header class="fav-header">
<div>
<h1 class="title">我的收藏</h1>
<p class="desc">展示你点赞/收藏过的文章随时回顾</p>
</div>
<div class="actions">
<button class="btn" type="button" @click="refresh" :disabled="loading">
{{ loading ? '刷新中...' : '刷新列表' }}
</button>
</div>
</header>
<div class="tabs" v-if="tags.length">
<button
v-for="t in tags"
:key="t"
type="button"
class="tab"
:class="{ active: activeTag === t }"
@click="activeTag = t"
>
{{ t }}
</button>
</div>
<div v-if="loading" class="state">加载中...</div>
<div v-else-if="error" class="state error">{{ error }}</div>
<div v-else-if="filteredArticles.length === 0" class="state">暂无收藏</div>
<div v-else class="cards-grid">
<MarketCard
v-for="it in filteredArticles"
:key="it.slug"
:cover="it.cover || defaultCover"
:title="it.title || '未命名文章'"
:tags="it.tagList || []"
:created-at="it.createdAt"
:owner-name="it.author?.username || '官方'"
:owner-avatar="it.author?.image || ''"
:views="it.views ?? 0"
:likes="getLikes(it)"
:favorited="!!it.favorited"
:detail-href="`/articles/${it.slug}`"
@toggle-like="() => toggleFavorite(it)"
/>
</div>
<div class="footer-actions" v-if="hasMore && !loading && !error">
<button class="btn ghost" type="button" @click="loadMore" :disabled="loadingMore">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
<div class="state" v-else-if="!hasMore && articles.length">已到底部</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { navigateTo } from '#app'
import { useApi, useAuthToken } from '@/composables/useApi'
import { useAuth } from '@/composables/useAuth'
import MarketCard from '@/components/home/MarketCard.vue'
definePageMeta({
layout: 'default',
title: '我的收藏',
})
interface ArticleItem {
slug: string
title: string
description?: string | null
cover?: string | null
tagList?: string[]
createdAt?: string
views?: number
favorited?: boolean
favoritesCount?: number
favorites_count?: number
author?: {
username?: string
image?: string | null
}
}
interface ArticlesResponse {
articles?: ArticleItem[]
}
const api = useApi()
const { token } = useAuthToken()
const { user: authUser, fetchMe } = useAuth() as any
const articles = ref<ArticleItem[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const error = ref('')
const offset = ref(0)
const PAGE_SIZE = 20
const defaultCover = '/cover.jpg'
const tags = ref<string[]>([])
const activeTag = ref('全部')
const username = computed(() => authUser.value?.username || '')
async function ensureLogin(): Promise<boolean> {
if (!token.value) {
await navigateTo('/login')
return false
}
if (!authUser.value) {
try {
await fetchMe()
} catch (err) {
console.error('[Favorites] fetch me failed', err)
}
}
if (!authUser.value?.username) {
await navigateTo('/login')
return false
}
return true
}
function getLikes(it: ArticleItem): number {
return Number(it.favoritesCount ?? it.favorites_count ?? 0)
}
const filteredArticles = computed(() => {
if (!activeTag.value || activeTag.value === '全部') return articles.value
return articles.value.filter((it) => it.tagList?.includes(activeTag.value))
})
function rebuildTags(): void {
const set = new Set<string>()
articles.value.forEach((it) => it.tagList?.forEach((t) => set.add(t)))
const list = Array.from(set)
tags.value = list.length ? ['全部', ...list] : []
if (!tags.value.includes(activeTag.value)) activeTag.value = tags.value[0] || '全部'
}
async function loadFavorites(reset = false): Promise<void> {
if (!(await ensureLogin())) return
if (loading.value || loadingMore.value) return
if (reset) {
loading.value = true
error.value = ''
hasMore.value = true
offset.value = 0
articles.value = []
} else {
if (!hasMore.value) return
loadingMore.value = true
}
try {
const res = (await api.get('/articles', {
favorited: username.value,
limit: PAGE_SIZE,
offset: offset.value,
})) as ArticlesResponse
const list = Array.isArray(res.articles) ? res.articles : []
if (reset) {
articles.value = list
} else {
articles.value = [...articles.value, ...list]
}
offset.value += list.length
hasMore.value = list.length === PAGE_SIZE
rebuildTags()
} catch (err: any) {
console.error('[Favorites] load failed', err)
error.value = err?.statusMessage || '加载收藏失败'
} finally {
loading.value = false
loadingMore.value = false
}
}
function refresh(): void {
loadFavorites(true)
}
async function loadMore(): Promise<void> {
await loadFavorites(false)
}
async function toggleFavorite(it: ArticleItem): Promise<void> {
if (!(await ensureLogin())) return
const before = it.favorited
const beforeCount = getLikes(it)
try {
if (before) {
await api.del(`/articles/${it.slug}/favorite`)
it.favorited = false
it.favoritesCount = Math.max(0, beforeCount - 1)
articles.value = articles.value.filter((a) => a.slug !== it.slug)
} else {
await api.post(`/articles/${it.slug}/favorite`)
it.favorited = true
it.favoritesCount = beforeCount + 1
}
} catch (err) {
console.error('[Favorites] toggle favorite failed', err)
it.favorited = before
it.favoritesCount = beforeCount
}
}
onMounted(() => {
loadFavorites(true)
})
watch(
() => token.value,
async (val) => {
if (!val) {
articles.value = []
return
}
await loadFavorites(true)
},
)
watch(
() => articles.value,
() => rebuildTags(),
{ deep: true },
)
</script>
<style scoped>
.fav-page {
min-height: 100vh;
background: #f7f9fc;
padding: 100px 16px 60px;
}
.fav-shell {
max-width: 1200px;
margin: 0 auto;
background: #fff;
border-radius: 20px;
padding: 20px 20px 32px;
border: 1px solid #e5e7eb;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.fav-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.3px;
}
.desc {
margin: 6px 0 0;
color: #6b7280;
font-size: 14px;
}
.actions {
display: flex;
gap: 10px;
}
.btn {
padding: 8px 14px;
border-radius: 10px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 600;
}
.btn.ghost {
background: #f8fafc;
}
.tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 8px 0 12px;
}
.tab {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid #e5e7eb;
background: #fff;
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
color: #4b5563;
}
.tab.active {
background: #0f172a;
color: #fff;
border-color: #0f172a;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.18);
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 14px;
}
.state {
padding: 16px 10px;
text-align: center;
color: #6b7280;
}
.state.error {
color: #b91c1c;
}
.footer-actions {
display: flex;
justify-content: center;
margin-top: 14px;
}
</style>