fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 4m27s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
SunCheng
2026-02-11 13:00:01 +08:00
parent ca3e929770
commit 51172e8c5a
88 changed files with 10076 additions and 142 deletions

23
Web/src/api/holiday.js Normal file
View File

@@ -0,0 +1,23 @@
import request from './request'
/**
* 获取指定年月的节假日数据
*/
export function getMonthHolidays (year, month) {
return request({
url: '/holiday/GetMonthHolidays',
method: 'get',
params: { year, month }
})
}
/**
* 手动触发同步节假日数据
*/
export function syncHolidays (year) {
return request({
url: '/holiday/SyncHolidays',
method: 'post',
params: { year }
})
}

View File

@@ -167,7 +167,8 @@ onMounted(() => {
left: 0;
right: 0;
width: 100%;
height: 95px;
// 基础高度 + 安全区域确保在PWA中正确贴底
height: calc(95px + env(safe-area-inset-bottom, 0px));
z-index: 1000;
pointer-events: none;
}

View File

@@ -1,4 +1,4 @@
<!-- eslint-disable vue/no-v-html -->
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="page-container-flex">
<van-nav-bar
@@ -156,7 +156,7 @@
description="暂无存款计划"
/>
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh>
</van-tab>
</van-tabs>
@@ -417,7 +417,7 @@
/>
</template>
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh>
</PopupContainer>
@@ -649,7 +649,7 @@ const fetchCategoryStats = async () => {
const data = res.data
overallStats.value = {
month: {
rate: data.month?.rate?.toFixed(1) || '0.0',
rate: data.month?.usagePercentage?.toFixed(1) || '0.0',
current: data.month?.current || 0,
limit: data.month?.limit || 0,
count: data.month?.count || 0,
@@ -657,7 +657,7 @@ const fetchCategoryStats = async () => {
description: data.month?.description || ''
},
year: {
rate: data.year?.rate?.toFixed(1) || '0.0',
rate: data.year?.usagePercentage?.toFixed(1) || '0.0',
current: data.year?.current || 0,
limit: data.year?.limit || 0,
count: data.year?.count || 0,

View File

@@ -12,7 +12,7 @@
/>
<!-- 底部安全距离 -->
<div style="height: calc(60px + env(safe-area-inset-bottom, 0px))" />
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
<!-- 日期交易列表弹出层 -->
<PopupContainer

View File

@@ -137,7 +137,7 @@
/>
</van-cell-group>
<!-- 底部安全距离 -->
<div style="height: calc(80px + env(safe-area-inset-bottom, 0px))" />
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
</div>
<!-- 液态玻璃底部导航栏 -->

View File

@@ -101,8 +101,7 @@ const router = useRouter()
// 底部导航栏
const activeTab = ref('calendar')
const handleTabClick = (item, index) => {
console.log('Tab clicked:', item.name, index)
const handleTabClick = () => {
// 导航逻辑已在组件内部处理
}
@@ -212,7 +211,14 @@ const onDatePickerConfirm = ({ selectedValues }) => {
// 更新日期
currentDate.value = newDate
selectedDate.value = newDate
// 判断是否选择了当前月(复用上面的 today 变量)
const isCurrentMonth = newDate.getFullYear() === today.getFullYear() &&
newDate.getMonth() === today.getMonth()
// 如果选择的是当前月,选中今天;否则选中该月第一天
selectedDate.value = isCurrentMonth ? today : newDate
calendarKey.value += 1
showDatePicker.value = false
}
@@ -258,9 +264,16 @@ const changeMonth = async (offset) => {
currentDate.value = newDate
// 根据切换方向选择合适的日期
// 判断是否切换到当前月(复用上面的 today 变量)
const isCurrentMonth = newDate.getFullYear() === today.getFullYear() &&
newDate.getMonth() === today.getMonth()
// 根据切换方向和是否为当前月选择合适的日期
let newSelectedDate
if (offset > 0) {
if (isCurrentMonth) {
// 如果切换到当前月,选中今天
newSelectedDate = today
} else if (offset > 0) {
// 切换到下个月,选中下个月的第一天
newSelectedDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1)
} else {
@@ -387,8 +400,8 @@ onBeforeUnmount(() => {
background-color: var(--bg-primary);
}
/* 底部安全距离 */
/* 底部安全距离 - 匹配底部导航栏高度 */
.bottom-spacer {
height: calc(60px + env(safe-area-inset-bottom, 0px));
height: calc(95px + env(safe-area-inset-bottom, 0px));
}
</style>

View File

@@ -32,6 +32,8 @@
:key="day.date"
class="day-cell"
@click="onDayClick(day)"
@touchstart="onTouchStartHoliday($event, day)"
@touchend="onTouchEndHoliday"
>
<div
class="day-number"
@@ -40,10 +42,19 @@
'day-selected': day.isSelected,
'day-has-data': day.hasData,
'day-over-limit': day.isOverLimit,
'day-other-month': !day.isCurrentMonth
'day-other-month': !day.isCurrentMonth,
'day-holiday': day.isHoliday && day.isRest,
'day-workday': day.isWorkday
}"
>
{{ day.dayNumber }}
<!-- 节假日标记 -->
<span
v-if="day.isHoliday"
class="holiday-badge"
>
{{ day.isWorkday ? '班' : '休' }}
</span>
</div>
<div
v-if="day.amount"
@@ -60,6 +71,26 @@
</div>
</Transition>
</div>
<!-- 节假日提示浮层 -->
<van-overlay
:show="showHolidayTooltip"
@click="closeHolidayTooltip"
>
<div
class="holiday-tooltip-wrapper"
@click.stop
>
<div class="holiday-tooltip">
<div class="tooltip-title">
{{ currentHolidayName }}
</div>
<div class="tooltip-desc">
{{ currentHolidayDesc }}
</div>
</div>
</div>
</van-overlay>
</div>
</template>
@@ -67,6 +98,7 @@
import { computed, watch, ref } from 'vue'
import { getDailyStatistics } from '@/api/statistics'
import { getBudgetList } from '@/api/budget'
import { getMonthHolidays } from '@/api/holiday'
const props = defineProps({
currentDate: Date,
@@ -90,6 +122,13 @@ const weekDays = ['一', '二', '三', '四', '五', '六', '日']
const dailyStatsMap = ref({})
const dailyBudget = ref(0)
const loading = ref(false)
const holidaysMap = ref({}) // 节假日数据映射
// 节假日提示相关
const showHolidayTooltip = ref(false)
const currentHolidayName = ref('')
const currentHolidayDesc = ref('')
let holdTimer = null
// 获取月度每日统计数据
const fetchDailyStats = async (year, month) => {
@@ -101,7 +140,9 @@ const fetchDailyStats = async (year, month) => {
// 构建日期 Map
const statsMap = {}
response.data.forEach(item => {
statsMap[item.date] = {
// 后端返回的是 day (1-31),需要构建完整的日期字符串
const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(item.day).padStart(2, '0')}`
statsMap[dateKey] = {
count: item.count,
expense: item.expense,
income: item.income
@@ -142,18 +183,20 @@ const fetchAllRelevantMonthsData = async (year, month) => {
// 并行加载所有需要的月份数据
// JavaScript Date.month 是 0-11但后端 API 期望 1-12
const promises = [fetchDailyStats(year, month + 1)]
const promises = [fetchDailyStats(year, month + 1), fetchHolidays(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))
promises.push(fetchHolidays(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))
promises.push(fetchHolidays(nextYear, nextMonth + 1))
}
await Promise.all(promises)
@@ -178,6 +221,23 @@ const fetchBudgetData = async () => {
}
}
// 获取节假日数据
const fetchHolidays = async (year, month) => {
try {
const response = await getMonthHolidays(year, month)
if (response.success && response.data) {
const map = {}
response.data.forEach(item => {
map[item.date] = item
})
holidaysMap.value = { ...holidaysMap.value, ...map }
}
} catch (error) {
// 静默失败,仅记录日志
console.error('获取节假日数据失败:', error)
}
}
// 监听 currentDate 变化,重新加载数据
watch(() => props.currentDate, async (newDate) => {
if (newDate) {
@@ -251,6 +311,7 @@ const createDayObject = (date, isCurrentMonth) => {
// 从 API 数据获取
const dateKey = formatDateKey(date)
const dayStats = dailyStatsMap.value[dateKey] || {}
const holidayInfo = holidaysMap.value[dateKey] // 获取节假日信息
// 计算净支出(支出 - 收入)
const netAmount = (dayStats.expense || 0) - (dayStats.income || 0)
@@ -258,6 +319,30 @@ const createDayObject = (date, isCurrentMonth) => {
// 收入大于支出为盈利(绿色),否则为支出(红色)
const isProfitable = hasData && netAmount < 0
// 判断是否是周末0=周日, 6=周六)
const dayOfWeek = date.getDay()
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6
// 节假日逻辑判断
let isHoliday = false
let holidayName = ''
let isWorkday = false
let isRest = false
if (holidayInfo) {
// 有节假日信息,以节假日信息为准
isHoliday = true
holidayName = holidayInfo.holidayName
isWorkday = holidayInfo.dayType === 3 // 调休工作日
isRest = holidayInfo.rest === 1
} else if (isWeekend) {
// 没有节假日信息,但是周末,标记为普通休息日
isHoliday = true
holidayName = dayOfWeek === 0 ? '周日' : '周六'
isWorkday = false
isRest = true
}
return {
date: date.getTime(),
dayNumber: date.getDate(),
@@ -267,7 +352,12 @@ const createDayObject = (date, isCurrentMonth) => {
hasData,
amount: hasData ? Math.abs(netAmount).toFixed(0) : '',
isOverLimit: netAmount > (dailyBudget.value || 0), // 超过每日预算标红
isProfitable // 是否盈利(收入>支出)
isProfitable, // 是否盈利(收入>支出)
// 节假日信息
isHoliday,
holidayName,
isWorkday, // 调休工作日
isRest // 是否休息
}
}
@@ -284,6 +374,31 @@ const onDayClick = (day) => {
emit('dayClick', day)
}
// 节假日长按事件处理
const onTouchStartHoliday = (e, day) => {
if (!day.isHoliday) {return}
// 长按500ms显示提示
holdTimer = setTimeout(() => {
currentHolidayName.value = day.holidayName
currentHolidayDesc.value = day.isWorkday
? `${day.holidayName} - 调休工作日`
: `${day.holidayName} - 休息日`
showHolidayTooltip.value = true
}, 500)
}
const onTouchEndHoliday = () => {
if (holdTimer) {
clearTimeout(holdTimer)
holdTimer = null
}
}
const closeHolidayTooltip = () => {
showHolidayTooltip.value = false
}
// 触摸事件
const onTouchStart = (e) => {
emit('touchStart', e)
@@ -384,7 +499,8 @@ const onTouchEnd = () => {
.calendar-grid-wrapper {
position: relative;
width: 100%;
overflow: hidden;
overflow: hidden; /* 保持 hidden 以支持切换动画 */
padding-top: 4px; /* 新增:为第一行的徽章留出空间 */
}
.calendar-grid {
@@ -392,6 +508,7 @@ const onTouchEnd = () => {
flex-direction: column;
gap: var(--spacing-lg);
width: 100%;
padding-top: 4px; /* 新增:为第一行徽章留出空间 */
}
.calendar-week {
@@ -410,6 +527,7 @@ const onTouchEnd = () => {
}
.day-number {
position: relative; /* 为绝对定位的徽章提供定位上下文 */
display: flex;
align-items: center;
justify-content: center;
@@ -451,4 +569,65 @@ const onTouchEnd = () => {
.day-amount.amount-profit {
color: var(--accent-success);
}
/* ========== 节假日样式 ========== */
/* 节假日放假样式(绿色系) */
.day-number.day-holiday {
background: linear-gradient(135deg, #E8F5E9 0%, #C8E6C9 100%);
color: #2E7D32;
font-weight: var(--font-bold);
}
/* 调休工作日样式(橙色/黄色系) */
.day-number.day-workday {
background: linear-gradient(135deg, #FFF3E0 0%, #FFE0B2 100%);
color: #E65100;
font-weight: var(--font-bold);
}
/* 节假日标记 */
.holiday-badge {
position: absolute;
top: -2px;
right: -2px;
font-size: 9px;
background-color: var(--accent-danger);
color: white;
border-radius: 4px;
padding: 1px 3px;
line-height: 1.2;
font-weight: var(--font-bold);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
z-index: 100;
}
/* 节假日提示浮层 */
.holiday-tooltip-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.holiday-tooltip {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 280px;
}
.tooltip-title {
font-size: 18px;
font-weight: bold;
color: var(--text-primary);
margin-bottom: 8px;
text-align: center;
}
.tooltip-desc {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
</style>

View File

@@ -243,7 +243,7 @@
</div>
</div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
</div>
</div>
</van-pull-refresh>

View File

@@ -397,12 +397,21 @@ const loadWeeklyData = async () => {
})
if (dailyResult?.success && dailyResult.data) {
// 转换数据格式以适配图表组件
trendStats.value = dailyResult.data.map(item => ({
date: item.date,
amount: (item.income || 0) - (item.expense || 0),
count: item.count || 0
}))
// ⚠️ 注意: API 返回的 data 按日期顺序排列,但只有 day 字段(天数)
// 需要根据 weekStart 和索引重建完整日期
trendStats.value = dailyResult.data.map((item, index) => {
// 从 weekStart 开始,按索引递增天数
const date = new Date(weekStart)
date.setDate(weekStart.getDate() + index)
const dateStr = formatDateToString(date)
return {
date: dateStr,
expense: item.expense || 0,
income: item.income || 0,
count: item.count || 0
}
})
}
} catch (error) {
console.error('加载周度数据失败:', error)
@@ -638,6 +647,9 @@ const isLastPeriod = () => {
const handleTouchStart = (e) => {
touchStartX.value = e.touches[0].clientX
touchStartY.value = e.touches[0].clientY
// 重置 touchEnd 值,防止使用上次的残留值
touchEndX.value = touchStartX.value
touchEndY.value = touchStartY.value
}
const handleTouchMove = (e) => {
@@ -645,12 +657,21 @@ const handleTouchMove = (e) => {
touchEndY.value = e.touches[0].clientY
}
const handleTouchEnd = () => {
const handleTouchEnd = (e) => {
// 如果 touchEnd 事件中还有 changedTouches,使用它来获取最终位置
if (e.changedTouches && e.changedTouches.length > 0) {
touchEndX.value = e.changedTouches[0].clientX
touchEndY.value = e.changedTouches[0].clientY
}
const deltaX = touchEndX.value - touchStartX.value
const deltaY = touchEndY.value - touchStartY.value
// 判断是否是水平滑动(水平距离大于垂直距离)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 50) {
// 最小滑动距离阈值(像素)
const MIN_SWIPE_DISTANCE = 50
// 判断是否是水平滑动(水平距离大于垂直距离且超过阈值)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > MIN_SWIPE_DISTANCE) {
if (deltaX > 0) {
// 右滑 - 上一个周期
handlePrevPeriod()
@@ -744,7 +765,7 @@ onMounted(() => {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
background-color: var(--bg-secondary);
background-color: var(--bg-primary);
/* 改善滚动性能 */
will-change: scroll-position;
/* 防止滚动卡顿 */
@@ -753,7 +774,7 @@ onMounted(() => {
.statistics-content {
padding: var(--spacing-md);
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(95px + env(safe-area-inset-bottom, 0px));
min-height: 100%;
/* 确保内容足够高以便滚动 */
display: flex;
@@ -781,7 +802,7 @@ onMounted(() => {
.statistics-content {
padding: var(--spacing-sm);
padding-bottom: calc(90px + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(95px + env(safe-area-inset-bottom, 0px));
}
}
</style>

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="daily-trend-card common-card">
<div class="card-header">
<h3 class="card-title">
@@ -333,7 +333,7 @@ onBeforeUnmount(() => {
@import '@/assets/theme.css';
.daily-trend-card {
background: var(--bg-primary);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);

View File

@@ -1,4 +1,4 @@
<template>
<template>
<!-- 支出分类统计 -->
<div
class="common-card"
@@ -274,7 +274,7 @@ onBeforeUnmount(() => {
// 通用卡片样式
.common-card {
background: var(--bg-primary);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="income-balance-card common-card">
<div class="stats-row">
<div class="stat-item">
@@ -50,7 +50,7 @@ const balanceClass = computed(() => ({
@import '@/assets/theme.css';
.income-balance-card {
background: var(--bg-primary);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);

View File

@@ -1,4 +1,4 @@
<template>
<template>
<!-- 收支和不计收支并列显示 -->
<div class="side-by-side-cards">
<!-- 收入分类统计 -->
@@ -155,7 +155,7 @@ const noneCategories = computed(() => {
// 通用卡片样式
.common-card {
background: var(--bg-primary);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);

View File

@@ -373,18 +373,16 @@ const updateChart = () => {
}
}
// 显示累计值和当日值
// 显示当日值
params.forEach((param) => {
const color = param.seriesName === '支出' ? '#ff6b6b' : '#4ade80'
const cumulativeValue = param.value
const dailyValue = param.seriesName === '支出' ? dailyExpense : dailyIncome
content += `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${color}"></span>`
content += `${param.seriesName}累计: ¥${cumulativeValue.toFixed(2)}`
if (dailyValue > 0) {
content += ` (当日: ¥${dailyValue.toFixed(2)})`
content += `<span style="display:inline-block;margin-right:5px;border-radius:50%;width:10px;height:10px;background-color:${color}"></span>`
content += `${param.seriesName}: ¥${dailyValue.toFixed(2)}`
content += '<br/>'
}
content += '<br/>'
})
} catch (error) {
console.warn('格式化tooltip失败:', error)
@@ -432,7 +430,7 @@ onBeforeUnmount(() => {
// 通用卡片样式
.common-card {
background: var(--bg-primary);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);