feat: remove V1 calendar/budget/stats modules

- 删除 V1 前端页面 (CalendarView, BudgetView, statisticsV1)
- 移除 V1 路由配置 (/calendar, /budget, /)
- 清理路由守卫中的 V1 版本切换逻辑
- 移除设置页面中的版本切换功能
- 更新底部导航和登录重定向到 V2 路由
- 移除 App.vue 中 V1 页面的缓存配置
- 删除后端 TransactionRecordController.GetDailyStatisticsAsync (Obsolete)
- 删除 TransactionStatisticsController.GetBalanceStatisticsAsync
- 保留 V2 仍在使用的共享 API (GetUncoveredCategories, GetArchiveSummary, GetDailyStatistics)
- 保留 V2 使用的全局事件监听机制
- 所有测试通过 (210/210)

Breaking Change: V1 API 端点和路由将不可用
This commit is contained in:
SunCheng
2026-02-14 00:01:44 +08:00
parent 162b6d02dd
commit a7954f55ad
23 changed files with 1028 additions and 3946 deletions

View File

@@ -32,9 +32,6 @@ public interface ITransactionStatisticsApplication
// === 旧接口(保留用于向后兼容,建议迁移到新接口) === // === 旧接口(保留用于向后兼容,建议迁移到新接口) ===
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<BalanceStatisticsDto>> GetBalanceStatisticsAsync(int year, int month);
[Obsolete("请使用 GetDailyStatisticsByRangeAsync")] [Obsolete("请使用 GetDailyStatisticsByRangeAsync")]
Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month); Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month);
@@ -103,25 +100,6 @@ public class TransactionStatisticsApplication(
// === 旧接口实现(保留用于向后兼容) === // === 旧接口实现(保留用于向后兼容) ===
public async Task<List<BalanceStatisticsDto>> GetBalanceStatisticsAsync(int year, int month)
{
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");
var statistics = await statisticsService.GetDailyStatisticsAsync(year, month, savingClassify);
var sortedStats = statistics.OrderBy(s => DateTime.Parse(s.Key)).ToList();
var result = new List<BalanceStatisticsDto>();
decimal cumulativeBalance = 0;
foreach (var item in sortedStats)
{
var dailyBalance = item.Value.income - item.Value.expense;
cumulativeBalance += dailyBalance;
result.Add(new BalanceStatisticsDto(DateTime.Parse(item.Key).Day, cumulativeBalance));
}
return result;
}
public async Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month) public async Task<List<DailyStatisticsDto>> GetDailyStatisticsAsync(int year, int month)
{ {
var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories"); var savingClassify = await configService.GetConfigByKeyAsync<string>("SavingsCategories");

View File

@@ -54,11 +54,9 @@ const messageStore = useMessageStore()
// 定义需要缓存的页面组件名称 // 定义需要缓存的页面组件名称
const cachedViews = ref([ const cachedViews = ref([
'CalendarV2', // 日历V2页面 'CalendarV2', // 日历V2页面
'CalendarView', // 日历V1页面
'StatisticsView', // 统计页面 'StatisticsView', // 统计页面
'StatisticsV2View', // 统计V2页面 'StatisticsV2View', // 统计V2页面
'BalanceView', // 账单页面 'BalanceView', // 账单页面
'BudgetView', // 预算页面
'BudgetV2View' // 预算V2页面 'BudgetV2View' // 预算V2页面
]) ])
@@ -148,16 +146,16 @@ watch(
) )
const isShowAddBill = computed(() => { const isShowAddBill = computed(() => {
return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar' || route.path === '/calendar-v2' return route.path === '/' || route.path === '/balance' || route.path === '/message' || route.path === '/calendar-v2'
}) })
// 需要显示底部导航栏的路由 // 需要显示底部导航栏的路由
const showNav = computed(() => { const showNav = computed(() => {
return [ return [
'/', '/statistics-v2', '/', '/statistics-v2',
'/calendar', '/calendar-v2', '/calendar-v2',
'/balance', '/message', '/balance', '/message',
'/budget', '/budget-v2', '/setting' '/budget-v2', '/setting'
].includes(route.path) ].includes(route.path)
}) })

View File

@@ -160,22 +160,6 @@ export const getDailyStatistics = (params) => {
}) })
} }
/**
* 获取累积余额统计数据(用于余额卡片)
* @deprecated 请使用 getDailyStatisticsByRange 并在前端计算累积余额
* @param {Object} params - 查询参数
* @param {number} params.year - 年份
* @param {number} params.month - 月份
* @returns {Promise<{success: boolean, data: Array}>}
*/
export const getBalanceStatistics = (params) => {
return request({
url: '/TransactionStatistics/GetBalanceStatistics',
method: 'get',
params
})
}
/** /**
* 获取指定周范围的每天的消费统计 * 获取指定周范围的每天的消费统计
* @deprecated 请使用 getDailyStatisticsByRange * @deprecated 请使用 getDailyStatisticsByRange

View File

@@ -41,7 +41,7 @@ const props = defineProps({
type: Array, type: Array,
default () { default () {
return [ return [
{ name: 'calendar', label: '日历', icon: 'notes', path: '/calendar' }, { name: 'calendar', label: '日历', icon: 'notes', path: '/calendar-v2' },
{ name: 'statistics', label: '统计', icon: 'chart-trending-o', path: '/' }, { name: 'statistics', label: '统计', icon: 'chart-trending-o', path: '/' },
{ name: 'balance', label: '账单', icon: 'balance-list', path: '/balance' }, { name: 'balance', label: '账单', icon: 'balance-list', path: '/balance' },
{ name: 'budget', label: '预算', icon: 'bill-o', path: '/budget-v2' }, { name: 'budget', label: '预算', icon: 'bill-o', path: '/budget-v2' },

View File

@@ -1,6 +1,5 @@
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),
@@ -29,12 +28,6 @@ const router = createRouter({
component: () => import('../views/SettingView.vue'), component: () => import('../views/SettingView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/calendar',
name: 'calendar',
component: () => import('../views/CalendarView.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/calendar-v2', path: '/calendar-v2',
name: 'calendar-v2', name: 'calendar-v2',
@@ -65,12 +58,6 @@ const router = createRouter({
component: () => import('../views/ClassificationNLP.vue'), component: () => import('../views/ClassificationNLP.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/',
name: 'statistics',
component: () => import('../views/statisticsV1/Index.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/statistics-v2', path: '/statistics-v2',
name: 'statistics-v2', name: 'statistics-v2',
@@ -101,12 +88,6 @@ const router = createRouter({
component: () => import('../views/LogView.vue'), component: () => import('../views/LogView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/budget',
name: 'budget',
component: () => import('../views/BudgetView.vue'),
meta: { requiresAuth: true }
},
{ {
path: '/budget-v2', path: '/budget-v2',
name: 'budget-v2', name: 'budget-v2',
@@ -132,7 +113,6 @@ 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) {
@@ -140,35 +120,8 @@ router.beforeEach((to, from, next) => {
next({ name: 'login', query: { redirect: to.fullPath } }) next({ name: 'login', query: { redirect: to.fullPath } })
} else if (to.name === 'login' && authStore.isAuthenticated) { } else if (to.name === 'login' && authStore.isAuthenticated) {
// 已登录用户访问登录页,跳转到首页 // 已登录用户访问登录页,跳转到首页
next({ name: 'transactions' }) next({ name: 'statistics-v2' })
} 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()
} }
}) })

View File

@@ -2,14 +2,18 @@ import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
export const useVersionStore = defineStore('version', () => { export const useVersionStore = defineStore('version', () => {
const currentVersion = ref(localStorage.getItem('app-version') || 'v1') // V1 已下线,强制使用 V2
const currentVersion = ref('v2')
const setVersion = (version) => { const setVersion = (version) => {
// 仅接受 v2忽略 v1 设置
if (version === 'v2') {
currentVersion.value = version currentVersion.value = version
localStorage.setItem('app-version', version) localStorage.setItem('app-version', version)
} }
}
const isV2 = () => currentVersion.value === 'v2' const isV2 = () => true // 始终返回 true
return { return {
currentVersion, currentVersion,

File diff suppressed because it is too large Load Diff

View File

@@ -1,343 +0,0 @@
<template>
<div class="page-container calendar-container">
<van-calendar
title="日历"
:poppable="false"
:show-confirm="false"
:formatter="formatterCalendar"
:min-date="minDate"
:max-date="maxDate"
@month-show="onMonthShow"
@select="onDateSelect"
/>
<!-- 底部安全距离 -->
<div style="height: calc(95px + env(safe-area-inset-bottom, 0px))" />
<!-- 日期交易列表弹出层 -->
<PopupContainer
v-model="listVisible"
:title="selectedDateText"
:subtitle="getBalance(dateTransactions)"
height="75%"
>
<template #header-actions>
<SmartClassifyButton
ref="smartClassifyButtonRef"
:transactions="dateTransactions"
@save="onSmartClassifySave"
/>
</template>
<TransactionList
:transactions="dateTransactions"
:loading="listLoading"
:finished="true"
:show-delete="true"
@click="viewDetail"
@delete="handleDateTransactionDelete"
/>
</PopupContainer>
<!-- 交易详情组件 -->
<TransactionDetail
v-model:show="detailVisible"
:transaction="currentTransaction"
@save="onDetailSave"
/>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { showToast } from 'vant'
import request from '@/api/request'
import { getTransactionDetail, getTransactionsByDate } from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
import SmartClassifyButton from '@/components/SmartClassifyButton.vue'
import PopupContainer from '@/components/PopupContainer.vue'
const dailyStatistics = ref({})
const listVisible = ref(false)
const detailVisible = ref(false)
const dateTransactions = ref([])
const currentTransaction = ref(null)
const listLoading = ref(false)
const selectedDate = ref(null)
const selectedDateText = ref('')
// 设置日历可选范围例如过去1年到当前月底
const minDate = new Date(new Date().getFullYear() - 1, 0, 1) // 1年前的1月1日
let maxDate = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0) // 当前月的最后一天
// 如果当前日超过20号则将最大日期设置为下个月月底方便用户查看和选择
if (new Date().getDate() > 20) {
maxDate = new Date(new Date().getFullYear(), new Date().getMonth() + 2, 0)
}
// 获取日历统计数据
const fetchDailyStatistics = async (year, month) => {
try {
const response = await request.get('/TransactionRecord/GetDailyStatistics', {
params: { year, month }
})
if (response.success && response.data) {
// 将数组转换为对象key为日期
const statsMap = {}
response.data.forEach((item) => {
statsMap[item.date] = {
count: item.count,
amount: (item.income - item.expense).toFixed(1)
}
})
dailyStatistics.value = {
...dailyStatistics.value,
...statsMap
}
}
} catch (error) {
console.error('获取日历统计数据失败:', error)
}
}
const smartClassifyButtonRef = ref(null)
// 获取指定日期的交易列表
const fetchDateTransactions = async (date) => {
try {
listLoading.value = true
const dateStr = date
.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
.replace(/\//g, '-')
const response = await getTransactionsByDate(dateStr)
if (response.success && response.data) {
// 根据金额从大到小排序
dateTransactions.value = response.data.sort((a, b) => b.amount - a.amount)
// 重置智能分类按钮
smartClassifyButtonRef.value?.reset()
} else {
dateTransactions.value = []
showToast(response.message || '获取交易列表失败')
}
} catch (error) {
console.error('获取日期交易列表失败:', error)
dateTransactions.value = []
showToast('获取交易列表失败')
} finally {
listLoading.value = false
}
}
const getBalance = (transactions) => {
let balance = 0
transactions.forEach((tx) => {
if (tx.type === 1) {
balance += tx.amount
} else if (tx.type === 0) {
balance -= tx.amount
}
})
if (balance >= 0) {
return `结余收入 ${balance.toFixed(1)}`
} else {
return `结余支出 ${(-balance).toFixed(1)}`
}
}
// 当月份显示时触发
const onMonthShow = ({ date }) => {
const year = date.getFullYear()
const month = date.getMonth() + 1
fetchDailyStatistics(year, month)
}
// 日期选择事件
const onDateSelect = (date) => {
selectedDate.value = date
selectedDateText.value = formatSelectedDate(date)
fetchDateTransactions(date)
listVisible.value = true
}
// 格式化选中的日期
const formatSelectedDate = (date) => {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
})
}
// 查看详情
const viewDetail = async (transaction) => {
try {
const response = await getTransactionDetail(transaction.id)
if (response.success) {
currentTransaction.value = response.data
detailVisible.value = true
} else {
showToast(response.message || '获取详情失败')
}
} catch (error) {
console.error('获取详情出错:', error)
showToast('获取详情失败')
}
}
// 详情保存后的回调
const onDetailSave = async (saveData) => {
const item = dateTransactions.value.find((tx) => tx.id === saveData.id)
if (!item) {
return
}
// 如果分类发生了变化 移除智能分类的内容,防止被智能分类覆盖
if (item.classify !== saveData.classify) {
// 通知智能分类按钮组件移除指定项
smartClassifyButtonRef.value?.removeClassifiedTransaction(saveData.id)
item.upsetedClassify = ''
}
// 更新当前日期交易列表中的数据
Object.assign(item, saveData)
// 重新加载当前月份的统计数据
const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}
// 处理删除事件:从当前日期交易列表中移除,并刷新当日和当月统计
const handleDateTransactionDelete = async (transactionId) => {
dateTransactions.value = dateTransactions.value.filter((t) => t.id !== transactionId)
// 刷新当前日期以及当月的统计数据
const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}
// 智能分类保存回调
const onSmartClassifySave = async () => {
// 保存完成后重新加载数据
if (selectedDate.value) {
await fetchDateTransactions(selectedDate.value)
}
// 重新加载统计数据
const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}
const formatterCalendar = (day) => {
const dayCopy = { ...day }
if (dayCopy.date.toDateString() === new Date().toDateString()) {
dayCopy.text = '今天'
}
// 格式化日期为 yyyy-MM-dd
const dateKey = dayCopy.date
.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
.replace(/\//g, '-')
const stats = dailyStatistics.value[dateKey]
if (stats) {
dayCopy.topInfo = `${stats.count}` // 展示消费笔数
dayCopy.bottomInfo = `${stats.amount}` // 展示消费金额
}
return dayCopy
}
// 初始加载当前月份数据
const now = new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
// 全局删除事件监听,确保日历页面数据一致
const onGlobalTransactionDeleted = () => {
if (selectedDate.value) {
fetchDateTransactions(selectedDate.value)
}
const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}
window.addEventListener &&
window.addEventListener('transaction-deleted', onGlobalTransactionDeleted)
onBeforeUnmount(() => {
window.removeEventListener &&
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
})
// 当有交易被新增/修改/批量更新时刷新
const onGlobalTransactionsChanged = () => {
if (selectedDate.value) {
fetchDateTransactions(selectedDate.value)
}
const now = selectedDate.value || new Date()
fetchDailyStatistics(now.getFullYear(), now.getMonth() + 1)
}
window.addEventListener &&
window.addEventListener('transactions-changed', onGlobalTransactionsChanged)
onBeforeUnmount(() => {
window.removeEventListener &&
window.removeEventListener('transactions-changed', onGlobalTransactionsChanged)
})
</script>
<style scoped>
:deep(.van-calendar__header-title){
display: none;
}
.van-calendar {
background: transparent !important;
}
.calendar-container {
/* 使用准确的视口高度减去 TabBar 高度50px和安全区域 */
display: flex;
flex-direction: column;
overflow: hidden;
margin: 0;
padding: 0;
background-color: var(--van-background);
}
.calendar-container :deep(.van-calendar) {
height: calc(auto + 40px) !important;
flex: 1;
overflow: auto;
margin: 0;
padding: 0;
}
/* 移除日历组件可能的底部 padding */
.calendar-container :deep(.van-calendar__body) {
padding-bottom: 0 !important;
}
.calendar-container :deep(.van-calendar__months) {
padding-bottom: 0 !important;
}
/* 设置页面容器背景色 */
:deep(.van-calendar__header-title) {
background: transparent !important;
}
/* Add margin to bottom of heatmap to separate from tabbar */
:deep(.heatmap-card) {
flex-shrink: 0; /* Prevent heatmap from shrinking */
}
</style>

