mirror of
https://git.fightbot.fun/hxuanyu/BingPaper.git
synced 2026-02-15 07:29:33 +08:00
大图查看页面增加日历展示,支持显示节假日信息,提高实用性
This commit is contained in:
7
webapp/package-lock.json
generated
7
webapp/package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"lunar-javascript": "^1.7.7",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -2022,6 +2023,12 @@
|
||||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lunar-javascript": {
|
||||
"version": "1.7.7",
|
||||
"resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz",
|
||||
"integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"lunar-javascript": "^1.7.7",
|
||||
"reka-ui": "^2.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
|
||||
593
webapp/src/components/ui/calendar/Calendar.vue
Normal file
593
webapp/src/components/ui/calendar/Calendar.vue
Normal file
@@ -0,0 +1,593 @@
|
||||
<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"
|
||||
:style="{ left: panelPos.x + 'px', top: panelPos.y + 'px' }"
|
||||
@mousedown="startDrag"
|
||||
@touchstart="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" @update:modelValue="onYearChange">
|
||||
<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" @update:modelValue="onMonthChange">
|
||||
<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 // YYYY-MM-DD
|
||||
}>()
|
||||
|
||||
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 initPanelPosition = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
const isMobile = windowWidth < 640 // sm breakpoint
|
||||
|
||||
if (isMobile) {
|
||||
// 移动端:居中显示
|
||||
const panelWidth = windowWidth - 16 // 左右各8px边距
|
||||
const panelHeight = 580 // 估计高度
|
||||
panelPos.value = {
|
||||
x: 8,
|
||||
y: Math.max(8, (windowHeight - panelHeight) / 2)
|
||||
}
|
||||
} else {
|
||||
// 桌面端:右上角
|
||||
const panelWidth = Math.min(420, windowWidth * 0.9)
|
||||
const panelHeight = 600
|
||||
panelPos.value = {
|
||||
x: windowWidth - panelWidth - 40,
|
||||
y: Math.min(80, (windowHeight - 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)
|
||||
}
|
||||
})
|
||||
|
||||
// 年份改变处理
|
||||
const onYearChange = (value: any) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
currentYear.value = Number(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 月份改变处理
|
||||
const onMonthChange = (value: any) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
currentMonth.value = Number(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成年份选项(从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
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载当前年份的假期数据
|
||||
onMounted(() => {
|
||||
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
|
||||
}
|
||||
|
||||
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: false })
|
||||
document.addEventListener('touchend', stopDrag)
|
||||
}
|
||||
|
||||
// 拖动中
|
||||
const onDrag = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
if (e instanceof TouchEvent) {
|
||||
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()
|
||||
const maxX = window.innerWidth - rect.width
|
||||
const maxY = window.innerHeight - rect.height
|
||||
|
||||
panelPos.value = {
|
||||
x: Math.max(0, Math.min(newX, maxX)),
|
||||
y: Math.max(0, 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)
|
||||
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(() => {
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('touchmove', onDrag)
|
||||
document.removeEventListener('touchend', stopDrag)
|
||||
})
|
||||
</script>
|
||||
65
webapp/src/lib/holiday-service.ts
Normal file
65
webapp/src/lib/holiday-service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// 假期API类型定义
|
||||
export interface HolidayDay {
|
||||
/** 节日名称 */
|
||||
name: string;
|
||||
/** 日期, ISO 8601 格式 */
|
||||
date: string;
|
||||
/** 是否为休息日 */
|
||||
isOffDay: boolean;
|
||||
}
|
||||
|
||||
export interface Holidays {
|
||||
/** 完整年份, 整数。*/
|
||||
year: number;
|
||||
/** 所用国务院文件网址列表 */
|
||||
papers: string[];
|
||||
days: HolidayDay[];
|
||||
}
|
||||
|
||||
// 假期数据缓存
|
||||
const holidayCache = new Map<number, Holidays>();
|
||||
|
||||
/**
|
||||
* 获取指定年份的假期数据
|
||||
*/
|
||||
export async function getHolidaysByYear(year: number): Promise<Holidays | null> {
|
||||
// 检查缓存
|
||||
if (holidayCache.has(year)) {
|
||||
return holidayCache.get(year)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.coding.icu/cnholiday/${year}.json`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`获取${year}年假期数据失败: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: Holidays = await response.json();
|
||||
|
||||
// 缓存数据
|
||||
holidayCache.set(year, data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`获取${year}年假期数据出错:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期的假期信息
|
||||
*/
|
||||
export function getHolidayByDate(holidays: Holidays | null, dateStr: string): HolidayDay | null {
|
||||
if (!holidays) return null;
|
||||
|
||||
return holidays.days.find(day => day.date === dateStr) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除假期缓存
|
||||
*/
|
||||
export function clearHolidayCache() {
|
||||
holidayCache.clear();
|
||||
}
|
||||
@@ -17,11 +17,20 @@ const router = createRouter({
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/image/:date',
|
||||
path: '/image/:date?',
|
||||
name: 'ImageView',
|
||||
component: ImageView,
|
||||
meta: {
|
||||
title: '图片详情'
|
||||
},
|
||||
beforeEnter: (to, _from, next) => {
|
||||
// 如果没有提供日期参数,重定向到今天的日期
|
||||
if (!to.params.date) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
next({ path: `/image/${today}`, replace: true })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
20
webapp/src/types/lunar-javascript.d.ts
vendored
Normal file
20
webapp/src/types/lunar-javascript.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
declare module 'lunar-javascript' {
|
||||
export class Solar {
|
||||
static fromDate(date: Date): Solar
|
||||
getLunar(): Lunar
|
||||
getFestivals(): string[]
|
||||
}
|
||||
|
||||
export class Lunar {
|
||||
getYearInChinese(): string
|
||||
getMonthInChinese(): string
|
||||
getDayInChinese(): string
|
||||
getDay(): number
|
||||
getJieQi(): string
|
||||
getFestivals(): string[]
|
||||
}
|
||||
|
||||
export class HolidayUtil {
|
||||
// Add methods if needed
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-6 z-10">
|
||||
<div
|
||||
v-show="!showCalendar"
|
||||
class="absolute top-0 left-0 right-0 bg-gradient-to-b from-black/80 to-transparent p-6 z-10 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': showCalendar }"
|
||||
>
|
||||
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
||||
<button
|
||||
@click="goBack"
|
||||
@@ -37,11 +41,11 @@
|
||||
|
||||
<!-- 信息悬浮层(类似 Windows 聚焦) -->
|
||||
<div
|
||||
v-if="showInfo"
|
||||
v-if="showInfo && !showCalendar"
|
||||
ref="infoPanel"
|
||||
class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 transform transition-opacity duration-300 z-10 select-none"
|
||||
class="fixed w-[90%] max-w-md bg-black/40 backdrop-blur-lg rounded-xl p-4 transform transition-all duration-300 z-10 select-none"
|
||||
:style="{ left: infoPanelPos.x + 'px', top: infoPanelPos.y + 'px' }"
|
||||
:class="{ 'opacity-100': showInfo, 'opacity-0': !showInfo }"
|
||||
:class="{ 'opacity-100': showInfo && !showCalendar, 'opacity-0 pointer-events-none': showCalendar }"
|
||||
>
|
||||
<!-- 拖动手柄 -->
|
||||
<div
|
||||
@@ -83,7 +87,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 z-10">
|
||||
<div
|
||||
v-show="!showCalendar"
|
||||
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 z-10 transition-opacity duration-300"
|
||||
:class="{ 'opacity-0 pointer-events-none': showCalendar }"
|
||||
>
|
||||
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
||||
<!-- 日期切换按钮 -->
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -110,21 +118,42 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 信息按钮 -->
|
||||
<button
|
||||
v-if="!showInfo"
|
||||
@click="showInfo = true"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">显示信息</span>
|
||||
</button>
|
||||
<!-- 右侧按钮组 -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- 日历按钮 -->
|
||||
<button
|
||||
@click="toggleCalendar(true)"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">日历</span>
|
||||
</button>
|
||||
|
||||
<!-- 信息按钮 -->
|
||||
<button
|
||||
v-if="!showInfo"
|
||||
@click="showInfo = true"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-white/10 backdrop-blur-md text-white rounded-lg hover:bg-white/20 transition-all"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">显示信息</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日历弹窗 -->
|
||||
<Calendar
|
||||
v-if="showCalendar"
|
||||
:selected-date="currentDate"
|
||||
@close="toggleCalendar(false)"
|
||||
/>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
@@ -141,16 +170,32 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useImageByDate } from '@/composables/useImages'
|
||||
import { bingPaperApi } from '@/lib/api-service'
|
||||
import Calendar from '@/components/ui/calendar/Calendar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 从 localStorage 读取日历状态,默认关闭
|
||||
const CALENDAR_STATE_KEY = 'imageView_showCalendar'
|
||||
|
||||
// 获取初始日历状态
|
||||
const getInitialCalendarState = (): boolean => {
|
||||
try {
|
||||
const stored = localStorage.getItem(CALENDAR_STATE_KEY)
|
||||
return stored === 'true'
|
||||
} catch (error) {
|
||||
console.warn('Failed to read calendar state from localStorage:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const currentDate = ref(route.params.date as string)
|
||||
const showInfo = ref(true)
|
||||
const showCalendar = ref(getInitialCalendarState())
|
||||
const navigating = ref(false)
|
||||
|
||||
// 前后日期可用性
|
||||
@@ -273,8 +318,16 @@ const checkAdjacentDates = async () => {
|
||||
// 初始化位置
|
||||
initPanelPosition()
|
||||
|
||||
// 监听showCalendar变化并自动保存到localStorage
|
||||
watch(showCalendar, (newValue) => {
|
||||
try {
|
||||
localStorage.setItem(CALENDAR_STATE_KEY, String(newValue))
|
||||
} catch (error) {
|
||||
console.warn('Failed to save calendar state:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听日期变化,检测前后日期可用性
|
||||
import { watch } from 'vue'
|
||||
watch(currentDate, () => {
|
||||
checkAdjacentDates()
|
||||
}, { immediate: true })
|
||||
@@ -337,6 +390,11 @@ const nextDay = () => {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 切换日历状态(watch会自动保存)
|
||||
const toggleCalendar = (state: boolean) => {
|
||||
showCalendar.value = state
|
||||
}
|
||||
|
||||
// 键盘导航
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowLeft' && hasPreviousDay.value) {
|
||||
@@ -344,9 +402,15 @@ const handleKeydown = (e: KeyboardEvent) => {
|
||||
} else if (e.key === 'ArrowRight' && hasNextDay.value) {
|
||||
nextDay()
|
||||
} else if (e.key === 'Escape') {
|
||||
goBack()
|
||||
if (showCalendar.value) {
|
||||
toggleCalendar(false)
|
||||
} else {
|
||||
goBack()
|
||||
}
|
||||
} else if (e.key === 'i' || e.key === 'I') {
|
||||
showInfo.value = !showInfo.value
|
||||
} else if (e.key === 'c' || e.key === 'C') {
|
||||
toggleCalendar(!showCalendar.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,33 @@ export default defineConfig(({ mode }) => {
|
||||
// 入口文件名
|
||||
entryFileNames: 'assets/[name]-[hash].js',
|
||||
// 手动分割代码
|
||||
manualChunks: {
|
||||
// 将 Vue 相关代码单独打包
|
||||
'vue-vendor': ['vue', 'vue-router'],
|
||||
// 将 UI 组件库单独打包(如果有的话)
|
||||
// 'ui-vendor': ['其他UI库']
|
||||
manualChunks: (id) => {
|
||||
// 将 node_modules 中的依赖分割成不同的 chunk
|
||||
if (id.includes('node_modules')) {
|
||||
// Vue 核心库
|
||||
if (id.includes('vue') || id.includes('vue-router')) {
|
||||
return 'vue-vendor'
|
||||
}
|
||||
// Radix UI / Reka UI 组件库
|
||||
if (id.includes('reka-ui') || id.includes('@vueuse')) {
|
||||
return 'ui-vendor'
|
||||
}
|
||||
// Lucide 图标库
|
||||
if (id.includes('lucide-vue-next')) {
|
||||
return 'icons'
|
||||
}
|
||||
// lunar-javascript 农历库
|
||||
if (id.includes('lunar-javascript')) {
|
||||
return 'lunar'
|
||||
}
|
||||
// 其他 node_modules 依赖
|
||||
return 'vendor'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 增加 chunk 大小警告限制
|
||||
chunkSizeWarningLimit: 1000
|
||||
},
|
||||
// 开发服务器配置
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user