fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 3s

This commit is contained in:
SunCheng
2026-02-02 19:42:35 +08:00
parent 534a726648
commit 488667bf9c

View File

@@ -1,6 +1,6 @@
<template>
<div class="calendar-v2">
<!-- 头部 -->
<div class="page-container-flex calendar-v2-wrapper">
<!-- 头部固定 -->
<header class="calendar-header">
<button
class="month-nav-btn"
@@ -30,160 +30,195 @@
</button>
</header>
<!-- 日历容器 -->
<div class="calendar-container">
<!-- 星期标题 -->
<div class="week-days">
<span
v-for="day in weekDays"
:key="day"
class="week-day"
>{{ day }}</span>
<!-- 可滚动内容区域 -->
<div class="calendar-scroll-content">
<!-- 日历容器 -->
<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>
<!-- 日历网格 -->
<div 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 class="daily-stats">
<div class="stats-header">
<h2 class="stats-title">
{{ selectedDateFormatted }}
</h2>
</div>
<div class="stats-card">
<div class="stats-dual-row">
<div class="stats-item">
<span class="stats-label">
{{ isToday ? '今日支出' : '当日支出' }}
</span>
<div class="stats-value">
¥{{ selectedDayExpense.toFixed(2) }}
</div>
</div>
<div
v-if="day.amount"
class="day-amount"
:class="{ 'amount-over': day.isOverLimit }"
>
{{ day.amount }}
<div class="stats-divider" />
<div class="stats-item stats-income-item">
<span class="stats-label stats-income-label">
{{ isToday ? '今日收入' : '当日收入' }}
</span>
<div class="stats-value stats-income-value">
¥{{ selectedDayIncome.toFixed(2) }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 每日统计 -->
<div class="daily-stats">
<div class="stats-header">
<h2 class="stats-title">
每日统计
</h2>
<span class="stats-date">{{ selectedDateFormatted }}</span>
</div>
<div class="stats-card">
<div class="stats-row">
<span class="stats-label">
{{ isToday ? '今日支出' : '当日支出' }}
</span>
<div class="stats-badge">
{{ isToday ? '今日预算' : '当日预算' }}
: ¥{{ dailyBudget }}
<!-- 交易列表 -->
<div class="transactions">
<div class="txn-header">
<h2 class="txn-title">
交易记录
</h2>
<div class="txn-actions">
<div class="txn-badge badge-success">
{{ transactionCount }} Items
</div>
<button
class="smart-btn"
@click="onSmartClick"
>
<van-icon name="fire" />
<span>Smart</span>
</button>
</div>
</div>
<div class="stats-value">
¥ {{ selectedDayExpense.toFixed(2) }}
</div>
</div>
</div>
<!-- 交易列表 -->
<div class="transactions">
<div class="txn-header">
<h2 class="txn-title">
交易记录
</h2>
<div class="txn-actions">
<div class="txn-badge badge-success">
{{ transactionCount }} Items
</div>
<button
class="smart-btn"
@click="onSmartClick"
>
<van-icon name="fire" />
<span>Smart</span>
</button>
</div>
</div>
<!-- 交易卡片 -->
<van-loading
v-if="transactionsLoading"
class="txn-loading"
size="24px"
vertical
>
加载中...
</van-loading>
<div
v-else-if="transactions.length === 0"
class="txn-empty"
>
<div class="empty-icon">
<van-icon
name="balance-list-o"
size="48"
/>
</div>
<div class="empty-text">
当天暂无交易记录
</div>
<div class="empty-hint">
轻松享受无消费的一天
</div>
</div>
<div
v-else
class="txn-list"
>
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
<!-- 交易卡片 -->
<van-loading
v-if="transactionsLoading"
class="txn-loading"
size="24px"
vertical
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
加载中...
</van-loading>
<div
v-else-if="transactions.length === 0"
class="txn-empty"
>
<div class="empty-icon">
<van-icon
:name="txn.icon"
:color="txn.iconColor"
name="balance-list-o"
size="48"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.name }}
</div>
<div class="txn-time">
{{ txn.time }}
</div>
<div class="empty-text">
当天暂无交易记录
</div>
<div class="txn-amount">
{{ txn.amount }}
<div class="empty-hint">
轻松享受无消费的一天
</div>
</div>
<div
v-else
class="txn-list"
>
<div
v-for="txn in transactions"
:key="txn.id"
class="txn-card"
@click="onTransactionClick(txn)"
>
<div
class="txn-icon"
:style="{ backgroundColor: txn.iconBg }"
>
<van-icon
:name="txn.icon"
:color="txn.iconColor"
/>
</div>
<div class="txn-content">
<div class="txn-name">
{{ txn.name }}
</div>
<div class="txn-footer">
<div class="txn-time">
{{ txn.time }}
</div>
<span
v-if="txn.classify"
class="txn-classify-tag"
:class="txn.type === 1 ? 'tag-income' : 'tag-expense'"
>
{{ txn.classify }}
</span>
</div>
</div>
<div class="txn-amount">
{{ txn.amount }}
</div>
</div>
</div>
</div>
</div>
<!-- 底部安全距离 -->
<div class="bottom-spacer" />
<!-- 底部安全距离 -->
<div class="bottom-spacer" />
</div>
</div>
</template>
@@ -214,6 +249,10 @@ const dailyStatsMap = ref({}) // 每日统计数据 Map: { '2026-01-15': { count
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()
@@ -305,6 +344,8 @@ const createDayObject = (date, isCurrentMonth) => {
// 计算净支出(支出 - 收入)
const netAmount = (dayStats.expense || 0) - (dayStats.income || 0)
const hasData = dayStats.count > 0
// 收入大于支出为盈利(绿色),否则为支出(红色)
const isProfitable = hasData && netAmount < 0
return {
date: date.getTime(),
@@ -314,7 +355,8 @@ const createDayObject = (date, isCurrentMonth) => {
isSelected,
hasData,
amount: hasData ? Math.abs(netAmount).toFixed(0) : '',
isOverLimit: netAmount > (dailyBudget.value || 0) // 超过每日预算标红
isOverLimit: netAmount > (dailyBudget.value || 0), // 超过每日预算标红
isProfitable // 是否盈利(收入>支出)
}
}
@@ -354,6 +396,7 @@ const fetchDailyStats = async (year, month) => {
// 统计数据
const dailyBudget = ref(0) // 每日预算限额
const selectedDayExpense = ref(0) // 选中日期的支出
const selectedDayIncome = ref(0) // 选中日期的收入
const transactionCount = computed(() => transactions.value.length)
// 交易列表数据
@@ -395,10 +438,14 @@ const fetchDayTransactions = async (date) => {
type: txn.type
}))
// 计算当日支出
// 计算当日支出和收入
selectedDayExpense.value = response.data
.filter(t => t.type === 0) // 只统计支出
.reduce((sum, t) => sum + t.amount, 0)
selectedDayIncome.value = response.data
.filter(t => t.type === 1) // 只统计收入
.reduce((sum, t) => sum + t.amount, 0)
}
} catch (_error) {
showToast('获取交易记录失败')
@@ -478,6 +525,12 @@ const onSmartClick = () => {
// 切换月份
const changeMonth = async (offset) => {
// 设置动画方向
slideDirection.value = offset > 0 ? 'slide-left' : 'slide-right'
// 更新 key 触发过渡
calendarKey.value += 1
const newDate = new Date(currentDate.value)
newDate.setMonth(newDate.getMonth() + offset)
currentDate.value = newDate
@@ -486,6 +539,60 @@ const changeMonth = async (offset) => {
await fetchDailyStats(newDate.getFullYear(), newDate.getMonth() + 1)
}
// 触摸滑动相关
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
}
// 监听当前月份变化
watch(() => currentDate.value, async (newDate) => {
await fetchDailyStats(newDate.getFullYear(), newDate.getMonth() + 1)
@@ -549,23 +656,27 @@ onBeforeUnmount(() => {
<style scoped>
@import '@/assets/theme.css';
.calendar-v2 {
min-height: 100vh;
/* ========== 页面容器 ========== */
.calendar-v2-wrapper {
background-color: var(--bg-primary);
font-family: var(--font-primary);
color: var(--text-primary);
/* 确保背景延伸到顶部 safe area */
margin-top: calc(-1 * max(0px, calc(env(safe-area-inset-top, 0px) * 0.75)));
padding-top: max(0px, calc(env(safe-area-inset-top, 0px) * 0.75));
}
.calendar-scroll-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ========== 头部 ========== */
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
padding: 8px 24px;
gap: 4px;
gap: 8px;
}
.header-content {
@@ -593,6 +704,7 @@ onBeforeUnmount(() => {
border: none;
cursor: pointer;
transition: opacity 0.2s;
margin-left: auto;
}
.notif-btn:active {
@@ -628,6 +740,65 @@ onBeforeUnmount(() => {
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 {
@@ -643,10 +814,17 @@ onBeforeUnmount(() => {
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 {
@@ -703,13 +881,17 @@ onBeforeUnmount(() => {
color: var(--accent-danger);
}
.day-amount.amount-profit {
color: var(--accent-success);
}
/* ========== 统计卡片 ========== */
.daily-stats {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
padding: var(--spacing-3xl);
padding-top: 0
padding-top: 8px
}
.stats-header {
@@ -726,12 +908,6 @@ onBeforeUnmount(() => {
margin: 0;
}
.stats-date {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.stats-card {
display: flex;
flex-direction: column;
@@ -747,8 +923,31 @@ onBeforeUnmount(() => {
justify-content: space-between;
}
.stats-header-row {
justify-content: flex-end;
}
.stats-dual-row {
display: flex;
align-items: stretch;
gap: var(--spacing-xl);
}
.stats-item {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.stats-divider {
width: 1px;
background-color: var(--bg-tertiary);
align-self: stretch;
}
.stats-label {
font-size: var(--font-md);
font-size: var(--font-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
@@ -764,11 +963,19 @@ onBeforeUnmount(() => {
.stats-value {
font-family: var(--font-display);
font-size: var(--font-3xl);
font-size: var(--font-2xl);
font-weight: var(--font-extrabold);
color: var(--text-primary);
}
.stats-income-label {
color: var(--accent-success);
}
.stats-income-value {
color: var(--accent-success);
}
/* ========== 交易列表 ========== */
.transactions {
display: flex;
@@ -867,12 +1074,22 @@ onBeforeUnmount(() => {
flex-direction: column;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
}
.txn-name {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-footer {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.txn-time {
@@ -881,10 +1098,30 @@ onBeforeUnmount(() => {
color: var(--text-tertiary);
}
.txn-classify-tag {
padding: 2px 8px;
font-size: var(--font-xs);
font-weight: var(--font-medium);
border-radius: var(--radius-full);
flex-shrink: 0;
}
.txn-classify-tag.tag-income {
background-color: rgba(34, 197, 94, 0.15);
color: var(--accent-success);
}
.txn-classify-tag.tag-expense {
background-color: rgba(59, 130, 246, 0.15);
color: #3B82F6;
}
.txn-amount {
font-size: var(--font-lg);
font-weight: var(--font-bold);
color: var(--text-primary);
flex-shrink: 0;
margin-left: var(--spacing-md);
}
/* ========== 空状态 ========== */