CatCat/miniprogram/src/pages/cafe/cafe.vue
2026-02-10 02:36:12 +08:00

496 lines
12 KiB
Vue
Raw 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>
<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>