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
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:
@@ -44,9 +44,8 @@ COPY Service/ ./Service/
|
|||||||
COPY WebApi/ ./WebApi/
|
COPY WebApi/ ./WebApi/
|
||||||
|
|
||||||
# 构建并发布
|
# 构建并发布
|
||||||
# 使用 -m:1 限制 CPU/内存并行度,减少容器构建崩溃风险
|
# 使用 /m:1 限制 CPU/内存并行度,减少容器构建崩溃风险
|
||||||
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore -m:1
|
RUN dotnet publish WebApi/WebApi.csproj -c Release -o /app/publish --no-restore /m:1
|
||||||
|
|
||||||
# 将前端构建产物复制到后端的 wwwroot 目录
|
# 将前端构建产物复制到后端的 wwwroot 目录
|
||||||
COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot
|
COPY --from=frontend-build /app/frontend/dist /app/publish/wwwroot
|
||||||
|
|
||||||
|
|||||||
130
Web/VERSION_SWITCH_SUMMARY.md
Normal file
130
Web/VERSION_SWITCH_SUMMARY.md
Normal 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
143
Web/VERSION_SWITCH_TEST.md
Normal 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`。
|
||||||
@@ -4,7 +4,17 @@
|
|||||||
class="app-provider"
|
class="app-provider"
|
||||||
>
|
>
|
||||||
<div class="app-root">
|
<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
|
<van-tabbar
|
||||||
v-show="showTabbar"
|
v-show="showTabbar"
|
||||||
v-model="active"
|
v-model="active"
|
||||||
@@ -79,6 +89,15 @@ import '@/styles/common.css'
|
|||||||
|
|
||||||
const messageStore = useMessageStore()
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
|
// 定义需要缓存的页面组件名称
|
||||||
|
const cachedViews = ref([
|
||||||
|
'CalendarV2', // 日历V2页面
|
||||||
|
'CalendarView', // 日历V1页面
|
||||||
|
'StatisticsView', // 统计页面
|
||||||
|
'BalanceView', // 账单页面
|
||||||
|
'BudgetView' // 预算页面
|
||||||
|
])
|
||||||
|
|
||||||
const updateVh = () => {
|
const updateVh = () => {
|
||||||
const vh = window.innerHeight
|
const vh = window.innerHeight
|
||||||
document.documentElement.style.setProperty('--vh', `${vh}px`)
|
document.documentElement.style.setProperty('--vh', `${vh}px`)
|
||||||
@@ -122,6 +141,7 @@ const showTabbar = computed(() => {
|
|||||||
return (
|
return (
|
||||||
route.path === '/' ||
|
route.path === '/' ||
|
||||||
route.path === '/calendar' ||
|
route.path === '/calendar' ||
|
||||||
|
route.path === '/calendar-v2' ||
|
||||||
route.path === '/message' ||
|
route.path === '/message' ||
|
||||||
route.path === '/setting' ||
|
route.path === '/setting' ||
|
||||||
route.path === '/balance' ||
|
route.path === '/balance' ||
|
||||||
@@ -136,6 +156,8 @@ const theme = ref('light')
|
|||||||
const updateTheme = () => {
|
const updateTheme = () => {
|
||||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
theme.value = isDark ? 'dark' : 'light'
|
theme.value = isDark ? 'dark' : 'light'
|
||||||
|
// 在文档根元素上设置 data-theme 属性,使 CSS 变量生效
|
||||||
|
document.documentElement.setAttribute('data-theme', theme.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 监听系统主题变化
|
||||||
@@ -165,6 +187,7 @@ const setActive = (path) => {
|
|||||||
active.value = (() => {
|
active.value = (() => {
|
||||||
switch (path) {
|
switch (path) {
|
||||||
case '/calendar':
|
case '/calendar':
|
||||||
|
case '/calendar-v2':
|
||||||
return 'ccalendar'
|
return 'ccalendar'
|
||||||
case '/balance':
|
case '/balance':
|
||||||
case '/message':
|
case '/message':
|
||||||
@@ -180,7 +203,7 @@ const setActive = (path) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isShowAddBill = computed(() => {
|
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(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useVersionStore } from '@/stores/version'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -34,6 +35,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/CalendarView.vue'),
|
component: () => import('../views/CalendarView.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/calendar-v2',
|
||||||
|
name: 'calendar-v2',
|
||||||
|
component: () => import('../views/CalendarV2.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/smart-classification',
|
path: '/smart-classification',
|
||||||
name: 'smart-classification',
|
name: 'smart-classification',
|
||||||
@@ -113,6 +120,7 @@ const router = createRouter({
|
|||||||
// 路由守卫
|
// 路由守卫
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const versionStore = useVersionStore()
|
||||||
const requiresAuth = to.meta.requiresAuth !== false // 默认需要认证
|
const requiresAuth = to.meta.requiresAuth !== false // 默认需要认证
|
||||||
|
|
||||||
if (requiresAuth && !authStore.isAuthenticated) {
|
if (requiresAuth && !authStore.isAuthenticated) {
|
||||||
@@ -122,6 +130,33 @@ router.beforeEach((to, from, next) => {
|
|||||||
// 已登录用户访问登录页,跳转到首页
|
// 已登录用户访问登录页,跳转到首页
|
||||||
next({ name: 'transactions' })
|
next({ name: 'transactions' })
|
||||||
} else {
|
} 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()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
19
Web/src/stores/version.js
Normal file
19
Web/src/stores/version.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="calendar-v2">
|
||||||
class="calendar-v2"
|
|
||||||
:data-theme="theme"
|
|
||||||
>
|
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<header class="calendar-header">
|
<header class="calendar-header">
|
||||||
|
<button
|
||||||
|
class="month-nav-btn"
|
||||||
|
aria-label="上一月"
|
||||||
|
@click="changeMonth(-1)"
|
||||||
|
>
|
||||||
|
<van-icon name="arrow-left" />
|
||||||
|
</button>
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1 class="header-title">
|
<h1 class="header-title">
|
||||||
{{ currentMonth }}
|
{{ currentMonth }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="month-nav-btn"
|
||||||
|
aria-label="下一月"
|
||||||
|
@click="changeMonth(1)"
|
||||||
|
>
|
||||||
|
<van-icon name="arrow" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="notif-btn"
|
class="notif-btn"
|
||||||
aria-label="通知"
|
aria-label="通知"
|
||||||
|
@click="onNotificationClick"
|
||||||
>
|
>
|
||||||
<van-icon name="bell" />
|
<van-icon name="bell" />
|
||||||
</button>
|
</button>
|
||||||
@@ -70,19 +82,22 @@
|
|||||||
<div class="daily-stats">
|
<div class="daily-stats">
|
||||||
<div class="stats-header">
|
<div class="stats-header">
|
||||||
<h2 class="stats-title">
|
<h2 class="stats-title">
|
||||||
Daily Stats
|
每日统计
|
||||||
</h2>
|
</h2>
|
||||||
<span class="stats-date">{{ selectedDateFormatted }}</span>
|
<span class="stats-date">{{ selectedDateFormatted }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-card">
|
<div class="stats-card">
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<span class="stats-label">Total Spent</span>
|
<span class="stats-label">
|
||||||
|
{{ isToday ? '今日支出' : '当日支出' }}
|
||||||
|
</span>
|
||||||
<div class="stats-badge">
|
<div class="stats-badge">
|
||||||
Daily Limit: {{ dailyLimit }}
|
{{ isToday ? '今日预算' : '当日预算' }}
|
||||||
|
: ¥{{ dailyBudget }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-value">
|
<div class="stats-value">
|
||||||
¥ {{ totalSpent }}
|
¥ {{ selectedDayExpense.toFixed(2) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,21 +106,52 @@
|
|||||||
<div class="transactions">
|
<div class="transactions">
|
||||||
<div class="txn-header">
|
<div class="txn-header">
|
||||||
<h2 class="txn-title">
|
<h2 class="txn-title">
|
||||||
Transactions
|
交易记录
|
||||||
</h2>
|
</h2>
|
||||||
<div class="txn-actions">
|
<div class="txn-actions">
|
||||||
<div class="txn-badge badge-success">
|
<div class="txn-badge badge-success">
|
||||||
{{ transactionCount }} Items
|
{{ transactionCount }} Items
|
||||||
</div>
|
</div>
|
||||||
<button class="smart-btn">
|
<button
|
||||||
<van-icon name="star-o" />
|
class="smart-btn"
|
||||||
|
@click="onSmartClick"
|
||||||
|
>
|
||||||
|
<van-icon name="fire" />
|
||||||
<span>Smart</span>
|
<span>Smart</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
v-for="txn in transactions"
|
v-for="txn in transactions"
|
||||||
:key="txn.id"
|
:key="txn.id"
|
||||||
@@ -142,33 +188,54 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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'
|
||||||
|
|
||||||
// 当前主题
|
// 定义组件名称(keep-alive 需要通过 name 识别)
|
||||||
const theme = ref('light') // 'light' | 'dark'
|
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 currentDate = ref(new Date())
|
||||||
const selectedDate = ref(new Date())
|
const selectedDate = ref(new Date())
|
||||||
|
|
||||||
// 当前月份格式化
|
// 当前月份格式化(中文)
|
||||||
const currentMonth = computed(() => {
|
const currentMonth = computed(() => {
|
||||||
return currentDate.value.toLocaleDateString('en-US', {
|
const year = currentDate.value.getFullYear()
|
||||||
year: 'numeric',
|
const month = currentDate.value.getMonth() + 1
|
||||||
month: 'long'
|
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(() => {
|
const selectedDateFormatted = computed(() => {
|
||||||
return selectedDate.value.toLocaleDateString('en-US', {
|
const year = selectedDate.value.getFullYear()
|
||||||
month: 'short',
|
const month = selectedDate.value.getMonth() + 1
|
||||||
day: 'numeric',
|
const day = selectedDate.value.getDate()
|
||||||
year: 'numeric'
|
return `${year}年${month}月${day}日`
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 生成日历数据
|
// 生成日历数据
|
||||||
@@ -231,8 +298,13 @@ const createDayObject = (date, isCurrentMonth) => {
|
|||||||
date.getMonth() === selectedDate.value.getMonth() &&
|
date.getMonth() === selectedDate.value.getMonth() &&
|
||||||
date.getFullYear() === selectedDate.value.getFullYear()
|
date.getFullYear() === selectedDate.value.getFullYear()
|
||||||
|
|
||||||
// 模拟数据 - 实际应该从 API 获取
|
// 从 API 数据获取
|
||||||
const mockData = getMockDataForDate(date)
|
const dateKey = formatDateKey(date)
|
||||||
|
const dayStats = dailyStatsMap.value[dateKey] || {}
|
||||||
|
|
||||||
|
// 计算净支出(支出 - 收入)
|
||||||
|
const netAmount = (dayStats.expense || 0) - (dayStats.income || 0)
|
||||||
|
const hasData = dayStats.count > 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: date.getTime(),
|
date: date.getTime(),
|
||||||
@@ -240,79 +312,237 @@ const createDayObject = (date, isCurrentMonth) => {
|
|||||||
isCurrentMonth,
|
isCurrentMonth,
|
||||||
isToday,
|
isToday,
|
||||||
isSelected,
|
isSelected,
|
||||||
hasData: mockData.hasData,
|
hasData,
|
||||||
amount: mockData.amount,
|
amount: hasData ? Math.abs(netAmount).toFixed(0) : '',
|
||||||
isOverLimit: mockData.isOverLimit
|
isOverLimit: netAmount > (dailyBudget.value || 0) // 超过每日预算标红
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟数据获取
|
// 格式化日期为 key (yyyy-MM-dd)
|
||||||
const getMockDataForDate = (date) => {
|
const formatDateKey = (date) => {
|
||||||
const day = date.getDate()
|
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 fetchDailyStats = async (year, month) => {
|
||||||
const amounts = [128, 45, 230, 12, 88, 223, 15, 34, 120, 56, 442]
|
try {
|
||||||
const amount = amounts[day % amounts.length]
|
loading.value = true
|
||||||
return {
|
const response = await getDailyStatistics({ year, month })
|
||||||
hasData: true,
|
|
||||||
amount: amount || '',
|
if (response.success && response.data) {
|
||||||
isOverLimit: amount > 200 // 超过限额标红
|
// 构建日期 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
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hasData: false, amount: '', isOverLimit: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
const dailyLimit = ref('2500')
|
const dailyBudget = ref(0) // 每日预算限额
|
||||||
const totalSpent = ref('1,248.50')
|
const selectedDayExpense = ref(0) // 选中日期的支出
|
||||||
const transactionCount = computed(() => transactions.value.length)
|
const transactionCount = computed(() => transactions.value.length)
|
||||||
|
|
||||||
// 交易列表数据
|
// 交易列表数据
|
||||||
const transactions = ref([
|
const transactions = ref([])
|
||||||
{
|
const transactionsLoading = ref(false)
|
||||||
id: 1,
|
|
||||||
name: 'Lunch',
|
// 获取预算数据
|
||||||
time: '12:30 PM',
|
const fetchBudgetData = async () => {
|
||||||
amount: '-58.00',
|
try {
|
||||||
icon: 'star',
|
const response = await getBudgetList()
|
||||||
iconColor: '#FF6B6B',
|
if (response.success && response.data && response.data.length > 0) {
|
||||||
iconBg: '#FFFFFF'
|
// 假设取第一个预算的月度限额除以30作为每日预算
|
||||||
},
|
const monthlyBudget = response.data[0].limit || 0
|
||||||
{
|
dailyBudget.value = Math.floor(monthlyBudget / 30)
|
||||||
id: 2,
|
}
|
||||||
name: 'Coffee',
|
} catch (error) {
|
||||||
time: '08:15 AM',
|
console.error('获取预算失败:', error)
|
||||||
amount: '-24.50',
|
|
||||||
icon: 'coffee-o',
|
|
||||||
iconColor: '#FCD34D',
|
|
||||||
iconBg: '#FFFFFF'
|
|
||||||
}
|
}
|
||||||
])
|
}
|
||||||
|
|
||||||
|
// 获取选中日期的交易列表
|
||||||
|
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}
|
if (!day.isCurrentMonth) {return}
|
||||||
selectedDate.value = new Date(day.date)
|
selectedDate.value = new Date(day.date)
|
||||||
// TODO: 加载选中日期的数据
|
// 加载选中日期的交易数据
|
||||||
console.log('Selected date:', day)
|
await fetchDayTransactions(selectedDate.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击交易
|
// 点击交易卡片 - 跳转到详情页
|
||||||
const onTransactionClick = (txn) => {
|
const onTransactionClick = (txn) => {
|
||||||
console.log('Transaction clicked:', txn)
|
// 跳转到交易详情(假设有详情页路由)
|
||||||
// TODO: 打开交易详情
|
router.push({
|
||||||
|
path: '/transaction-detail',
|
||||||
|
query: { id: txn.id }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换主题
|
// 点击通知按钮
|
||||||
const toggleTheme = () => {
|
const onNotificationClick = () => {
|
||||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
router.push('/message')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露切换主题方法供外部调用
|
// 点击 Smart 按钮 - 跳转到智能分类页面
|
||||||
defineExpose({
|
const onSmartClick = () => {
|
||||||
toggleTheme
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -343,9 +573,9 @@ defineExpose({
|
|||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
font-family: var(--font-primary);
|
font-family: var(--font-primary);
|
||||||
font-size: var(--font-xl);
|
font-size: var(--font-2xl);
|
||||||
font-weight: var(--font-medium);
|
font-weight: var(--font-medium);
|
||||||
color: var(--text-secondary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,12 +596,35 @@ defineExpose({
|
|||||||
opacity: 0.7;
|
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 {
|
.calendar-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: var(--spacing-xl);
|
||||||
padding: 24px;
|
padding: var(--spacing-3xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-days {
|
.week-days {
|
||||||
@@ -390,19 +643,20 @@ defineExpose({
|
|||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-week {
|
.calendar-week {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
min-height: 56px; /* 固定最小高度:32px(day-number) + 16px(day-amount) + 8px(gap) */
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-cell {
|
.day-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: var(--spacing-xs);
|
||||||
width: 44px;
|
width: 44px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -422,15 +676,18 @@ defineExpose({
|
|||||||
|
|
||||||
.day-number.day-has-data {
|
.day-number.day-has-data {
|
||||||
background-color: var(--bg-tertiary);
|
background-color: var(--bg-tertiary);
|
||||||
|
font-weight: var(--font-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-number.day-selected {
|
.day-number.day-selected {
|
||||||
background-color: var(--accent-primary);
|
background-color: var(--accent-primary);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
|
font-weight: var(--font-bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-number.day-other-month {
|
.day-number.day-other-month {
|
||||||
opacity: 0.3;
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-amount {
|
.day-amount {
|
||||||
@@ -447,8 +704,9 @@ defineExpose({
|
|||||||
.daily-stats {
|
.daily-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: var(--spacing-xl);
|
||||||
padding: 24px;
|
padding: var(--spacing-3xl);
|
||||||
|
padding-top: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-header {
|
.stats-header {
|
||||||
@@ -474,8 +732,8 @@ defineExpose({
|
|||||||
.stats-card {
|
.stats-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: var(--spacing-lg);
|
||||||
padding: 20px;
|
padding: var(--spacing-2xl);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
@@ -512,8 +770,9 @@ defineExpose({
|
|||||||
.transactions {
|
.transactions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: var(--spacing-lg);
|
||||||
padding: 24px;
|
padding: var(--spacing-3xl);
|
||||||
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-header {
|
.txn-header {
|
||||||
@@ -533,7 +792,7 @@ defineExpose({
|
|||||||
.txn-actions {
|
.txn-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-badge {
|
.txn-badge {
|
||||||
@@ -551,6 +810,7 @@ defineExpose({
|
|||||||
.smart-btn {
|
.smart-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: 6px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
background-color: var(--accent-info-bg);
|
background-color: var(--accent-info-bg);
|
||||||
@@ -570,14 +830,14 @@ defineExpose({
|
|||||||
.txn-list {
|
.txn-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.txn-card {
|
.txn-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 16px;
|
padding: var(--spacing-xl);
|
||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -601,7 +861,7 @@ defineExpose({
|
|||||||
.txn-content {
|
.txn-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: var(--spacing-xs);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +883,42 @@ defineExpose({
|
|||||||
color: var(--text-primary);
|
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 {
|
.bottom-spacer {
|
||||||
height: calc(60px + env(safe-area-inset-bottom, 0px));
|
height: calc(60px + env(safe-area-inset-bottom, 0px));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<van-nav-bar
|
<van-nav-bar
|
||||||
title="设置"
|
title="设置"
|
||||||
@@ -115,6 +115,12 @@
|
|||||||
is-link
|
is-link
|
||||||
@click="handleScheduledTasks"
|
@click="handleScheduledTasks"
|
||||||
/>
|
/>
|
||||||
|
<van-cell
|
||||||
|
title="切换版本"
|
||||||
|
is-link
|
||||||
|
:value="versionStore.currentVersion.toUpperCase()"
|
||||||
|
@click="handleVersionSwitch"
|
||||||
|
/>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -139,14 +145,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 { uploadBillFile } from '@/api/billImport'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useVersionStore } from '@/stores/version'
|
||||||
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
|
import { getVapidPublicKey, subscribe, testNotification } from '@/api/notification'
|
||||||
import { updateServiceWorker } from '@/registerServiceWorker'
|
import { updateServiceWorker } from '@/registerServiceWorker'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const versionStore = useVersionStore()
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
const currentType = ref('')
|
const currentType = ref('')
|
||||||
const notificationEnabled = ref(false)
|
const notificationEnabled = ref(false)
|
||||||
@@ -381,6 +389,64 @@ const handleReloadFromNetwork = async () => {
|
|||||||
const handleScheduledTasks = () => {
|
const handleScheduledTasks = () => {
|
||||||
router.push({ name: 'scheduled-tasks' })
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace WebApi.Controllers;
|
using Service.AI;
|
||||||
|
|
||||||
|
namespace WebApi.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
@@ -224,7 +226,7 @@ public class TransactionCategoryController(
|
|||||||
4. 使用单色,fill=""currentColor""
|
4. 使用单色,fill=""currentColor""
|
||||||
5. 简洁的设计,适合作为应用图标";
|
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);
|
var svgContent = await openAiService.ChatAsync(systemPrompt, userPrompt, timeoutSeconds: 30);
|
||||||
if (string.IsNullOrWhiteSpace(svgContent))
|
if (string.IsNullOrWhiteSpace(svgContent))
|
||||||
@@ -261,7 +263,7 @@ public class TransactionCategoryController(
|
|||||||
return "更新分类图标失败".Fail<string>();
|
return "更新分类图标失败".Fail<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
return svg.Ok();
|
return svg.Ok<string>();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user