Files
BingPaper/webapp/src/components/ui/calendar/Calendar.vue

653 lines
21 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="fixed inset-0 z-40">
<div
ref="calendarPanel"
class="fixed bg-gradient-to-br from-black/30 via-black/20 to-black/30 backdrop-blur-xl rounded-3xl p-3 sm:p-4 w-[calc(100%-1rem)] sm:w-full max-w-[95vw] sm:max-w-[420px] shadow-2xl border border-white/10 cursor-move select-none touch-none"
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
@mousedown="startDrag"
@touchstart.passive="startDrag"
@click.stop
>
<!-- 拖动手柄指示器 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2 w-12 h-1 bg-white/20 rounded-full"></div>
<!-- 头部 -->
<div class="flex items-center justify-between mb-3 sm:mb-4 mt-2">
<div class="flex items-center gap-1.5 sm:gap-2 flex-1">
<button
@click.stop="previousMonth"
:disabled="!canGoPrevious"
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div class="text-center flex-1">
<!-- 年月选择器 -->
<div class="flex items-center justify-center gap-1 sm:gap-1.5 mb-0.5">
<!-- 年份选择 -->
<Select v-model="currentYearString">
<SelectTrigger
class="w-[90px] sm:w-[105px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
@click.stop
@mousedown.stop
>
<SelectValue>{{ currentYear }}</SelectValue>
</SelectTrigger>
<SelectContent class="max-h-[300px] bg-gray-900/95 backdrop-blur-xl border-white/20">
<SelectItem
v-for="year in yearOptions"
:key="year"
:value="String(year)"
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
>
{{ year }}
</SelectItem>
</SelectContent>
</Select>
<!-- 月份选择 -->
<Select v-model="currentMonthString">
<SelectTrigger
class="w-[65px] sm:w-[75px] h-6 sm:h-7 bg-white/10 text-white border-white/20 hover:bg-white/20 backdrop-blur-md font-bold text-xs sm:text-sm px-1.5 sm:px-2"
@click.stop
@mousedown.stop
>
<SelectValue>{{ currentMonth + 1 }}</SelectValue>
</SelectTrigger>
<SelectContent class="bg-gray-900/95 backdrop-blur-xl border-white/20">
<SelectItem
v-for="month in 12"
:key="month"
:value="String(month - 1)"
class="text-white hover:bg-white/20 focus:bg-white/20 cursor-pointer"
>
{{ month }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="text-[10px] sm:text-xs text-white/60 drop-shadow-md font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] leading-relaxed">
{{ lunarMonthYear }}
</div>
</div>
<button
@click.stop="nextMonth"
:disabled="!canGoNext"
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
<button
@click.stop="$emit('close')"
class="p-1 sm:p-1.5 hover:bg-white/20 rounded-lg transition-colors ml-1.5 sm:ml-2"
>
<svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white drop-shadow-lg" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- 星期标题 -->
<div class="grid grid-cols-7 gap-1 sm:gap-1.5 mb-1.5 sm:mb-2 pointer-events-none">
<div
v-for="(day, idx) in weekDays"
:key="day"
class="text-center text-[11px] sm:text-[13px] font-medium py-1 sm:py-1.5 drop-shadow-md leading-none"
:class="idx === 0 || idx === 6 ? 'text-red-300/80' : 'text-white/70'"
>
{{ day }}
</div>
</div>
<!-- 日期格子 -->
<div class="grid grid-cols-7 gap-1 sm:gap-1.5">
<div
v-for="(day, index) in calendarDays"
:key="index"
class="relative aspect-square flex flex-col items-center justify-center rounded-lg transition-opacity pointer-events-none py-0.5 sm:py-1"
:class="[
day.isCurrentMonth && !day.isFuture ? 'text-white' : 'text-white/25',
day.isToday ? 'bg-blue-400/40 ring-2 ring-blue-300/50' : '',
day.isSelected ? 'bg-white/30 ring-1 ring-white/40' : '',
day.isFuture ? 'opacity-40' : '',
day.isWeekend && day.isCurrentMonth ? 'text-red-200/90' : '',
(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300' : ''
]"
>
<!-- 休息/上班标记 (API优先其次周末) - 使用圆形SVG -->
<div
v-if="day.isCurrentMonth && (day.apiHoliday || day.isWeekend)"
class="absolute top-0 right-0 w-[14px] h-[14px] sm:w-4 sm:h-4"
>
<svg viewBox="0 0 20 20" class="w-full h-full drop-shadow-md">
<circle
cx="10"
cy="10"
r="9"
:fill="day.apiHoliday ? (day.apiHoliday.isOffDay ? '#ef4444' : '#3b82f6') : '#ef4444'"
opacity="0.65"
/>
<text
x="9.8"
y="10.5"
text-anchor="middle"
dominant-baseline="middle"
fill="white"
font-size="11"
font-weight="bold"
font-family="'Microsoft YaHei UI','Microsoft YaHei','PingFang SC','Hiragino Sans GB',sans-serif"
>
{{ day.apiHoliday ? (day.apiHoliday.isOffDay ? '休' : '班') : '休' }}
</text>
</svg>
</div>
<!-- 公历日期 -->
<div
class="text-[13px] sm:text-[15px] font-medium drop-shadow-md font-['Helvetica','Arial',sans-serif] leading-none mb-0.5 sm:mb-1"
:class="(day.apiHoliday?.isOffDay || (!day.apiHoliday && day.isWeekend)) ? 'text-red-300 font-bold' : ''"
>
{{ day.day }}
</div>
<!-- 农历/节日/节气 (不显示API节假日名称) -->
<div
class="text-[9px] sm:text-[10px] leading-tight drop-shadow-sm font-['Microsoft_YaHei_UI','Microsoft_YaHei',sans-serif] text-center px-0.5"
:class="[
day.festival || day.solarTerm || day.lunarFestival ? 'text-red-300 font-semibold' : 'text-white/60'
]"
>
{{ day.festival || day.solarTerm || day.lunarFestival || day.lunarDay }}
</div>
</div>
</div>
<!-- 今日按钮 -->
<div class="mt-3 sm:mt-4 flex justify-center">
<button
@click.stop="goToToday"
class="px-4 sm:px-5 py-1 sm:py-1.5 bg-white/15 hover:bg-white/30 text-white rounded-lg text-[11px] sm:text-xs font-medium transition-all hover:scale-105 active:scale-95 drop-shadow-lg"
>
回到今天
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Solar } from 'lunar-javascript'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getHolidaysByYear, getHolidayByDate, type Holidays, type HolidayDay } from '@/lib/holiday-service'
interface CalendarDay {
day: number
isCurrentMonth: boolean
isToday: boolean
isSelected: boolean
isFuture: boolean
isWeekend: boolean
isHoliday: boolean
holidayName: string
apiHoliday: HolidayDay | null // API返回的假期信息
lunarDay: string
festival: string
lunarFestival: string
solarTerm: string
date: Date
}
const props = defineProps<{
selectedDate?: string,
mkt?: string
}>()
const emit = defineEmits<{
close: []
}>()
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
// 日历面板位置
const calendarPanel = ref<HTMLElement | null>(null)
const panelPos = ref({ x: 0, y: 0 })
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
// 响应式窗口大小
const windowSize = ref({ width: window.innerWidth, height: window.innerHeight })
const isMobile = computed(() => windowSize.value.width < 768)
// 计算图片实际显示区域与ImageView保持一致
const getImageDisplayBounds = () => {
const windowWidth = windowSize.value.width
const windowHeight = windowSize.value.height
// 必应图片通常是16:9或类似宽高比
const imageAspectRatio = 16 / 9
const windowAspectRatio = windowWidth / windowHeight
let displayWidth: number
let displayHeight: number
let offsetX: number
let offsetY: number
if (windowAspectRatio > imageAspectRatio) {
// 窗口更宽,图片上下占满,左右留黑边
displayHeight = windowHeight
displayWidth = displayHeight * imageAspectRatio
offsetX = (windowWidth - displayWidth) / 2
offsetY = 0
} else {
// 窗口更高,图片左右占满,上下留黑边
displayWidth = windowWidth
displayHeight = displayWidth / imageAspectRatio
offsetX = 0
offsetY = (windowHeight - displayHeight) / 2
}
return {
left: offsetX,
top: offsetY,
right: offsetX + displayWidth,
bottom: offsetY + displayHeight,
width: displayWidth,
height: displayHeight
}
}
// 初始化面板位置(移动端居中,桌面端右上角,限制在图片显示区域内)
const initPanelPosition = () => {
if (typeof window !== 'undefined') {
const bounds = getImageDisplayBounds()
if (isMobile.value) {
// 移动端:居中显示,尽量在图片内,但不强求
const panelWidth = Math.min(bounds.width - 16, windowSize.value.width - 16)
const panelHeight = 580 // 估计高度
panelPos.value = {
x: Math.max(bounds.left, bounds.left + (bounds.width - panelWidth) / 2),
y: Math.max(bounds.top + 8, bounds.top + (bounds.height - panelHeight) / 2)
}
} else {
// 桌面端:在图片区域右上角
const panelWidth = Math.min(420, bounds.width * 0.9)
const panelHeight = 600
panelPos.value = {
x: bounds.right - panelWidth - 40,
y: Math.max(bounds.top + 80, bounds.top + (bounds.height - panelHeight) / 2)
}
}
}
}
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth())
const isChangingMonth = ref(false)
// 假期数据
const holidaysData = ref<Map<number, Holidays | null>>(new Map())
const loadingHolidays = ref(false)
// 字符串版本的年月用于Select组件
const currentYearString = computed({
get: () => String(currentYear.value),
set: (val: string) => {
currentYear.value = Number(val)
}
})
const currentMonthString = computed({
get: () => String(currentMonth.value),
set: (val: string) => {
currentMonth.value = Number(val)
}
})
// 生成年份选项从2009年到当前年份+10年
const yearOptions = computed(() => {
const currentYearValue = new Date().getFullYear()
const years: number[] = []
for (let year = currentYearValue - 30; year <= currentYearValue + 10; year++) {
years.push(year)
}
return years
})
// 计算是否可以切换月份(不限制)
const canGoPrevious = computed(() => {
return !isChangingMonth.value
})
const canGoNext = computed(() => {
return !isChangingMonth.value
})
// 初始化为选中的日期
watch(() => props.selectedDate, (newDate) => {
if (newDate) {
const date = new Date(newDate)
currentYear.value = date.getFullYear()
currentMonth.value = date.getMonth()
}
}, { immediate: true })
// 初始化位置
initPanelPosition()
// 加载假期数据
const loadHolidaysForYear = async (year: number) => {
if (holidaysData.value.has(year)) {
return
}
loadingHolidays.value = true
try {
const data = await getHolidaysByYear(year)
holidaysData.value.set(year, data)
} catch (error) {
console.error(`加载${year}年假期数据失败:`, error)
holidaysData.value.set(year, null)
} finally {
loadingHolidays.value = false
}
}
// 窗口缩放处理
const handleResize = () => {
windowSize.value = {
width: window.innerWidth,
height: window.innerHeight
}
initPanelPosition()
}
// 组件挂载时加载当前年份的假期数据
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize)
}
const currentYearValue = currentYear.value
loadHolidaysForYear(currentYearValue)
// 预加载前后一年的数据
loadHolidaysForYear(currentYearValue - 1)
loadHolidaysForYear(currentYearValue + 1)
})
// 监听年份变化,加载对应的假期数据
watch(currentYear, (newYear) => {
loadHolidaysForYear(newYear)
// 预加载前后一年
loadHolidaysForYear(newYear - 1)
loadHolidaysForYear(newYear + 1)
})
// 开始拖动
const startDrag = (e: MouseEvent | TouchEvent) => {
const target = e.target as HTMLElement
// 如果点击的是按钮或其子元素,不触发拖拽
if (target.closest('button') || target.closest('[class*="grid"]')) {
return
}
if (e instanceof MouseEvent) {
e.preventDefault()
}
isDragging.value = true
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
dragStart.value = {
x: clientX - panelPos.value.x,
y: clientY - panelPos.value.y
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: true })
document.addEventListener('touchend', stopDrag)
}
// 拖动中
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
if (e instanceof MouseEvent) {
e.preventDefault()
}
const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX
const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY
const newX = clientX - dragStart.value.x
const newY = clientY - dragStart.value.y
// 限制在有效区域内
if (calendarPanel.value) {
const rect = calendarPanel.value.getBoundingClientRect()
let minX, maxX, minY, maxY
if (isMobile.value) {
// 移动端:不限制区域,限制在视口内即可
minX = 0
maxX = windowSize.value.width - rect.width
minY = 0
maxY = windowSize.value.height - rect.height
} else {
// 桌面端:限制在图片实际显示区域内
const bounds = getImageDisplayBounds()
minX = bounds.left
maxX = bounds.right - rect.width
minY = bounds.top
maxY = bounds.bottom - rect.height
}
panelPos.value = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
}
}
}
// 停止拖动
const stopDrag = () => {
isDragging.value = false
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
}
// 农历月份年份
const lunarMonthYear = computed(() => {
const solar = Solar.fromDate(new Date(currentYear.value, currentMonth.value, 15))
const lunar = solar.getLunar()
return `${lunar.getYearInChinese()}${lunar.getMonthInChinese()}`
})
// 获取日历天数
const calendarDays = computed<CalendarDay[]>(() => {
const year = currentYear.value
const month = currentMonth.value
// 当月第一天
const firstDay = new Date(year, month, 1)
const firstDayWeek = firstDay.getDay()
// 当月最后一天
const lastDay = new Date(year, month + 1, 0)
const lastDate = lastDay.getDate()
// 上月最后几天
const prevLastDay = new Date(year, month, 0)
const prevLastDate = prevLastDay.getDate()
const days: CalendarDay[] = []
const today = new Date()
today.setHours(0, 0, 0, 0)
// 填充上月日期
for (let i = firstDayWeek - 1; i >= 0; i--) {
const day = prevLastDate - i
const date = new Date(year, month - 1, day)
days.push(createDayObject(date, false))
}
// 填充当月日期
for (let day = 1; day <= lastDate; day++) {
const date = new Date(year, month, day)
days.push(createDayObject(date, true))
}
// 填充下月日期
const remainingDays = 42 - days.length // 6行7列
for (let day = 1; day <= remainingDays; day++) {
const date = new Date(year, month + 1, day)
days.push(createDayObject(date, false))
}
return days
})
// 创建日期对象
const createDayObject = (date: Date, isCurrentMonth: boolean): CalendarDay => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const selectedDate = new Date(props.selectedDate || new Date())
selectedDate.setHours(0, 0, 0, 0)
// 转换为农历
const solar = Solar.fromDate(date)
const lunar = solar.getLunar()
// 获取节日
const festivals = solar.getFestivals()
const festival = festivals.length > 0 ? festivals[0] : ''
// 获取农历节日
const lunarFestivals = lunar.getFestivals()
const lunarFestival = lunarFestivals.length > 0 ? lunarFestivals[0] : ''
// 获取节气
const solarTerm = lunar.getJieQi()
// 获取API假期数据 - 使用本地时间避免时区偏移
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const yearHolidays = holidaysData.value.get(date.getFullYear())
const apiHoliday = getHolidayByDate(yearHolidays || null, dateStr)
// 检查是否为假期使用lunar-javascript的节日信息
let isHoliday = false
let holidayName = ''
try {
if (festival || lunarFestival) {
// 常见法定节假日
const legalHolidays = ['元旦', '春节', '清明', '劳动节', '端午', '中秋', '国庆']
const holidayNames = [festival, lunarFestival].filter(Boolean)
for (const name of holidayNames) {
if (legalHolidays.some(legal => name.includes(legal))) {
isHoliday = true
holidayName = name
break
}
}
}
} catch (e) {
console.debug('假期信息获取失败:', e)
}
// 判断是否为周末(周六或周日)
const isWeekend = date.getDay() === 0 || date.getDay() === 6
// 农历日期显示
let lunarDay = lunar.getDayInChinese()
if (lunar.getDay() === 1) {
lunarDay = lunar.getMonthInChinese() + '月'
}
return {
day: date.getDate(),
isCurrentMonth,
isToday: date.getTime() === today.getTime(),
isSelected: date.getTime() === selectedDate.getTime(),
isFuture: date > today,
isWeekend,
isHoliday,
holidayName,
apiHoliday,
lunarDay,
festival,
lunarFestival,
solarTerm,
date
}
}
// 上一月
const previousMonth = () => {
if (!canGoPrevious.value) return
if (currentMonth.value === 0) {
currentMonth.value = 11
currentYear.value--
} else {
currentMonth.value--
}
}
// 下一月
const nextMonth = () => {
if (!canGoNext.value) return
if (currentMonth.value === 11) {
currentMonth.value = 0
currentYear.value++
} else {
currentMonth.value++
}
}
// 回到今天
const goToToday = () => {
const today = new Date()
currentYear.value = today.getFullYear()
currentMonth.value = today.getMonth()
}
// 不再支持点击日期选择
// 日历仅作为台历展示功能
// 清理
import { onUnmounted } from 'vue'
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize)
}
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
})
</script>