FurBall/pages/index/records.vue

878 lines
21 KiB
Vue

<template>
<view class="records-page">
<scroll-view class="records-scroll" scroll-y :scroll-with-animation="true"
:style="{ paddingBottom: scrollPaddingBottom + 'px' }">
<view class="hero-panel">
<view class="hero-main">
<view class="hero-copy">
<text class="hero-kicker">健康档案</text>
<text class="hero-title">{{ heroTitle }}</text>
<text class="hero-desc">{{ heroDesc }}</text>
</view>
<view class="hero-action" @click="handleAddRecord">
<TnIcon name="add-lack" :size="34" color="#FFFFFF" />
</view>
</view>
<view class="summary-row">
<view class="summary-item">
<text class="summary-value">{{ records.length }}</text>
<text class="summary-label">当前记录</text>
</view>
<view class="summary-divider" />
<view class="summary-item">
<text class="summary-value">{{ monthCount }}</text>
<text class="summary-label">覆盖月份</text>
</view>
<view class="summary-divider" />
<view class="summary-item">
<text class="summary-value">{{ totalCostText }}</text>
<text class="summary-label">累计花费</text>
</view>
</view>
</view>
<view class="category-strip">
<TnTabs v-model="currentFilterIndex" height="78rpx" bar-width="36rpx" bar-color="#F97316"
active-color="#F97316" color="#64748B" bg-color="#FFFFFF" font-size="25rpx" active-font-size="27rpx"
:bottom-shadow="false" @change="onFilterChange">
<TnTabsItem v-for="tab in filterTabs" :key="tab" :title="tab" />
</TnTabs>
</view>
<view class="records-stage">
<view v-if="loading" class="loading-state">
<TnLoading :show="true" mode="circle" color="#F97316" :size="54" />
<text class="loading-text">正在整理健康记录...</text>
<view class="loading-card" v-for="i in 3" :key="i">
<view class="skeleton-icon" />
<view class="skeleton-content">
<view class="skeleton-line skeleton-line-title" />
<view class="skeleton-line skeleton-line-meta" />
</view>
</view>
</view>
<view v-else-if="groupedRecords.length === 0" class="empty-state">
<TnEmpty mode="data" color="#F97316" size="xl" />
<text class="empty-title">{{ emptyTitle }}</text>
<text class="empty-desc">{{ emptyDesc }}</text>
<TnButton text-color="#FFFFFF" shape="round" :bold="true" :shadow="true"
:custom-style="primaryButtonStyle" width="320" height="88" @click="handleAddRecord">
<view class="button-inner">
<TnIcon name="add-lack" :size="28" />
<text>添加记录</text>
</view>
</TnButton>
</view>
<view v-else class="records-content">
<view class="section-head">
<view>
<text class="section-title">记录时间线</text>
<text class="section-subtitle">{{ activeFilterLabel }} · {{ records.length }} </text>
</view>
<TnTag bg-color="#FFF7ED" text-color="#F97316" shape="round" size="sm">
{{ latestDateText }}
</TnTag>
</view>
<view class="month-group" v-for="(group, groupIndex) in groupedRecords" :key="group.monthKey">
<view class="month-head">
<view class="month-dot" :class="{ 'month-dot-active': groupIndex === 0 }" />
<text class="month-label">{{ group.monthLabel }}</text>
<TnTag bg-color="#F1F5F9" text-color="#475569" shape="round" size="sm">
{{ group.records.length }} 条
</TnTag>
</view>
<view v-for="record in group.records" :key="record._id" class="record-card"
hover-class="record-card-active" @click="handleRecordTap(record)">
<view class="record-accent" :style="{ background: getTypeStyle(record.type).accent }" />
<view class="record-icon" :style="{ backgroundColor: getTypeStyle(record.type).bg }">
<TnIcon :name="getTypeStyle(record.type).icon" :size="38"
:color="getTypeStyle(record.type).color" />
</view>
<view class="record-main">
<view class="record-title-row">
<text class="record-title">{{ getRecordTitle(record) }}</text>
<TnTag :bg-color="getTypeStyle(record.type).soft"
:text-color="getTypeStyle(record.type).color" shape="round" size="sm">
{{ getTypeLabel(record.type) }}
</TnTag>
</view>
<text class="record-desc">{{ getRecordDesc(record) }}</text>
<view class="record-meta">
<view class="meta-pill">
<TnIcon name="calendar" :size="22" color="#64748B" />
<text>{{ formatRecordDate(record.date) }}</text>
</view>
<view v-if="record.petName" class="meta-pill">
<TnIcon name="floral" :size="22" color="#64748B" />
<text>{{ record.petName }}</text>
</view>
<view v-if="record.hospital" class="meta-pill meta-pill-wide">
<TnIcon name="location" :size="22" color="#64748B" />
<text>{{ record.hospital }}</text>
</view>
<view v-if="record.cost" class="meta-pill cost-pill">
<text>¥{{ formatCost(record.cost) }}</text>
</view>
</view>
</view>
<TnIcon name="right" :size="28" color="#CBD5E1" />
</view>
</view>
<view class="bottom-tip">
<TnIcon name="success-circle-fill" :size="24" color="#14B8A6" />
<text>已展示当前分类的全部记录</text>
</view>
</view>
</view>
</scroll-view>
<view v-if="!loading && groupedRecords.length > 0" class="fab-wrap" :style="{ bottom: fabBottom + 'px' }">
<TnButton text-color="#FFFFFF" shape="round" :bold="true" :shadow="true"
:custom-style="primaryButtonStyle" width="280" height="88" :font-size="28" @click="handleAddRecord">
<view class="button-inner">
<TnIcon name="add-lack" :size="28" />
<text>添加记录</text>
</view>
</TnButton>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import TnButton from '@/uni_modules/tuniaoui-vue3/components/button/src/button.vue'
import TnEmpty from '@/uni_modules/tuniaoui-vue3/components/empty/src/empty.vue'
import TnIcon from '@/uni_modules/tuniaoui-vue3/components/icon/src/icon.vue'
import TnLoading from '@/uni_modules/tuniaoui-vue3/components/loading/src/loading.vue'
import TnTabs from '@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs.vue'
import TnTabsItem from '@/uni_modules/tuniaoui-vue3/components/tabs/src/tabs-item.vue'
import TnTag from '@/uni_modules/tuniaoui-vue3/components/tag/src/tag.vue'
interface HealthRecord {
_id : string
pet_id : string
user_id : string
type : string
title : string
date : number | string
nextDate ?: number | string
hospital ?: string
doctor ?: string
description ?: string
vaccineName ?: string
batchNumber ?: string
dewormType ?: string
brand ?: string
diagnosis ?: string
medications ?: Array<{ name : string; dosage : string; frequency : string }>
images ?: string[]
cost ?: number
tags ?: string[]
createdAt : number
updatedAt : number
petName ?: string
}
interface RecordGroup {
monthKey : string
monthLabel : string
records : HealthRecord[]
}
interface TypeStyle {
icon : string
color : string
bg : string
soft : string
accent : string
}
const loading = ref(true)
const hasLoaded = ref(false)
const records = ref<HealthRecord[]>([])
const groupedRecords = ref<RecordGroup[]>([])
const scrollPaddingBottom = ref(190)
const fabBottom = ref(78)
const currentFilterIndex = ref(0)
let recordsRequestId = 0
const primaryButtonStyle = {
background: 'linear-gradient(135deg, #F97316, #FB923C)',
}
const filterTabs = ['全部', '疫苗', '驱虫', '病历', '体检', '其他']
const filterValues = ['all', 'vaccine', 'deworm', 'medical', 'checkup', 'other']
const typeStyleMap : Record<string, TypeStyle> = {
vaccine: {
icon: 'safe-fill',
color: '#2563EB',
bg: '#EFF6FF',
soft: '#DBEAFE',
accent: 'linear-gradient(180deg, #2563EB, #60A5FA)',
},
deworm: {
icon: 'notice-fill',
color: '#7C3AED',
bg: '#F5F3FF',
soft: '#EDE9FE',
accent: 'linear-gradient(180deg, #7C3AED, #A78BFA)',
},
medical: {
icon: 'write-feather',
color: '#F97316',
bg: '#FFF7ED',
soft: '#FFEDD5',
accent: 'linear-gradient(180deg, #F97316, #FDBA74)',
},
checkup: {
icon: 'data-fill',
color: '#0D9488',
bg: '#F0FDFA',
soft: '#CCFBF1',
accent: 'linear-gradient(180deg, #0D9488, #5EEAD4)',
},
other: {
icon: 'inventory-fill',
color: '#475569',
bg: '#F8FAFC',
soft: '#E2E8F0',
accent: 'linear-gradient(180deg, #64748B, #CBD5E1)',
},
}
const typeLabelMap : Record<string, string> = {
vaccine: '疫苗',
deworm: '驱虫',
medical: '病历',
checkup: '体检',
other: '其他',
}
const activeFilterLabel = computed(() => filterTabs[currentFilterIndex.value] || '全部')
const monthCount = computed(() => groupedRecords.value.length)
const latestDateText = computed(() => {
const latest = records.value[0]
return latest ? formatRecordDate(latest.date) : '暂无'
})
const totalCostText = computed(() => {
const total = records.value.reduce((sum, item) => sum + (Number(item.cost) || 0), 0)
if (total >= 10000) return `${(total / 10000).toFixed(1)}w`
if (total > 0) return `¥${Math.round(total)}`
return '¥0'
})
const heroTitle = computed(() => {
if (loading.value) return '正在同步记录'
if (!records.value.length) return currentFilterIndex.value === 0 ? '从第一条记录开始' : `${activeFilterLabel.value}暂无记录`
return currentFilterIndex.value === 0 ? '毛球健康一眼掌握' : `${activeFilterLabel.value}记录`
})
const heroDesc = computed(() => {
if (loading.value) return '正在读取疫苗、驱虫、病历和体检信息'
if (!records.value.length) return '添加记录后,这里会自动生成按月整理的健康时间线'
return `最近更新 ${latestDateText.value},共整理 ${records.value.length} 条健康事件`
})
const emptyTitle = computed(() => (currentFilterIndex.value === 0 ? '还没有健康记录' : `暂无${activeFilterLabel.value}记录`))
const emptyDesc = computed(() => (
currentFilterIndex.value === 0
? '记录疫苗、驱虫、体检和就诊情况,让每一次照护都有迹可循。'
: '切换其他分类看看,或为毛球补上一条新的健康记录。'
))
onMounted(() => {
calcLayout()
loadRecords()
})
onShow(() => {
loadRecords(!hasLoaded.value)
})
function calcLayout() {
const systemInfo = uni.getSystemInfoSync()
const bottom = systemInfo.safeAreaInsets?.bottom || 0
fabBottom.value = bottom + 58
scrollPaddingBottom.value = bottom + 190
}
async function loadRecords(showLoading = !hasLoaded.value) {
const requestId = ++recordsRequestId
if (showLoading) loading.value = true
try {
const uid = uni.getStorageSync('uid')
if (!uid) {
if (requestId !== recordsRequestId) return
records.value = []
groupedRecords.value = []
return
}
const res = await uniCloud.callFunction({
name: 'recordManager',
data: {
action: 'list',
uid,
type: currentFilterIndex.value === 0 ? undefined : filterValues[currentFilterIndex.value],
},
})
if (requestId !== recordsRequestId) return
if (res.result?.code === 0 && res.result.data) {
const list = (res.result.data.list || []) as HealthRecord[]
records.value = sortRecords(list)
groupedRecords.value = groupByMonth(records.value)
} else {
records.value = []
groupedRecords.value = []
}
} catch (e) {
if (requestId !== recordsRequestId) return
console.warn('加载健康记录失败', e)
records.value = []
groupedRecords.value = []
} finally {
if (requestId === recordsRequestId) {
loading.value = false
hasLoaded.value = true
}
}
}
function onFilterChange(index : number | string) {
currentFilterIndex.value = Number(index)
loadRecords(false)
}
function sortRecords(list : HealthRecord[]) {
return [...list].sort((a, b) => getTimeValue(b.date) - getTimeValue(a.date))
}
function groupByMonth(list : HealthRecord[]) {
const map = new Map<string, HealthRecord[]>()
list.forEach((record) => {
const date = getDate(record.date)
if (!date) return
const month = date.getMonth() + 1
const key = `${date.getFullYear()}-${String(month).padStart(2, '0')}`
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(record)
})
return Array.from(map.entries()).map(([monthKey, groupRecords]) => ({
monthKey,
monthLabel: getMonthLabel(groupRecords[0].date),
records: groupRecords,
}))
}
function getTypeStyle(type : string) {
return typeStyleMap[type] || typeStyleMap.other
}
function getTypeLabel(type : string) {
return typeLabelMap[type] || '其他'
}
function getRecordTitle(record : HealthRecord) {
if (record.title) return record.title
if (record.vaccineName) return record.vaccineName
if (record.diagnosis) return record.diagnosis
return getTypeLabel(record.type)
}
function getRecordDesc(record : HealthRecord) {
if (record.description) return record.description
if (record.hospital && record.doctor) return `${record.hospital} · ${record.doctor}`
if (record.hospital) return record.hospital
if (record.brand) return `品牌:${record.brand}`
if (record.batchNumber) return `批号:${record.batchNumber}`
return '点击查看记录详情'
}
function formatRecordDate(value : number | string | undefined) {
const date = getDate(value)
if (!date) return ''
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}${day}`
}
function getMonthLabel(value : number | string | undefined) {
const date = getDate(value)
if (!date) return '未知月份'
const year = date.getFullYear()
const month = date.getMonth() + 1
const now = new Date()
if (year === now.getFullYear() && month === now.getMonth() + 1) return `${month}`
return `${year}${month}`
}
function getDate(value : number | string | undefined) {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function getTimeValue(value : number | string | undefined) {
return getDate(value)?.getTime() || 0
}
function formatCost(value : number) {
const num = Number(value) || 0
return Number.isInteger(num) ? String(num) : num.toFixed(2)
}
function handleAddRecord() {
uni.navigateTo({ url: '/pages/record/type-select' })
}
function handleRecordTap(record : HealthRecord) {
uni.navigateTo({ url: `/pages/record/detail?id=${record._id}` })
}
</script>
<style lang="scss" scoped>
$primary: #F97316;
$primary-light: #FB923C;
$primary-bg: #FFF7ED;
$text-dark: #0F172A;
$text-body: #475569;
$text-muted: #64748B;
$border: #E2E8F0;
$card-bg: #FFFFFF;
$shadow: 0 8rpx 24rpx rgba(15, 23, 42, 0.06);
$shadow-active: 0 12rpx 34rpx rgba(15, 23, 42, 0.1);
.records-page {
position: relative;
height: 100%;
background: #F8FAFC;
}
.records-scroll {
height: 100vh;
box-sizing: border-box;
}
.hero-panel {
margin: 24rpx 24rpx 0;
padding: 30rpx 28rpx 26rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, #F97316 0%, #FB923C 54%, #2DD4BF 145%);
box-shadow: 0 14rpx 36rpx rgba(249, 115, 22, 0.22);
overflow: hidden;
}
.hero-main {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
}
.hero-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.hero-kicker {
font-size: 23rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.78);
}
.hero-title {
margin-top: 8rpx;
font-size: 40rpx;
font-weight: 900;
color: #FFFFFF;
line-height: 1.25;
}
.hero-desc {
margin-top: 12rpx;
font-size: 25rpx;
color: rgba(255, 255, 255, 0.82);
line-height: 1.5;
}
.hero-action {
width: 76rpx;
height: 76rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.28);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.2s ease;
&:active {
opacity: 0.78;
}
}
.summary-row {
margin-top: 28rpx;
display: flex;
align-items: center;
padding: 18rpx 0;
border-radius: 18rpx;
background: rgba(255, 255, 255, 0.16);
}
.summary-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
min-width: 0;
}
.summary-value {
font-size: 32rpx;
font-weight: 900;
color: #FFFFFF;
max-width: 180rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-label {
font-size: 21rpx;
color: rgba(255, 255, 255, 0.78);
}
.summary-divider {
width: 1rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.28);
}
.category-strip {
display: grid;
place-items: center;
height: 80rpx;
position: sticky;
top: 0;
z-index: 20;
margin: 20rpx 24rpx 0;
padding: 4rpx;
border-radius: 22rpx;
background: $card-bg;
box-shadow: $shadow;
overflow: hidden;
}
.category-strip :deep(.tn-tabs) {
width: 100%;
align-self: center;
}
.category-strip :deep(.tn-tabs__container) {
align-items: center;
}
.category-strip :deep(.tn-tabs__bar-container) {
bottom: 4rpx;
}
.records-stage {
min-height: 720rpx;
}
.loading-state {
padding: 48rpx 24rpx 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
}
.loading-text {
font-size: 26rpx;
color: $text-muted;
margin-bottom: 10rpx;
}
.loading-card {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx;
border-radius: 24rpx;
background: $card-bg;
box-shadow: $shadow;
}
.skeleton-icon {
width: 78rpx;
height: 78rpx;
border-radius: 22rpx;
background: #E2E8F0;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 14rpx;
}
.skeleton-line {
height: 24rpx;
border-radius: 12rpx;
background: #E2E8F0;
}
.skeleton-line-title {
width: 64%;
}
.skeleton-line-meta {
width: 42%;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
padding: 98rpx 48rpx 0;
}
.empty-title {
font-size: 32rpx;
font-weight: 800;
color: $text-dark;
}
.empty-desc {
max-width: 520rpx;
font-size: 26rpx;
line-height: 1.6;
text-align: center;
color: $text-muted;
margin-bottom: 10rpx;
}
.records-content {
padding: 26rpx 24rpx 0;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 18rpx;
}
.section-title {
display: block;
font-size: 31rpx;
font-weight: 900;
color: $text-dark;
}
.section-subtitle {
display: block;
margin-top: 6rpx;
font-size: 23rpx;
color: $text-muted;
}
.month-group {
margin-top: 20rpx;
}
.month-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 14rpx;
padding: 0 4rpx;
}
.month-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background: #CBD5E1;
box-shadow: 0 0 0 8rpx #F1F5F9;
flex-shrink: 0;
&.month-dot-active {
background: $primary;
box-shadow: 0 0 0 8rpx #FFEDD5;
}
}
.month-label {
flex: 1;
font-size: 27rpx;
font-weight: 800;
color: $text-dark;
}
.record-card {
position: relative;
display: flex;
align-items: center;
gap: 18rpx;
min-height: 150rpx;
margin-bottom: 16rpx;
padding: 22rpx 20rpx 22rpx 24rpx;
border-radius: 24rpx;
background: $card-bg;
box-shadow: $shadow;
overflow: hidden;
transition: transform 0.18s ease, box-shadow 0.18s ease;
&.record-card-active {
transform: scale(0.985);
box-shadow: $shadow-active;
}
}
.record-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 7rpx;
}
.record-icon {
width: 82rpx;
height: 82rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.record-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.record-title-row {
display: flex;
align-items: center;
gap: 12rpx;
min-width: 0;
}
.record-title {
flex: 1;
min-width: 0;
font-size: 29rpx;
font-weight: 800;
color: $text-dark;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-desc {
font-size: 24rpx;
color: $text-muted;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 2rpx;
}
.meta-pill {
max-width: 220rpx;
height: 42rpx;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 5rpx;
padding: 0 12rpx;
border-radius: 999rpx;
background: #F8FAFC;
border: 1rpx solid $border;
font-size: 21rpx;
color: $text-body;
text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.meta-pill-wide {
max-width: 280rpx;
}
.cost-pill {
background: #FEF2F2;
border-color: #FEE2E2;
color: #DC2626;
font-weight: 800;
}
.bottom-tip {
height: 86rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 23rpx;
color: $text-muted;
}
.button-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
}
.fab-wrap {
position: fixed;
left: 50%;
transform: translateX(-50%);
z-index: 60;
}
</style>