Files
EmailBill/Web/src/views/calendarV2/modules/Calendar.vue
SunCheng 1a3d0658bb
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
fix
2026-02-04 15:31:22 +08:00

455 lines
11 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>
<!-- 日历容器 -->
<div
class="calendar-container"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<!-- 星期标题 -->
<div class="week-days">
<span
v-for="day in weekDays"
:key="day"
class="week-day"
>{{ day }}</span>
</div>
<!-- 日历网格 -->
<div class="calendar-grid-wrapper">
<Transition :name="slideDirection">
<div
:key="calendarKey"
class="calendar-grid"
>
<div
v-for="(week, weekIndex) in calendarWeeks"
:key="weekIndex"
class="calendar-week"
>
<div
v-for="day in week"
:key="day.date"
class="day-cell"
@click="onDayClick(day)"
>
<div
class="day-number"
:class="{
'day-today': day.isToday,
'day-selected': day.isSelected,
'day-has-data': day.hasData,
'day-over-limit': day.isOverLimit,
'day-other-month': !day.isCurrentMonth
}"
>
{{ day.dayNumber }}
</div>
<div
v-if="day.amount"
class="day-amount"
:class="{
'amount-over': day.isOverLimit,
'amount-profit': day.isProfitable
}"
>
{{ day.amount }}
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup>
import { computed, watch, ref } from 'vue'
import { getDailyStatistics } from '@/api/statistics'
import { getBudgetList } from '@/api/budget'
const props = defineProps({
currentDate: Date,
selectedDate: Date,
slideDirection: {
default: 'slide-left',
type: String
},
calendarKey: {
default: '',
type: [String, Number]
}
})
const emit = defineEmits(['dayClick', 'touchStart', 'touchMove', 'touchEnd'])
// 星期标题(中文)
const weekDays = ['一', '二', '三', '四', '五', '六', '日']
// 组件内部数据
const dailyStatsMap = ref({})
const dailyBudget = ref(0)
const loading = ref(false)
// 获取月度每日统计数据
const fetchDailyStats = async (year, month) => {
try {
loading.value = true
const response = await getDailyStatistics({ year, month })
if (response.success && response.data) {
// 构建日期 Map
const statsMap = {}
response.data.forEach(item => {
statsMap[item.date] = {
count: item.count,
expense: item.expense,
income: item.income
}
})
dailyStatsMap.value = { ...dailyStatsMap.value, ...statsMap }
}
} catch (error) {
console.error('获取日历数据失败:', error)
} finally {
loading.value = false
}
}
// 获取日历中涉及的所有月份数据(包括上月末和下月初)
const fetchAllRelevantMonthsData = async (year, month) => {
try {
loading.value = true
// 获取当月第一天
const firstDay = new Date(year, month, 1)
// 获取第一天是星期几 (0=Sunday, 调整为 0=Monday)
let startDayOfWeek = firstDay.getDay() - 1
if (startDayOfWeek === -1) {startDayOfWeek = 6}
// 判断是否需要加载上月数据
const needPrevMonth = startDayOfWeek > 0
// 获取当月最后一天
const lastDay = new Date(year, month + 1, 0)
// 计算总共需要多少行
const totalDays = startDayOfWeek + lastDay.getDate()
const totalWeeks = Math.ceil(totalDays / 7)
const totalCells = totalWeeks * 7
// 判断是否需要加载下月数据
const needNextMonth = totalCells > (startDayOfWeek + lastDay.getDate())
// 并行加载所有需要的月份数据
// JavaScript Date.month 是 0-11但后端 API 期望 1-12
const promises = [fetchDailyStats(year, month + 1)]
if (needPrevMonth) {
const prevMonth = month === 0 ? 11 : month - 1
const prevYear = month === 0 ? year - 1 : year
promises.push(fetchDailyStats(prevYear, prevMonth + 1))
}
if (needNextMonth) {
const nextMonth = month === 11 ? 0 : month + 1
const nextYear = month === 11 ? year + 1 : year
promises.push(fetchDailyStats(nextYear, nextMonth + 1))
}
await Promise.all(promises)
} catch (error) {
console.error('获取日历数据失败:', error)
} finally {
loading.value = false
}
}
// 获取预算数据
const fetchBudgetData = async () => {
try {
const response = await getBudgetList()
if (response.success && response.data && response.data.length > 0) {
// 取第一个预算的月度限额除以30作为每日预算
const monthlyBudget = response.data[0].limit || 0
dailyBudget.value = Math.floor(monthlyBudget / 30)
}
} catch (error) {
console.error('获取预算失败:', error)
}
}
// 监听 currentDate 变化,重新加载数据
watch(() => props.currentDate, async (newDate) => {
if (newDate) {
await fetchAllRelevantMonthsData(newDate.getFullYear(), newDate.getMonth())
}
}, { immediate: true })
// 初始加载预算数据
fetchBudgetData()
// 生成日历数据
const calendarWeeks = computed(() => {
const year = props.currentDate.getFullYear()
const month = props.currentDate.getMonth()
// 获取当月第一天
const firstDay = new Date(year, month, 1)
// 获取当月最后一天
const lastDay = new Date(year, month + 1, 0)
// 获取第一天是星期几 (0=Sunday, 调整为 0=Monday)
let startDayOfWeek = firstDay.getDay() - 1
if (startDayOfWeek === -1) {startDayOfWeek = 6}
const weeks = []
let currentWeek = []
// 填充上月日期
for (let i = 0; i < startDayOfWeek; i++) {
const date = new Date(year, month, -(startDayOfWeek - i - 1))
currentWeek.push(createDayObject(date, false))
}
// 填充当月日期
for (let day = 1; day <= lastDay.getDate(); day++) {
const date = new Date(year, month, day)
currentWeek.push(createDayObject(date, true))
if (currentWeek.length === 7) {
weeks.push(currentWeek)
currentWeek = []
}
}
// 填充下月日期
if (currentWeek.length > 0) {
const remainingDays = 7 - currentWeek.length
for (let i = 1; i <= remainingDays; i++) {
const date = new Date(year, month + 1, i)
currentWeek.push(createDayObject(date, false))
}
weeks.push(currentWeek)
}
return weeks
})
// 创建日期对象
const createDayObject = (date, isCurrentMonth) => {
const today = new Date()
const isToday =
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
const isSelected =
date.getDate() === props.selectedDate.getDate() &&
date.getMonth() === props.selectedDate.getMonth() &&
date.getFullYear() === props.selectedDate.getFullYear()
// 从 API 数据获取
const dateKey = formatDateKey(date)
const dayStats = dailyStatsMap.value[dateKey] || {}
// 计算净支出(支出 - 收入)
const netAmount = (dayStats.expense || 0) - (dayStats.income || 0)
const hasData = dayStats.count > 0
// 收入大于支出为盈利(绿色),否则为支出(红色)
const isProfitable = hasData && netAmount < 0
return {
date: date.getTime(),
dayNumber: date.getDate(),
isCurrentMonth,
isToday,
isSelected,
hasData,
amount: hasData ? Math.abs(netAmount).toFixed(0) : '',
isOverLimit: netAmount > (dailyBudget.value || 0), // 超过每日预算标红
isProfitable // 是否盈利(收入>支出)
}
}
// 格式化日期为 key (yyyy-MM-dd)
const formatDateKey = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 点击日期
const onDayClick = (day) => {
emit('dayClick', day)
}
// 触摸事件
const onTouchStart = (e) => {
emit('touchStart', e)
}
const onTouchMove = (e) => {
emit('touchMove', e)
}
const onTouchEnd = () => {
emit('touchEnd')
}
</script>
<style scoped>
@import '@/assets/theme.css';
/* ========== 日历容器 ========== */
.calendar-container {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
padding: var(--spacing-3xl);
position: relative;
padding-bottom: 0;
}
/* ========== 月份切换动画 ========== */
/* 向左滑动(下一月) */
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease-out;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.slide-left-enter-active {
position: relative;
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(100%);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.slide-left-leave-active {
position: absolute;
}
/* 向右滑动(上一月) */
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.3s ease-out;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.slide-right-enter-active {
position: relative;
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-100%);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(100%);
}
.slide-right-leave-active {
position: absolute;
}
.week-days {
display: flex;
justify-content: space-between;
}
.week-day {
width: 44px;
text-align: center;
font-size: var(--font-base);
font-weight: var(--font-semibold);
color: var(--text-tertiary);
}
.calendar-grid-wrapper {
position: relative;
width: 100%;
overflow: hidden;
}
.calendar-grid {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
width: 100%;
}
.calendar-week {
display: flex;
justify-content: space-between;
min-height: 56px; /* 固定最小高度32px(day-number) + 16px(day-amount) + 8px(gap) */
}
.day-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
width: 44px;
cursor: pointer;
}
.day-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 16px;
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-primary);
transition: all 0.2s;
}
.day-number.day-has-data {
background-color: var(--bg-tertiary);
font-weight: var(--font-semibold);
}
.day-number.day-selected {
background-color: var(--accent-primary);
color: #FFFFFF;
font-weight: var(--font-bold);
}
.day-number.day-other-month {
color: var(--text-tertiary);
opacity: 0.4;
}
.day-amount {
font-size: var(--font-xs);
font-weight: var(--font-medium);
color: var(--text-tertiary);
}
.day-amount.amount-over {
color: var(--accent-danger);
}
.day-amount.amount-profit {
color: var(--accent-success);
}
</style>