diff --git a/Web/src/components/CategoryBillPopup.vue b/Web/src/components/CategoryBillPopup.vue
index a2868f9..7392ea8 100644
--- a/Web/src/components/CategoryBillPopup.vue
+++ b/Web/src/components/CategoryBillPopup.vue
@@ -4,9 +4,7 @@
:title="title"
:height="'80%'"
>
-
-
-
-
-
- 加载中...
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ txn.reason }}
-
-
-
-
- {{ formatAmount(txn.amount, txn.type) }}
-
-
-
-
-
-
- 加载中...
-
-
- 加载更多
-
-
-
-
-
- 已加载全部
-
-
-
+
-
@@ -130,7 +39,8 @@ import { ref, computed, watch } from 'vue'
import { showToast } from 'vant'
import TransactionDetailSheet from '@/components/Transaction/TransactionDetailSheet.vue'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
-import { getTransactionList } from '@/api/transactionRecord'
+import BillListComponent from '@/components/Bill/BillListComponent.vue'
+import { getTransactionList, getTransactionDetail } from '@/api/transactionRecord'
const props = defineProps({
modelValue: {
@@ -157,20 +67,17 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'refresh'])
-// 双向绑定
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
-// 标题
const title = computed(() => {
const classifyText = props.classify || '未分类'
const typeText = props.type === 0 ? '支出' : props.type === 1 ? '收入' : '不计收支'
return `${classifyText} - ${typeText}`
})
-// 数据状态
const transactions = ref([])
const loading = ref(false)
const finished = ref(false)
@@ -178,48 +85,11 @@ const pageIndex = ref(1)
const pageSize = 20
const total = ref(0)
-// 详情弹窗
const showDetail = ref(false)
const currentTransaction = ref(null)
-// 格式化日期时间
-const formatDateTime = (dateTimeStr) => {
- const date = new Date(dateTimeStr)
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- const hours = String(date.getHours()).padStart(2, '0')
- const minutes = String(date.getMinutes()).padStart(2, '0')
- return `${month}-${day} ${hours}:${minutes}`
-}
-
-// 格式化金额
-const formatAmount = (amount, type) => {
- const sign = type === 1 ? '+' : '-'
- return `${sign}${amount.toFixed(2)}`
-}
-
-// 根据分类获取图标
-const getIconByClassify = (classify) => {
- const iconMap = {
- 餐饮: 'food',
- 购物: 'shopping',
- 交通: 'logistics',
- 娱乐: 'play-circle',
- 医疗: 'medic',
- 工资: 'gold-coin',
- 红包: 'gift'
- }
- return iconMap[classify] || 'bill'
-}
-
-// 根据类型获取颜色
-const getColorByType = (type) => {
- return type === 1 ? '#22C55E' : '#FF6B6B'
-}
-
-// 加载数据
const loadData = async (isRefresh = false) => {
- if (loading.value || finished.value) {
+ if (loading.value) {
return
}
@@ -249,15 +119,7 @@ const loadData = async (isRefresh = false) => {
if (response.success) {
const newList = response.data || []
- // 转换数据格式,添加显示所需的字段
- const formattedList = newList.map((txn) => ({
- ...txn,
- icon: getIconByClassify(txn.classify),
- iconColor: getColorByType(txn.type),
- iconBg: '#FFFFFF'
- }))
-
- transactions.value = [...transactions.value, ...formattedList]
+ transactions.value = [...transactions.value, ...newList]
total.value = response.total
if (newList.length === 0 || newList.length < pageSize) {
@@ -278,42 +140,50 @@ const loadData = async (isRefresh = false) => {
}
}
-// 加载更多
const loadMore = () => {
- loadData(false)
+ if (!finished.value && !loading.value) {
+ loadData(false)
+ }
}
-// 点击交易
-const onTransactionClick = (txn) => {
- currentTransaction.value = txn
- showDetail.value = true
+const onTransactionClick = async (txn) => {
+ try {
+ const response = await getTransactionDetail(txn.id)
+ if (response.success) {
+ currentTransaction.value = response.data
+ showDetail.value = true
+ } else {
+ showToast(response.message || '获取详情失败')
+ }
+ } catch (error) {
+ console.error('获取详情出错:', error)
+ showToast('获取详情失败')
+ }
}
-// 保存交易
const handleSave = () => {
showDetail.value = false
- // 重新加载数据
loadData(true)
- // 通知父组件刷新
emit('refresh')
}
-// 删除交易
const handleDelete = (id) => {
- showDetail.value = false
- // 从列表中移除
transactions.value = transactions.value.filter((t) => t.id !== id)
total.value--
- // 通知父组件刷新
emit('refresh')
}
-// 监听弹窗打开
+const handleTransactionDelete = (id) => {
+ showDetail.value = false
+ transactions.value = transactions.value.filter((t) => t.id !== id)
+ total.value--
+ emit('refresh')
+}
+
watch(visible, (newValue) => {
if (newValue) {
loadData(true)
} else {
- // 关闭时重置状态
transactions.value = []
pageIndex.value = 1
finished.value = false
@@ -324,145 +194,4 @@ watch(visible, (newValue) => {
diff --git a/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue b/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue
index dceb9de..4d59ed3 100644
--- a/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue
+++ b/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue
@@ -400,7 +400,7 @@ const handleShowDetail = (budget) => {
console.log('Details 内容:', budget.Details)
}
console.log('===================')
-
+
currentBudget.value = budget
showDetailPopup.value = true
}
@@ -431,38 +431,38 @@ const expenseCurrent = computed(() => matchedExpenseBudget.value?.current || 0)
// 归档和未来预算的汇总 (仅用于年度存款计划)
const hasArchivedIncome = computed(() => {
- if (!currentBudget.value?.details) return false
+ if (!currentBudget.value?.details) {return false}
return currentBudget.value.details.incomeItems.some(item => item.isArchived)
})
const archivedIncomeTotal = computed(() => {
- if (!currentBudget.value?.details) return 0
+ if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.incomeItems
.filter(item => item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const futureIncomeTotal = computed(() => {
- if (!currentBudget.value?.details) return 0
+ if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.incomeItems
.filter(item => !item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const hasArchivedExpense = computed(() => {
- if (!currentBudget.value?.details) return false
+ if (!currentBudget.value?.details) {return false}
return currentBudget.value.details.expenseItems.some(item => item.isArchived)
})
const archivedExpenseTotal = computed(() => {
- if (!currentBudget.value?.details) return 0
+ if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.expenseItems
.filter(item => item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
})
const futureExpenseTotal = computed(() => {
- if (!currentBudget.value?.details) return 0
+ if (!currentBudget.value?.details) {return 0}
return currentBudget.value.details.expenseItems
.filter(item => !item.isArchived)
.reduce((sum, item) => sum + item.effectiveAmount, 0)
diff --git a/openspec/changes/unify-bill-list-components/.openspec.yaml b/openspec/changes/unify-bill-list-components/.openspec.yaml
new file mode 100644
index 0000000..d0ec88b
--- /dev/null
+++ b/openspec/changes/unify-bill-list-components/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-20
diff --git a/openspec/changes/unify-bill-list-components/design.md b/openspec/changes/unify-bill-list-components/design.md
new file mode 100644
index 0000000..e2fb5c6
--- /dev/null
+++ b/openspec/changes/unify-bill-list-components/design.md
@@ -0,0 +1,66 @@
+## Context
+
+项目中存在多处账单列表展示,但实现方式不一致:
+- `TransactionsRecord.vue`(标准)使用 `BillListComponent`,功能完整
+- `CategoryBillPopup.vue` 自定义实现,样式和交互与标准不一致
+- `calendarV2/modules/TransactionList.vue` 使用 `BillListComponent` 但配置可能不一致
+- `BudgetCard.vue` 使用 `BillListComponent` Custom 模式
+- `EmailRecord.vue` 使用 `BillListComponent` Custom 模式
+
+核心组件 `BillListComponent.vue` 已具备统一能力,但各使用方配置参数不统一。
+
+## Goals / Non-Goals
+
+**Goals:**
+- 统一所有账单列表的 UI 样式(图标、金额、标签布局)
+- 统一基础交互(左滑删除、点击详情、空状态)
+- 确保 `BillListComponent` 正确配置
+
+**Non-Goals:**
+- 不添加搜索功能(弹窗场景不需要)
+- 不修改 API 接口
+- 不重构 `BillListComponent` 核心代码
+
+## Decisions
+
+### 决策 1: 统一使用 BillListComponent
+
+**选择**: 所有账单列表统一使用 `BillListComponent.vue`
+
+**理由**:
+- 该组件已具备所有必要功能(筛选、分页、删除、多选)
+- 支持 API 模式和 Custom 模式
+- 已有完善的暗黑模式支持
+
+**备选方案**: 为每个场景创建独立组件 → 放弃(维护成本高、样式难以统一)
+
+### 决策 2: 标准配置模板
+
+**选择**: 定义统一的 props 配置模板
+
+```typescript
+// 弹窗场景标准配置
+const popupConfig = {
+ dataSource: 'custom',
+ transactions: [...],
+ enableFilter: false, // 弹窗不需要筛选
+ showCheckbox: false,
+ showDelete: true, // 支持删除
+ compact: true, // 紧凑布局
+}
+```
+
+### 决策 3: 交互事件统一
+
+**选择**: 统一使用组件 emit 事件 + 全局事件总线
+
+- `@click` → 触发详情查看
+- `transaction-deleted` → 全局广播删除事件
+
+## Risks / Trade-offs
+
+| 风险 | 缓解措施 |
+|------|----------|
+| CategoryBillPopup 自定义实现,改动较大 | 先对比差异,逐步对齐 |
+| 各场景数据源不同 | 统一使用 Custom 模式,父组件管理数据 |
+| 事件处理不一致 | 统一使用组件 emit 事件 |
diff --git a/openspec/changes/unify-bill-list-components/proposal.md b/openspec/changes/unify-bill-list-components/proposal.md
new file mode 100644
index 0000000..083e826
--- /dev/null
+++ b/openspec/changes/unify-bill-list-components/proposal.md
@@ -0,0 +1,41 @@
+## Why
+
+当前项目中存在多处账单列表展示,但样式和功能不一致,导致用户体验不统一。统计页面分类账单弹窗与 `/balance` 标准页面的账单列表在 UI 样式和交互行为上存在明显差异。为提升用户体验一致性和降低维护成本,需要统一所有账单列表组件的样式和基础功能。
+
+## What Changes
+
+- 统一 4 处账单列表弹窗/组件的样式和交互:
+ - `CategoryBillPopup.vue`(统计页面分类账单弹窗)
+ - `calendarV2/modules/TransactionList.vue`(日历视图账单列表)
+ - `Budget/BudgetCard.vue`(预算关联账单弹窗)
+ - `EmailRecord.vue`(邮件关联账单弹窗)
+- 对齐以下方面到 `TransactionsRecord.vue` 标准:
+ - 列表项样式(图标、文字、金额布局)
+ - 左滑删除交互
+ - 点击查看详情交互
+ - 空状态展示
+- **不涉及**:搜索功能(弹窗场景不需要搜索)
+
+## Capabilities
+
+### New Capabilities
+
+无新增能力。
+
+### Modified Capabilities
+
+- `bill-list-display`: 统一账单列表展示样式和基础交互功能
+
+## Impact
+
+**前端组件**:
+- `Web/src/components/CategoryBillPopup.vue` - 统计分类账单弹窗
+- `Web/src/views/calendarV2/modules/TransactionList.vue` - 日历账单列表
+- `Web/src/components/Budget/BudgetCard.vue` - 预算关联账单弹窗
+- `Web/src/views/EmailRecord.vue` - 邮件关联账单弹窗
+
+**参考标准**:
+- `Web/src/views/TransactionsRecord.vue` - 标准账单列表实现
+- `Web/src/components/Bill/BillListComponent.vue` - 核心账单列表组件
+
+**API 依赖**: 无新增 API,复用现有 `getTransactionList` 接口
diff --git a/openspec/changes/unify-bill-list-components/specs/transaction-list-display/spec.md b/openspec/changes/unify-bill-list-components/specs/transaction-list-display/spec.md
new file mode 100644
index 0000000..c719cd9
--- /dev/null
+++ b/openspec/changes/unify-bill-list-components/specs/transaction-list-display/spec.md
@@ -0,0 +1,89 @@
+## ADDED Requirements
+
+### Requirement: CategoryBillPopup 统一样式
+统计页面分类账单弹窗必须使用 BillListComponent,样式与 Balance 页面一致。
+
+#### Scenario: 使用 BillListComponent
+- **WHEN** 用户在统计页面点击分类卡片
+- **THEN** 弹窗使用 `BillListComponent` 展示账单列表,配置为 `dataSource="api"` 模式
+
+#### Scenario: 列表项样式对齐
+- **WHEN** 账单列表渲染
+- **THEN** 使用与 `TransactionsRecord.vue` 相同的卡片样式(图标、金额、标签布局)
+
+#### Scenario: 左滑删除
+- **WHEN** 用户在账单项上左滑
+- **THEN** 显示红色删除按钮,点击后确认删除
+
+#### Scenario: 点击查看详情
+- **WHEN** 用户点击账单项
+- **THEN** 打开 `TransactionDetailSheet` 查看详情
+
+### Requirement: CalendarV2 TransactionList 对齐
+日历页面的交易列表样式必须与 Balance 页面一致。
+
+#### Scenario: 紧凑布局
+- **WHEN** 日历页面展示当天账单列表
+- **THEN** 使用 `compact={true}` 紧凑布局
+
+#### Scenario: 删除交互
+- **WHEN** 用户左滑删除账单
+- **THEN** 与 Balance 页面删除交互一致
+
+### Requirement: BudgetCard 关联账单对齐
+预算页面的关联账单弹窗样式必须与 Balance 页面一致。
+
+#### Scenario: 统一卡片样式
+- **WHEN** 预算卡片展示关联账单
+- **THEN** 账单项样式与 Balance 页面一致
+
+### Requirement: EmailRecord 关联账单对齐
+邮件记录页面的关联账单弹窗样式必须与 Balance 页面一致。
+
+#### Scenario: 统一卡片样式
+- **WHEN** 邮件记录展示关联账单
+- **THEN** 账单项样式与 Balance 页面一致
+
+#### Scenario: 删除功能
+- **WHEN** 用户删除账单
+- **THEN** 删除交互与 Balance 页面一致
+
+## MODIFIED Requirements
+
+### Requirement: 功能对等性
+新组件必须保持旧版所有功能,确保迁移不丢失特性。
+
+#### Scenario: 批量选择功能
+- **WHEN** TransactionsRecord 需要批量操作
+- **THEN** 新组件通过 `showCheckbox` 和 `selectedIds` 提供相同功能
+
+#### Scenario: 删除后刷新
+- **WHEN** 账单删除成功
+- **THEN** 新组件派发 `transaction-deleted` 全局事件,保持与旧版相同的事件机制
+
+#### Scenario: 自定义数据源
+- **WHEN** 页面需要展示离线或缓存数据
+- **THEN** 新组件通过 `dataSource="custom"` 和 `transactions` prop 支持自定义数据
+
+#### Scenario: 弹窗场景数据源
+- **WHEN** 弹窗组件(CategoryBillPopup、BudgetCard、EmailRecord)展示账单
+- **THEN** 使用 `dataSource="api"` 或 `dataSource="custom"`,并配置 `enableFilter={false}` 禁用筛选
+
+### Requirement: 视觉升级
+新组件必须基于 v2 的现代化设计,提供更好的视觉体验。
+
+#### Scenario: 卡片样式
+- **WHEN** 展示账单列表
+- **THEN** 使用 v2 的卡片样式(圆角、阴影、图标),调整为紧凑间距
+
+#### Scenario: 图标展示
+- **WHEN** 账单有分类信息
+- **THEN** 显示对应的分类图标(如餐饮用 food 图标),带有彩色背景
+
+#### Scenario: 标签样式
+- **WHEN** 显示账单类型
+- **THEN** 使用彩色标签(支出红色、收入绿色),位于卡片右上角
+
+#### Scenario: 空状态展示
+- **WHEN** 账单列表为空
+- **THEN** 显示统一的空状态图标和提示文案
diff --git a/openspec/changes/unify-bill-list-components/tasks.md b/openspec/changes/unify-bill-list-components/tasks.md
new file mode 100644
index 0000000..b648f5c
--- /dev/null
+++ b/openspec/changes/unify-bill-list-components/tasks.md
@@ -0,0 +1,41 @@
+## 1. 分析差异
+
+- [x] 1.1 对比 CategoryBillPopup.vue 与 TransactionsRecord.vue 的样式差异
+- [x] 1.2 对比 calendarV2/modules/TransactionList.vue 与标准的差异
+- [x] 1.3 对比 BudgetCard.vue 关联账单与标准的差异
+- [x] 1.4 对比 EmailRecord.vue 关联账单与标准的差异
+
+## 2. 修改 CategoryBillPopup
+
+- [x] 2.1 重构 CategoryBillPopup 使用 BillListComponent
+- [x] 2.2 配置 props: dataSource="custom", enableFilter=false, showDelete=true
+- [x] 2.3 统一列表项样式(图标、金额、标签)
+- [x] 2.4 实现左滑删除交互
+- [x] 2.5 实现点击查看详情(打开 TransactionDetailSheet)
+- [x] 2.6 测试删除和详情功能
+
+## 3. 修改 CalendarV2 TransactionList
+
+- [x] 3.1 检查 BillListComponent 配置
+- [x] 3.2 确保 compact={true} 紧凑布局
+- [x] 3.3 统一删除交互
+- [x] 3.4 测试日历页面账单列表
+
+## 4. 修改 BudgetCard 关联账单
+
+- [x] 4.1 检查 BillListComponent 配置
+- [x] 4.2 统一卡片样式
+- [x] 4.3 测试预算关联账单弹窗
+
+## 5. 修改 EmailRecord 关联账单
+
+- [x] 5.1 检查 BillListComponent 配置
+- [x] 5.2 统一卡片样式和删除功能
+- [x] 5.3 测试邮件关联账单弹窗
+
+## 6. 验证
+
+- [x] 6.1 验证所有弹窗的账单列表样式一致
+- [x] 6.2 验证删除功能在各场景正常工作
+- [x] 6.3 验证详情查看功能正常
+- [x] 6.4 验证暗黑模式适配