FurBall/pages/index/schedules.vue

1105 lines
28 KiB
Vue

<template>
<view class="schedules-page">
<scroll-view class="schedules-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="handleAddSchedule">
<TnIcon name="add-lack" :size="34" color="#FFFFFF" />
</view>
</view>
<view class="summary-row">
<view class="summary-item">
<text class="summary-value">{{ todayCount }}</text>
<text class="summary-label">今日</text>
</view>
<view class="summary-divider" />
<view class="summary-item">
<text class="summary-value">{{ pendingCount }}</text>
<text class="summary-label">待办</text>
</view>
<view class="summary-divider" />
<view class="summary-item">
<text class="summary-value">{{ overdueCount }}</text>
<text class="summary-label">逾期</text>
</view>
</view>
</view>
<view class="week-strip">
<view v-for="day in weekDays" :key="day.key" class="week-day" :class="{ 'week-day-active': day.isToday }">
<text class="week-name">{{ day.name }}</text>
<text class="week-date">{{ day.date }}</text>
<view class="week-dot" :class="{ 'week-dot-on': day.count > 0 }" />
</view>
</view>
<view class="tab-card">
<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">
<TnTabsItem v-for="tab in filterTabs" :key="tab" :title="tab" />
</TnTabs>
</view>
<view class="schedules-stage">
<view v-if="loading" class="loading-state">
<TnLoading :show="true" mode="circle" color="#F97316" :size="54" />
<text class="loading-text">正在同步提醒...</text>
<view v-for="i in 3" :key="i" class="skeleton-card">
<view class="skeleton-date" />
<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="filteredSchedules.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="handleAddSchedule">
<view class="button-inner">
<TnIcon name="add-lack" :size="28" />
<text>添加提醒</text>
</view>
</TnButton>
</view>
<view v-else class="schedule-content">
<view class="section-head">
<view class="section-title-block">
<text class="section-title">{{ activeFilterLabel }}</text>
<text class="section-subtitle">{{ sectionSubtitle }}</text>
</view>
<TnTag :bg-color="nextTag.bg" :text-color="nextTag.color" shape="round" size="sm">
{{ nextTag.text }}
</TnTag>
</view>
<view class="schedule-list">
<view v-for="item in filteredSchedules" :key="item._id" class="schedule-card"
:class="{ 'schedule-card-completed': item.derivedStatus === 'completed' }"
hover-class="schedule-card-active" @click="editSchedule(item)">
<view class="date-chip" :class="`date-chip-${item.derivedStatus}`">
<text class="date-month">{{ formatMonth(item.scheduledDate) }}</text>
<text class="date-day">{{ formatDay(item.scheduledDate) }}</text>
</view>
<view class="schedule-main">
<view class="title-row">
<view class="type-icon" :style="{ backgroundColor: getTypeStyle(item.type).bg }">
<TnIcon :name="getTypeStyle(item.type).icon" :size="30"
:color="getTypeStyle(item.type).color" />
</view>
<text class="schedule-title">{{ item.title || getTypeLabel(item.type) }}</text>
</view>
<view class="pet-row">
<TnAvatar :url="item.petAvatar || ''" :icon="item.petAvatar ? '' : 'floral'"
:size="42" shape="circle" bg-color="#FFF7ED" />
<text class="pet-name">{{ item.petName || '未关联宠物' }}</text>
<TnTag :bg-color="getStatusStyle(item.derivedStatus).bg"
:text-color="getStatusStyle(item.derivedStatus).color" shape="round" size="sm">
{{ getDueText(item) }}
</TnTag>
</view>
<view class="meta-row">
<view class="meta-pill">
<TnIcon name="calendar" :size="21" color="#64748B" />
<text>{{ formatDate(item.scheduledDate) }}</text>
</view>
<view class="meta-pill">
<TnIcon name="clock" :size="21" color="#64748B" />
<text>{{ getFrequencyLabel(item.frequency) }}</text>
</view>
</view>
<text v-if="item.note" class="schedule-note">{{ item.note }}</text>
</view>
<view class="action-col" @click.stop>
<view class="icon-button" @click="toggleComplete(item)">
<TnIcon :name="item.derivedStatus === 'completed' ? 'refresh' : 'success-circle-fill'"
:size="30" :color="item.derivedStatus === 'completed' ? '#64748B' : '#0D9488'" />
</view>
<view class="icon-button danger-button" @click="deleteSchedule(item)">
<TnIcon name="delete-fill" :size="30" color="#DC2626" />
</view>
</view>
</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 && filteredSchedules.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="handleAddSchedule">
<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 TnAvatar from '@/uni_modules/tuniaoui-vue3/components/avatar/src/avatar.vue'
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'
type ScheduleStatus = 'pending' | 'completed' | 'overdue' | 'skipped'
type DerivedStatus = ScheduleStatus | 'today'
interface Pet {
_id : string
name : string
avatarUrl ?: string
_displayAvatar ?: string
}
interface ScheduleItem {
_id : string
pet_id : string
user_id : string
type : string
title : string
scheduledDate : number | string
frequency ?: string
interval ?: number
status ?: ScheduleStatus
reminderDays ?: number[]
relatedRecordId ?: string
note ?: string
createdAt ?: number | string
updatedAt ?: number | string
petName ?: string
petAvatar ?: string
derivedStatus : DerivedStatus
}
interface TypeStyle {
icon : string
color : string
bg : string
}
const loading = ref(true)
const hasLoaded = ref(false)
const schedules = ref<ScheduleItem[]>([])
const currentFilterIndex = ref(0)
const scrollPaddingBottom = ref(118)
const fabBottom = ref(78)
let schedulesRequestId = 0
const primaryButtonStyle = {
background: 'linear-gradient(135deg, #F97316, #FB923C)',
}
const filterTabs = ['全部', '今日', '待办', '已逾期', '已完成']
const filterValues = ['all', 'today', 'pending', 'overdue', 'completed']
const typeStyleMap : Record<string, TypeStyle> = {
vaccine: { icon: 'safe-fill', color: '#2563EB', bg: '#EFF6FF' },
deworm: { icon: 'notice-fill', color: '#7C3AED', bg: '#F5F3FF' },
checkup: { icon: 'data-fill', color: '#0D9488', bg: '#F0FDFA' },
custom: { icon: 'calendar-fill', color: '#F97316', bg: '#FFF7ED' },
}
const statusStyleMap : Record<DerivedStatus, { color : string; bg : string }> = {
pending: { color: '#2563EB', bg: '#EFF6FF' },
today: { color: '#F97316', bg: '#FFF7ED' },
overdue: { color: '#DC2626', bg: '#FEF2F2' },
completed: { color: '#0D9488', bg: '#F0FDFA' },
skipped: { color: '#64748B', bg: '#F8FAFC' },
}
const typeLabelMap : Record<string, string> = {
vaccine: '疫苗',
deworm: '驱虫',
checkup: '体检',
custom: '自定义',
}
const frequencyLabelMap : Record<string, string> = {
once: '单次',
daily: '每天',
weekly: '每周',
monthly: '每月',
yearly: '每年',
}
const activeFilterLabel = computed(() => filterTabs[currentFilterIndex.value] || '全部')
const filteredSchedules = computed(() => {
const filter = filterValues[currentFilterIndex.value] || 'all'
if (filter === 'all') return schedules.value
if (filter === 'pending') {
return schedules.value.filter((item) => item.derivedStatus === 'pending' || item.derivedStatus === 'today')
}
return schedules.value.filter((item) => item.derivedStatus === filter)
})
const todayCount = computed(() => schedules.value.filter((item) => item.derivedStatus === 'today').length)
const pendingCount = computed(() => schedules.value.filter((item) => item.derivedStatus === 'pending' || item.derivedStatus === 'today').length)
const overdueCount = computed(() => schedules.value.filter((item) => item.derivedStatus === 'overdue').length)
const nextSchedule = computed(() => schedules.value.find((item) => item.derivedStatus === 'today' || item.derivedStatus === 'pending'))
const heroTitle = computed(() => {
if (loading.value) return '正在整理提醒'
if (nextSchedule.value) return `${getDueText(nextSchedule.value)} · ${nextSchedule.value.title || getTypeLabel(nextSchedule.value.type)}`
if (schedules.value.length) return '所有提醒都处理好了'
return '从第一条提醒开始'
})
const heroDesc = computed(() => {
if (loading.value) return '同步疫苗、驱虫、体检和自定义日程'
if (overdueCount.value > 0) return `${overdueCount.value} 条提醒已经逾期,建议优先处理。`
if (nextSchedule.value) return `${nextSchedule.value.petName || '毛球'} · ${formatDate(nextSchedule.value.scheduledDate)}`
return '添加下一次疫苗、驱虫或体检提醒,照护节奏会更清楚。'
})
const sectionSubtitle = computed(() => `${filteredSchedules.value.length} 条提醒 · 共 ${schedules.value.length} 条日程`)
const emptyTitle = computed(() => {
if (currentFilterIndex.value === 0) return '还没有日程提醒'
return `暂无${activeFilterLabel.value}提醒`
})
const emptyDesc = computed(() => {
if (currentFilterIndex.value === 0) return '把下一次疫苗、驱虫、复查或体检时间记下来,到点就不会慌。'
return '切换其他状态看看,或添加一条新的照护提醒。'
})
const nextTag = computed(() => {
if (loading.value) return { text: '同步中', color: '#F97316', bg: '#FFF7ED' }
if (nextSchedule.value) return { text: getDueText(nextSchedule.value), ...getStatusStyle(nextSchedule.value.derivedStatus) }
return { text: '已清空', color: '#0D9488', bg: '#F0FDFA' }
})
const weekDays = computed(() => {
const now = new Date()
const start = addDays(startOfDay(now), -3)
const names = ['日', '一', '二', '三', '四', '五', '六']
return Array.from({ length: 7 }).map((_, index) => {
const date = addDays(start, index)
const count = schedules.value.filter((item) => isSameDay(getDate(item.scheduledDate), date)).length
return {
key: formatDateKey(date),
name: names[date.getDay()],
date: String(date.getDate()).padStart(2, '0'),
isToday: isSameDay(date, now),
count,
}
})
})
onMounted(() => {
calcLayout()
loadSchedules()
})
onShow(() => {
loadSchedules(!hasLoaded.value)
})
function calcLayout() {
const systemInfo = uni.getSystemInfoSync()
const safeBottom = systemInfo.safeAreaInsets?.bottom || 0
fabBottom.value = safeBottom + 58
scrollPaddingBottom.value = safeBottom + 118
}
async function loadSchedules(showLoading = !hasLoaded.value) {
const requestId = ++schedulesRequestId
if (showLoading) loading.value = true
try {
const uid = uni.getStorageSync('uid')
if (!uid) {
if (requestId !== schedulesRequestId) return
schedules.value = []
return
}
const [petList, scheduleList] = await Promise.all([
fetchPets(uid),
fetchSchedules(uid),
])
if (requestId !== schedulesRequestId) return
schedules.value = enrichSchedules(scheduleList, petList)
} catch (e) {
if (requestId !== schedulesRequestId) return
console.warn('加载日程失败', e)
schedules.value = []
} finally {
if (requestId === schedulesRequestId) {
loading.value = false
hasLoaded.value = true
}
}
}
async function fetchPets(uid : string) {
const res = await uniCloud.callFunction({ name: 'petManager', data: { action: 'list', uid } })
const list = res.result?.code === 0 ? (res.result.data || []) as Pet[] : []
await resolveAvatars(list)
return list
}
async function fetchSchedules(uid : string) {
const res = await uniCloud.callFunction({ name: 'scheduleManager', data: { action: 'list', uid } })
if (res.result?.code !== 0) throw new Error(res.result?.message || '读取日程失败')
return (res.result.data?.list || []) as ScheduleItem[]
}
async function resolveAvatars(list : Pet[]) {
const cloudList = list.filter((pet) => pet.avatarUrl?.startsWith('cloud://'))
list.forEach((pet) => {
if (pet.avatarUrl && !pet.avatarUrl.startsWith('cloud://')) pet._displayAvatar = pet.avatarUrl
})
if (!cloudList.length) return
try {
const res = await uniCloud.getTempFileURL({ fileList: cloudList.map((pet) => pet.avatarUrl) })
const urlMap : Record<string, string> = {}
res.fileList?.forEach((item : any) => {
if (item.tempFileURL) urlMap[item.fileID] = item.tempFileURL
})
cloudList.forEach((pet) => {
pet._displayAvatar = urlMap[pet.avatarUrl || ''] || ''
})
} catch (e) {
console.warn('解析宠物头像失败', e)
}
}
function enrichSchedules(list : ScheduleItem[], petList : Pet[]) {
const petMap = new Map(petList.map((pet) => [pet._id, pet]))
return [...list]
.map((item) => {
const pet = petMap.get(item.pet_id)
return {
...item,
frequency: item.frequency || 'once',
status: item.status || 'pending',
petName: pet?.name || item.petName || '',
petAvatar: pet?._displayAvatar || item.petAvatar || '',
derivedStatus: getDerivedStatus(item),
}
})
.sort((a, b) => {
const statusWeight : Record<DerivedStatus, number> = { overdue: 0, today: 1, pending: 2, skipped: 3, completed: 4 }
const diff = statusWeight[a.derivedStatus] - statusWeight[b.derivedStatus]
if (diff !== 0) return diff
return getTimeValue(a.scheduledDate) - getTimeValue(b.scheduledDate)
})
}
function getDerivedStatus(item : ScheduleItem) : DerivedStatus {
if (item.status === 'completed') return 'completed'
if (item.status === 'skipped') return 'skipped'
const date = getDate(item.scheduledDate)
if (!date) return item.status || 'pending'
const dayDiff = diffDays(startOfDay(date), startOfDay(new Date()))
if (dayDiff < 0) return 'overdue'
if (dayDiff === 0) return 'today'
return 'pending'
}
function getTypeStyle(type : string) {
return typeStyleMap[type] || typeStyleMap.custom
}
function getStatusStyle(status : DerivedStatus) {
return statusStyleMap[status] || statusStyleMap.pending
}
function getTypeLabel(type : string) {
return typeLabelMap[type] || '提醒'
}
function getFrequencyLabel(value ?: string) {
return frequencyLabelMap[value || 'once'] || '单次'
}
function getDueText(item : ScheduleItem) {
if (item.derivedStatus === 'completed') return '已完成'
if (item.derivedStatus === 'skipped') return '已跳过'
const date = getDate(item.scheduledDate)
if (!date) return '待安排'
const days = diffDays(startOfDay(date), startOfDay(new Date()))
if (days < 0) return `逾期${Math.abs(days)}`
if (days === 0) return '今天'
if (days === 1) return '明天'
return `${days}天后`
}
function formatMonth(value : number | string) {
const date = getDate(value)
return date ? `${date.getMonth() + 1}` : '--'
}
function formatDay(value : number | string) {
const date = getDate(value)
return date ? String(date.getDate()).padStart(2, '0') : '--'
}
function formatDate(value : number | string) {
const date = getDate(value)
if (!date) return '日期未定'
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return `${date.getMonth() + 1}${date.getDate()}${days[date.getDay()]}`
}
function getDate(value : number | string | undefined) {
if (!value) return null
if (typeof value === 'string') {
const dateOnly = value.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/)
if (dateOnly) {
return new Date(Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]))
}
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function getTimeValue(value : number | string | undefined) {
return getDate(value)?.getTime() || 0
}
function startOfDay(date : Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function addDays(date : Date, days : number) {
const next = new Date(date)
next.setDate(next.getDate() + days)
return next
}
function diffDays(left : Date, right : Date) {
return Math.round((left.getTime() - right.getTime()) / 86400000)
}
function isSameDay(left : Date | null, right : Date | null) {
if (!left || !right) return false
return left.getFullYear() === right.getFullYear()
&& left.getMonth() === right.getMonth()
&& left.getDate() === right.getDate()
}
function formatDateKey(date : Date) {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}
function handleAddSchedule() {
uni.navigateTo({ url: '/pages/schedule/edit' })
}
function editSchedule(item : ScheduleItem) {
uni.navigateTo({ url: `/pages/schedule/edit?id=${item._id}` })
}
async function toggleComplete(item : ScheduleItem) {
try {
const uid = uni.getStorageSync('uid')
const nextStatus = item.derivedStatus === 'completed' ? 'pending' : 'completed'
const res = await uniCloud.callFunction({
name: 'scheduleManager',
data: {
action: 'update',
uid,
scheduleId: item._id,
status: nextStatus,
},
})
if (res.result?.code !== 0) throw new Error(res.result?.message || '更新失败')
uni.showToast({ title: nextStatus === 'completed' ? '已完成' : '已恢复', icon: 'success' })
loadSchedules(false)
} catch (e) {
console.error('更新提醒状态失败', e)
uni.showToast({ title: '操作失败,请稍后再试', icon: 'none' })
}
}
function deleteSchedule(item : ScheduleItem) {
uni.showModal({
title: '删除提醒',
content: `确认删除「${item.title || getTypeLabel(item.type)}」吗?`,
confirmText: '删除',
confirmColor: '#DC2626',
success: async (res) => {
if (!res.confirm) return
try {
const uid = uni.getStorageSync('uid')
const result = await uniCloud.callFunction({
name: 'scheduleManager',
data: { action: 'delete', uid, scheduleId: item._id },
})
if (result.result?.code !== 0) throw new Error(result.result?.message || '删除失败')
uni.showToast({ title: '已删除', icon: 'success' })
loadSchedules(false)
} catch (e) {
console.error('删除提醒失败', e)
uni.showToast({ title: '删除失败,请稍后再试', icon: 'none' })
}
},
})
}
</script>
<style lang="scss" scoped>
$primary: #F97316;
$primary-light: #FB923C;
$primary-bg: #FFF7ED;
$teal: #0D9488;
$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);
.schedules-page {
position: relative;
height: 100%;
background: #F8FAFC;
}
.schedules-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 56%, #14B8A6 150%);
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: 800;
color: rgba(255, 255, 255, 0.78);
}
.hero-title {
margin-top: 8rpx;
font-size: 39rpx;
font-weight: 900;
color: #FFFFFF;
line-height: 1.25;
}
.hero-desc {
margin-top: 12rpx;
font-size: 25rpx;
color: rgba(255, 255, 255, 0.84);
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;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.summary-value {
font-size: 32rpx;
font-weight: 900;
color: #FFFFFF;
}
.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);
}
.week-strip {
margin: 20rpx 24rpx 0;
padding: 16rpx 12rpx;
border-radius: 24rpx;
background: $card-bg;
box-shadow: $shadow;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6rpx;
}
.week-day {
min-height: 92rpx;
border-radius: 18rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5rpx;
color: $text-muted;
}
.week-day-active {
background: $primary-bg;
color: $primary;
}
.week-name {
font-size: 20rpx;
font-weight: 700;
}
.week-date {
font-size: 28rpx;
font-weight: 900;
}
.week-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: transparent;
}
.week-dot-on {
background: $primary;
}
.tab-card {
display: grid;
place-items: center;
height: 82rpx;
position: sticky;
top: 0;
z-index: 20;
margin: 20rpx 24rpx 0;
padding: 4rpx;
border-radius: 22rpx;
background: $card-bg;
box-shadow: $shadow;
overflow: hidden;
}
.tab-card :deep(.tn-tabs) {
width: 100%;
align-self: center;
}
.tab-card :deep(.tn-tabs__container) {
align-items: center;
}
.tab-card :deep(.tn-tabs__bar-container) {
bottom: 4rpx;
}
.schedules-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;
}
.skeleton-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-date {
width: 88rpx;
height: 96rpx;
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 {
min-height: 620rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 18rpx;
padding: 0 48rpx;
}
.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;
}
.schedule-content {
padding: 26rpx 24rpx 0;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-bottom: 18rpx;
}
.section-title-block {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.section-title {
font-size: 31rpx;
font-weight: 900;
color: $text-dark;
}
.section-subtitle {
font-size: 23rpx;
color: $text-muted;
}
.schedule-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.schedule-card {
display: flex;
align-items: stretch;
gap: 18rpx;
min-height: 178rpx;
padding: 22rpx 18rpx 22rpx 20rpx;
border-radius: 24rpx;
background: $card-bg;
box-shadow: $shadow;
transition: transform 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
&.schedule-card-active {
transform: scale(0.985);
box-shadow: $shadow-active;
}
}
.schedule-card-completed {
opacity: 0.76;
}
.date-chip {
width: 88rpx;
border-radius: 22rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rpx;
flex-shrink: 0;
}
.date-chip-pending {
background: #EFF6FF;
color: #2563EB;
}
.date-chip-today {
background: $primary-bg;
color: $primary;
}
.date-chip-overdue {
background: #FEF2F2;
color: #DC2626;
}
.date-chip-completed {
background: #F0FDFA;
color: $teal;
}
.date-chip-skipped {
background: #F8FAFC;
color: $text-muted;
}
.date-month {
font-size: 20rpx;
font-weight: 700;
}
.date-day {
font-size: 36rpx;
font-weight: 900;
}
.schedule-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.title-row,
.pet-row,
.meta-row {
display: flex;
align-items: center;
min-width: 0;
}
.title-row {
gap: 10rpx;
}
.type-icon {
width: 48rpx;
height: 48rpx;
border-radius: 15rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.schedule-title {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 850;
color: $text-dark;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pet-row {
gap: 9rpx;
}
.pet-name {
flex: 1;
min-width: 0;
font-size: 24rpx;
color: $text-body;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-row {
flex-wrap: wrap;
gap: 10rpx;
}
.meta-pill {
max-width: 220rpx;
height: 40rpx;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 5rpx;
padding: 0 11rpx;
border-radius: 999rpx;
background: #F8FAFC;
border: 1rpx solid $border;
font-size: 21rpx;
color: $text-body;
text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.schedule-note {
font-size: 23rpx;
color: $text-muted;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.action-col {
width: 64rpx;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12rpx;
flex-shrink: 0;
}
.icon-button {
width: 62rpx;
height: 62rpx;
border-radius: 20rpx;
background: #F8FAFC;
border: 1rpx solid $border;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.18s ease;
&:active {
opacity: 0.68;
}
}
.danger-button {
background: #FEF2F2;
border-color: #FEE2E2;
}
.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>