Files
yunzerwebsiteallinone/uniapp/pages/tools/schedule/index.vue
T
2026-10-11 23:25:36 +08:00

718 lines
16 KiB
Vue
Raw 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">
<scroll-view class="body" scroll-y :show-scrollbar="false">
<view class="body-inner">
<!-- 本周概览横幅 -->
<view class="hero-banner">
<view class="hero-info">
<view class="hero-title-row">
<text class="hero-title">本周日程</text>
<!-- <text class="hero-unit">(项)</text> -->
<!-- <text class="hero-count">{{ weekCount }}</text> -->
</view>
<text class="hero-sub">{{ heroRangeLabel }}</text>
</view>
<view class="hero-icon">
<FaIcon name="calendar-check" color="#ffffff" :size="26" />
</view>
</view>
<!-- 可收缩日历 -->
<view class="cal-card">
<view v-if="expanded" class="cal-head">
<text class="cal-title">{{ monthLabel }}</text>
<view class="cal-head-right">
<view class="cal-nav" @tap="shiftMonth(-1)">
<FaIcon name="chevron-left" color="#475569" :size="13" />
</view>
<view class="cal-today-btn" @tap="goToday">
<text>今天</text>
</view>
<view class="cal-nav" @tap="shiftMonth(1)">
<FaIcon name="chevron-right" color="#475569" :size="13" />
</view>
</view>
</view>
<view class="cal-week">
<text v-for="w in WEEK_LABELS" :key="w" class="cal-week-item">{{ w }}</text>
</view>
<view class="cal-grid">
<view
v-for="cell in gridCells"
:key="cell.key"
class="cal-cell"
@tap="pickDay(cell)"
>
<view
class="cal-day"
:class="{
dim: !cell.inMonth,
today: cell.isToday && cell.key !== selectedKey,
selected: cell.key === selectedKey
}"
>{{ cell.day }}</view>
<view class="cal-dots">
<view
v-for="(dot, di) in dayDots(cell.key)"
:key="di"
class="cal-dot"
:class="[dot, { on: cell.key === selectedKey }]"
/>
</view>
</view>
</view>
<view class="cal-toggle" @tap="expanded = !expanded">
<text>{{ expanded ? '收起' : '展开' }}</text>
<FaIcon :name="expanded ? 'chevron-up' : 'chevron-down'" color="#94a3b8" :size="11" />
</view>
</view>
<!-- 选中日期的日程 -->
<view class="day-head">
<text class="day-title">{{ selectedLabel }}</text>
<text class="day-count">{{ dayItems.length }} 项</text>
</view>
<u-empty
v-if="!loading && dayItems.length === 0"
mode="list"
:icon-size="72"
text="当天暂无日程"
/>
<view
v-for="item in dayItems"
:key="item.id"
class="sched-card"
:class="{ done: item.completed }"
>
<view class="sched-main" @tap="openDetail(item.id)">
<view class="sched-left">
<text class="sched-day">{{ dayLabel(item.timestamp) }}</text>
<text class="sched-time">{{ timeRangeText(item) }}</text>
</view>
<view class="sched-sep" />
<view class="sched-right">
<view class="sched-title-row">
<text class="sched-content">{{ item.title }}</text>
<view class="status-chip" :class="item.completed ? 'done' : 'pending'">
<text>{{ item.completed ? '已完成' : '待办' }}</text>
</view>
</view>
<view v-if="item.priority > 0 || item.reminder_id > 0" class="sched-meta">
<text
v-if="item.priority > 0"
class="priority-tag"
:class="item.priority === 2 ? 'urgent' : 'important'"
>{{ item.priority === 2 ? '紧急' : '重要' }}</text>
<FaIcon v-if="item.reminder_id > 0" name="bell" color="#94a3b8" :size="11" />
<text v-if="item.reminder_id > 0">已设提醒</text>
</view>
</view>
</view>
</view>
<view class="bottom-space" />
</view>
</scroll-view>
<view class="fab" @tap="createSchedule">
<FaIcon name="plus" color="#ffffff" :size="22" />
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { fetchOaSchedules } from '@/api/oaSchedule.js'
import { pad, startOfDay, isToday, isTomorrow, dayLabel } from '@/utils/date.js'
import { isLoggedIn } from '@/utils/auth.js'
/** 周一为一周第一天 */
const WEEK_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
const schedules = ref([])
const loading = ref(false)
/** 默认收缩,仅显示选中日所在周的一行 */
const expanded = ref(false)
const monthCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1))
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())}`
}
const monthLabel = computed(
() => `${monthCursor.value.getFullYear()}年${monthCursor.value.getMonth() + 1}月`
)
const selectedKey = computed(() => dateKey(selectedDate.value))
/** 选中日所在周(周一~周日)的 7 个单元格 */
const weekCells = computed(() => {
const base = selectedDate.value
const offset = (base.getDay() + 6) % 7
const cells = []
for (let i = 0; i < 7; i++) {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() - offset + i)
cells.push({
key: dateKey(d),
date: d,
day: d.getDate(),
inMonth: true,
isToday: isToday(d)
})
}
return cells
})
/** 月历 6×7 单元格,含上下月补齐日期 */
const monthCells = computed(() => {
const year = monthCursor.value.getFullYear()
const month = monthCursor.value.getMonth()
const offset = (new Date(year, month, 1).getDay() + 6) % 7
const start = new Date(year, month, 1 - offset)
const cells = []
for (let i = 0; i < 42; i++) {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i)
cells.push({
key: dateKey(d),
date: d,
day: d.getDate(),
inMonth: d.getMonth() === month,
isToday: isToday(d)
})
}
return cells
})
const gridCells = computed(() => (expanded.value ? monthCells.value : weekCells.value))
/** 日程按日期聚合,key 为 YYYY-MM-DD(OA 日程的 schedule_date) */
const dayMap = computed(() => {
const map = {}
schedules.value.forEach((item) => {
if (!item.schedule_date) return
if (!map[item.schedule_date]) map[item.schedule_date] = []
map[item.schedule_date].push(item)
})
return map
})
/** 选中日期的日程:全天在前,其余按开始时间升序 */
const dayItems = computed(() => {
const list = dayMap.value[selectedKey.value] || []
return list.slice().sort((a, b) => {
if (a.allDay !== b.allDay) return a.allDay ? -1 : 1
const cmp = String(a.start_time || '').localeCompare(String(b.start_time || ''))
return cmp !== 0 ? cmp : Number(b.id) - Number(a.id)
})
})
/** 本周(周一~周日)范围与日程数 */
const weekRange = computed(() => {
const now = startOfDay(new Date())
const offset = (now.getDay() + 6) % 7
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset)
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 7)
return { start, end }
})
const weekCount = computed(() => {
const { start, end } = weekRange.value
const startKey = dateKey(start)
const endKey = dateKey(end)
return schedules.value.filter(
(item) => item.schedule_date >= startKey && item.schedule_date < endKey
).length
})
const heroRangeLabel = computed(() => {
const { start, end } = weekRange.value
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate() - 1)
return `${start.getMonth() + 1}月${start.getDate()}日 - ${last.getMonth() + 1}月${last.getDate()}日`
})
const selectedLabel = computed(() => {
const d = selectedDate.value
const week = `周${WEEK_LABELS[(d.getDay() + 6) % 7].slice(1)}`
if (isToday(d)) return `今天 ${d.getMonth() + 1}月${d.getDate()}日`
if (isTomorrow(d)) return `明天 ${d.getMonth() + 1}月${d.getDate()}日`
return `${d.getMonth() + 1}月${d.getDate()}日 ${week}`
})
/** 日历标记点:未完成(蓝)靠左优先排,已完成(绿)靠后,最多 3 个 */
function dayDots(key) {
const list = dayMap.value[key] || []
const pending = list.filter((item) => !item.completed)
const done = list.filter((item) => item.completed)
return pending.concat(done).slice(0, 3).map((item) => (item.completed ? 'done' : 'pending'))
}
/** 时间展示:全天显示「全天」,其余显示开始时间或起止区间 */
function timeRangeText(item) {
if (item.allDay) return '全天'
if (!item.start_time) return ''
return item.end_time ? `${item.start_time} - ${item.end_time}` : item.start_time
}
/** 数据加载区间:当前日历月前后各扩 7 天,覆盖周视图跨月格子 */
function currentRange() {
const year = monthCursor.value.getFullYear()
const month = monthCursor.value.getMonth()
const start = new Date(year, month, -6)
const end = new Date(year, month + 1, 7)
return { startDate: dateKey(start), endDate: dateKey(end) }
}
function shiftMonth(step) {
const cur = monthCursor.value
monthCursor.value = new Date(cur.getFullYear(), cur.getMonth() + step, 1)
loadSchedules()
}
function goToday() {
const now = new Date()
monthCursor.value = new Date(now.getFullYear(), now.getMonth(), 1)
selectedDate.value = startOfDay(now)
loadSchedules()
}
function pickDay(cell) {
const monthChanged =
cell.date.getFullYear() !== monthCursor.value.getFullYear() ||
cell.date.getMonth() !== monthCursor.value.getMonth()
selectedDate.value = startOfDay(cell.date)
monthCursor.value = new Date(cell.date.getFullYear(), cell.date.getMonth(), 1)
// 月历里选完日期后自动收回周栏
if (expanded.value) expanded.value = false
if (monthChanged) loadSchedules()
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
loadSchedules()
})
onShow(() => {
if (isLoggedIn()) loadSchedules()
})
async function loadSchedules() {
loading.value = true
try {
// OA 工作日程:按日历区间拉取(前后各扩 7 天),端上按天聚合
const { startDate, endDate } = currentRange()
const res = await fetchOaSchedules({ startDate, endDate, pageSize: 500 })
schedules.value = res.list
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function createSchedule() {
uni.navigateTo({ url: '/pages/tools/schedule/edit' })
}
function openDetail(id) {
uni.navigateTo({ url: `/pages/tools/schedule/detail?id=${id}` })
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
overflow: hidden;
}
.body {
flex: 1;
min-height: 0;
}
.body-inner {
padding: 20rpx $page-padding-x 0;
}
/* ---------- 本周概览横幅 ---------- */
.hero-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
border-radius: $radius-lg;
background: $hero-gradient;
box-shadow: 0 10rpx 28rpx rgba(37, 99, 235, 0.28);
}
.hero-info {
min-width: 0;
}
.hero-title-row {
display: flex;
align-items: baseline;
gap: 8rpx;
}
.hero-title {
font-size: 32rpx;
font-weight: 600;
color: #ffffff;
}
.hero-unit {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.85);
}
.hero-count {
margin-left: 8rpx;
font-size: 44rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.hero-sub {
display: block;
margin-top: 8rpx;
font-size: 23rpx;
color: rgba(255, 255, 255, 0.75);
}
.hero-icon {
width: 84rpx;
height: 84rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* ---------- 可收缩日历 ---------- */
.cal-card {
margin-top: 20rpx;
padding: 16rpx 16rpx 8rpx;
background: $color-card;
border-radius: $radius-lg;
box-shadow: $shadow-card;
}
.cal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4rpx 8rpx 12rpx;
}
.cal-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.cal-head-right {
display: flex;
align-items: center;
gap: 12rpx;
}
.cal-nav {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: $color-bg-page;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: $color-primary-bg;
}
}
.cal-today-btn {
height: 52rpx;
padding: 0 20rpx;
border-radius: $radius-full;
background: $color-primary-bg;
color: $color-primary;
font-size: 24rpx;
display: flex;
align-items: center;
&:active {
opacity: 0.8;
}
}
.cal-week {
display: flex;
padding: 4rpx 0 8rpx;
}
.cal-week-item {
flex: 1;
text-align: center;
font-size: 23rpx;
color: $color-text-muted;
}
.cal-grid {
display: flex;
flex-wrap: wrap;
}
.cal-cell {
flex: 0 0 14.2857%;
display: flex;
flex-direction: column;
align-items: center;
padding: 6rpx 0;
}
.cal-day {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 27rpx;
color: $color-text;
box-sizing: border-box;
&.dim {
color: $color-text-muted;
opacity: 0.55;
}
&.today {
color: $color-primary;
font-weight: 600;
}
&.selected {
background: $color-primary;
color: #ffffff;
font-weight: 600;
opacity: 1;
}
}
.cal-dots {
height: 12rpx;
display: flex;
align-items: center;
gap: 4rpx;
}
.cal-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $color-danger;
/* 未完成:红色 */
&.pending {
background: $color-danger;
}
/* 已完成:绿色 */
&.done {
background: $color-success;
}
/* 选中日:红点转浅红、绿点转浅绿,保证在深蓝底上的对比度 */
&.pending.on {
background: #fca5a5;
}
&.done.on {
background: #6ee7b7;
}
}
.cal-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 14rpx 0 10rpx;
font-size: 24rpx;
color: $color-text-muted;
&:active {
opacity: 0.7;
}
}
/* ---------- 当日日程 ---------- */
.day-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 28rpx 0 16rpx;
}
.day-title {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
}
.day-count {
font-size: 24rpx;
color: $color-text-muted;
}
.sched-card {
@include card;
margin-bottom: 16rpx;
overflow: hidden;
&.done .sched-content {
text-decoration: line-through;
color: $color-text-muted;
}
}
.sched-main {
display: flex;
align-items: stretch;
padding: 24rpx 8rpx;
&:active {
background: $color-bg-page;
}
}
.sched-left {
width: 128rpx;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
}
.sched-day {
font-size: 22rpx;
color: $color-text-muted;
}
.sched-time {
font-size: 32rpx;
font-weight: 600;
color: $color-text;
}
.sched-sep {
width: 1rpx;
margin: 4rpx 20rpx;
background: $color-divider;
}
.sched-right {
flex: 1;
min-width: 0;
}
.sched-title-row {
display: flex;
align-items: flex-start;
gap: 12rpx;
}
.sched-content {
flex: 1;
min-width: 0;
font-size: 28rpx;
line-height: 1.5;
color: $color-text;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.status-chip {
@include chip;
flex-shrink: 0;
&.done {
background: #ecfdf5;
color: $color-success;
}
}
.sched-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 10rpx;
font-size: 23rpx;
color: $color-text-muted;
}
.priority-tag {
padding: 2rpx 12rpx;
border-radius: $radius-full;
font-size: 21rpx;
&.important {
background: #fff7ed;
color: #ea580c;
}
&.urgent {
background: #fef2f2;
color: #dc2626;
}
}
.bottom-space {
height: 160rpx;
}
.fab {
position: fixed;
right: 40rpx;
bottom: calc(60rpx + env(safe-area-inset-bottom));
width: 104rpx;
height: 104rpx;
border-radius: 50%;
background: $color-primary;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 28rpx rgba(37, 99, 235, 0.35);
z-index: 20;
&:active {
opacity: 0.9;
}
}
</style>