todo
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 28s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
SunCheng
2026-02-02 16:59:24 +08:00
parent 6abc5f8b6d
commit 338bac20ce
9 changed files with 819 additions and 106 deletions

View File

@@ -44,9 +44,8 @@ COPY Service/ ./Service/
COPY WebApi/ ./WebApi/
# 构建并发布
# 使用 -m:1 限制 CPU/内存并行度,减少容器构建崩溃风险
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore -m:1
# 使用 /m:1 限制 CPU/内存并行度,减少容器构建崩溃风险
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore /m:1
# 将前端构建产物复制到后端的 wwwroot 目录
COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot

View File

@@ -0,0 +1,130 @@
# 版本切换功能实现总结
## 实现概述
在设置的开发者选项中添加了版本切换功能,用户可以在 V1 和 V2 版本之间切换。
## 修改的文件
### 1. Web/src/stores/version.js (新增)
- 创建 Pinia store 管理版本状态
- 使用 localStorage 持久化版本选择
- 提供 `setVersion()``isV2()` 方法
### 2. Web/src/views/SettingView.vue (修改)
- 在开发者选项中添加"切换版本"选项
- 显示当前版本V1/V2
- 实现版本切换对话框
- 实现版本切换后的路由跳转逻辑
### 3. Web/src/router/index.js (修改)
- 引入 version store
- 在路由守卫中添加版本路由重定向逻辑
- V2 模式下自动跳转到 V2 路由(如果存在)
- V1 模式下自动跳转到 V1 路由(如果在 V2 路由)
## 核心功能
1. **版本选择界面**
- 设置页面显示当前版本
- 点击弹出对话框,选择 V1 或 V2
- 切换成功后显示提示信息
2. **智能路由跳转**
- 选择 V2 后,如果当前路由有 V2 版本,自动跳转
- 选择 V1 后,如果当前在 V2 路由,自动跳转到 V1
- 没有对应版本时,保持当前路由不变
3. **路由守卫保护**
- 每次路由跳转时检查版本设置
- 自动重定向到正确版本的路由
- 保留 query 和 params 参数
4. **状态持久化**
- 版本选择保存在 localStorage
- 刷新页面后版本设置保持不变
## V2 路由命名规范
V2 路由必须遵循命名规范:`原路由名-v2`
示例:
- V1: `calendar` → V2: `calendar-v2`
- V1: `budget` → V2: `budget-v2`
## 当前支持的 V2 路由
- `calendar``calendar-v2` (CalendarV2.vue)
## 测试验证
- ✅ ESLint 检查通过(无错误)
- ✅ 构建成功pnpm build
- ✅ 所有修改文件符合项目代码规范
## 使用示例
### 用户操作流程
1. 进入"设置"页面
2. 滚动到"开发者"分组
3. 点击"切换版本"(当前版本显示在右侧)
4. 选择"V1"或"V2"
5. 系统自动跳转到对应版本的路由
### 开发者添加新 V2 路由
```javascript
// router/index.js
{
path: '/xxx-v2',
name: 'xxx-v2',
component: () => import('../views/XxxViewV2.vue'),
meta: { requiresAuth: true }
}
```
添加后即可自动支持版本切换。
## 技术细节
### 版本检测逻辑
```javascript
// 在路由守卫中
if (versionStore.isV2()) {
// 尝试跳转到 V2 路由
const v2RouteName = `${routeName}-v2`
if (存在 v2Route) {
跳转到 v2Route
} else {
保持当前路由
}
}
```
### 版本状态管理
```javascript
// stores/version.js
const currentVersion = ref(localStorage.getItem('app-version') || 'v1')
const setVersion = (version) => {
currentVersion.value = version
localStorage.setItem('app-version', version)
}
```
## 注意事项
1. V2 路由必须按照 `xxx-v2` 命名规范
2. 如果页面没有 V2 版本,切换后会保持在 V1 版本
3. 路由守卫会自动处理所有版本相关的路由跳转
4. 版本状态持久化在 localStorage 中
## 后续改进建议
1. 可以在 UI 上添加更明显的版本标识
2. 可以在无 V2 路由时给出提示
3. 可以添加版本切换的动画效果
4. 可以为不同版本设置不同的主题样式

