FurBall/pages/index/pets.vue

553 lines
14 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>
<view class="pets-page">
<scroll-view class="pets-scroll" scroll-y :scroll-with-animation="true"
:style="{ paddingBottom: listPaddingBottom + 'px' }">
<!-- 顶部欢迎横幅 -->
<view class="header-banner">
<view class="header-bg" />
<view class="header-content">
<view class="header-greeting">
<text class="greeting-sub">{{ greetingTime }}</text>
<text class="greeting-main">{{ greeting }}</text>
</view>
<view class="header-stats-row">
<view class="header-stat-item">
<text class="header-stat-num">{{ statsData.petCount }}</text>
<text class="header-stat-label">毛球成员</text>
</view>
<view class="header-stat-divider" />
<view class="header-stat-item">
<text class="header-stat-num">{{ statsData.recordCount }}</text>
<text class="header-stat-label">健康记录</text>
</view>
<view class="header-stat-divider" />
<view class="header-stat-item">
<text class="header-stat-num">{{ statsData.scheduleCount }}</text>
<text class="header-stat-label">待办事项</text>
</view>
</view>
</view>
</view>
<!-- 快捷操作 -->
<view class="quick-section">
<view class="quick-card" @click="goAddRecord">
<view class="quick-icon-wrap quick-teal">
<TnIcon name="write-feather" :size="36" color="#FFFFFF" />
</view>
<text class="quick-label">记录健康</text>
</view>
<view class="quick-card" @click="goAddSchedule">
<view class="quick-icon-wrap quick-purple">
<TnIcon name="notice-fill" :size="36" color="#FFFFFF" />
</view>
<text class="quick-label">添加提醒</text>
</view>
<view class="quick-card" @click="goNearbyHospital">
<view class="quick-icon-wrap quick-pink">
<TnIcon name="safe-fill" :size="36" color="#FFFFFF" />
</view>
<text class="quick-label">附近医院</text>
</view>
<view class="quick-card" @click="handleAddPet">
<view class="quick-icon-wrap quick-orange">
<TnIcon name="add-lack" :size="36" color="#FFFFFF" />
</view>
<text class="quick-label">添加宠物</text>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-wrap">
<TnLoading :show="true" mode="circle" color="#F97316" :size="48" />
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="petList.length === 0" class="empty-wrap">
<TnEmpty mode="list" color="#F97316" size="lg" />
<text class="empty-title">还没有宠物</text>
<text class="empty-desc">添加你的第一只毛球,开启健康管理之旅</text>
<TnButton text-color="#FFFFFF" shape="round" :bold="true" class="empty-add-btn"
:custom-style="primaryButtonStyle" @click="handleAddPet">
<TnIcon name="add-lack" :size="28" />
<text class="add-btn-text">添加宠物</text>
</TnButton>
</view>
<!-- 宠物列表 -->
<view v-else class="pets-section">
<text class="section-title">我的毛球们</text>
<view class="pets-list">
<view v-for="pet in petList" :key="pet._id" class="pet-card" @click="handlePetDetail(pet)">
<view class="pet-avatar-wrap">
<TnAvatar :url="pet._displayAvatar || ''" :icon="pet._displayAvatar ? '' : 'floral'"
:size="140" shape="square" :shadow="true" bg-color="#FFF7ED" />
</view>
<view class="pet-info">
<view class="pet-name-row">
<text class="pet-name">{{ pet.name }}</text>
<view class="pet-gender-badge"
:class="pet.gender === 'female' ? 'badge-female' : 'badge-male'">
<TnIcon :name="pet.gender === 'female' ? 'sex-female' : 'sex-male'" :size="22"
:color="pet.gender === 'female' ? '#EC4899' : '#3B82F6'" />
</view>
</view>
<text class="pet-breed">
{{ getSpeciesLabel(pet.species) }}{{ pet.breed ? ' · ' + pet.breed : '' }}
</text>
<view class="pet-tags">
<TnTag v-if="pet.birthday" size="sm" shape="round" bg-color="#FFF7ED"
text-color="#F97316">
{{ calcAge(pet.birthday) }}
</TnTag>
<TnTag v-if="pet.weight" size="sm" shape="round" bg-color="#F0FDFA"
text-color="#14B8A6">
{{ pet.weight }}kg
</TnTag>
<TnTag v-if="pet.neutered" size="sm" shape="round" bg-color="#F0FDF4"
text-color="#22C55E">
已绝育
</TnTag>
</view>
</view>
<TnIcon name="right" :size="28" color="#CBD5E1" />
</view>
</view>
</view>
</scroll-view>
<!-- FAB紧贴 TabBar 上方 -->
<view v-if="!loading && petList.length > 0" class="fab-bar" :style="{ bottom: fabBottom + 'px' }">
<TnButton text-color="#FFFFFF" shape="round" :bold="true" :custom-style="primaryButtonStyle"
width="280" height="88" :font-size="28" @click="handleAddPet">
<TnIcon name="add-lack" :size="28" />
<text class="add-btn-text">添加宠物</text>
</TnButton>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import TnLoading from '@/uni_modules/tuniaoui-vue3/components/loading/src/loading.vue'
import TnEmpty from '@/uni_modules/tuniaoui-vue3/components/empty/src/empty.vue'
import TnButton from '@/uni_modules/tuniaoui-vue3/components/button/src/button.vue'
import TnAvatar from '@/uni_modules/tuniaoui-vue3/components/avatar/src/avatar.vue'
import TnTag from '@/uni_modules/tuniaoui-vue3/components/tag/src/tag.vue'
import TnIcon from '@/uni_modules/tuniaoui-vue3/components/icon/src/icon.vue'
interface Pet {
_id : string
name : string
species : string
breed : string
gender : string
birthday : string
weight : number | null
avatarUrl : string
color : string
neutered : boolean
_displayAvatar ?: string
}
const loading = ref(true)
const petList = ref<Pet[]>([])
const listPaddingBottom = ref(160)
const fabBottom = ref(58)
const primaryButtonStyle = {
background: 'linear-gradient(135deg, #F97316, #FB923C)',
}
const statsData = reactive({ petCount: 0, recordCount: 0, scheduleCount: 0 })
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了,好好休息'
if (h < 10) return '早上好,新的一天开始啦'
if (h < 13) return '上午好,关心一下毛孩子吧'
if (h < 18) return '下午好,毛球在等你'
if (h < 22) return '晚上好,陪陪你的宠物吧'
return '夜深了,记得早睡哦'
})
const greetingTime = computed(() => {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const d = new Date()
return `${d.getMonth() + 1}${d.getDate()}${days[d.getDay()]}`
})
onMounted(() => {
calcLayout()
loadPets()
loadStats()
})
onShow(() => {
loadPets()
loadStats()
})
function calcLayout() {
const sys = uni.getSystemInfoSync()
const safeBottom = sys.safeAreaInsets?.bottom || 0
fabBottom.value = safeBottom + 50 + 8
listPaddingBottom.value = safeBottom + 50 + 90 + 16
}
async function loadPets() {
loading.value = true
try {
const uid = uni.getStorageSync('uid')
if (!uid) { loading.value = false; return }
const res = await uniCloud.callFunction({ name: 'petManager', data: { action: 'list', uid } })
if (res.result?.code === 0) {
petList.value = res.result.data || []
await resolveAvatars()
}
} catch (e) {
console.warn('加载宠物列表失败', e)
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const uid = uni.getStorageSync('uid')
if (!uid) return
const res = await uniCloud.callFunction({ name: 'userManager', data: { action: 'stats', uid } })
if (res.result?.code === 0 && res.result.data) {
const d = res.result.data
statsData.petCount = d.petCount ?? 0
statsData.recordCount = d.recordCount ?? 0
statsData.scheduleCount = d.pendingScheduleCount ?? 0
}
} catch (e) { /* ignore */ }
}
async function resolveAvatars() {
const cloudList = petList.value.filter(p => p.avatarUrl?.startsWith('cloud://'))
petList.value.forEach(p => {
if (p.avatarUrl && !p.avatarUrl.startsWith('cloud://')) p._displayAvatar = p.avatarUrl
})
if (!cloudList.length) return
try {
const res = await uniCloud.getTempFileURL({ fileList: cloudList.map(p => p.avatarUrl) })
const map : Record<string, string> = {}
res.fileList?.forEach((item : any) => { if (item.tempFileURL) map[item.fileID] = item.tempFileURL })
cloudList.forEach(p => { p._displayAvatar = map[p.avatarUrl] || '' })
} catch (e) { console.warn('解析头像失败', e) }
}
function getSpeciesLabel(s : string) {
const m : Record<string, string> = { dog: '狗狗', cat: '猫咪', bird: '鸟类', fish: '鱼类', hamster: '仓鼠', rabbit: '兔子' }
return m[s] || '宠物'
}
function calcAge(birthday : string) {
if (!birthday) return ''
const birth = new Date(birthday)
if (isNaN(birth.getTime())) return ''
const months = (new Date().getFullYear() - birth.getFullYear()) * 12 + (new Date().getMonth() - birth.getMonth())
if (months < 1) return '不到1个月'
if (months < 12) return `${months}个月`
const y = Math.floor(months / 12), m = months % 12
return m === 0 ? `${y}` : `${y}${m}个月`
}
function handleAddPet() { uni.navigateTo({ url: '/pages/pet/edit' }) }
function handlePetDetail(pet : Pet) { uni.navigateTo({ url: `/pages/pet/edit?id=${pet._id}` }) }
function goAddRecord() { uni.navigateTo({ url: '/pages/record/type-select' }) }
function goAddSchedule() { uni.navigateTo({ url: '/pages/schedule/edit' }) }
function goNearbyHospital() { uni.navigateTo({ url: '/pages/discovery/hospital-list' }) }
</script>
<style lang="scss" scoped>
$primary: #F97316;
$primary-light: #FB923C;
$primary-bg: #FFF7ED;
$text-dark: #1E293B;
$text-body: #475569;
$text-muted: #94A3B8;
$card-bg: #FFFFFF;
$shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
$radius-lg: 24rpx;
.pets-page {
position: relative;
height: 100%;
background: #F8FAFC;
}
.pets-scroll {
height: 100vh;
box-sizing: border-box;
}
.header-banner {
position: relative;
margin: 24rpx 24rpx 0;
border-radius: 28rpx;
overflow: hidden;
}
.header-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #F97316 0%, #FB923C 55%, #FDBA74 100%);
}
.header-content {
position: relative;
padding: 32rpx 28rpx 28rpx;
}
.header-greeting {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-bottom: 24rpx;
}
.greeting-sub {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.8);
}
.greeting-main {
font-size: 34rpx;
font-weight: 800;
color: #FFFFFF;
}
.header-stats-row {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.15);
border-radius: 16rpx;
padding: 18rpx 0;
}
.header-stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.header-stat-num {
font-size: 36rpx;
font-weight: 800;
color: #FFFFFF;
}
.header-stat-label {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.8);
}
.header-stat-divider {
width: 1rpx;
height: 44rpx;
background: rgba(255, 255, 255, 0.3);
}
.quick-section {
display: flex;
gap: 16rpx;
padding: 20rpx 24rpx 0;
}
.quick-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
padding: 20rpx 8rpx;
background: $card-bg;
border-radius: $radius-lg;
box-shadow: $shadow;
transition: transform 0.2s ease;
&:active {
transform: scale(0.95);
}
}
.quick-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
}
.quick-orange {
background: linear-gradient(135deg, #F97316, #FB923C);
}
.quick-teal {
background: linear-gradient(135deg, #2DD4BF, #0D9488);
}
.quick-purple {
background: linear-gradient(135deg, #8B5CF6, #7C3AED);
}
.quick-pink {
background: linear-gradient(135deg, #EC4899, #DB2777);
}
.quick-label {
font-size: 22rpx;
font-weight: 600;
color: $text-body;
text-align: center;
}
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
padding-top: 100rpx;
}
.loading-text {
font-size: 26rpx;
color: $text-muted;
}
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 60rpx;
gap: 16rpx;
}
.empty-title {
font-size: 32rpx;
font-weight: 700;
color: $text-dark;
margin-top: 8rpx;
}
.empty-desc {
font-size: 26rpx;
color: $text-muted;
text-align: center;
max-width: 480rpx;
}
.empty-add-btn {
margin-top: 24rpx;
}
.add-btn-text {
margin-left: 8rpx;
}
.pets-section {
padding: 24rpx 24rpx 0;
}
.section-title {
font-size: 28rpx;
font-weight: 700;
color: $text-dark;
display: block;
margin-bottom: 16rpx;
}
.pets-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.pet-card {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
background: $card-bg;
border-radius: $radius-lg;
box-shadow: $shadow;
transition: transform 0.2s ease;
&:active {
transform: scale(0.98);
}
}
.pet-avatar-wrap {
flex-shrink: 0;
}
.pet-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.pet-name-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.pet-name {
font-size: 30rpx;
font-weight: 800;
color: $text-dark;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pet-gender-badge {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.badge-male {
background: #EFF6FF;
}
&.badge-female {
background: #FDF2F8;
}
}
.pet-breed {
font-size: 24rpx;
color: $text-body;
}
.pet-tags {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 4rpx;
}
.fab-bar {
position: fixed;
left: 50%;
transform: translateX(-50%);
z-index: 50;
}
</style>