feat: enhance collection categories page

This commit is contained in:
hajimi
2026-08-03 19:02:58 +08:00
parent 476b17b0c2
commit 22113f9af8
+425 -49
View File
@@ -7,64 +7,440 @@
/>
<!-- #endif -->
</page-meta>
<z-paging
ref="paging"
v-model="collectData"
@query="queryList"
:fixed="false"
height="100%"
use-page-scroll
>
<u-swipe-action
:show="item.show"
:index="index"
v-for="(item, index) in collectData"
:key="item.id"
@click="handleCollect"
:options="options"
btn-width="120"
>
<news-card :item="item" :newsId="item.article_id"></news-card>
</u-swipe-action>
</z-paging>
<view class="collection-page">
<scroll-view scroll-x class="collection-tabs" :show-scrollbar="false">
<view class="collection-tabs__inner">
<view v-for="tab in collectionTabs" :key="tab.key" class="collection-tab"
:class="{ 'collection-tab--active': activeType === tab.key }"
@tap="switchType(tab.key)">
<u-icon :name="tab.icon" size="28" :color="activeType === tab.key ? '#1468f5' : '#7b8492'" />
<text>{{ tab.label }}</text>
<text v-if="tab.key === 'article' && articleCount" class="collection-tab__count">{{ articleCount }}</text>
<text v-if="tab.key === 'crypto' && cryptoCount" class="collection-tab__count">{{ cryptoCount }}</text>
</view>
</view>
</scroll-view>
<scroll-view scroll-y class="collection-scroll" refresher-enabled :refresher-triggered="refreshing"
@refresherrefresh="refreshCollection">
<view v-if="loading" class="collection-state">
<u-loading mode="circle" color="#1468f5" />
<text>加载收藏中...</text>
</view>
<template v-else-if="activeType === 'article'">
<view v-if="articles.length" class="collection-list">
<view v-for="item in articles" :key="item.id || item.article_id" class="article-item"
@tap="openArticle(item)">
<image v-if="item.image" class="article-item__image" :src="item.image" mode="aspectFill" />
<view v-else class="article-item__image article-item__image--empty">
<u-icon name="file-text" size="38" color="#8cb4f4" />
</view>
<view class="article-item__body">
<text class="article-item__title">{{ item.title || '未命名文章' }}</text>
<text v-if="item.desc" class="article-item__desc">{{ item.desc }}</text>
<view class="article-item__meta">
<text>{{ item.collect_time || item.create_time || '' }}</text>
<view class="article-item__remove" @tap.stop="removeArticle(item)">
<u-icon name="star-fill" size="26" color="#f0ad4e" />
<text>取消收藏</text>
</view>
</view>
</view>
</view>
</view>
<view v-else class="collection-state collection-state--empty">
<u-icon name="star" size="72" color="#cbd5e1" />
<text class="collection-state__title">暂无资讯收藏</text>
<text class="collection-state__desc">收藏文章后会显示在这里</text>
</view>
</template>
<template v-else-if="activeType === 'crypto'">
<view v-if="coins.length" class="collection-list coin-list">
<view v-for="coin in coins" :key="coin.symbol" class="coin-item" @tap="openCoin(coin)">
<image v-if="coin.image" class="coin-item__icon" :src="coin.image" mode="aspectFit" />
<view v-else class="coin-item__icon coin-item__icon--fallback">{{ coin.symbol.slice(0, 1) }}</view>
<view class="coin-item__name">
<text>{{ coin.symbol }}</text>
<text>{{ coin.name_zh || coin.name }}</text>
</view>
<view class="coin-item__quote">
<text>${{ formatPrice(coin.current_price) }}</text>
<text :class="coin.price_change_percentage_24h >= 0 ? 'is-up' : 'is-down'">
{{ formatChange(coin.price_change_percentage_24h) }}
</text>
</view>
<view class="coin-item__remove" @tap.stop="removeCoin(coin)">
<u-icon name="star-fill" size="28" color="#f0ad4e" />
</view>
</view>
</view>
<view v-else class="collection-state collection-state--empty">
<u-icon name="star" size="72" color="#cbd5e1" />
<text class="collection-state__title">暂无加密货币收藏</text>
<text class="collection-state__desc">在行情页点击星标即可加入收藏</text>
</view>
</template>
<view v-else class="collection-state collection-state--empty">
<u-icon name="star" size="72" color="#cbd5e1" />
<text class="collection-state__title">暂无{{ activeTabLabel }}收藏</text>
<text class="collection-state__desc">该分类的收藏内容会显示在这里</text>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { ref, reactive, shallowRef } from 'vue'
import { getCollect, cancelCollect } from '@/api/news'
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { cancelCollect, getCollect } from '@/api/news'
import {
cancelCryptoFavorite,
formatPrice,
getCryptoFavorites,
getCryptoMarket,
type CryptoMarketCoin
} from '@/api/crypto'
const paging = shallowRef()
const options = reactive([
{
text: '取消收藏',
style: {
color: '#FFFFFF',
backgroundColor: '#FF2C3C'
}
}
])
const collectData: any = ref([])
type CollectionType = 'article' | 'post' | 'match' | 'crypto'
const queryList = async (pageNo, pageSize) => {
const { lists } = await getCollect()
lists.forEach((item: any) => {
item.show = false
})
collectData.value = lists
paging.value.complete(lists)
const collectionTabs: Array<{ key: CollectionType; label: string; icon: string }> = [
{ key: 'article', label: '资讯', icon: 'file-text' },
{ key: 'post', label: '帖子', icon: 'chat' },
{ key: 'match', label: '比赛', icon: 'calendar' },
{ key: 'crypto', label: '加密货币', icon: 'star' }
]
const activeType = ref<CollectionType>('article')
const articles = ref<any[]>([])
const coins = ref<CryptoMarketCoin[]>([])
const loading = ref(false)
const refreshing = ref(false)
const articleCount = computed(() => articles.value.length)
const cryptoCount = computed(() => coins.value.length)
const activeTabLabel = computed(() => collectionTabs.find((tab) => tab.key === activeType.value)?.label || '')
const loadArticles = async () => {
const result = await getCollect()
articles.value = Array.isArray(result?.lists) ? result.lists : []
}
const handleCollect = async (index: number): Promise<void> => {
const loadCrypto = async () => {
const [market, favorites] = await Promise.all([getCryptoMarket(), getCryptoFavorites()])
const symbols = Array.isArray(favorites?.symbols)
? favorites.symbols.map((symbol) => String(symbol).toUpperCase())
: []
const symbolSet = new Set(symbols)
coins.value = Array.isArray(market?.coins)
? market.coins.filter((coin) => symbolSet.has(String(coin.symbol).toUpperCase()))
: []
}
const loadCollection = async () => {
loading.value = true
try {
const article_id: number = collectData.value[index].article_id
await cancelCollect({ id: article_id })
uni.$u.toast('已取消收藏')
paging.value.reload()
} catch (err) {
//TODO handle the exception
console.log('取消收藏报错=>', err)
if (activeType.value === 'article') {
await loadArticles()
} else if (activeType.value === 'crypto') {
await loadCrypto()
}
} catch (error) {
console.error('[Collection] load failed:', error)
uni.showToast({ title: '收藏加载失败', icon: 'none' })
} finally {
loading.value = false
refreshing.value = false
}
}
const switchType = (type: CollectionType) => {
if (activeType.value === type) return
activeType.value = type
loadCollection()
}
const refreshCollection = () => {
refreshing.value = true
loadCollection()
}
const openArticle = (item: any) => {
const id = item.article_id || item.id
if (id) uni.navigateTo({ url: `/pages/news_detail/news_detail?id=${id}` })
}
const removeArticle = async (item: any) => {
const id = item.article_id || item.id
if (!id) return
try {
await cancelCollect({ id: Number(id) })
articles.value = articles.value.filter((article) => (article.article_id || article.id) !== id)
uni.showToast({ title: '已取消收藏', icon: 'none' })
} catch (error) {
console.error('[Collection] remove article failed:', error)
}
}
const openCoin = (coin: CryptoMarketCoin) => {
uni.navigateTo({ url: `/pages/crypto/crypto_detail?symbol=${encodeURIComponent(coin.symbol)}` })
}
const removeCoin = async (coin: CryptoMarketCoin) => {
try {
await cancelCryptoFavorite(coin.symbol)
coins.value = coins.value.filter((item) => item.symbol !== coin.symbol)
uni.showToast({ title: '已取消收藏', icon: 'none' })
} catch (error) {
console.error('[Collection] remove crypto failed:', error)
}
}
const formatChange = (value: number) => {
const number = Number(value || 0)
return `${number >= 0 ? '+' : ''}${number.toFixed(2)}%`
}
onShow(() => {
if (activeType.value === 'article' || activeType.value === 'crypto') loadCollection()
})
</script>
<style scoped></style>
<style lang="scss" scoped>
.collection-page {
min-height: 100vh;
background: #f5f7fb;
}
.collection-tabs {
background: #fff;
border-bottom: 1rpx solid #edf0f5;
&__inner {
display: inline-flex;
min-width: 100%;
padding: 0 24rpx;
}
}
.collection-tab {
min-width: 150rpx;
height: 88rpx;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
position: relative;
color: #697386;
font-size: 27rpx;
&--active {
color: #1468f5;
font-weight: 600;
&::after {
content: '';
position: absolute;
left: 34rpx;
right: 34rpx;
bottom: 0;
height: 5rpx;
border-radius: 5rpx 5rpx 0 0;
background: #1468f5;
}
}
&__count {
min-width: 28rpx;
padding: 1rpx 7rpx;
border-radius: 999rpx;
background: #edf4ff;
color: #1468f5;
font-size: 18rpx;
text-align: center;
}
}
.collection-scroll {
height: calc(100vh - 88rpx);
}
.collection-list {
padding: 16rpx 20rpx 36rpx;
}
.article-item,
.coin-item {
display: flex;
align-items: center;
gap: 18rpx;
margin-bottom: 14rpx;
padding: 18rpx;
background: #fff;
border: 1rpx solid #e8edf5;
border-radius: 18rpx;
box-shadow: 0 5rpx 18rpx rgba(30, 71, 120, 0.04);
}
.article-item {
align-items: stretch;
&__image {
width: 190rpx;
height: 138rpx;
flex-shrink: 0;
border-radius: 12rpx;
background: #edf3fb;
&--empty {
display: flex;
align-items: center;
justify-content: center;
}
}
&__body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
}
&__title {
display: -webkit-box;
overflow: hidden;
color: #1f2937;
font-size: 28rpx;
font-weight: 600;
line-height: 1.35;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
&__desc {
display: -webkit-box;
margin-top: 8rpx;
overflow: hidden;
color: #8a94a5;
font-size: 22rpx;
line-height: 1.35;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
&__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
margin-top: auto;
color: #a0a8b5;
font-size: 20rpx;
}
&__remove,
.article-item__remove {
display: inline-flex;
align-items: center;
gap: 5rpx;
flex-shrink: 0;
color: #e3a33c;
font-size: 20rpx;
}
}
.coin-item {
min-height: 92rpx;
&__icon {
width: 64rpx;
height: 64rpx;
flex-shrink: 0;
border-radius: 50%;
}
&__icon--fallback {
display: flex;
align-items: center;
justify-content: center;
background: #1468f5;
color: #fff;
font-size: 28rpx;
font-weight: 700;
}
&__name {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
text:first-child {
color: #1f2937;
font-size: 28rpx;
font-weight: 700;
}
text:last-child {
overflow: hidden;
color: #8a94a5;
font-size: 21rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
}
&__quote {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 5rpx;
text:first-child {
color: #1f2937;
font-size: 24rpx;
font-weight: 600;
}
text:last-child {
font-size: 21rpx;
}
}
&__remove {
width: 44rpx;
display: flex;
justify-content: flex-end;
flex-shrink: 0;
}
}
.is-up { color: #16a36a; }
.is-down { color: #ef5350; }
.collection-state {
min-height: 50vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14rpx;
color: #8d98a8;
font-size: 24rpx;
&__title {
color: #596579;
font-size: 29rpx;
font-weight: 600;
}
&__desc {
color: #a1aab8;
font-size: 22rpx;
}
}
</style>