2026-02-03 17:56:32 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="page-container-flex calendar-v2-wrapper">
|
|
|
|
|
|
<!-- 头部固定 -->
|
|
|
|
|
|
<header class="calendar-header">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="month-nav-btn"
|
|
|
|
|
|
aria-label="上一月"
|
|
|
|
|
|
@click="changeMonth(-1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<van-icon name="arrow-left" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div class="header-content">
|
|
|
|
|
|
<h1 class="header-title">
|
|
|
|
|
|
{{ currentMonth }}
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="month-nav-btn"
|
|
|
|
|
|
aria-label="下一月"
|
|
|
|
|
|
@click="changeMonth(1)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<van-icon name="arrow" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="notif-btn"
|
|
|
|
|
|
aria-label="通知"
|
|
|
|
|
|
@click="onNotificationClick"
|
|
|
|
|
|
>
|
|
|
|
|
|
<van-icon name="bell" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 可滚动内容区域 -->
|
|
|
|
|
|
<div class="calendar-scroll-content">
|
|
|
|
|
|
<!-- 下拉刷新 -->
|
|
|
|
|
|
<van-pull-refresh
|
|
|
|
|
|
v-model="refreshing"
|
|
|
|
|
|
@refresh="onRefresh"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 日历模块 -->
|
|
|
|
|
|
<CalendarModule
|
|
|
|
|
|
:current-date="currentDate"
|
|
|
|
|
|
:selected-date="selectedDate"
|
|
|
|
|
|
:slide-direction="slideDirection"
|
|
|
|
|
|
:calendar-key="calendarKey"
|
|
|
|
|
|
@day-click="onDayClick"
|
|
|
|
|
|
@touch-start="onTouchStart"
|
|
|
|
|
|
@touch-move="onTouchMove"
|
|
|
|
|
|
@touch-end="onTouchEnd"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 统计模块 -->
|
|
|
|
|
|
<StatsModule
|
|
|
|
|
|
:selected-date="selectedDate"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 交易列表模块 -->
|
|
|
|
|
|
<TransactionListModule
|
|
|
|
|
|
:selected-date="selectedDate"
|
|
|
|
|
|
@transaction-click="onTransactionClick"
|
|
|
|
|
|
@smart-click="onSmartClick"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 底部安全距离 -->
|
|
|
|
|
|
<div class="bottom-spacer" />
|
|
|
|
|
|
</van-pull-refresh>
|
|
|
|
|
|
</div>
|
2026-02-04 15:36:42 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 交易详情弹窗 -->
|
|
|
|
|
|
<TransactionDetailSheet
|
|
|
|
|
|
v-model:show="showTransactionDetail"
|
|
|
|
|
|
:transaction="currentTransaction"
|
|
|
|
|
|
@save="handleTransactionSave"
|
|
|
|
|
|
@delete="handleTransactionDelete"
|
|
|
|
|
|
/>
|
2026-02-03 17:56:32 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
|
|
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
import { showToast } from 'vant'
|
|
|
|
|
|
import CalendarModule from './modules/Calendar.vue'
|
|
|
|
|
|
import StatsModule from './modules/Stats.vue'
|
|
|
|
|
|
import TransactionListModule from './modules/TransactionList.vue'
|
2026-02-04 15:36:42 +08:00
|
|
|
|
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
|
|
|
|
|
|
import { getTransactionDetail } from '@/api/transactionRecord'
|
2026-02-03 17:56:32 +08:00
|
|
|
|
|
|
|
|
|
|
// 定义组件名称(keep-alive 需要通过 name 识别)
|
|
|
|
|
|
defineOptions({
|
|
|
|
|
|
name: 'CalendarV2'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 路由
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
|
|
|
|
|
|
// 下拉刷新状态
|
|
|
|
|
|
const refreshing = ref(false)
|
|
|
|
|
|
|
|
|
|
|
|
// 当前日期
|
|
|
|
|
|
const currentDate = ref(new Date())
|
|
|
|
|
|
const selectedDate = ref(new Date())
|
|
|
|
|
|
|
|
|
|
|
|
// 动画方向和 key(用于触发过渡)
|
|
|
|
|
|
const slideDirection = ref('slide-left')
|
|
|
|
|
|
const calendarKey = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
// 当前月份格式化(中文)
|
|
|
|
|
|
const currentMonth = computed(() => {
|
|
|
|
|
|
const year = currentDate.value.getFullYear()
|
|
|
|
|
|
const month = currentDate.value.getMonth() + 1
|
|
|
|
|
|
return `${year}年${month}月`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 格式化日期为 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 = async (day) => {
|
|
|
|
|
|
const clickedDate = new Date(day.date)
|
|
|
|
|
|
|
|
|
|
|
|
// 如果点击的是其他月份的单元格,切换到对应的月份
|
|
|
|
|
|
if (!day.isCurrentMonth) {
|
|
|
|
|
|
// 设置动画方向:点击上月日期向右滑,点击下月日期向左滑
|
|
|
|
|
|
const clickedMonth = clickedDate.getMonth()
|
|
|
|
|
|
const currentMonth = currentDate.value.getMonth()
|
|
|
|
|
|
|
|
|
|
|
|
slideDirection.value = clickedMonth > currentMonth || (clickedMonth === 0 && currentMonth === 11)
|
|
|
|
|
|
? 'slide-left'
|
|
|
|
|
|
: 'slide-right'
|
|
|
|
|
|
|
|
|
|
|
|
// 更新 key 触发过渡
|
|
|
|
|
|
calendarKey.value += 1
|
|
|
|
|
|
|
|
|
|
|
|
// 切换到点击日期所在的月份
|
|
|
|
|
|
currentDate.value = new Date(clickedDate.getFullYear(), clickedDate.getMonth(), 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 选中点击的日期
|
|
|
|
|
|
selectedDate.value = clickedDate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 15:36:42 +08:00
|
|
|
|
// 交易详情弹窗相关
|
|
|
|
|
|
const showTransactionDetail = ref(false)
|
|
|
|
|
|
const currentTransaction = ref(null)
|
|
|
|
|
|
|
|
|
|
|
|
// 点击交易卡片 - 打开详情弹窗
|
|
|
|
|
|
const onTransactionClick = async (txn) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 获取完整的交易详情
|
|
|
|
|
|
const response = await getTransactionDetail(txn.id)
|
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
|
currentTransaction.value = response.data
|
|
|
|
|
|
showTransactionDetail.value = true
|
|
|
|
|
|
} else {
|
|
|
|
|
|
showToast('获取交易详情失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取交易详情失败:', error)
|
|
|
|
|
|
showToast('获取交易详情失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 保存交易后刷新列表
|
|
|
|
|
|
const handleTransactionSave = () => {
|
|
|
|
|
|
handleTransactionsChanged()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 删除交易后刷新列表
|
|
|
|
|
|
const handleTransactionDelete = () => {
|
|
|
|
|
|
handleTransactionsChanged()
|
2026-02-03 17:56:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 点击通知按钮
|
|
|
|
|
|
const onNotificationClick = () => {
|
|
|
|
|
|
router.push('/message')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 点击 Smart 按钮 - 跳转到智能分类页面
|
|
|
|
|
|
const onSmartClick = () => {
|
|
|
|
|
|
router.push({
|
|
|
|
|
|
path: '/smart-classification',
|
|
|
|
|
|
query: {
|
|
|
|
|
|
date: formatDateKey(selectedDate.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 切换月份
|
|
|
|
|
|
const changeMonth = async (offset) => {
|
|
|
|
|
|
const newDate = new Date(currentDate.value)
|
|
|
|
|
|
newDate.setMonth(newDate.getMonth() + offset)
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否是最后一个月(当前月)且尝试切换到下一个月
|
|
|
|
|
|
const today = new Date()
|
|
|
|
|
|
const currentYear = currentDate.value.getFullYear()
|
|
|
|
|
|
const currentMonthValue = currentDate.value.getMonth()
|
|
|
|
|
|
const todayYear = today.getFullYear()
|
|
|
|
|
|
const todayMonth = today.getMonth()
|
|
|
|
|
|
|
|
|
|
|
|
// 如果当前显示的是今天所在的月份,且尝试切换到下一个月,则阻止
|
|
|
|
|
|
if (offset > 0 && currentYear === todayYear && currentMonthValue === todayMonth) {
|
|
|
|
|
|
showToast('已经是最后一个月了')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 设置动画方向
|
|
|
|
|
|
slideDirection.value = offset > 0 ? 'slide-left' : 'slide-right'
|
|
|
|
|
|
|
|
|
|
|
|
// 更新 key 触发过渡
|
|
|
|
|
|
calendarKey.value += 1
|
|
|
|
|
|
|
|
|
|
|
|
currentDate.value = newDate
|
|
|
|
|
|
|
|
|
|
|
|
// 根据切换方向选择合适的日期
|
|
|
|
|
|
let newSelectedDate
|
|
|
|
|
|
if (offset > 0) {
|
|
|
|
|
|
// 切换到下个月,选中下个月的第一天
|
|
|
|
|
|
newSelectedDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 切换到上一月,选中上一月的最后一天
|
|
|
|
|
|
newSelectedDate = new Date(newDate.getFullYear(), newDate.getMonth() + 1, 0)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
selectedDate.value = newSelectedDate
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 触摸滑动相关
|
|
|
|
|
|
const touchStartX = ref(0)
|
|
|
|
|
|
const touchStartY = ref(0)
|
|
|
|
|
|
const touchEndX = ref(0)
|
|
|
|
|
|
const touchEndY = ref(0)
|
|
|
|
|
|
const isSwiping = ref(false)
|
|
|
|
|
|
const minSwipeDistance = 50 // 最小滑动距离(像素)
|
|
|
|
|
|
|
|
|
|
|
|
const onTouchStart = (e) => {
|
|
|
|
|
|
touchStartX.value = e.changedTouches[0].screenX
|
|
|
|
|
|
touchStartY.value = e.changedTouches[0].screenY
|
|
|
|
|
|
touchEndX.value = touchStartX.value
|
|
|
|
|
|
touchEndY.value = touchStartY.value
|
|
|
|
|
|
isSwiping.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const onTouchMove = (e) => {
|
|
|
|
|
|
touchEndX.value = e.changedTouches[0].screenX
|
|
|
|
|
|
touchEndY.value = e.changedTouches[0].screenY
|
|
|
|
|
|
|
|
|
|
|
|
const deltaX = Math.abs(touchEndX.value - touchStartX.value)
|
|
|
|
|
|
const deltaY = Math.abs(touchEndY.value - touchStartY.value)
|
|
|
|
|
|
|
|
|
|
|
|
// 如果水平滑动距离大于垂直滑动距离,判定为滑动操作
|
|
|
|
|
|
if (deltaX > deltaY && deltaX > 10) {
|
|
|
|
|
|
isSwiping.value = true
|
|
|
|
|
|
// 阻止页面滚动
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const onTouchEnd = async () => {
|
|
|
|
|
|
const distance = touchStartX.value - touchEndX.value
|
|
|
|
|
|
const absDistance = Math.abs(distance)
|
|
|
|
|
|
|
|
|
|
|
|
// 只有在滑动状态下且达到最小滑动距离时才切换月份
|
|
|
|
|
|
if (isSwiping.value && absDistance > minSwipeDistance) {
|
|
|
|
|
|
if (distance > 0) {
|
|
|
|
|
|
// 向左滑动 - 下一月
|
|
|
|
|
|
await changeMonth(1)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 向右滑动 - 上一月
|
|
|
|
|
|
await changeMonth(-1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 重置触摸位置
|
|
|
|
|
|
touchStartX.value = 0
|
|
|
|
|
|
touchStartY.value = 0
|
|
|
|
|
|
touchEndX.value = 0
|
|
|
|
|
|
touchEndY.value = 0
|
|
|
|
|
|
isSwiping.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理交易变更事件(来自全局添加账单)
|
|
|
|
|
|
const handleTransactionsChanged = () => {
|
|
|
|
|
|
// 触发子组件刷新:通过改变日期引用强制重新查询
|
|
|
|
|
|
const temp = selectedDate.value
|
|
|
|
|
|
selectedDate.value = new Date(temp)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 下拉刷新
|
|
|
|
|
|
const onRefresh = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 触发子组件刷新
|
|
|
|
|
|
handleTransactionsChanged()
|
|
|
|
|
|
showToast({
|
|
|
|
|
|
message: '刷新成功',
|
|
|
|
|
|
duration: 1500
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch (_error) {
|
|
|
|
|
|
showToast('刷新失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
refreshing.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 组件挂载
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
// 监听交易变更事件(来自全局添加账单)
|
|
|
|
|
|
window.addEventListener('transactions-changed', handleTransactionsChanged)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 页面激活时的钩子(从缓存恢复时触发)
|
|
|
|
|
|
onActivated(() => {
|
|
|
|
|
|
// 依赖全局事件 'transactions-changed' 来刷新数据
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 页面失活时的钩子(被缓存时触发)
|
|
|
|
|
|
onDeactivated(() => {
|
|
|
|
|
|
// 目前 CalendarV2 没有需要清理的资源
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 组件卸载前清理
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.removeEventListener('transactions-changed', handleTransactionsChanged)
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
@import '@/assets/theme.css';
|
|
|
|
|
|
|
|
|
|
|
|
/* ========== 页面容器 ========== */
|
|
|
|
|
|
.calendar-v2-wrapper {
|
2026-02-04 15:36:42 +08:00
|
|
|
|
background-color: var(--bg-primary);
|
2026-02-03 17:56:32 +08:00
|
|
|
|
font-family: var(--font-primary);
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.calendar-scroll-content {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
|
-webkit-overflow-scrolling: touch;
|
|
|
|
|
|
overscroll-behavior: contain;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ========== 头部 ========== */
|
|
|
|
|
|
.calendar-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
padding: 8px 24px;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
background: transparent !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-title {
|
|
|
|
|
|
font-family: var(--font-primary);
|
|
|
|
|
|
font-size: var(--font-2xl);
|
|
|
|
|
|
font-weight: var(--font-medium);
|
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notif-btn {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 44px;
|
|
|
|
|
|
height: 44px;
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
background-color: var(--bg-button);
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: opacity 0.2s;
|
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notif-btn:active {
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.month-nav-btn {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.month-nav-btn:active {
|
|
|
|
|
|
background-color: var(--bg-tertiary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 底部安全距离 */
|
|
|
|
|
|
.bottom-spacer {
|
|
|
|
|
|
height: calc(60px + env(safe-area-inset-bottom, 0px));
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|