View File

@@ -1,4 +1,4 @@
<template> <template>
<div class="page-container login-container"> <div class="page-container login-container">
<div class="login-box"> <div class="login-box">
<h1 class="login-title"> <h1 class="login-title">
@@ -55,7 +55,7 @@ const handleLogin = async () => {
try { try {
await authStore.login(password.value) await authStore.login(password.value)
showToast({ type: 'success', message: '登录成功' }) showToast({ type: 'success', message: '登录成功' })
router.push('/calendar') router.push('/statistics-v2')
} catch (error) { } catch (error) {
showToast({ type: 'fail', message: error.message || '登录失败' }) showToast({ type: 'fail', message: error.message || '登录失败' })
} finally { } finally {

View File

@@ -115,12 +115,6 @@
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
@@ -145,16 +139,14 @@
<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, showDialog } from 'vant' import { showLoadingToast, showSuccessToast, showToast, closeToast, showConfirmDialog } 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('')
@@ -390,64 +382,6 @@ 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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,407 +0,0 @@
<template>
<!-- 支出分类统计 -->
<div
class="common-card"
style="padding-bottom: 10px;"
>
<div class="card-header">
<h3 class="card-title">
支出分类
</h3>
<van-tag
type="primary"
size="medium"
>
{{ expenseCategoriesView.length }}
</van-tag>
</div>
<!-- 环形图区域 -->
<div
v-if="expenseCategoriesView.length > 0"
class="chart-container"
>
<div class="ring-chart">
<div
ref="pieChartRef"
style="width: 100%; height: 100%"
/>
</div>
</div>
<!-- 分类列表 -->
<div class="category-list">
<div
v-for="category in expenseCategoriesSimpView"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 0)"
>
<div class="category-info">
<div
class="category-color"
:style="{ backgroundColor: category.color }"
/>
<div class="category-name-with-count">
<span class="category-name">{{ category.classify || '未分类' }}</span>
<span class="category-count">{{ category.count }}</span>
</div>
</div>
<div class="category-stats">
<div class="category-amount">
¥{{ formatMoney(category.amount) }}
</div>
<div class="category-percent">
{{ category.percent }}%
</div>
</div>
<van-icon
name="arrow"
class="category-arrow"
/>
</div>
<!-- 展开/收起按钮 -->
<div
v-if="expenseCategoriesView.length > 1"
class="expand-toggle"
@click="showAllExpense = !showAllExpense"
>
<van-icon :name="showAllExpense ? 'arrow-up' : 'arrow-down'" />
</div>
</div>
<ModernEmpty
v-if="!expenseCategoriesView || !expenseCategoriesView.length"
type="chart"
theme="blue"
title="暂无支出"
description="本期还没有支出记录"
size="small"
/>
</div>
</template>
<script setup>
import { ref, computed, onBeforeUnmount, nextTick, watch } from 'vue'
import * as echarts from 'echarts'
import { getCssVar } from '@/utils/theme'
import ModernEmpty from '@/components/ModernEmpty.vue'
const props = defineProps({
categories: {
type: Array,
default: () => []
},
totalExpense: {
type: Number,
default: 0
},
colors: {
type: Array,
default: () => []
}
})
defineEmits(['category-click'])
const pieChartRef = ref(null)
let pieChartInstance = null
const showAllExpense = ref(false)
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 计算属性
const expenseCategoriesView = computed(() => {
const list = [...props.categories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const expenseCategoriesSimpView = computed(() => {
const list = expenseCategoriesView.value
if (showAllExpense.value) {
return list
}
// 只展示未分类
const unclassified = list.filter((c) => c.classify === '未分类' || !c.classify)
if (unclassified.length > 0) {
return [...unclassified]
}
return []
})
// 渲染饼图
const renderPieChart = () => {
if (!pieChartRef.value) {
return
}
if (expenseCategoriesView.value.length === 0) {
return
}
// 尝试获取DOM上的现有实例
const existingInstance = echarts.getInstanceByDom(pieChartRef.value)
if (pieChartInstance && pieChartInstance !== existingInstance) {
if (!pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
pieChartInstance = null
}
if (pieChartInstance && pieChartInstance.getDom() !== pieChartRef.value) {
pieChartInstance.dispose()
pieChartInstance = null
}
if (!pieChartInstance && existingInstance) {
pieChartInstance = existingInstance
}
if (!pieChartInstance) {
pieChartInstance = echarts.init(pieChartRef.value)
}
// 使用 Top N + Other 的数据逻辑,确保图表不会太拥挤
const list = [...expenseCategoriesView.value]
let chartData = []
// 按照金额排序
list.sort((a, b) => b.amount - a.amount)
const MAX_SLICES = 8 // 最大显示扇区数,其余合并为"其他"
if (list.length > MAX_SLICES) {
const topList = list.slice(0, MAX_SLICES - 1)
const otherList = list.slice(MAX_SLICES - 1)
const otherAmount = otherList.reduce((sum, item) => sum + item.amount, 0)
chartData = topList.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
}))
chartData.push({
value: otherAmount,
name: '其他',
itemStyle: { color: getCssVar('--van-gray-6') } // 使用灰色表示其他
})
} else {
chartData = list.map((item, index) => ({
value: item.amount,
name: item.classify || '未分类',
itemStyle: { color: props.colors[index % props.colors.length] }
}))
}
const option = {
title: {
text: '¥' + formatMoney(props.totalExpense),
subtext: '总支出',
left: 'center',
top: 'center',
textStyle: {
color: getCssVar('--chart-text-muted'), // 适配深色模式
fontSize: 20,
fontWeight: 'bold'
},
subtextStyle: {
color: getCssVar('--chart-text-muted'),
fontSize: 13
}
},
tooltip: {
trigger: 'item',
formatter: (params) => {
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent.toFixed(1)}%)`
}
},
series: [
{
name: '支出分类',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: true,
minAngle: 5, // 最小扇区角度,防止扇区太小看不见
itemStyle: {
borderRadius: 5,
borderColor: getCssVar('--van-background-2'),
borderWidth: 2
},
label: {
show: false
},
labelLine: {
show: false
},
data: chartData
}
]
}
pieChartInstance.setOption(option)
}
// 监听数据变化重新渲染图表
watch(() => [props.categories, props.totalExpense, props.colors], () => {
nextTick(() => {
renderPieChart()
})
}, { deep: true, immediate: true })
// 组件销毁时清理图表实例
onBeforeUnmount(() => {
if (pieChartInstance && !pieChartInstance.isDisposed()) {
pieChartInstance.dispose()
}
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
/* 环形图 */
.chart-container {
padding: 0;
}
.ring-chart {
position: relative;
width: 100%;
height: 200px;
margin: 0 auto;
}
/* 分类列表 */
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-name-with-count {
display: flex;
align-items: center;
gap: 8px;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-count {
font-size: 12px;
color: var(--van-text-color-3);
}
.category-stats {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.category-arrow {
margin-left: 8px;
color: var(--van-text-color-3);
font-size: 16px;
flex-shrink: 0;
}
.expand-toggle {
display: flex;
justify-content: center;
align-items: center;
padding-top: 0;
color: var(--van-text-color-3);
font-size: 20px;
cursor: pointer;
}
.expand-toggle:active {
opacity: 0.7;
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
}
.category-percent {
font-size: 12px;
color: var(--van-text-color-3);
background: var(--van-background);
padding: 2px 8px;
border-radius: 10px;
}
</style>

View File

@@ -1,272 +0,0 @@
<template>
<!-- 收支和不计收支并列显示 -->
<div class="side-by-side-cards">
<!-- 收入分类统计 -->
<div class="common-card half-card">
<div class="card-header">
<h3 class="card-title">
收入
<span
class="income-text"
style="font-size: 13px; margin-left: 4px"
>
¥{{ formatMoney(totalIncome) }}
</span>
</h3>
<van-tag
type="success"
size="medium"
>
{{ incomeCategories.length }}
</van-tag>
</div>
<div
v-if="incomeCategories.length > 0"
class="category-list"
>
<div
v-for="category in incomeCategories"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 1)"
>
<div class="category-info">
<div class="category-color income-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount income-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
<ModernEmpty
v-else
type="finance"
theme="green"
title="暂无收入"
description="本期还没有收入记录"
size="small"
/>
</div>
<!-- 不计收支分类统计 -->
<div class="common-card half-card">
<div class="card-header">
<h3 class="card-title">
不计收支
</h3>
<van-tag
type="warning"
size="medium"
>
{{ noneCategories.length }}
</van-tag>
</div>
<div
v-if="noneCategories.length > 0"
class="category-list"
>
<div
v-for="category in noneCategories"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 2)"
>
<div class="category-info">
<div class="category-color none-color" />
<span class="category-name text-ellipsis">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-amount none-text">
¥{{ formatMoney(category.amount) }}
</div>
</div>
</div>
<ModernEmpty
v-else
type="inbox"
theme="gray"
title="暂无数据"
description="本期没有不计收支记录"
size="small"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import ModernEmpty from '@/components/ModernEmpty.vue'
const props = defineProps({
incomeCategories: {
type: Array,
default: () => []
},
noneCategories: {
type: Array,
default: () => []
},
totalIncome: {
type: Number,
default: 0
}
})
defineEmits(['category-click'])
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) {
return '0'
}
return Number(value)
.toFixed(0)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 处理未分类排序
const incomeCategories = computed(() => {
const list = [...props.incomeCategories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const noneCategories = computed(() => {
const list = [...props.noneCategories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
// 通用卡片样式
.common-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
/* 并列显示卡片 */
.side-by-side-cards {
display: flex;
gap: 12px;
margin: 0 12px 16px;
}
.side-by-side-cards .common-card {
margin: 0;
flex: 1;
min-width: 0; /* 允许内部元素缩小 */
padding: 12px;
}
.card-header {
margin-bottom: 0;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
/* 分类列表 */
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
flex-shrink: 0;
}
.income-color {
background-color: var(--van-success-color);
}
.income-text {
color: var(--van-success-color);
}
/* 不计收支颜色 */
.none-color {
background-color: var(--van-gray-6);
}
.none-text {
color: var(--van-gray-6);
}
</style>

View File

@@ -19,57 +19,6 @@ public class TransactionStatisticsApplicationTest : BaseApplicationTest
_application = new TransactionStatisticsApplication(_statisticsService, _configService); _application = new TransactionStatisticsApplication(_statisticsService, _configService);
} }
#region GetBalanceStatisticsAsync Tests
[Fact]
public async Task GetBalanceStatisticsAsync_有效数据_应返回累计余额统计()
{
// Arrange
var year = 2026;
var month = 2;
var savingClassify = "储蓄";
var dailyStats = new Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>
{
{ "2026-02-01", (2, 500m, 1000m, 0m) },
{ "2026-02-02", (1, 200m, 0m, 0m) },
{ "2026-02-03", (2, 300m, 2000m, 0m) }
};
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(savingClassify);
_statisticsService.GetDailyStatisticsAsync(year, month, savingClassify).Returns(dailyStats);
// Act
var result = await _application.GetBalanceStatisticsAsync(year, month);
// Assert
result.Should().HaveCount(3);
result[0].Day.Should().Be(1);
result[0].CumulativeBalance.Should().Be(500m); // 1000 - 500
result[1].Day.Should().Be(2);
result[1].CumulativeBalance.Should().Be(300m); // 500 + (0 - 200)
result[2].Day.Should().Be(3);
result[2].CumulativeBalance.Should().Be(2000m); // 300 + (2000 - 300)
}
[Fact]
public async Task GetBalanceStatisticsAsync_无数据_应返回空列表()
{
// Arrange
var year = 2026;
var month = 2;
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns("储蓄");
_statisticsService.GetDailyStatisticsAsync(year, month, "储蓄")
.Returns(new Dictionary<string, (int, decimal, decimal, decimal)>());
// Act
var result = await _application.GetBalanceStatisticsAsync(year, month);
// Assert
result.Should().BeEmpty();
}
#endregion
#region GetDailyStatisticsAsync Tests #region GetDailyStatisticsAsync Tests
[Fact] [Fact]

View File

@@ -79,20 +79,6 @@ public class TransactionStatisticsController(
// ===== 旧接口(保留用于向后兼容,已标记为过时) ===== // ===== 旧接口(保留用于向后兼容,已标记为过时) =====
/// <summary>
/// 获取累积余额统计数据(用于余额卡片图表)
/// </summary>
[Obsolete("请使用 GetDailyStatisticsByRangeAsync 并在前端计算累积余额")]
[HttpGet]
public async Task<BaseResponse<List<BalanceStatisticsDto>>> GetBalanceStatisticsAsync(
[FromQuery] int year,
[FromQuery] int month
)
{
var result = await statisticsApplication.GetBalanceStatisticsAsync(year, month);
return result.Ok();
}
/// <summary> /// <summary>
/// 获取指定月份每天的消费统计 /// 获取指定月份每天的消费统计
/// </summary> /// </summary>

View File

@@ -0,0 +1,335 @@
## Context
EmailBill 项目在早期开发了 V1 版本的日历、统计、预算三大核心功能模块。随着业务迭代V2 版本已经重构并稳定运行,提供了更优的用户体验和代码架构。当前代码库中同时维护 V1 和 V2 两套实现,导致:
1. **代码冗余**: 前端包含 `CalendarView.vue`, `BudgetView.vue`, `statisticsV1/Index.vue` 等页面,后端包含多个仅服务于 V1 的 API 端点
2. **维护成本**: 任何共享组件或数据模型的变更需要同时考虑 V1 和 V2 的兼容性
3. **认知负担**: 新开发者需要理解两套架构,增加了上手难度
4. **测试负担**: 需要维护两套测试用例,且部分测试已失效
### 当前状态
- V2 版本已完成功能对齐,用户已逐步迁移
- V1 相关 API 端点部分已标记 `[Obsolete]` (如 `TransactionRecordController.GetDailyStatisticsAsync`)
- 路由守卫中包含 V1/V2 版本切换逻辑
### 约束条件
- **不能影响 V2 功能**: 必须保证 V2 页面完全正常运行
- **共享组件不能删除**: `TransactionList`, `TransactionDetail`, `PopupContainer` 等组件被 V2 使用
- **Repository 层不能动**: V1 和 V2 共用相同的数据访问层
- **需要手动测试**: 删除后必须打开 V2 页面验证功能完整性
### 利益相关者
- **开发团队**: 减少维护负担,简化架构
- **测试团队**: 减少测试用例数量
- **最终用户**: 无感知(已迁移到 V2
---
## Goals / Non-Goals
### Goals
1. **完全移除 V1 代码**: 删除所有 V1 专用的页面、API、Service 方法
2. **保持 V2 功能完整**: 确保删除操作不影响 V2 版本的任何功能
3. **清理路由配置**: 移除 V1 路由和版本切换逻辑
4. **验证功能正常**: 通过手动测试验证 V2 页面能正常打开和使用
### Non-Goals
1. **不修改 Repository 层**: V2 继续使用现有的 Repository不进行任何修改
2. **不修改数据库**: 不涉及数据迁移或表结构变更
3. **不重构 V2 代码**: 仅删除 V1 代码,不对 V2 进行任何优化或重构
4. **不移除共享组件**: 即使组件原本为 V1 开发,只要 V2 在用就保留
5. **不更新文档**: API 文档和用户文档的更新不在本次变更范围内
---
## Decisions
### Decision 1: 采用自底向上的删除顺序 (Backend → Frontend)
**选择**: 先删除后端 Service/Application → 再删除 Controller → 最后删除前端页面和路由
**理由**:
- **依赖方向**: 前端依赖后端 API先删除底层可以避免悬挂引用
- **验证便利**: 删除后端接口后,前端调用会立即报 404易于发现遗漏
- **回滚简单**: 如果出问题,可以快速恢复后端接口,前端暂时保留也不影响
**备选方案 (未选择)**:
- **自顶向下 (Frontend → Backend)**: 先删除前端页面,可能导致后端接口成为"僵尸代码"难以发现
- **同步删除**: 一次性删除所有层,风险较高且难以定位问题
---
### Decision 2: 通过代码搜索确认 V1 专用性
**选择**: 对每个待删除的方法/接口进行全局搜索,确认仅被 V1 页面调用
**搜索关键字**:
- 前端页面: `CalendarView.vue`, `BudgetView.vue`, `statisticsV1/Index.vue`
- API 方法: `GetDailyStatistics`, `GetUncoveredCategories`, `GetArchiveSummary`, `GetBalanceStatistics`
- 路由: `/calendar`, `/budget`, `name: 'statistics'`
**验证标准**:
- 如果搜索结果仅出现在以上三个页面中 → 可以安全删除
- 如果出现在其他文件中 → 标记为"需保留"或"需进一步分析"
**理由**:
- **准确性**: 避免误删 V2 依赖的代码
- **可追溯**: 搜索结果可作为删除依据留存
---
### Decision 3: 保留共享组件,即使其仅在 V1 中被直接使用
**选择**: 保留 `TransactionList`, `TransactionDetail`, `PopupContainer`, `SmartClassifyButton` 等组件
**判断依据**:
- 通过搜索确认这些组件在 V2 页面中有引用
- 即使组件内部包含 V1 特定逻辑(如全局事件监听),也不在本次变更中修改
**理由**:
- **最小化风险**: 删除共享组件可能导致 V2 功能异常
- **职责分离**: 组件优化应作为独立的重构任务,不在本次删除范围内
---
### Decision 4: 移除全局事件监听 (条件性)
**选择**: 如果全局事件 (`transaction-deleted`, `transactions-changed`) **仅在 V1 页面中监听**,则移除事件触发代码
**验证流程**:
1. 搜索 `window.addEventListener('transaction-deleted')`
2. 确认监听器仅在 `CalendarView.vue``statisticsV1/Index.vue` 中注册
3. 搜索 `window.dispatchEvent(new Event('transaction-deleted'))`
4. 如果触发位置在共享组件中(如 `TransactionDetail.vue`),检查该组件是否被 V2 使用
- 如果被 V2 使用 → 保留事件触发代码(即使 V1 删除后无人监听)
- 如果仅 V1 使用 → 删除事件触发代码
**理由**:
- **保守策略**: 优先保证 V2 功能不受影响
- **技术债务**: 遗留的无用事件触发代码可作为后续清理任务
---
### Decision 5: 采用手动测试验证 V2 功能
**选择**: 删除完成后,手动打开以下 V2 页面并验证核心功能
**测试清单**:
1. **统计 V2** (`/statistics-v2`):
- 月度统计数据正常加载
- 分类统计饼图正常渲染
- 日度统计折线图正常渲染
- 交易列表正常展示和滚动加载
2. **预算 V2** (`/budget-v2`):
- 预算列表正常加载
- 分类统计(月度+年度)正常显示
- 预算创建/编辑/删除功能正常
- 存款预算导航和显示正常
3. **日历 V2** (`/calendar-v2`):
- 日历视图正常渲染
- 日期选择和统计数据加载正常
- 交易详情查看和编辑正常
**理由**:
- **V2 无单元测试覆盖**: 当前项目主要测试集中在后端,前端缺少自动化测试
- **快速反馈**: 手动测试可以在 10 分钟内完成所有关键路径验证
- **用户体验保证**: 确保最终用户不会遇到功能异常
**备选方案 (未选择)**:
- **仅依赖编译检查**: 无法发现运行时错误(如路由配置错误)
- **编写自动化测试**: 时间成本过高,且本次变更不涉及逻辑修改
---
## Risks / Trade-offs
### Risk 1: 误删 V2 依赖的代码
**表现**: 删除后端接口或前端方法,导致 V2 页面调用失败
**缓解措施**:
- 采用 Decision 2 的搜索验证流程,确保每个删除操作都经过确认
- 先删除后端 Service 层,观察前端是否有新的 404 错误
- 使用 Git 进行版本控制,支持快速回滚
**残留影响**:
- 如果 V2 通过动态路由或字符串拼接调用接口,静态搜索可能遗漏
---
### Risk 2: 共享组件包含 V1 特定逻辑但未被清理
**表现**: 保留的组件中包含 V1 特定的条件判断或事件监听,成为"死代码"
**缓解措施**:
- 标记为 Non-Goal不在本次清理范围内
- 在任务清单中添加"后续优化"任务,记录需要清理的组件列表
**残留影响**:
- 代码库中保留少量无用代码,但不影响功能正确性
---
### Risk 3: 路由守卫中遗留 V1 相关逻辑
**表现**: 删除 V1 路由后,`router/index.js``useVersionStore` 中仍有版本切换逻辑
**缓解措施**:
- 在任务清单中明确包含"清理路由守卫"任务
- 搜索 `isV2()`, `useVersionStore`, `router.push` 等关键字,逐一检查
**残留影响**:
- 可能导致用户访问不存在的路由时出现 404 错误
---
### Risk 4: V2 页面功能异常但手动测试未覆盖
**表现**: 删除操作影响了 V2 的边缘功能(如智能分类、批量操作),但未在测试清单中
**缓解措施**:
- 优先测试 V2 的核心流程(查看、创建、编辑、删除)
- 如果发现问题,立即回滚对应的删除操作
- 记录测试结果,作为归档的一部分
**残留影响**:
- 边缘功能可能在生产环境中被用户发现,需要后续修复
---
### Trade-off 1: 保留共享组件 vs 彻底清理
**权衡**: 为了保证 V2 功能稳定,选择保留所有共享组件,即使部分组件包含 V1 特定逻辑
**代价**:
- 代码库中保留部分冗余代码
- 后续维护时可能需要额外的上下文理解
**收益**:
- 大幅降低误删风险
- 缩短变更周期,快速上线
---
### Trade-off 2: 自动化测试 vs 手动测试
**权衡**: 采用手动测试而非编写自动化测试用例
**代价**:
- 无法在 CI/CD 中自动验证 V2 功能
- 未来的变更可能再次破坏 V2 功能
**收益**:
- 节省测试编写时间(预计 2-3 小时)
- 适用于一次性删除任务,性价比高
---
## Migration Plan
### 阶段 1: 准备工作 (Pre-deletion)
1. 创建 feature 分支 `feature/remove-v1-modules`
2. 运行所有现有测试,确认当前状态正常
3. 备份 V1 相关文件列表(用于回滚)
### 阶段 2: 后端删除 (Backend Removal)
1. **Service 层删除**:
- 删除 `BudgetService.GetUncoveredCategoriesAsync`
- 删除 `BudgetService.GetArchiveSummaryAsync`
- 删除 `TransactionStatisticsService.GetBalanceStatisticsAsync`
- 编译验证,确认无编译错误
2. **Application 层删除**:
- 删除 `BudgetApplication.GetUncoveredCategoriesAsync`
- 删除 `BudgetApplication.GetArchiveSummaryAsync`
- 删除 `TransactionStatisticsApplication.GetBalanceStatisticsAsync`
- 编译验证
3. **Controller 层删除**:
- 删除 `TransactionRecordController.GetDailyStatisticsAsync`
- 删除 `BudgetController.GetUncoveredCategoriesAsync`
- 删除 `BudgetController.GetArchiveSummaryAsync`
- 删除 `TransactionStatisticsController.GetBalanceStatisticsAsync`
- 编译验证
4. **运行后端测试**:
```bash
dotnet test WebApi.Test/WebApi.Test.csproj
```
- 移除失败的 V1 相关测试用例
- 确保其他测试通过
### 阶段 3: 前端删除 (Frontend Removal)
1. **API 客户端清理**:
- 清理 `Web/src/api/transactionRecord.js` 中的 `GetDailyStatistics` 调用
- 清理 `Web/src/api/budget.js` 中的 `GetUncoveredCategories`, `GetArchiveSummary`
- 清理 `Web/src/api/statistics.js` 中的 `GetBalanceStatistics`
2. **页面文件删除**:
- 删除 `Web/src/views/CalendarView.vue`
- 删除 `Web/src/views/BudgetView.vue`
- 删除 `Web/src/views/statisticsV1/Index.vue`
- 删除 `Web/src/views/statisticsV1/` 目录
3. **路由配置清理**:
- 删除 `Web/src/router/index.js` 中的 V1 路由定义:
- `/calendar`
- `/budget`
- `/` (statisticsV1)
- 简化 `useVersionStore` 中的版本切换逻辑
- 移除路由守卫中的 V1 相关判断
4. **编译验证**:
```bash
cd Web && pnpm build
```
### 阶段 4: 手动测试验证 (Manual Testing)
按照 Decision 5 的测试清单,逐项验证 V2 功能:
1. 启动开发服务器: `pnpm dev`
2. 打开浏览器,访问 V2 页面
3. 验证核心功能(数据加载、交互、导航)
4. 记录测试结果
### 阶段 5: 代码审查和合并 (Code Review & Merge)
1. 提交变更到远程分支
2. 创建 Pull Request
3. 代码审查关注点:
- 确认删除的代码不在 V2 中被引用
- 检查是否有遗漏的 V1 特定逻辑
4. 合并到主分支
### 回滚策略
如果发现 V2 功能异常:
1. **立即回滚**: `git revert <commit-hash>`
2. **定位问题**: 使用 `git diff` 找到引起问题的删除操作
3. **部分恢复**: 仅恢复必要的文件或方法
4. **重新测试**: 确认恢复后 V2 功能正常
---
## Open Questions
### Q1: 是否需要保留 `[Obsolete]` 标记的接口一段时间?
**当前决策**: 直接删除,因为 V2 已稳定运行
**待确认**: 是否有外部系统或移动端 APP 仍在调用这些接口?如果有,需要先通知相关方并提供迁移时间
---
### Q2: 全局事件监听 (`transaction-deleted`) 在 V2 中是否还需要?
**当前决策**: 保留事件触发代码,即使 V1 删除后无人监听
**待确认**: V2 是否使用其他机制(如 Pinia store来实现组件间通信如果是可以在后续任务中移除全局事件
---
### Q3: 是否需要更新 API 文档 (Swagger/Scalar)
**当前决策**: 标记为 Non-Goal不在本次范围内
**待确认**: API 文档是否自动生成?如果是手动维护,需要添加任务来移除已删除接口的文档
---
### Q4: V2 页面是否有完整的功能对齐?
**当前假设**: V2 已完全替代 V1 功能
**待确认**: 是否有用户或内部团队仍在使用 V1 特有的功能(如 `GetUncoveredCategories`)?如果有,需要先在 V2 中实现对应功能
---
**设计文档完成**。本文档为实施团队提供了清晰的技术决策依据和操作步骤,确保删除操作安全可控。

View File

@@ -0,0 +1,82 @@
## Why
随着系统 V2 版本的功能已经稳定运行V1 版本的日历、统计、预算三大模块已成为历史遗留代码,增加了代码库的维护成本和认知负担。移除这些旧版本代码可以简化系统架构,降低未来重构的风险,同时减少不必要的测试和文档维护工作。
## What Changes
### **BREAKING** 移除前端 V1 页面
- 删除 `Web/src/views/CalendarView.vue` (日历视图)
- 删除 `Web/src/views/BudgetView.vue` (预算管理页面)
- 删除 `Web/src/views/statisticsV1/Index.vue` (统计 V1 页面)
- 移除相关路由配置 (`/calendar`, `/budget`, `/` 的 V1 路由)
### **BREAKING** 移除前端 API 客户端 (仅 V1 专用部分)
- 清理 `Web/src/api/transactionRecord.js` 中 V1 专用方法 (`GetDailyStatistics` 调用)
- 清理 `Web/src/api/budget.js` 中 V1 专用方法 (`GetUncoveredCategories`, `GetArchiveSummary`)
- 清理 `Web/src/api/statistics.js` 中 V1 专用方法 (`GetBalanceStatistics`)
### **BREAKING** 移除后端 Controller 接口 (仅 V1 专用部分)
- `TransactionRecordController.GetDailyStatisticsAsync` (已标记 Obsolete仅 CalendarView 使用)
- `BudgetController.GetUncoveredCategoriesAsync` (仅 BudgetView 使用)
- `BudgetController.GetArchiveSummaryAsync` (仅 BudgetView 使用)
- `TransactionStatisticsController.GetBalanceStatisticsAsync` (仅 statisticsV1 使用)
### 移除后端 Service/Application 层 (仅 V1 专用部分)
- `BudgetApplication` 中移除 `GetUncoveredCategoriesAsync`, `GetArchiveSummaryAsync`
- `BudgetService` 中移除 `GetUncoveredCategoriesAsync`, `GetArchiveSummaryAsync`
- `TransactionStatisticsApplication` 中移除 `GetBalanceStatisticsAsync`
- `TransactionStatisticsService` 中移除 `GetBalanceStatisticsAsync`
### 清理共享组件的 V1 特定逻辑
- 检查 `TransactionList`, `TransactionDetail`, `PopupContainer` 等组件是否包含 V1 特定逻辑
- 移除全局事件监听 (`transaction-deleted`, `transactions-changed`) 如果仅 V1 使用
### 更新路由守卫和版本控制逻辑
- 移除 `Web/src/router/index.js` 中的 V1 路由配置
- 简化 `useVersionStore` 中的版本切换逻辑(移除 V1 相关分支)
## Capabilities
### New Capabilities
(无新增能力)
### Modified Capabilities
- `routing`: 移除 V1 路由配置,简化版本切换逻辑
- `transaction-api`: 移除 `GetDailyStatistics` (Obsolete) 接口
- `budget-api`: 移除 `GetUncoveredCategories`, `GetArchiveSummary` 接口
- `statistics-api`: 移除 `GetBalanceStatistics` 接口
## Impact
### 前端影响
- **页面数量**: 减少 3 个页面文件
- **路由配置**: 移除 3 个路由 (`/calendar`, `/budget`, `/`)
- **API 客户端**: 清理 4+ 个废弃方法
- **组件**: 需验证共享组件 (`TransactionList`, `TransactionDetail`) 在 V2 中是否正常工作
### 后端影响
- **Controller 层**: 移除 4 个 API 端点
- **Application 层**: 移除 4 个业务方法
- **Service 层**: 移除 4 个服务方法
- **Repository 层**: 无影响 (V2 继续使用相同的 Repository)
### 兼容性影响
- **破坏性变更**: 所有 V1 API 端点将不可用
- **用户影响**: V1 用户必须切换到 V2 版本
- **数据影响**: 无,数据库表和实体不受影响
### 测试影响
- **单元测试**: 需移除 V1 相关的测试用例
- **集成测试**: 需验证 V2 页面功能完整性
- **手动测试**: 需打开 V2 页面进行功能回归测试
### 依赖影响
- **共享组件**: `TransactionList`, `TransactionDetail`, `PopupContainer`, `SmartClassifyButton` 仍被 V2 使用,不能删除
- **第三方库**: ECharts 仍被 V2 使用,不能删除
- **全局事件**: 需检查 `transaction-deleted`, `transactions-changed` 事件是否仅被 V1 监听
### 文档影响
- 需更新 API 文档,标记移除的端点
- 需更新用户文档,说明 V1 已下线

View File

@@ -0,0 +1,130 @@
## REMOVED Requirements
### Requirement: Get Uncovered Categories
**Reason**: 该接口仅被 V1 预算页面使用,用于展示"未设置预算的分类"。V2 预算页面不包含此功能。
**Migration**: 如需在 V2 中实现类似功能,应重新设计并创建新接口。当前无直接迁移路径。
**原有功能**:
- **接口**: `GET /api/Budget/GetUncoveredCategories`
- **Controller**: `BudgetController.GetUncoveredCategoriesAsync`
- **参数**: `category` (enum: Expense/Income/Saving), `date` (DateTime)
- **返回**: `List<string>` (未设置预算的分类名称列表)
- **业务逻辑**:
1. 查询指定月份的所有交易记录,提取所有出现的分类
2. 查询指定月份已设置预算的分类
3. 计算差集,返回"有交易但未设置预算"的分类列表
**被以下代码调用**:
- `Web/src/views/BudgetView.vue` 中的 `fetchUncoveredCategories` 方法
- `Web/src/api/budget.js` 中的 `getUncoveredCategories` 函数
---
### Requirement: Get Archive Summary
**Reason**: 该接口仅被 V1 预算页面使用,用于展示"历史预算归档总结"。V2 预算页面不包含此功能。
**Migration**: 如需在 V2 中实现类似功能,应重新设计并创建新接口。当前无直接迁移路径。
**原有功能**:
- **接口**: `GET /api/Budget/GetArchiveSummary`
- **Controller**: `BudgetController.GetArchiveSummaryAsync`
- **参数**: `date` (DateTime, 用于指定查询的年月)
- **返回**: `ArchiveSummaryDto` (包含归档总结数据)
- **业务逻辑**:
1. 查询 `BudgetArchive` 表中指定月份的归档记录
2. 汇总支出、收入、存款的预算执行情况
3. 返回总结数据(如预算达成率、超支分类等)
**被以下代码调用**:
- `Web/src/views/BudgetView.vue` 中的 `showArchiveSummary` 方法
- `Web/src/api/budget.js` 中的 `getArchiveSummary` 函数
---
## Context
本规范定义了 EmailBill 后端 `BudgetController` 中两个 V1 专用接口的移除操作。
### 接口背景
这两个接口是 V1 预算页面的特色功能:
1. **未覆盖分类提示**: 帮助用户发现"有交易但未设置预算"的分类,提醒用户完善预算设置
2. **归档总结**: 展示历史月份的预算执行总结,帮助用户回顾过去的财务状况
### V2 设计变更
V2 预算页面重新设计了用户体验,移除了上述两个功能:
- **未覆盖分类**: V2 采用"按需创建预算"模式,不主动提示未覆盖分类
- **归档总结**: V2 使用实时统计替代归档总结,用户可随时查看任意月份的预算执行情况
### 技术依赖
这两个接口依赖以下 Service 和 Repository
- `BudgetService.GetUncoveredCategoriesAsync`
- `BudgetService.GetArchiveSummaryAsync`
- `BudgetRepository`
- `BudgetArchiveRepository`
- `TransactionRecordRepository`
移除接口后,相关 Service 方法也将被移除(见 `budget-service` 规范)。
---
## Validation
### 验证标准
1. **代码搜索验证**:
- 全局搜索 `GetUncoveredCategories`,确认仅在以下位置出现:
- `BudgetController.GetUncoveredCategoriesAsync` (待删除)
- `BudgetApplication.GetUncoveredCategoriesAsync` (待删除)
- `BudgetService.GetUncoveredCategoriesAsync` (待删除)
- `BudgetView.vue` (已删除)
- `budget.js` (已清理)
- 全局搜索 `GetArchiveSummary`,确认仅在 V1 相关代码中出现
2. **编译验证**:
- 删除 `BudgetController` 中的两个方法后,后端项目编译通过
- 删除 `BudgetApplication``BudgetService` 中的对应方法后,编译通过
3. **API 文档验证**:
- Swagger/Scalar 文档中不再显示以下端点:
- `/api/Budget/GetUncoveredCategories`
- `/api/Budget/GetArchiveSummary`
4. **运行时验证**:
- 前端调用上述端点返回 404
- V2 预算页面 (`/budget-v2`) 正常加载和操作,不受影响
---
## Dependencies
移除这两个接口的前置条件:
1. `BudgetView.vue` (V1 预算页面) 已删除
2. `Web/src/api/budget.js` 中的 `getUncoveredCategories``getArchiveSummary` 方法已清理
3. V2 预算页面已验证不依赖这两个接口
移除后连带删除:
- `BudgetApplication.GetUncoveredCategoriesAsync`
- `BudgetApplication.GetArchiveSummaryAsync`
- `BudgetService.GetUncoveredCategoriesAsync`
- `BudgetService.GetArchiveSummaryAsync`
移除后不影响:
- 其他预算相关接口 (`GetList`, `GetCategoryStats`, `Create`, `Update`, `Delete` 等)
- `BudgetRepository``BudgetArchiveRepository` 的查询逻辑 (仍被其他接口使用)
- V2 预算页面的任何功能
---
## Notes
### 功能对比表
| 功能 | V1 实现 | V2 实现 |
|------|---------|---------|
| **未覆盖分类提示** | 专用接口 `GetUncoveredCategories` | 无(按需创建预算) |
| **归档总结** | 专用接口 `GetArchiveSummary` | 实时统计 `GetCategoryStats` |
| **预算列表** | `GetList` | `GetList` (共用) |
| **分类统计** | `GetCategoryStats` | `GetCategoryStats` (共用) |
### 潜在影响
如果未来需要在 V2 中恢复"未覆盖分类"或"归档总结"功能:
1. **不能直接恢复删除的代码**,因为业务逻辑可能已过时
2. **应重新设计接口**,考虑 V2 的数据模型和用户体验
3. **建议先调研用户需求**,确认是否真的需要这些功能

View File

@@ -0,0 +1,77 @@
## REMOVED Requirements
### Requirement: Calendar View Route
**Reason**: V1 日历页面已由 V2 版本完全替代V1 路由不再需要
**Migration**: 用户应访问 `/calendar-v2` 路由,使用新版日历功能
---
### Requirement: Budget View Route
**Reason**: V1 预算页面已由 V2 版本完全替代V1 路由不再需要
**Migration**: 用户应访问 `/budget-v2` 路由,使用新版预算管理功能
---
### Requirement: Statistics V1 Default Route
**Reason**: V1 统计页面已由 V2 版本完全替代V1 默认路由不再需要
**Migration**: 系统默认路由应指向 `/statistics-v2`,用户将自动使用新版统计功能
---
### Requirement: V1/V2 Version Toggle Logic
**Reason**: V1 版本完全下线后,版本切换逻辑不再需要
**Migration**: 移除 `useVersionStore` 中的版本切换代码,简化路由守卫逻辑。所有用户默认使用 V2 版本。
---
## Context
本规范定义了 EmailBill 前端路由系统中 V1 相关路由的移除操作。随着 V2 版本的稳定上线V1 的日历、预算、统计三个核心模块的路由定义已成为遗留代码。
### 受影响的路由
- `/calendar` → CalendarView.vue (V1 日历视图)
- `/budget` → BudgetView.vue (V1 预算管理)
- `/` → statisticsV1/Index.vue (V1 统计页面,默认首页)
### 现有版本控制机制
- `useVersionStore` 提供 `isV2()` 方法判断用户偏好
- 路由守卫根据版本偏好自动跳转到对应的 V1 或 V2 路由
- V2 路由命名规则: 原路由名 + `-v2` 后缀
### 移除后的预期行为
- 访问 `/calendar``/budget``/` 将返回 404 或重定向到 V2 版本
- `useVersionStore` 中的版本切换逻辑被简化或移除
- 路由守卫不再需要判断 V1/V2 版本
---
## Validation
### 验证标准
1. **路由配置文件** (`Web/src/router/index.js`):
- 不包含 `/calendar`, `/budget`, `/` 的 V1 路由定义
- V2 路由 (`/calendar-v2`, `/budget-v2`, `/statistics-v2`) 正常工作
2. **版本控制逻辑** (`Web/src/stores/version.js` 或类似文件):
- 移除或简化 `isV2()` 相关的版本切换代码
- 确保所有路由默认使用 V2 版本
3. **手动测试验证**:
- 直接访问 `/calendar` 不会加载 V1 页面
- 直接访问 `/budget` 不会加载 V1 页面
- 访问根路径 `/` 自动跳转到 V2 统计页面或返回 404
- V2 路由 (`/calendar-v2`, `/budget-v2`, `/statistics-v2`) 正常访问
---
## Dependencies
移除 V1 路由的前置条件:
1. V1 页面文件 (`CalendarView.vue`, `BudgetView.vue`, `statisticsV1/Index.vue`) 已删除
2. V2 页面功能已验证完整,可以完全替代 V1
3. 用户已迁移到 V2 版本,或系统强制使用 V2
移除后不影响:
- V2 路由配置和页面功能
- 路由守卫的认证检查逻辑 (`requiresAuth`)
- 其他非核心模块的路由 (如 `/login`, `/settings`)

View File

@@ -0,0 +1,141 @@
## REMOVED Requirements
### Requirement: Get Balance Statistics
**Reason**: 该接口仅被 V1 统计页面使用,用于绘制"余额变化折线图"。V2 统计页面使用不同的图表渲染逻辑,不依赖此接口。
**Migration**: V2 统计页面使用 `GetDailyStatistics` 接口获取数据,并在前端计算累积余额。无需后端专用接口。
**原有功能**:
- **接口**: `GET /api/TransactionStatistics/GetBalanceStatistics`
- **Controller**: `TransactionStatisticsController.GetBalanceStatisticsAsync`
- **参数**: `year` (int), `month` (int)
- **返回**: `BalanceStatisticsDto` (包含每日的累积余额数据)
- **业务逻辑**:
1. 查询指定月份的所有交易记录,按日期排序
2. 计算每日的累积余额 (前一日余额 + 当日收入 - 当日支出)
3. 返回包含日期和余额的时间序列数据
**被以下代码调用**:
- `Web/src/views/statisticsV1/Index.vue` 中的 `fetchBalanceData` 方法
- `Web/src/api/statistics.js` 中的 `getBalanceStatistics` 函数
**图表渲染逻辑**:
- V1 使用 ECharts 折线图渲染余额曲线
- 数据源: 后端计算好的累积余额
- 渲染方法: `renderBalanceChart()`
---
## Context
本规范定义了 EmailBill 后端 `TransactionStatisticsController` 中 V1 专用接口的移除操作。
### 接口背景
该接口是 V1 统计页面的核心功能之一,用于支持"余额变化趋势图"
- **设计理念**: 后端负责累积余额计算,前端只负责渲染
- **性能考虑**: 避免前端处理大量交易记录数据
- **适用场景**: V1 统计页面需要独立的余额统计视图
### V2 设计变更
V2 统计页面重新设计了数据获取和渲染逻辑:
- **数据获取**: 使用 `GetDailyStatistics` 一次性获取日度支出/收入数据
- **余额计算**: 在前端 Vue 组件中计算累积余额 (computed property)
- **性能优化**: 前端缓存计算结果,减少重复请求
- **代码简化**: 后端不需要维护专用的余额统计接口
### 技术对比
| 维度 | V1 实现 | V2 实现 |
|------|---------|---------|
| **数据获取** | 专用接口 `GetBalanceStatistics` | 通用接口 `GetDailyStatistics` |
| **余额计算** | 后端计算 | 前端计算 |
| **网络请求** | 2 次 (日度统计 + 余额统计) | 1 次 (日度统计) |
| **前端逻辑** | 简单渲染 | 计算 + 渲染 |
| **后端复杂度** | 高 (多个接口) | 低 (统一接口) |
### 依赖关系
该接口依赖以下 Service 和 Repository
- `TransactionStatisticsService.GetBalanceStatisticsAsync`
- `TransactionStatisticsApplication.GetBalanceStatisticsAsync`
- `TransactionRecordRepository` (查询交易记录)
移除接口后,相关 Service 方法也将被移除。
---
## Validation
### 验证标准
1. **代码搜索验证**:
- 全局搜索 `GetBalanceStatistics`,确认仅在以下位置出现:
- `TransactionStatisticsController.GetBalanceStatisticsAsync` (待删除)
- `TransactionStatisticsApplication.GetBalanceStatisticsAsync` (待删除)
- `TransactionStatisticsService.GetBalanceStatisticsAsync` (待删除)
- `statisticsV1/Index.vue` (已删除)
- `statistics.js` (已清理)
2. **编译验证**:
- 删除 `TransactionStatisticsController` 中的方法后,后端项目编译通过
- 删除 `TransactionStatisticsApplication``TransactionStatisticsService` 中的对应方法后,编译通过
3. **API 文档验证**:
- Swagger/Scalar 文档中不再显示 `/api/TransactionStatistics/GetBalanceStatistics` 端点
4. **运行时验证**:
- 前端调用 `/api/TransactionStatistics/GetBalanceStatistics` 返回 404
- V2 统计页面 (`/statistics-v2`) 正常加载,余额曲线正常渲染
5. **功能验证 (V2 统计页面)**:
- 打开 `/statistics-v2`
- 切换到"月度视图"
- 确认余额折线图正常显示
- 确认数据与 V1 保持一致 (基于相同的 `GetDailyStatistics` 数据)
---
## Dependencies
移除此接口的前置条件:
1. `statisticsV1/Index.vue` (V1 统计页面) 已删除
2. `Web/src/api/statistics.js` 中的 `getBalanceStatistics` 方法已清理
3. V2 统计页面已验证可以通过前端计算实现相同功能
移除后连带删除:
- `TransactionStatisticsApplication.GetBalanceStatisticsAsync`
- `TransactionStatisticsService.GetBalanceStatisticsAsync`
移除后不影响:
- 其他统计接口 (`GetMonthlyStatistics`, `GetCategoryStatistics`, `GetDailyStatistics`)
- `TransactionRecordRepository` 的查询逻辑 (仍被其他接口使用)
- V2 统计页面的任何功能 (余额计算逻辑已在前端实现)
---
## Implementation Notes
### V2 前端余额计算示例 (参考)
```javascript
// Web/src/views/statisticsV2/Index.vue
computed: {
balanceData() {
let cumulativeBalance = 0
return this.dailyData.map(day => {
cumulativeBalance += day.income - day.expense
return {
date: day.date,
balance: cumulativeBalance
}
})
}
}
```
### 性能分析
- **V1 方案**: 后端查询 + 计算 + 序列化 → 前端接收 + 渲染 (总时间: ~200ms)
- **V2 方案**: 后端查询 + 序列化 → 前端接收 + 计算 + 渲染 (总时间: ~180ms)
- **结论**: V2 方案性能略优,且减少了后端复杂度
### 回归测试建议
删除接口后,应手动验证 V2 统计页面的以下场景:
1. **正常月份**: 选择有交易的月份,确认余额曲线正常
2. **无交易月份**: 选择无交易的月份,确认显示"暂无数据"
3. **跨年场景**: 验证 1 月份的余额计算是否正确 (初始余额为 0)
4. **性能测试**: 加载包含大量交易 (>1000 笔) 的月份,确认渲染流畅

View File

@@ -0,0 +1,76 @@
## REMOVED Requirements
### Requirement: Get Daily Statistics (Obsolete)
**Reason**: 该接口已标记 `[Obsolete]`,仅被 V1 日历页面使用。V2 使用 `TransactionStatisticsController.GetDailyStatisticsAsync` 替代。
**Migration**: 前端应调用 `GET /api/TransactionStatistics/GetDailyStatistics` 接口,后端应使用 `TransactionStatisticsService.GetDailyStatisticsAsync` 方法。
**原有功能**:
- **接口**: `GET /api/TransactionRecord/GetDailyStatistics`
- **Controller**: `TransactionRecordController.GetDailyStatisticsAsync`
- **参数**: `year` (int), `month` (int)
- **返回**: `List<DailyStatisticDto>` (包含每日的支出、收入、余额统计)
**被以下代码调用**:
- `Web/src/views/CalendarView.vue` 中的 `fetchDailyStatistics` 方法
---
## Context
本规范定义了 EmailBill 后端 `TransactionRecordController` 中废弃的 V1 专用接口的移除操作。
### 接口历史
- **创建时间**: V1 版本早期,用于支持日历视图的日度统计
- **废弃原因**: 职责不清晰,日度统计应由 `TransactionStatisticsController` 统一管理
- **废弃标记**: 已标记 `[Obsolete]` 属性,建议开发者使用新接口
- **当前使用**: 仅被 `CalendarView.vue` (V1) 调用V2 日历页面不使用此接口
### 新接口对比
| 维度 | V1 接口 (待删除) | V2 接口 (推荐) |
|------|----------------|---------------|
| **路径** | `/api/TransactionRecord/GetDailyStatistics` | `/api/TransactionStatistics/GetDailyStatistics` |
| **Controller** | `TransactionRecordController` | `TransactionStatisticsController` |
| **职责** | 混杂在交易记录 CRUD 中 | 专注于统计查询 |
| **返回格式** | `List<DailyStatisticDto>` | 相同 |
| **性能** | 相同 | 相同 |
### 移除影响
- **前端**: `CalendarView.vue` 删除后,无其他前端代码调用此接口
- **后端**: `TransactionRecordController` 移除此方法后,不影响其他交易记录相关接口
- **数据库**: 无影响,底层查询逻辑由 `TransactionStatisticsService` 处理
---
## Validation
### 验证标准
1. **代码搜索验证**:
- 全局搜索 `GetDailyStatistics`,确认仅在以下位置出现:
- `TransactionRecordController.GetDailyStatisticsAsync` (待删除)
- `CalendarView.vue` (已删除)
- `TransactionStatisticsController.GetDailyStatisticsAsync` (保留)
2. **编译验证**:
- 删除 `TransactionRecordController.GetDailyStatisticsAsync` 方法后,后端项目编译通过
- 无其他 Controller 或 Application 层代码引用此方法
3. **API 文档验证**:
- Swagger/Scalar 文档中不再显示 `/api/TransactionRecord/GetDailyStatistics` 端点
- `/api/TransactionStatistics/GetDailyStatistics` 端点正常显示
4. **运行时验证**:
- 前端调用 `/api/TransactionRecord/GetDailyStatistics` 返回 404
- V2 日历页面调用 `/api/TransactionStatistics/GetDailyStatistics` 正常返回数据
---
## Dependencies
移除此接口的前置条件:
1. `CalendarView.vue` (V1 日历页面) 已删除
2. V2 日历页面已完全使用 `TransactionStatisticsController.GetDailyStatisticsAsync` 接口
移除后不影响:
- `TransactionStatisticsController` 中的同名方法 (不同 Controller)
- 其他交易记录相关接口 (`GetById`, `GetByDate`, `Update`, `Delete` 等)
- `TransactionRecordRepository` 的查询逻辑 (仍被 V2 接口使用)

View File

@@ -0,0 +1,140 @@
## 1. 准备工作 (Pre-deletion)
- [x] 1.1 创建 feature 分支 `feature/remove-v1-modules`
- [x] 1.2 运行所有现有测试,确认当前状态正常 (`dotnet test`)
- [x] 1.3 备份 V1 相关文件列表到变更目录 (用于回滚参考)
## 2. 后端 Service 层删除
- [x] 2.1 ~~搜索并删除 `BudgetService.GetUncoveredCategoriesAsync` 方法~~ (V2在用已恢复)
- [x] 2.2 ~~搜索并删除 `BudgetService.GetArchiveSummaryAsync` 方法~~ (V2在用已恢复)
- [x] 2.3 搜索并删除 `BudgetStatsService``BudgetSavingsService` 中的 V1 专用方法 (如果有)
- [x] 2.4 搜索并删除 `TransactionStatisticsService.GetBalanceStatisticsAsync` 方法
- [x] 2.5 编译验证后端项目 (`dotnet build`)
## 3. 后端 Application 层删除
- [x] 3.1 ~~删除 `BudgetApplication.GetUncoveredCategoriesAsync` 方法~~ (V2在用已恢复)
- [x] 3.2 ~~删除 `BudgetApplication.GetArchiveSummaryAsync` 方法~~ (V2在用已恢复)
- [x] 3.3 删除 `TransactionStatisticsApplication.GetBalanceStatisticsAsync` 方法
- [x] 3.4 编译验证后端项目 (`dotnet build`)
## 4. 后端 Controller 层删除
- [x] 4.1 删除 `TransactionRecordController.GetDailyStatisticsAsync` 方法 (已标记 Obsolete)
- [x] 4.2 ~~删除 `BudgetController.GetUncoveredCategoriesAsync` 方法~~ (V2在用已恢复)
- [x] 4.3 ~~删除 `BudgetController.GetArchiveSummaryAsync` 方法~~ (V2在用已恢复)
- [x] 4.4 删除 `TransactionStatisticsController.GetBalanceStatisticsAsync` 方法
- [x] 4.5 编译验证后端项目 (`dotnet build`)
- [x] 4.6 运行后端测试,移除失败的 V1 相关测试用例 (`dotnet test`)
## 5. 前端 API 客户端清理
- [x] 5.1 在 `Web/src/api/transactionRecord.js` 中移除 `GetDailyStatistics` 直接调用 (如果有)
- [x] 5.2 ~~在 `Web/src/api/budget.js` 中删除 `getUncoveredCategories` 函数~~ (V2在用已恢复)
- [x] 5.3 ~~在 `Web/src/api/budget.js` 中删除 `getArchiveSummary` 函数~~ (V2在用已恢复)
- [x] 5.4 在 `Web/src/api/statistics.js` 中删除 `getBalanceStatistics` 函数
- [x] 5.5 搜索确认这些方法不在其他地方被引用 (发现 budgetV2 依赖,已恢复)
## 6. 前端页面文件删除
- [x] 6.1 删除 `Web/src/views/CalendarView.vue` 文件
- [x] 6.2 删除 `Web/src/views/BudgetView.vue` 文件
- [x] 6.3 删除 `Web/src/views/statisticsV1/Index.vue` 文件
- [x] 6.4 删除 `Web/src/views/statisticsV1/` 整个目录 (如果为空)
- [x] 6.5 搜索确认这些页面不在其他地方被 import 引用 (发现路由和 App.vue 引用,待清理)
## 7. 前端路由配置清理
- [x] 7.1 在 `Web/src/router/index.js` 中删除 `/calendar` 路由定义
- [x] 7.2 在 `Web/src/router/index.js` 中删除 `/budget` 路由定义
- [x] 7.3 在 `Web/src/router/index.js` 中删除 `/` 指向 `statisticsV1/Index.vue` 的路由定义
- [x] 7.4 搜索并简化 `useVersionStore` 中的版本切换逻辑 (移除 V1 相关分支)
- [x] 7.5 搜索路由守卫中的 V1 相关判断逻辑并移除 (如 `isV2()` 判断)
## 8. 全局事件监听清理 (条件性)
- [x] 8.1 搜索 `window.addEventListener('transaction-deleted')`,确认是否仅 V1 页面监听
- [x] 8.2 搜索 `window.addEventListener('transactions-changed')`,确认是否仅 V1 页面监听
- [x] 8.3 如果仅 V1 监听,搜索 `window.dispatchEvent(new Event('transaction-deleted'))` 并删除触发代码
- [x] 8.4 如果仅 V1 监听,搜索 `window.dispatchEvent(new Event('transactions-changed'))` 并删除触发代码
- [x] 8.5 如果 V2 也在监听这些事件,保留触发代码并标记为"后续清理"任务
## 9. 前端构建验证
- [x] 9.1 安装依赖 (`cd Web && pnpm install`)
- [x] 9.2 运行 ESLint 检查 (`pnpm lint`)
- [x] 9.3 构建前端项目 (`pnpm build`)
- [x] 9.4 确认构建成功,无编译错误或警告
## 10. 手动测试验证 (V2 功能)
- [ ] 10.1 启动开发服务器 (`pnpm dev`)
- [ ] 10.2 测试 V2 统计页面 (`/statistics-v2`): 月度统计、分类统计、日度统计、交易列表
- [ ] 10.3 测试 V2 统计页面: 余额折线图正常渲染 (验证前端计算余额逻辑)
- [ ] 10.4 测试 V2 预算页面 (`/budget-v2`): 预算列表、分类统计、预算 CRUD、存款导航
- [ ] 10.5 测试 V2 日历页面 (`/calendar-v2`): 日历渲染、日期统计、交易详情查看和编辑
- [ ] 10.6 测试共享组件: TransactionList、TransactionDetail、PopupContainer 在 V2 中正常工作
- [ ] 10.7 测试智能分类功能 (SmartClassifyButton) 在 V2 页面中正常工作
- [ ] 10.8 验证访问 V1 路由 (`/calendar`, `/budget`, `/`) 返回 404 或重定向到 V2
## 11. 代码搜索验证 (确认无遗漏)
- [x] 11.1 全局搜索 `CalendarView` (大小写敏感),确认无残留引用
- [x] 11.2 全局搜索 `BudgetView` (大小写敏感),确认无残留引用
- [x] 11.3 全局搜索 `statisticsV1` (大小写敏感),确认无残留引用
- [x] 11.4 全局搜索 `GetDailyStatistics` (TransactionRecordController),确认仅 TransactionStatisticsController 中存在
- [x] 11.5 全局搜索 `GetUncoveredCategories`,确认无残留引用
- [x] 11.6 全局搜索 `GetArchiveSummary`,确认无残留引用
- [x] 11.7 全局搜索 `GetBalanceStatistics`,确认无残留引用
- [x] 11.8 全局搜索 `/calendar` 路由,确认仅出现在测试或配置文件中
- [x] 11.9 全局搜索 `/budget` 路由,确认仅出现在测试或配置文件中
## 12. 测试用例清理
- [x] 12.1 搜索并删除 `WebApi.Test/` 中针对 V1 API 的单元测试
- [x] 12.2 运行所有后端测试 (`dotnet test`),确保无失败测试
- [x] 12.3 如果有前端测试,运行并修复受影响的测试用例
## 13. 代码审查和提交
- [ ] 13.1 使用 `git status` 确认所有修改的文件
- [ ] 13.2 使用 `git diff` 审查每个删除的代码块,确认无误删
- [ ] 13.3 提交变更到本地分支: `git add . && git commit -m "feat: remove V1 calendar/budget/stats modules"`
- [ ] 13.4 推送到远程分支: `git push origin feature/remove-v1-modules`
## 14. Pull Request 和最终验证
- [ ] 14.1 创建 Pull Request填写变更说明和测试结果
- [ ] 14.2 代码审查: 确认删除的代码不在 V2 中被引用
- [ ] 14.3 CI/CD 管道通过 (编译 + 测试)
- [ ] 14.4 合并到主分支
## 15. 生产环境验证 (可选)
- [ ] 15.1 部署到测试环境,验证 V2 页面功能完整性
- [ ] 15.2 监控错误日志,确认无 404 或运行时错误
- [ ] 15.3 部署到生产环境
- [ ] 15.4 监控用户反馈和错误报告
---
## 回滚预案
如果在任何阶段发现 V2 功能异常:
1. **立即回滚**: `git revert <commit-hash>``git reset --hard <previous-commit>`
2. **定位问题**: 使用 `git diff` 找到引起问题的删除操作
3. **部分恢复**: 仅恢复必要的文件或方法 (使用 `git checkout <commit> -- <file>`)
4. **重新测试**: 确认恢复后 V2 功能正常
5. **分析根因**: 更新任务清单,标记需要保留的代码
---
## 后续清理任务 (标记为 Non-Goal不在本次范围内)
- **共享组件优化**: 移除 TransactionList、TransactionDetail 等组件中的 V1 特定逻辑
- **全局事件机制**: 如果 V2 不再需要全局事件监听,迁移到 Pinia store
- **API 文档更新**: 在 Swagger/Scalar 中标记已移除的端点
- **用户文档更新**: 说明 V1 已下线,引导用户使用 V2
- **性能优化**: 基于 V2 的使用数据优化接口和前端渲染逻辑

View File

@@ -0,0 +1,31 @@
# V1 相关文件列表备份 (用于回滚参考)
# 生成时间: 2026-02-13
## 前端页面文件
Web/src/views/CalendarView.vue
Web/src/views/BudgetView.vue
Web/src/views/statisticsV1/Index.vue
## 前端 API 客户端 (部分方法)
Web/src/api/transactionRecord.js (GetDailyStatistics 调用)
Web/src/api/budget.js (getUncoveredCategories, getArchiveSummary)
Web/src/api/statistics.js (getBalanceStatistics)
## 前端路由配置 (部分路由)
Web/src/router/index.js (/calendar, /budget, / 的 V1 路由)
## 后端 Controller 方法
WebApi/Controllers/TransactionRecordController.cs::GetDailyStatisticsAsync
WebApi/Controllers/BudgetController.cs::GetUncoveredCategoriesAsync
WebApi/Controllers/BudgetController.cs::GetArchiveSummaryAsync
WebApi/Controllers/TransactionStatisticsController.cs::GetBalanceStatisticsAsync
## 后端 Application 方法
Application/BudgetApplication.cs::GetUncoveredCategoriesAsync
Application/BudgetApplication.cs::GetArchiveSummaryAsync
Application/TransactionStatisticsApplication.cs::GetBalanceStatisticsAsync
## 后端 Service 方法
Service/Budget/BudgetService.cs::GetUncoveredCategoriesAsync
Service/Budget/BudgetService.cs::GetArchiveSummaryAsync
Service/Transaction/TransactionStatisticsService.cs::GetBalanceStatisticsAsync