Files
2026-11-03 11:13:59 +08:00

671 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="page">
<!-- 本周概览 -->
<view class="hero-card">
<view class="hero-left">
<text class="hero-sub">查看每周日程提醒</text>
<view class="hero-num-row">
<text class="hero-label">本周日程(条)</text>
<text class="hero-num">{{ weekItems.length }}</text>
</view>
</view>
<view class="hero-illus">
<FaIcon name="bell" color="#ffffff" :size="26" />
</view>
</view>
<!-- 月历(可下拉展开) -->
<view class="cal-card">
<view class="cal-header">
<view class="cal-nav" @tap="switchMonth(-1)">
<FaIcon name="chevron-left" color="#94a3b8" :size="13" />
</view>
<text class="cal-title">{{ monthLabel }}</text>
<view class="cal-nav" @tap="switchMonth(1)">
<FaIcon name="chevron-right" color="#94a3b8" :size="13" />
</view>
<view class="cal-toggle" @tap="monthOpen = !monthOpen">
<text class="cal-toggle-text">{{ monthOpen ? '收起' : '展开月历' }}</text>
<FaIcon :name="monthOpen ? 'chevron-up' : 'chevron-down'" color="#2563eb" :size="12" />
</view>
</view>
<!-- 周历条 -->
<view class="week-strip">
<view
v-for="d in weekDays"
:key="d.key"
class="week-item"
@tap="pickWeekDay(d)"
>
<text class="week-name">{{ d.name }}</text>
<view
class="week-date"
:class="{ today: d.isToday, selected: d.key === selectedKey }"
>{{ d.day }}</view>
<view class="week-dot" :class="{ show: d.count > 0 }" />
</view>
</view>
<!-- 月历展开 -->
<view v-if="monthOpen" class="month-grid">
<text v-for="n in WEEK_LABELS" :key="'w' + n" class="month-weekname">周{{ n }}</text>
<view
v-for="cell in monthCells"
:key="cell.key"
class="month-day"
:class="{ muted: !cell.inMonth, today: cell.isToday, selected: cell.selected }"
@tap="pickMonthDay(cell)"
>
<view class="month-day-num"><text>{{ cell.day }}</text></view>
<view class="week-dot" :class="{ show: cell.count > 0 }" />
</view>
</view>
</view>
<!-- 我的日程 -->
<view class="section-head">
<text class="section-title">我的日程({{ shownItems.length }})</text>
<view class="range-tabs">
<text class="range-tab" :class="{ active: range === 'day' }" @tap="showToday">今日</text>
<text class="range-sep">|</text>
<text class="range-tab" :class="{ active: range === 'week' }" @tap="range = 'week'">本周</text>
</view>
</view>
<u-empty
v-if="!loading && shownItems.length === 0"
mode="list"
:icon-size="72"
:text="range === 'week' ? '本周暂无日程提醒' : '当天暂无日程提醒'"
/>
<view v-else class="rem-list">
<view v-for="item in shownItems" :key="item.id" class="rem-card" @tap="openEdit(item.id)">
<view class="rem-time">
<text class="rem-day">{{ shortDayLabel(item.datetime) }}</text>
<text class="rem-clock">{{ timeText(item.datetime) }}</text>
</view>
<view class="rem-body">
<text class="rem-title">{{ item.content || item.title }}</text>
<view class="rem-meta">
<FaIcon name="clock" color="#94a3b8" :size="11" />
<text class="rem-meta-text">{{ fullTime(item.datetime) }}</text>
</view>
<view class="rem-meta">
<FaIcon name="bell" color="#94a3b8" :size="11" />
<text class="rem-meta-text">{{ remindLine(item) }}</text>
</view>
</view>
</view>
</view>
<view class="bottom-space" />
<!-- 新增按钮 -->
<view class="fab" @tap="openCreate">
<FaIcon name="plus" color="#ffffff" :size="20" />
<text class="fab-text">新增</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { fetchSchedules, getRemindChannelLabel, getRemindMinutesLabel } from '@/api/schedule.js'
import { pad, startOfDay, isToday } from '@/utils/date.js'
import { isLoggedIn } from '@/utils/auth.js'
import { getGuestToken } from '@/utils/guest.js'
/** 周一为一周第一天 */
const WEEK_LABELS = ['一', '二', '三', '四', '五', '六', '日']
const schedules = ref([])
const loading = ref(false)
const range = ref('day')
const selectedDate = ref(startOfDay(new Date()))
function dateKey(value) {
const d = value instanceof Date ? value : new Date(value)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function startOfWeek(base = new Date()) {
const d = startOfDay(base)
const offset = (d.getDay() + 6) % 7
return new Date(d.getFullYear(), d.getMonth(), d.getDate() - offset)
}
const weekStart = computed(() => startOfWeek(selectedDate.value))
const selectedKey = computed(() => dateKey(selectedDate.value))
/* ---------- 月历(可下拉展开) ---------- */
const monthOpen = ref(false)
const viewYear = ref(new Date().getFullYear())
const viewMonth = ref(new Date().getMonth()) // 0-based
const monthLabel = computed(() => `${viewYear.value}年${viewMonth.value + 1}月`)
function switchMonth(step) {
let y = viewYear.value
let m = viewMonth.value + step
if (m < 0) {
m = 11
y -= 1
} else if (m > 11) {
m = 0
y += 1
}
viewYear.value = y
viewMonth.value = m
}
/** 月历格子:从包含 1 号的那周周一起,固定 6 行 × 7 列 */
const monthCells = computed(() => {
const first = new Date(viewYear.value, viewMonth.value, 1)
const offset = (first.getDay() + 6) % 7
const start = new Date(viewYear.value, viewMonth.value, 1 - offset)
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)
const key = dateKey(d)
return {
key,
date: d,
day: d.getDate(),
inMonth: d.getMonth() === viewMonth.value && d.getFullYear() === viewYear.value,
isToday: isToday(d),
selected: key === selectedKey.value,
count: (reminderMap.value[key] || []).length
}
})
})
function pickMonthDay(cell) {
selectedDate.value = startOfDay(cell.date)
range.value = 'day'
}
/** 只保留已设置提醒渠道的日程 */
const reminders = computed(() =>
schedules.value.filter((item) => item.remindChannels && item.remindChannels.length > 0)
)
/** 提醒按日期聚合,key 为 YYYY-MM-DD */
const reminderMap = computed(() => {
const map = {}
reminders.value.forEach((item) => {
if (!item.datetime) return
const key = dateKey(new Date(item.datetime))
if (!map[key]) map[key] = []
map[key].push(item)
})
return map
})
const weekDays = computed(() => {
const base = weekStart.value
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + i)
const key = dateKey(d)
return {
key,
date: d,
name: '周' + WEEK_LABELS[i],
day: d.getDate(),
isToday: isToday(d),
count: (reminderMap.value[key] || []).length
}
})
})
/** 本周(周一~周日)的提醒,按时间升序 */
const weekItems = computed(() => {
const base = weekStart.value
const startTs = base.getTime()
const endTs = startTs + 7 * 24 * 60 * 60 * 1000
return reminders.value
.filter((item) => item.datetime >= startTs && item.datetime < endTs)
.sort((a, b) => a.datetime - b.datetime)
})
const shownItems = computed(() => {
if (range.value === 'week') return weekItems.value
return (reminderMap.value[selectedKey.value] || [])
.slice()
.sort((a, b) => a.datetime - b.datetime)
})
function timeText(timestamp) {
const d = new Date(timestamp)
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function fullTime(timestamp) {
const d = new Date(timestamp)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${timeText(timestamp)}`
}
function shortDayLabel(timestamp) {
if (isToday(timestamp)) return '今天'
const d = new Date(timestamp)
return '周' + WEEK_LABELS[(d.getDay() + 6) % 7]
}
function remindLine(item) {
const channels = (item.remindChannels || [])
.map((ch) => getRemindChannelLabel(ch))
.join('、')
const parts = [getRemindMinutesLabel(item.remindMinutes)]
if (channels) parts.push(channels)
return parts.join(' · ')
}
function pickWeekDay(d) {
selectedDate.value = startOfDay(d.date)
viewYear.value = d.date.getFullYear()
viewMonth.value = d.date.getMonth()
range.value = 'day'
}
function showToday() {
selectedDate.value = startOfDay(new Date())
viewYear.value = selectedDate.value.getFullYear()
viewMonth.value = selectedDate.value.getMonth()
range.value = 'day'
}
function openEdit(id) {
uni.navigateTo({ url: `/pages/tools/schedule/reminder-edit?id=${id}` })
}
function openCreate() {
uni.navigateTo({ url: '/pages/tools/schedule/reminder-edit' })
}
onMounted(() => {
// 免登录页面:登录态或游客令牌均可用;两者皆无回门户触发授权/登录
if (!isLoggedIn() && !getGuestToken()) {
uni.reLaunch({ url: '/pages/nav/nav' })
return
}
loadReminders()
})
// 新增/编辑返回后刷新列表
onShow(() => {
if (isLoggedIn() || getGuestToken()) loadReminders()
})
async function loadReminders() {
loading.value = true
try {
const res = await fetchSchedules({ pageSize: 100 })
schedules.value = res.list
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
padding: 24rpx $page-padding-x 0;
background: $color-bg-page;
box-sizing: border-box;
}
/* ---------- 本周概览 ---------- */
.hero-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-radius: $radius-lg;
background: linear-gradient(135deg, #1e3a8a, #2563eb);
box-shadow: 0 12rpx 30rpx rgba(37, 99, 235, 0.28);
}
.hero-left {
flex: 1;
min-width: 0;
}
.hero-sub {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.78);
}
.hero-num-row {
display: flex;
align-items: baseline;
gap: 16rpx;
margin-top: 12rpx;
}
.hero-label {
font-size: 28rpx;
color: #ffffff;
font-weight: 500;
}
.hero-num {
font-size: 56rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.hero-illus {
flex-shrink: 0;
width: 96rpx;
height: 96rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
/* ---------- 月历(可下拉展开) ---------- */
.cal-card {
margin-top: 20rpx;
padding: 12rpx 8rpx 16rpx;
background: $color-card;
border-radius: $radius-lg;
box-shadow: $shadow-card;
}
.cal-header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
padding: 8rpx 16rpx 12rpx;
}
.cal-nav {
display: flex;
align-items: center;
padding: 8rpx 12rpx;
&:active {
opacity: 0.6;
}
}
.cal-title {
min-width: 170rpx;
text-align: center;
font-size: 28rpx;
font-weight: 600;
color: $color-text;
}
.cal-toggle {
position: absolute;
right: 8rpx;
display: flex;
align-items: center;
gap: 6rpx;
padding: 8rpx 12rpx;
&:active {
opacity: 0.6;
}
}
.cal-toggle-text {
font-size: 24rpx;
color: $color-primary;
}
/* ---------- 周历条 ---------- */
.week-strip {
display: flex;
padding: 8rpx 8rpx 4rpx;
}
.week-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
}
.week-name {
font-size: 22rpx;
color: $color-text-muted;
}
.week-date {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: $color-text;
&.today {
color: $color-primary;
font-weight: 600;
}
&.selected {
background: $color-primary;
color: #ffffff;
font-weight: 600;
}
}
.week-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: transparent;
&.show {
background: $color-primary;
}
}
/* ---------- 月历网格 ---------- */
.month-grid {
display: flex;
flex-wrap: wrap;
margin-top: 8rpx;
padding: 8rpx 8rpx 0;
border-top: 1rpx solid $color-divider;
}
.month-weekname {
width: calc(100% / 7);
text-align: center;
font-size: 22rpx;
color: $color-text-muted;
padding: 10rpx 0;
}
.month-day {
width: calc(100% / 7);
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
padding: 8rpx 0;
}
.month-day-num {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: $color-text;
}
.month-day.today .month-day-num {
color: $color-primary;
font-weight: 600;
}
.month-day.selected .month-day-num {
background: $color-primary;
color: #ffffff;
font-weight: 600;
}
.month-day.muted .month-day-num {
color: #c4cbd6;
}
.month-day:active {
opacity: 0.6;
}
/* ---------- 我的日程 ---------- */
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 32rpx 0 16rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.range-tabs {
display: flex;
align-items: center;
gap: 14rpx;
}
.range-tab {
font-size: 26rpx;
color: $color-text-muted;
&.active {
color: $color-primary;
font-weight: 600;
}
}
.range-sep {
font-size: 22rpx;
color: $color-border;
}
.rem-list {
display: flex;
flex-direction: column;
}
.rem-card {
@include card;
display: flex;
gap: 24rpx;
padding: 26rpx 24rpx;
margin-bottom: 16rpx;
&:active {
background: $color-bg-page;
}
}
.rem-time {
flex-shrink: 0;
width: 110rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
}
.rem-day {
font-size: 24rpx;
color: $color-text-muted;
}
.rem-clock {
font-size: 32rpx;
font-weight: 700;
color: $color-text;
}
.rem-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
border-left: 1rpx solid $color-divider;
padding-left: 24rpx;
}
.rem-title {
font-size: 29rpx;
font-weight: 600;
color: $color-text;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.rem-meta {
display: flex;
align-items: center;
gap: 10rpx;
}
.rem-meta-text {
flex: 1;
min-width: 0;
font-size: 23rpx;
color: $color-text-muted;
word-break: break-all;
}
.bottom-space {
height: 60rpx;
}
/* ---------- 新增按钮 ---------- */
.fab {
position: fixed;
right: 32rpx;
bottom: calc(48rpx + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 10rpx;
padding: 22rpx 36rpx;
border-radius: 44rpx;
background: $color-primary;
box-shadow: 0 12rpx 28rpx rgba(37, 99, 235, 0.36);
&:active {
opacity: 0.88;
}
}
.fab-text {
font-size: 28rpx;
color: #ffffff;
font-weight: 600;
}
</style>