496 lines
12 KiB
Vue
496 lines
12 KiB
Vue
<template>
|
||
<view class="container">
|
||
<!-- 全局/我的猫咪切换标签 -->
|
||
<view class="tab-bar">
|
||
<view
|
||
class="tab-item"
|
||
:class="{ active: activeTab === 'global' }"
|
||
@tap="switchTab('global')"
|
||
>
|
||
<text>全球猫咖</text>
|
||
</view>
|
||
<view
|
||
class="tab-item"
|
||
:class="{ active: activeTab === 'mine' }"
|
||
@tap="switchTab('mine')"
|
||
>
|
||
<text>我的猫咖</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Cat list with pull-down refresh (需求 6.1, 6.2) -->
|
||
<scroll-view
|
||
class="cat-scroll"
|
||
scroll-y
|
||
:refresher-enabled="true"
|
||
:refresher-triggered="isRefreshing"
|
||
@refresherrefresh="onRefresh"
|
||
@scrolltolower="onLoadMore"
|
||
>
|
||
<view v-if="displayCats.length > 0" class="cat-list">
|
||
<view
|
||
v-for="cat in displayCats"
|
||
:key="cat.id"
|
||
class="cat-card-wrapper"
|
||
@tap="viewDetail(cat.id)"
|
||
>
|
||
<!-- 猫咪卡片(带动画和信息)(需求 6.3) -->
|
||
<view class="cat-card">
|
||
<view class="cat-animation-area">
|
||
<AnimatedCat
|
||
:imageUrl="cat.imageUrl"
|
||
:containerWidth="300"
|
||
:containerHeight="150"
|
||
:catSize="60"
|
||
:speed="1.5"
|
||
:active="true"
|
||
/>
|
||
</view>
|
||
<view class="cat-info">
|
||
<text class="cat-name">{{ cat.name }}</text>
|
||
<view class="cat-meta">
|
||
<text class="similarity">相似度: {{ cat.similarity.toFixed(1) }}%</text>
|
||
<text class="author">by {{ cat.authorName }}</text>
|
||
</view>
|
||
</view>
|
||
<!-- 投票按钮(需求 6.4) -->
|
||
<view class="vote-area">
|
||
<view
|
||
class="vote-btn like-btn"
|
||
:class="{ voted: getVoteStatus(cat.id) === 'like' }"
|
||
@tap="handleVote(cat.id, 'like')"
|
||
>
|
||
<text class="vote-icon">👍</text>
|
||
<text class="vote-count">{{ cat.likes }}</text>
|
||
</view>
|
||
<view
|
||
class="vote-btn dislike-btn"
|
||
:class="{ voted: getVoteStatus(cat.id) === 'dislike' }"
|
||
@tap="handleVote(cat.id, 'dislike')"
|
||
>
|
||
<text class="vote-icon">👎</text>
|
||
<text class="vote-count">{{ cat.dislikes }}</text>
|
||
</view>
|
||
<!-- 收藏按钮(需求 7.5) -->
|
||
<view
|
||
class="vote-btn favorite-btn"
|
||
:class="{ favorited: isFavorited(cat.id) }"
|
||
@tap="handleFavorite(cat.id)"
|
||
>
|
||
<text class="vote-icon">{{ isFavorited(cat.id) ? '❤️' : '🤍' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 空状态 -->
|
||
<view v-else class="empty-state">
|
||
<text class="empty-icon">🐱</text>
|
||
<text class="empty-text">{{ activeTab === 'global' ? '暂无猫咪作品' : '你还没有创建猫咪' }}</text>
|
||
<button v-if="activeTab === 'mine'" class="create-btn" @tap="goToCreate">
|
||
去画一只猫
|
||
</button>
|
||
</view>
|
||
|
||
<!-- 加载更多指示器 -->
|
||
<view v-if="isLoading && displayCats.length > 0" class="loading-more">
|
||
<text>加载中...</text>
|
||
</view>
|
||
|
||
<!-- 没有更多数据 -->
|
||
<view v-if="!hasMore && displayCats.length > 0" class="no-more">
|
||
<text>没有更多了</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useCatsStore, type Cat } from '@/stores/cats'
|
||
import { useUserStore } from '@/stores/user'
|
||
import { get, post, del } from '@/utils/api'
|
||
import { hapticFeedback, showSuccess, showError, showInfo } from '@/utils/feedback'
|
||
import { showErrorToast } from '@/utils/error-handler'
|
||
import AnimatedCat from '@/components/AnimatedCat.vue'
|
||
|
||
const catsStore = useCatsStore()
|
||
const userStore = useUserStore()
|
||
|
||
// 标签状态
|
||
const activeTab = ref<'global' | 'mine'>('global')
|
||
|
||
// 加载状态
|
||
const isRefreshing = ref(false)
|
||
const isLoading = ref(false)
|
||
|
||
// 投票状态追踪 (catId -> 'like' | 'dislike' | null)
|
||
const voteStatus = ref<Map<string, 'like' | 'dislike'>>(new Map())
|
||
|
||
// 收藏追踪
|
||
const favoritedIds = ref<Set<string>>(new Set())
|
||
|
||
// 根据当前标签计算展示的猫咪
|
||
const displayCats = computed(() => {
|
||
return activeTab.value === 'global' ? catsStore.globalCats : catsStore.myCats
|
||
})
|
||
|
||
const hasMore = computed(() => catsStore.hasMore)
|
||
|
||
// 切换标签
|
||
const switchTab = (tab: 'global' | 'mine') => {
|
||
if (activeTab.value === tab) return
|
||
// Haptic feedback (需求 10.1)
|
||
hapticFeedback('light')
|
||
activeTab.value = tab
|
||
catsStore.resetPagination()
|
||
loadCats(true)
|
||
}
|
||
|
||
// 从 API 加载猫咪(需求 6.1)
|
||
const loadCats = async (refresh = false) => {
|
||
if (isLoading.value) return
|
||
|
||
isLoading.value = true
|
||
|
||
try {
|
||
const endpoint = activeTab.value === 'global' ? '/cats/global' : '/cats/mine'
|
||
const page = refresh ? 1 : catsStore.currentPage
|
||
|
||
if (activeTab.value === 'global') {
|
||
const response = await get<{ items: Cat[], hasMore: boolean }>(
|
||
`${endpoint}?page=${page}&limit=20`
|
||
)
|
||
|
||
if (response.success && response.data) {
|
||
const cats = (response.data.items || []).map(cat => ({
|
||
...cat,
|
||
createdAt: new Date(cat.createdAt)
|
||
}))
|
||
catsStore.setGlobalCats(cats, !refresh)
|
||
catsStore.setHasMore(response.data.hasMore)
|
||
if (!refresh) catsStore.nextPage()
|
||
}
|
||
} else {
|
||
const response = await get<Cat[]>(`${endpoint}`)
|
||
|
||
if (response.success && response.data) {
|
||
const cats = (response.data || []).map(cat => ({
|
||
...cat,
|
||
createdAt: new Date(cat.createdAt)
|
||
}))
|
||
catsStore.setMyCats(cats)
|
||
catsStore.setHasMore(false)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load cats:', error)
|
||
// 使用错误处理器显示用户友好消息(需求 9.3)
|
||
showErrorToast(error)
|
||
} finally {
|
||
isLoading.value = false
|
||
isRefreshing.value = false
|
||
}
|
||
}
|
||
|
||
// 下拉刷新(需求 6.2)
|
||
const onRefresh = () => {
|
||
isRefreshing.value = true
|
||
catsStore.resetPagination()
|
||
loadCats(true)
|
||
}
|
||
|
||
// 滚动到底部加载更多
|
||
const onLoadMore = () => {
|
||
if (hasMore.value && !isLoading.value) {
|
||
loadCats(false)
|
||
}
|
||
}
|
||
|
||
// 获取猫咪的投票状态
|
||
const getVoteStatus = (catId: string): 'like' | 'dislike' | null => {
|
||
return voteStatus.value.get(catId) || null
|
||
}
|
||
|
||
// 处理投票(需求 6.4)
|
||
const handleVote = async (catId: string, type: 'like' | 'dislike') => {
|
||
// 检查是否已投票
|
||
const currentVote = voteStatus.value.get(catId)
|
||
if (currentVote) {
|
||
showInfo('你已经投过票了')
|
||
return
|
||
}
|
||
|
||
// Haptic feedback (需求 10.1)
|
||
hapticFeedback('light')
|
||
|
||
try {
|
||
const response = await post<{ likes: number, dislikes: number }>(
|
||
`/cats/${catId}/vote`,
|
||
{ type }
|
||
)
|
||
|
||
if (response.success && response.data) {
|
||
// 更新本地投票状态
|
||
voteStatus.value.set(catId, type)
|
||
|
||
// 更新 store 中的猫咪投票数
|
||
catsStore.updateCatVotes(catId, response.data.likes, response.data.dislikes)
|
||
|
||
// 显示反馈(需求 10.3)
|
||
showSuccess(type === 'like' ? '点赞成功' : '已踩')
|
||
}
|
||
} catch (error) {
|
||
console.error('Vote failed:', error)
|
||
showError('投票失败')
|
||
}
|
||
}
|
||
|
||
// 检查猫咪是否已收藏
|
||
const isFavorited = (catId: string): boolean => {
|
||
return favoritedIds.value.has(catId)
|
||
}
|
||
|
||
// 处理收藏(需求 7.5)
|
||
const handleFavorite = async (catId: string) => {
|
||
// Haptic feedback (需求 10.1)
|
||
hapticFeedback('light')
|
||
|
||
const isFav = isFavorited(catId)
|
||
|
||
try {
|
||
if (isFav) {
|
||
await del(`/favorites/${catId}`)
|
||
favoritedIds.value.delete(catId)
|
||
showSuccess('已取消收藏')
|
||
} else {
|
||
await post(`/favorites/${catId}`)
|
||
favoritedIds.value.add(catId)
|
||
showSuccess('收藏成功')
|
||
}
|
||
} catch (error) {
|
||
console.error('Favorite operation failed:', error)
|
||
showError('操作失败')
|
||
}
|
||
}
|
||
|
||
// 跳转到创建页面
|
||
const goToCreate = () => {
|
||
uni.switchTab({ url: '/pages/index/index' })
|
||
}
|
||
|
||
// 跳转到猫咪详情页
|
||
const viewDetail = (catId: string) => {
|
||
uni.navigateTo({
|
||
url: `/pages/cat-detail/cat-detail?id=${catId}`
|
||
})
|
||
}
|
||
|
||
// 加载用户收藏
|
||
const loadFavorites = async () => {
|
||
try {
|
||
const response = await get<Cat[]>('/favorites')
|
||
if (response.success && response.data) {
|
||
const cats = Array.isArray(response.data) ? response.data : []
|
||
favoritedIds.value = new Set(cats.map(c => (c as any)._id || c.id))
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load favorites:', error)
|
||
}
|
||
}
|
||
|
||
// 加载用户投票历史
|
||
const loadVoteHistory = async () => {
|
||
// 服务端暂无投票历史查询接口,跳过
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadCats(true)
|
||
loadFavorites()
|
||
loadVoteHistory()
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.container {
|
||
min-height: 100vh;
|
||
background-color: #f8f8f8;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tab-bar {
|
||
display: flex;
|
||
background-color: #fff;
|
||
border-bottom: 1rpx solid #eee;
|
||
|
||
.tab-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 24rpx 0;
|
||
position: relative;
|
||
|
||
text {
|
||
font-size: 30rpx;
|
||
color: #666;
|
||
}
|
||
|
||
&.active {
|
||
text {
|
||
color: #FF6B6B;
|
||
font-weight: bold;
|
||
}
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 60rpx;
|
||
height: 4rpx;
|
||
background-color: #FF6B6B;
|
||
border-radius: 2rpx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.cat-scroll {
|
||
flex: 1;
|
||
height: calc(100vh - 100rpx);
|
||
}
|
||
|
||
.cat-list {
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.cat-card-wrapper {
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.cat-card {
|
||
background-color: #fff;
|
||
border-radius: 16rpx;
|
||
overflow: hidden;
|
||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.cat-animation-area {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 16rpx;
|
||
background: linear-gradient(180deg, #f0f8ff 0%, #e8f4f8 100%);
|
||
}
|
||
|
||
.cat-info {
|
||
padding: 20rpx 24rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
|
||
.cat-name {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.cat-meta {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
|
||
.similarity {
|
||
font-size: 24rpx;
|
||
color: #4ECDC4;
|
||
}
|
||
|
||
.author {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
}
|
||
}
|
||
}
|
||
|
||
.vote-area {
|
||
display: flex;
|
||
padding: 16rpx 24rpx;
|
||
gap: 24rpx;
|
||
|
||
.vote-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 12rpx 20rpx;
|
||
border-radius: 24rpx;
|
||
background-color: #f5f5f5;
|
||
transition: all 0.2s ease;
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.vote-icon {
|
||
font-size: 32rpx;
|
||
margin-right: 8rpx;
|
||
}
|
||
|
||
.vote-count {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
}
|
||
}
|
||
|
||
.like-btn.voted {
|
||
background-color: #e8f5e9;
|
||
}
|
||
|
||
.dislike-btn.voted {
|
||
background-color: #ffebee;
|
||
}
|
||
|
||
.favorite-btn {
|
||
margin-left: auto;
|
||
|
||
&.favorited {
|
||
background-color: #fce4ec;
|
||
}
|
||
}
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 120rpx 40rpx;
|
||
|
||
.empty-icon {
|
||
font-size: 100rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
margin-bottom: 32rpx;
|
||
}
|
||
|
||
.create-btn {
|
||
background: linear-gradient(135deg, #FF6B6B, #FF8E53);
|
||
color: #fff;
|
||
font-size: 28rpx;
|
||
padding: 16rpx 48rpx;
|
||
border-radius: 32rpx;
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.loading-more, .no-more {
|
||
text-align: center;
|
||
padding: 24rpx;
|
||
|
||
text {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
}
|
||
}
|
||
</style> |