1105 lines
28 KiB
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>
|