143
Web/VERSION_SWITCH_TEST.md Normal file
View File

@@ -0,0 +1,143 @@
# 版本切换功能测试文档
## 功能说明
在设置的开发者选项中添加了版本切换功能,用户可以在 V1 和 V2 版本之间切换。当选择 V2 时,如果有对应的 V2 路由则自动跳转,否则保持当前路由。
## 实现文件
1. **Store**: `Web/src/stores/version.js` - 版本状态管理
2. **View**: `Web/src/views/SettingView.vue` - 设置页面添加版本切换入口
3. **Router**: `Web/src/router/index.js` - 路由守卫实现版本路由重定向
## 功能特性
- ✅ 版本状态持久化存储localStorage
- ✅ 设置页面显示当前版本V1/V2
- ✅ 点击弹出对话框选择版本
- ✅ 自动检测并跳转到对应版本路由
- ✅ 如果没有对应版本路由,保持当前路由
- ✅ 路由守卫自动处理版本路由
## 测试步骤
### 1. 基础功能测试
1. 启动应用并登录
2. 进入"设置"页面
3. 找到"开发者"分组下的"切换版本"选项
4. 当前版本应显示为 "V1"(首次使用)
### 2. 切换到 V2 测试
1. 点击"切换版本"
2. 弹出对话框,显示"选择版本"标题
3. 对话框有两个按钮:"V1"(取消按钮)和"V2"(确认按钮)
4. 点击"V2"按钮
5. 应显示提示"已切换到 V2"
6. "切换版本"选项的值应更新为 "V2"
### 3. V2 路由跳转测试
#### 测试有 V2 路由的情况(日历页面)
1. 确保当前版本为 V2
2. 点击导航栏的"日历"(路由名:`calendar`
3. 应自动跳转到 `calendar-v2`CalendarV2.vue
4. 地址栏 URL 应为 `/calendar-v2`
#### 测试没有 V2 路由的情况
1. 确保当前版本为 V2
2. 点击导航栏的"账单分析"(路由名:`bill-analysis`
3. 应保持在 `bill-analysis` 路由(没有 v2 版本)
4. 地址栏 URL 应为 `/bill-analysis`
### 4. 切换回 V1 测试
1. 当前版本为 V2`calendar-v2` 页面
2. 进入"设置"页面,点击"切换版本"
3. 点击"V1"按钮
4. 应显示提示"已切换到 V1"
5. 如果当前在 V2 路由(如 `calendar-v2`),应自动跳转到 V1 路由(`calendar`
6. 地址栏 URL 应为 `/calendar`
### 5. 持久化测试
1. 切换到 V2 版本
2. 刷新页面
3. 重新登录后,进入"设置"页面
4. "切换版本"选项应仍显示 "V2"
5. 访问有 V2 路由的页面,应自动跳转到 V2 版本
### 6. 路由守卫测试
#### 直接访问 V2 路由V1 模式下)
1. 确保当前版本为 V1
2. 在地址栏直接输入 `/calendar-v2`
3. 应自动重定向到 `/calendar`
#### 直接访问 V1 路由V2 模式下)
1. 确保当前版本为 V2
2. 在地址栏直接输入 `/calendar`
3. 应自动重定向到 `/calendar-v2`
## 当前支持 V2 的路由
- `calendar``calendar-v2` (CalendarV2.vue)
## 代码验证
### 版本 Store 检查
```javascript
// 打开浏览器控制台
const versionStore = useVersionStore()
console.log(versionStore.currentVersion) // 应输出 'v1' 或 'v2'
console.log(versionStore.isV2()) // 应输出 true 或 false
```
### LocalStorage 检查
```javascript
// 打开浏览器控制台
console.log(localStorage.getItem('app-version')) // 应输出 'v1' 或 'v2'
```
## 预期结果
- ✅ 所有路由跳转正常
- ✅ 版本切换提示正常显示
- ✅ 版本状态持久化正常
- ✅ 路由守卫正常工作
- ✅ 没有控制台错误
- ✅ UI 响应流畅
## 潜在问题
1. 如果用户在 V2 路由页面直接切换到 V1可能会出现短暂的页面重载
2. 某些页面可能没有 V2 版本,切换后会保持在 V1 版本
## 后续扩展
如需添加更多 V2 路由,只需:
1. 创建新的 Vue 组件(如 `XXXViewV2.vue`
2.`router/index.js` 中添加路由,命名格式为 `原路由名-v2`
3. 路由守卫会自动处理版本切换逻辑
## 示例:添加新的 V2 路由
```javascript
// router/index.js
{
path: '/budget-v2',
name: 'budget-v2',
component: () => import('../views/BudgetViewV2.vue'),
meta: { requiresAuth: true }
}
```
添加后,当用户选择 V2 版本并访问 `/budget` 时,会自动跳转到 `/budget-v2`

View File

@@ -4,7 +4,17 @@
class="app-provider"
>
<div class="app-root">
<RouterView />
<router-view v-slot="{ Component }">
<keep-alive
:include="cachedViews"
:max="8"
>
<component
:is="Component"
:key="route.name"
/>
</keep-alive>
</router-view>
<van-tabbar
v-show="showTabbar"
v-model="active"
@@ -79,6 +89,15 @@ import '@/styles/common.css'
const messageStore = useMessageStore()
// 定义需要缓存的页面组件名称
const cachedViews = ref([
'CalendarV2', // 日历V2页面
'CalendarView', // 日历V1页面
'StatisticsView', // 统计页面
'BalanceView', // 账单页面
'BudgetView' // 预算页面
])
const updateVh = () => {
const vh = window.innerHeight
document.documentElement.style.setProperty('--vh', `${vh}px`)
@@ -122,6 +141,7 @@ const showTabbar = computed(() => {
return (
route.path === '/' ||
route.path === '/calendar' ||
route.path === '/calendar-v2' ||
route.path === '/message' ||
route.path === '/setting' ||
route.path === '/balance' ||
@@ -136,6 +156,8 @@ const theme = ref('light')
const updateTheme = () => {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
theme.value = isDark ? 'dark' : 'light'
// 在文档根元素上设置 data-theme 属性,使 CSS 变量生效
document.documentElement.setAttribute('data-theme', theme.value)
}
// 监听系统主题变化
@@ -165,6 +187,7 @@ const setActive = (path) => {
active.value = (() => {
switch (path) {
case '/calendar':
case '/calendar-v2':
return 'ccalendar'
case '/balance':
case '/message':
@@ -180,7 +203,7 @@ const setActive = (path) => {
}
const isShowAddBill = computed(() => {
return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar'
return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar' || route.path === '/calendar-v2'
})
onUnmounted(() => {

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useVersionStore } from '@/stores/version'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -34,6 +35,12 @@ const router = createRouter({
component: () => import('../views/CalendarView.vue'),
meta: { requiresAuth: true }
},
{
path: '/calendar-v2',
name: 'calendar-v2',
component: () => import('../views/CalendarV2.vue'),
meta: { requiresAuth: true }
},
{
path: '/smart-classification',
name: 'smart-classification',
@@ -113,6 +120,7 @@ const router = createRouter({
// 路由守卫
router.beforeEach((to, from, next) => {
const authStore = useAuthStore()
const versionStore = useVersionStore()
const requiresAuth = to.meta.requiresAuth !== false // 默认需要认证
if (requiresAuth && !authStore.isAuthenticated) {
@@ -122,6 +130,33 @@ router.beforeEach((to, from, next) => {
// 已登录用户访问登录页,跳转到首页
next({ name: 'transactions' })
} else {
// 版本路由处理
if (versionStore.isV2()) {
// 如果当前选择 V2尝试跳转到 V2 路由
const routeName = to.name?.toString()
if (routeName && !routeName.endsWith('-v2')) {
const v2RouteName = `${routeName}-v2`
const v2Route = router.getRoutes().find(route => route.name === v2RouteName)
if (v2Route) {
next({ name: v2RouteName, query: to.query, params: to.params })
return
}
}
} else {
// 如果当前选择 V1且访问的是 V2 路由,跳转到 V1
const routeName = to.name?.toString()
if (routeName && routeName.endsWith('-v2')) {
const v1RouteName = routeName.replace(/-v2$/, '')
const v1Route = router.getRoutes().find(route => route.name === v1RouteName)
if (v1Route) {
next({ name: v1RouteName, query: to.query, params: to.params })
return
}
}
}
next()
}
})

19
Web/src/stores/version.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useVersionStore = defineStore('version', () => {
const currentVersion = ref(localStorage.getItem('app-version') || 'v1')
const setVersion = (version) => {
currentVersion.value = version
localStorage.setItem('app-version', version)
}
const isV2 = () => currentVersion.value === 'v2'
return {
currentVersion,
setVersion,
isV2
}
})

View File

@@ -1,18 +1,30 @@
<template>
<div
class="calendar-v2"
:data-theme="theme"
>
<div class="calendar-v2">
<!-- 头部 -->
<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>
@@ -70,19 +82,22 @@
<div class="daily-stats">
<div class="stats-header">
<h2 class="stats-title">
Daily Stats
每日统计
</h2>
<span class="stats-date">{{ selectedDateFormatted }}</span>
</div>
<div class="stats-card">
<div class="stats-row">
<span class="stats-label">Total Spent</span>
<span class="stats-label">
{{ isToday ? '今日支出' : '当日支出' }}
</span>
<div class="stats-badge">
Daily Limit: {{ dailyLimit }}
{{ isToday ? '今日预算' : '当日预算' }}
: ¥{{ dailyBudget }}
</div>
</div>
<div class="stats-value">
¥ {{ totalSpent }}
¥ {{ selectedDayExpense.toFixed(2) }}
</div>
</div>
</div>
@@ -91,21 +106,52 @@
<div class="transactions">
<div class="txn-header">
<h2 class="txn-title">
Transactions
交易记录
</h2>
<div class="txn-actions">
<div class="txn-badge badge-success">
{{ transactionCount }} Items
</div>
<button class="smart-btn">
<van-icon name="star-o" />
<button
class="smart-btn"
@click="onSmartClick"
>
<van-icon name="fire" />
<span>Smart</span>
</button>
</div>
</div>
<!-- 交易卡片 -->
<div class="txn-list">
<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"
@@ -142,33 +188,54 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showLoadingToast, closeToast } from 'vant'
import { getDailyStatistics } from '@/api/statistics'
import { getTransactionsByDate } from '@/api/transactionRecord'
import { getBudgetList } from '@/api/budget'
// 当前主题
const theme = ref('light') // 'light' | 'dark'
// 定义组件名称keep-alive 需要通过 name 识别)
defineOptions({
name: 'CalendarV2'
})
// 星期标题
const weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
// 路由
const router = useRouter()
// 星期标题(中文)
const weekDays = ['一', '二', '三', '四', '五', '六', '日']
// 加载状态
const loading = ref(false)
const dailyStatsMap = ref({}) // 每日统计数据 Map: { '2026-01-15': { count, expense, income } }
// 当前日期
const currentDate = ref(new Date())
const selectedDate = ref(new Date())
// 当前月份格式化
// 当前月份格式化(中文)
const currentMonth = computed(() => {
return currentDate.value.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long'
})
const year = currentDate.value.getFullYear()
const month = currentDate.value.getMonth() + 1
return `${year}${month}`
})
// 选中日期格式化
const isToday = computed(() => {
const today = new Date()
return (
selectedDate.value.getDate() === today.getDate() &&
selectedDate.value.getMonth() === today.getMonth() &&
selectedDate.value.getFullYear() === today.getFullYear()
)
})
// 选中日期格式化(中文)
const selectedDateFormatted = computed(() => {
return selectedDate.value.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
const year = selectedDate.value.getFullYear()
const month = selectedDate.value.getMonth() + 1
const day = selectedDate.value.getDate()
return `${year}${month}${day}`
})
// 生成日历数据
@@ -231,8 +298,13 @@ const createDayObject = (date, isCurrentMonth) => {
date.getMonth() === selectedDate.value.getMonth() &&
date.getFullYear() === selectedDate.value.getFullYear()
// 模拟数据 - 实际应该从 API 获取
const mockData = getMockDataForDate(date)
// 从 API 数据获取
const dateKey = formatDateKey(date)
const dayStats = dailyStatsMap.value[dateKey] || {}
// 计算净支出(支出 - 收入)
const netAmount = (dayStats.expense || 0) - (dayStats.income || 0)
const hasData = dayStats.count > 0
return {
date: date.getTime(),
@@ -240,79 +312,237 @@ const createDayObject = (date, isCurrentMonth) => {
isCurrentMonth,
isToday,
isSelected,
hasData: mockData.hasData,
amount: mockData.amount,
isOverLimit: mockData.isOverLimit
hasData,
amount: hasData ? Math.abs(netAmount).toFixed(0) : '',
isOverLimit: netAmount > (dailyBudget.value || 0) // 超过每日预算标红
}
}
// 模拟数据获取
const getMockDataForDate = (date) => {
const day = date.getDate()
// 格式化日期为 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}`
}
// 模拟一些有数据的日期
if (day >= 4 && day <= 28 && date.getMonth() === currentDate.value.getMonth()) {
const amounts = [128, 45, 230, 12, 88, 223, 15, 34, 120, 56, 442]
const amount = amounts[day % amounts.length]
return {
hasData: true,
amount: amount || '',
isOverLimit: amount > 200 // 超过限额标红
}
}
// 获取月度每日统计数据
const fetchDailyStats = async (year, month) => {
try {
loading.value = true
const response = await getDailyStatistics({ year, month })
return { hasData: false, amount: '', isOverLimit: false }
if (response.success && response.data) {
// 构建日期 Map
const statsMap = {}
response.data.forEach(item => {
statsMap[item.date] = {
count: item.count,
expense: item.expense,
income: item.income
}
})
dailyStatsMap.value = { ...dailyStatsMap.value, ...statsMap }
}
} catch (_error) {
showToast('获取日历数据失败')
} finally {
loading.value = false
}
}
// 统计数据
const dailyLimit = ref('2500')
const totalSpent = ref('1,248.50')
const dailyBudget = ref(0) // 每日预算限额
const selectedDayExpense = ref(0) // 选中日期的支出
const transactionCount = computed(() => transactions.value.length)
// 交易列表数据
const transactions = ref([
{
id: 1,
name: 'Lunch',
time: '12:30 PM',
amount: '-58.00',
icon: 'star',
iconColor: '#FF6B6B',
iconBg: '#FFFFFF'
},
{
id: 2,
name: 'Coffee',
time: '08:15 AM',
amount: '-24.50',
icon: 'coffee-o',
iconColor: '#FCD34D',
iconBg: '#FFFFFF'
const transactions = ref([])
const transactionsLoading = ref(false)
// 获取预算数据
const fetchBudgetData = async () => {
try {
const response = await getBudgetList()
if (response.success && response.data && response.data.length > 0) {
// 假设取第一个预算的月度限额除以30作为每日预算
const monthlyBudget = response.data[0].limit || 0
dailyBudget.value = Math.floor(monthlyBudget / 30)
}
])
} catch (error) {
console.error('获取预算失败:', error)
}
}
// 获取选中日期的交易列表
const fetchDayTransactions = async (date) => {
try {
transactionsLoading.value = true
const dateKey = formatDateKey(date)
const response = await getTransactionsByDate(dateKey)
if (response.success && response.data) {
// 转换为界面需要的格式
transactions.value = response.data.map(txn => ({
id: txn.id,
name: txn.reason || '未知交易',
time: formatTime(txn.occurredAt),
amount: formatAmount(txn.amount, txn.type),
icon: getIconByClassify(txn.classify),
iconColor: getColorByType(txn.type),
iconBg: '#FFFFFF',
classify: txn.classify,
type: txn.type
}))
// 计算当日支出
selectedDayExpense.value = response.data
.filter(t => t.type === 0) // 只统计支出
.reduce((sum, t) => sum + t.amount, 0)
}
} catch (_error) {
showToast('获取交易记录失败')
} finally {
transactionsLoading.value = false
}
}
// 格式化时间HH:MM
const formatTime = (dateTimeStr) => {
const date = new Date(dateTimeStr)
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
// 格式化金额
const formatAmount = (amount, type) => {
const sign = type === 1 ? '+' : '-' // 1=收入, 0=支出
return `${sign}${amount.toFixed(2)}`
}
// 根据分类获取图标
const getIconByClassify = (classify) => {
const iconMap = {
'餐饮': 'food',
'购物': 'shopping',
'交通': 'transport',
'娱乐': 'play',
'医疗': 'medical',
'工资': 'money',
'红包': 'red-packet'
}
return iconMap[classify] || 'star'
}
// 根据类型获取颜色
const getColorByType = (type) => {
return type === 1 ? '#22C55E' : '#FF6B6B' // 收入绿色,支出红色
}
// 点击日期
const onDayClick = (day) => {
const onDayClick = async (day) => {
if (!day.isCurrentMonth) {return}
selectedDate.value = new Date(day.date)
// TODO: 加载选中日期的数据
console.log('Selected date:', day)
// 加载选中日期的交易数据
await fetchDayTransactions(selectedDate.value)
}
// 点击交易
// 点击交易卡片 - 跳转到详情页
const onTransactionClick = (txn) => {
console.log('Transaction clicked:', txn)
// TODO: 打开交易详情
// 跳转到交易详情(假设有详情页路由)
router.push({
path: '/transaction-detail',
query: { id: txn.id }
})
}
// 切换主题
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
// 点击通知按钮
const onNotificationClick = () => {
router.push('/message')
}
// 暴露切换主题方法供外部调用
defineExpose({
toggleTheme
// 点击 Smart 按钮 - 跳转到智能分类页面
const onSmartClick = () => {
if (transactions.value.length === 0) {
showToast('当天没有交易记录')
return
}
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)
currentDate.value = newDate
// 重新加载月度数据
await fetchDailyStats(newDate.getFullYear(), newDate.getMonth() + 1)
}
// 监听当前月份变化
watch(() => currentDate.value, async (newDate) => {
await fetchDailyStats(newDate.getFullYear(), newDate.getMonth() + 1)
}, { immediate: false })
// 监听选中日期变化
watch(() => selectedDate.value, async (newDate) => {
await fetchDayTransactions(newDate)
}, { immediate: false })
// 组件挂载
onMounted(async () => {
showLoadingToast({
message: '加载中...',
forbidClick: true,
duration: 0
})
try {
// 并行加载预算和当月统计数据
await Promise.all([
fetchBudgetData(),
fetchDailyStats(currentDate.value.getFullYear(), currentDate.value.getMonth() + 1)
])
// 加载今天的交易数据
await fetchDayTransactions(selectedDate.value)
} finally {
closeToast()
}
// 监听交易变更事件(来自全局添加账单)
window.addEventListener('transactions-changed', handleTransactionsChanged)
})
// 页面激活时的钩子(从缓存恢复时触发)
onActivated(() => {
// 当前依赖全局事件 'transactions-changed' 来刷新数据
// 如果需要在页面激活时强制刷新,可以在这里调用相关函数
})
// 页面失活时的钩子(被缓存时触发)
onDeactivated(() => {
// 如果有定时器、WebSocket 等,应该在这里清理
// 目前 CalendarV2 没有这些资源,所以无需处理
})
// 处理交易变更事件
const handleTransactionsChanged = async () => {
// 重新加载当前月和当天数据
await fetchDailyStats(currentDate.value.getFullYear(), currentDate.value.getMonth() + 1)
await fetchDayTransactions(selectedDate.value)
}
// 组件卸载前清理
onBeforeUnmount(() => {
window.removeEventListener('transactions-changed', handleTransactionsChanged)
})
</script>
@@ -343,9 +573,9 @@ defineExpose({
.header-title {
font-family: var(--font-primary);
font-size: var(--font-xl);
font-size: var(--font-2xl);
font-weight: var(--font-medium);
color: var(--text-secondary);
color: var(--text-primary);
margin: 0;
}
@@ -366,12 +596,35 @@ defineExpose({
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);
}
.txn-loading {
padding: var(--spacing-3xl);
text-align: center;
}
/* ========== 日历容器 ========== */
.calendar-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
gap: var(--spacing-xl);
padding: var(--spacing-3xl);
}
.week-days {
@@ -390,19 +643,20 @@ defineExpose({
.calendar-grid {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--spacing-lg);
}
.calendar-week {
display: flex;
justify-content: space-between;
min-height: 56px; /* 固定最小高度32px(day-number) + 16px(day-amount) + 8px(gap) */
}
.day-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
gap: var(--spacing-xs);
width: 44px;
cursor: pointer;
}
@@ -422,15 +676,18 @@ defineExpose({
.day-number.day-has-data {
background-color: var(--bg-tertiary);
font-weight: var(--font-semibold);
}
.day-number.day-selected {
background-color: var(--accent-primary);
color: #FFFFFF;
font-weight: var(--font-bold);
}
.day-number.day-other-month {
opacity: 0.3;
color: var(--text-tertiary);
opacity: 0.4;
}
.day-amount {
@@ -447,8 +704,9 @@ defineExpose({
.daily-stats {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
gap: var(--spacing-xl);
padding: var(--spacing-3xl);
padding-top: 0
}
.stats-header {
@@ -474,8 +732,8 @@ defineExpose({
.stats-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
gap: var(--spacing-lg);
padding: var(--spacing-2xl);
background-color: var(--bg-secondary);
border-radius: var(--radius-lg);
}
@@ -512,8 +770,9 @@ defineExpose({
.transactions {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
gap: var(--spacing-lg);
padding: var(--spacing-3xl);
padding-top: 0;
}
.txn-header {
@@ -533,7 +792,7 @@ defineExpose({
.txn-actions {
display: flex;
align-items: center;
gap: 8px;
gap: var(--spacing-md);
}
.txn-badge {
@@ -551,6 +810,7 @@ defineExpose({
.smart-btn {
display: flex;
align-items: center;
margin-left: 6px;
gap: 6px;
padding: 6px 12px;
background-color: var(--accent-info-bg);
@@ -570,14 +830,14 @@ defineExpose({
.txn-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--spacing-lg);
}
.txn-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
padding: var(--spacing-xl);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
@@ -601,7 +861,7 @@ defineExpose({
.txn-content {
display: flex;
flex-direction: column;
gap: 2px;
gap: var(--spacing-xs);
flex: 1;
}
@@ -623,6 +883,42 @@ defineExpose({
color: var(--text-primary);
}
/* ========== 空状态 ========== */
.txn-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 240px;
padding: var(--spacing-4xl) var(--spacing-2xl);
gap: var(--spacing-md);
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-tertiary);
margin-bottom: var(--spacing-sm);
}
.empty-text {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-secondary);
}
.empty-hint {
font-size: var(--font-md);
font-weight: var(--font-medium);
color: var(--text-tertiary);
opacity: 0.8;
}
/* 底部安全距离 */
.bottom-spacer {
height: calc(60px + env(safe-area-inset-bottom, 0px));

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="page-container-flex">
<van-nav-bar
title="设置"
@@ -115,6 +115,12 @@
is-link
@click="handleScheduledTasks"
/>
<van-cell
title="切换版本"
is-link
:value="versionStore.currentVersion.toUpperCase()"
@click="handleVersionSwitch"
/>
</van-cell-group>
<div
@@ -139,14 +145,16 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } from 'vant'
import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog, showDialog } from 'vant'
import { uploadBillFile } from '@/api/billImport'
import { useAuthStore } from '@/stores/auth'
import { useVersionStore } from '@/stores/version'
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
import { updateServiceWorker } from '@/registerServiceWorker'
const router = useRouter()
const authStore = useAuthStore()
const versionStore = useVersionStore()
const fileInputRef = ref(null)
const currentType = ref('')
const notificationEnabled = ref(false)
@@ -381,6 +389,64 @@ const handleReloadFromNetwork = async () => {
const handleScheduledTasks = () => {
router.push({ name: 'scheduled-tasks' })
}
/**
* 处理版本切换
*/
const handleVersionSwitch = async () => {
try {
await showDialog({
title: '选择版本',
message: '请选择要使用的版本',
showCancelButton: true,
confirmButtonText: 'V2',
cancelButtonText: 'V1'
}).then(() => {
// 选择 V2
versionStore.setVersion('v2')
showSuccessToast('已切换到 V2')
// 尝试跳转到当前路由的 V2 版本
redirectToVersionRoute()
}).catch(() => {
// 选择 V1
versionStore.setVersion('v1')
showSuccessToast('已切换到 V1')
// 尝试跳转到当前路由的 V1 版本
redirectToVersionRoute()
})
} catch (error) {
console.error('版本切换失败:', error)
}
}
/**
* 根据当前版本重定向路由
*/
const redirectToVersionRoute = () => {
const currentRoute = router.currentRoute.value
const currentRouteName = currentRoute.name
if (versionStore.isV2()) {
// 尝试跳转到 V2 路由
const v2RouteName = `${currentRouteName}-v2`
const v2Route = router.getRoutes().find(route => route.name === v2RouteName)
if (v2Route) {
router.push({ name: v2RouteName })
}
// 如果没有 V2 路由,保持当前路由
} else {
// V1 版本:如果当前在 V2 路由,跳转到 V1
if (currentRouteName && currentRouteName.toString().endsWith('-v2')) {
const v1RouteName = currentRouteName.toString().replace(/-v2$/, '')
const v1Route = router.getRoutes().find(route => route.name === v1RouteName)
if (v1Route) {
router.push({ name: v1RouteName })
}
}
}
}
</script>
<style scoped>

View File

@@ -1,4 +1,6 @@
namespace WebApi.Controllers;
using Service.AI;
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]/[action]")]
@@ -224,7 +226,7 @@ public class TransactionCategoryController(
4. 使用单色fill=""currentColor""
5. 简洁的设计,适合作为应用图标";
var userPrompt = $"为分类"{category.Name}"{(category.Type == TransactionType.Expense ? "" : category.Type == TransactionType.Income ? "" : "")}生成一个SVG图标";
var userPrompt = $"为分类\"{category.Name}\"{(category.Type == TransactionType.Expense ? "" : category.Type == TransactionType.Income ? "" : "")}生成一个SVG图标";
var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30);
if (string.IsNullOrWhiteSpace(svgContent))
@@ -261,7 +263,7 @@ public class TransactionCategoryController(
return "更新分类图标失败".Fail<string>();
}
return svg.Ok();
return svg.Ok<string>();
}
catch (Exception ex)
{