553 lines
14 KiB
Vue
553 lines
14 KiB
Vue
<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>
|