fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
SunCheng
2026-02-20 13:56:29 +08:00
parent 2cf19a45e5
commit 6e95568906
13 changed files with 1038 additions and 1269 deletions

View File

@@ -1,843 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<!-- 普通预算卡片 -->
<div
v-if="!budget.noLimit"
class="common-card budget-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag>
</slot>
<h3 class="card-title">
{{ budget.name }}
</h3>
<span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }})
</span>
</div>
<van-icon
name="arrow-down"
class="expand-icon"
/>
</div>
<div class="collapsed-footer">
<div class="collapsed-item">
<span class="compact-label">实际/目标</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{
budget.current !== undefined && budget.limit !== undefined
? `¥${budget.current?.toFixed(0) || 0} / ¥${budget.limit?.toFixed(0) || 0}`
: '--'
}}
</slot>
</span>
</div>
<div class="collapsed-item">
<span class="compact-label">达成率</span>
<span
class="compact-value"
:class="percentClass"
>{{ percentage }}%</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<div
v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info">
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
<span
v-if="budget.isMandatoryExpense"
class="mandatory-mark"
>📌</span>
</van-tag>
</slot>
<h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }}
</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="amount-info">
<slot name="amount-info" />
</div>
<div class="progress-section">
<slot name="progress-info">
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
<van-progress
:percentage="Math.min(percentage, 100)"
stroke-width="8"
:color="progressColor"
:show-pivot="false"
/>
<span
class="percent"
:class="percentClass"
>{{ percentage }}%</span>
</slot>
</div>
<div class="progress-section time-progress">
<span class="period-type">时间进度</span>
<van-progress
:percentage="timePercentage"
stroke-width="4"
color="var(--van-gray-6)"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
<transition
name="collapse"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div>
</transition>
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
<!-- 不记额预算卡片 -->
<div
v-else
class="common-card budget-card no-limit-card"
:class="{ 'cursor-default': budget.category === 2 }"
@click="toggleExpand"
>
<div class="budget-content-wrapper">
<!-- 折叠状态 -->
<div
v-if="!isExpanded"
class="budget-collapsed"
>
<div class="collapsed-header">
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3 class="card-title">
{{ budget.name }}
</h3>
<span
v-if="budget.selectedCategories?.length"
class="card-subtitle"
>
({{ budget.selectedCategories.join('、') }})
</span>
</div>
<van-icon
name="arrow-down"
class="expand-icon"
/>
</div>
<div class="collapsed-footer no-limit-footer">
<div class="collapsed-item">
<span class="compact-label">实际</span>
<span class="compact-value">
<slot name="collapsed-amount">
{{ budget.current !== undefined ? `¥${budget.current?.toFixed(0) || 0}` : '--' }}
</slot>
</span>
</div>
</div>
</div>
<!-- 展开状态 -->
<div
v-else
class="budget-inner-card"
>
<div
class="card-header"
style="margin-bottom: 0"
>
<div class="budget-info">
<slot name="tag">
<van-tag
type="success"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
</van-tag>
</slot>
<h3
class="card-title"
style="max-width: 120px"
>
{{ budget.name }}
</h3>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="small"
:type="showDescription ? 'primary' : 'default'"
plain
@click.stop="showDescription = !showDescription"
/>
<van-button
icon="orders-o"
size="small"
plain
title="查询关联账单"
@click.stop="handleQueryBills"
/>
<template v-if="budget.category !== 2">
<van-button
icon="edit"
size="small"
plain
@click.stop="$emit('click', budget)"
/>
</template>
</slot>
</div>
</div>
<div class="budget-body">
<div
v-if="budget.selectedCategories?.length"
class="category-tags"
>
<van-tag
v-for="cat in budget.selectedCategories"
:key="cat"
size="mini"
class="category-tag"
plain
round
>
{{ cat }}
</van-tag>
</div>
<div class="no-limit-amount-info">
<div class="amount-item">
<span>
<span class="label">实际</span>
<span
class="value"
style="margin-left: 12px"
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
</span>
</div>
</div>
<div class="no-limit-notice">
<span>
<van-icon
name="info-o"
style="margin-right: 4px"
/>
不记额预算 - 直接计入存款明细
</span>
</div>
<transition
name="collapse"
@enter="onEnter"
@after-enter="onAfterEnter"
@leave="onLeave"
@after-leave="onAfterLeave"
>
<div
v-if="budget.description && showDescription"
class="budget-collapse-wrapper"
>
<div class="budget-description">
<div
class="description-content rich-html-content"
v-html="budget.description"
/>
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- 关联账单列表弹窗 -->
<PopupContainer
v-model="showBillListModal"
title="关联账单列表"
height="75%"
>
<TransactionList
:transactions="billList"
:loading="billLoading"
:finished="true"
:show-delete="false"
:show-checkbox="false"
@click="handleBillClick"
@delete="handleBillDelete"
/>
</PopupContainer>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
import PopupContainer from '@/components/PopupContainer.vue'
import TransactionList from '@/components/TransactionList.vue'
import { getTransactionList } from '@/api/transactionRecord'
const props = defineProps({
budget: {
type: Object,
required: true
},
progressColor: {
type: String,
default: 'var(--van-primary-color)'
},
percentClass: {
type: [String, Object],
default: ''
},
periodLabel: {
type: String,
default: ''
}
})
const emit = defineEmits(['click'])
const isExpanded = ref(props.budget.category === 2)
const showDescription = ref(false)
const showBillListModal = ref(false)
const billList = ref([])
const billLoading = ref(false)
const toggleExpand = () => {
// 存款类型category === 2强制保持展开状态不可折叠
if (props.budget.category === 2) {
return
}
isExpanded.value = !isExpanded.value
}
const handleQueryBills = async () => {
showBillListModal.value = true
billLoading.value = true
try {
const classify = props.budget.selectedCategories
? props.budget.selectedCategories.join(',')
: ''
if (classify === '') {
// 如果没有选中任何分类,则不查询
billList.value = []
billLoading.value = false
return
}
const response = await getTransactionList({
page: 1,
pageSize: 100,
startDate: props.budget.periodStart,
endDate: props.budget.periodEnd,
classify: classify,
type: props.budget.category,
sortByAmount: true
})
if (response.success) {
billList.value = response.data || []
} else {
billList.value = []
}
} catch (error) {
console.error('查询账单列表失败:', error)
billList.value = []
} finally {
billLoading.value = false
}
}
const percentage = computed(() => {
if (!props.budget.limit) {
return 0
}
return Math.round((props.budget.current / props.budget.limit) * 100)
})
const timePercentage = computed(() => {
if (!props.budget.periodStart || !props.budget.periodEnd) {
return 0
}
const start = new Date(props.budget.periodStart).getTime()
const end = new Date(props.budget.periodEnd).getTime()
const now = new Date().getTime()
if (now <= start) {
return 0
}
if (now >= end) {
return 100
}
return Math.round(((now - start) / (end - start)) * 100)
})
const onEnter = (el) => {
el.style.height = '0'
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = `${el.scrollHeight}px`
}
const onAfterEnter = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
const onLeave = (el) => {
el.style.height = `${el.scrollHeight}px`
el.style.overflow = 'hidden'
// Force reflow
el.offsetHeight
el.style.transition = 'height 0.3s ease-in-out'
el.style.height = '0'
}
const onAfterLeave = (el) => {
el.style.height = ''
el.style.overflow = ''
el.style.transition = ''
}
</script>
<style scoped>
.budget-card {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
padding: 8px 12px;
overflow: hidden;
position: relative;
cursor: pointer;
}
.budget-card.cursor-default {
cursor: default;
}
.no-limit-card {
border-left: 3px solid var(--van-success-color);
}
.collapsed-footer.no-limit-footer {
justify-content: flex-start;
}
.budget-content-wrapper {
position: relative;
width: 100%;
}
.budget-inner-card {
width: 100%;
}
/* 折叠状态样式 */
.budget-collapsed {
display: flex;
flex-direction: column;
gap: 6px;
}
.collapsed-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.collapsed-left {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
min-width: 0;
}
.card-title-compact {
margin: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.status-tag-compact) {
padding: 2px 6px !important;
font-size: 11px !important;
height: auto !important;
flex-shrink: 0;
}
.collapsed-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.collapsed-item {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
}
.collapsed-item:first-child {
flex: 2;
}
.collapsed-item:last-child {
flex: 1;
}
.compact-label {
font-size: 12px;
color: var(--van-text-color-2);
line-height: 1.2;
}
.compact-value {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-value.warning {
color: var(--van-warning-color);
}
.compact-value.income {
color: var(--van-success-color);
}
.expand-icon {
color: var(--van-primary-color);
font-size: 14px;
transition: transform 0.3s ease;
flex-shrink: 0;
}
.collapse-icon {
color: var(--van-primary-color);
font-size: 16px;
cursor: pointer;
}
.budget-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.card-subtitle {
font-size: 12px;
color: var(--van-text-color-2);
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.header-actions {
display: flex;
gap: 8px;
}
.category-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 8px 0 4px;
}
.category-tag {
opacity: 0.7;
font-size: 10px;
}
.amount-info {
display: flex;
justify-content: space-between;
margin: 12px 0;
text-align: center;
}
:deep(.info-item) .label {
font-size: 12px;
color: var(--van-text-color-2);
margin-bottom: 2px;
}
:deep(.info-item) .value {
font-size: 15px;
font-weight: 600;
}
:deep(.value.expense) {
color: var(--van-danger-color);
}
:deep(.value.income) {
color: var(--van-success-color);
}
.progress-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
color: var(--van-gray-6);
}
.progress-section :deep(.van-progress) {
flex: 1;
}
.period-type {
white-space: nowrap;
width: 65px;
}
.percent {
white-space: nowrap;
width: 35px;
text-align: right;
}
.percent.warning {
color: var(--van-warning-color);
font-weight: bold;
}
.percent.income {
color: var(--van-success-color);
font-weight: bold;
}
.time-progress {
margin-top: -8px;
opacity: 0.8;
}
.time-progress .period-type,
.time-progress .percent {
font-size: 11px;
}
.no-limit-notice {
text-align: center;
font-size: 12px;
color: var(--van-text-color-2);
background-color: var(--van-light-gray);
border-radius: 4px;
margin-top: 8px;
}
.no-limit-amount-info {
display: flex;
justify-content: center;
margin: 0px 0;
}
.amount-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.amount-item .label {
font-size: 12px;
color: var(--van-text-color-2);
}
.amount-item .value {
font-size: 20px;
font-weight: 600;
color: var(--van-success-color);
}
.budget-collapse-wrapper {
overflow: hidden;
}
.budget-description {
border-top: 1px solid var(--van-border-color);
margin-top: 8px;
}
.description-content {
font-size: 11px;
color: var(--van-gray-6);
line-height: 1.4;
}
.mandatory-mark {
margin-left: 4px;
font-size: 14px;
display: inline-block;
}
</style>

View File

@@ -6,14 +6,15 @@
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
<!-- 月度健康度 -->
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
<span class="chart-title-text">
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
</span>
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'month'"
class="info-icon"
@click="handleShowDescription('month')"
/>
</div>
</div>
@@ -27,15 +28,15 @@
/>
<div class="gauge-text-overlay">
<div class="balance-label">
余额
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
</div>
<div
class="balance-value"
:style="{
color:
overallStats.month.current > overallStats.month.limit
? 'var(--van-danger-color)'
: ''
activeTab === BudgetCategory.Expense
? (overallStats.month.current > overallStats.month.limit ? 'var(--van-danger-color)' : '')
: (overallStats.month.current < overallStats.month.limit ? 'var(--van-danger-color)' : '')
}"
>
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
@@ -44,11 +45,11 @@
</div>
<div class="gauge-footer">
<div class="gauge-item">
<span class="label">已用</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
</div>
<div class="gauge-item">
<span class="label">预算</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
</div>
</div>
@@ -58,13 +59,15 @@
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
<span class="chart-title-text">
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
</span>
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
style="margin-left: auto; cursor: pointer"
@click="showDescriptionPopup = true; activeDescTab = 'year'"
class="info-icon"
@click="handleShowDescription('year')"
/>
</div>
</div>
@@ -78,16 +81,15 @@
/>
<div class="gauge-text-overlay">
<div class="balance-label">
余额
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
</div>
<div
class="balance-value"
:style="{
color:
activeTab === BudgetCategory.Expense &&
overallStats.year.current > overallStats.year.limit
? 'var(--van-danger-color)'
: ''
activeTab === BudgetCategory.Expense
? (overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '')
: (overallStats.year.current < overallStats.year.limit ? 'var(--van-danger-color)' : '')
}"
>
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
@@ -96,11 +98,11 @@
</div>
<div class="gauge-footer">
<div class="gauge-item">
<span class="label">已用</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
</div>
<div class="gauge-item">
<span class="label">预算</span>
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
</div>
</div>
@@ -117,7 +119,7 @@
预算进度月度
</div>
<div class="chart-subtitle">
预算剩余消耗趋势
{{ activeTab === BudgetCategory.Expense ? '预算剩余消耗趋势' : '收入积累趋势' }}
</div>
</div>
<BaseChart
@@ -204,7 +206,7 @@
</template>
<script setup>
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
import { ref, computed } from 'vue'
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
import { getCssVar } from '@/utils/theme'
import PopupContainer from '@/components/PopupContainer.vue'
@@ -239,6 +241,12 @@ const props = defineProps({
const showDescriptionPopup = ref(false)
const activeDescTab = ref('month')
// 显示描述弹窗
const handleShowDescription = (tab) => {
activeDescTab.value = tab
showDescriptionPopup.value = true
}
// Chart.js 相关
const { getChartOptions } = useChartTheme()
@@ -595,10 +603,19 @@ const varianceChartOptions = computed(() => {
callbacks: {
label: (context) => {
const item = context.dataset._meta[context.dataIndex]
const diffText =
item.value > 0
const isExpense = props.activeTab === BudgetCategory.Expense
let diffText
if (isExpense) {
diffText = item.value > 0
? `超支: ¥${formatMoney(item.value)}`
: `结余: ¥${formatMoney(Math.abs(item.value))}`
} else {
diffText = item.value > 0
? `超额: ¥${formatMoney(item.value)}`
: `未达标: ¥${formatMoney(Math.abs(item.value))}`
}
return [
`预算: ¥${formatMoney(item.limit)}`,
`实际: ¥${formatMoney(item.current)}`,
@@ -1038,9 +1055,24 @@ const yearBurndownChartOptions = computed(() => {
font-weight: 600;
color: var(--van-text-color);
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
}
.chart-title-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.info-icon {
flex-shrink: 0;
cursor: pointer;
padding: 4px;
margin: -4px;
}
.chart-subtitle {

View File

@@ -121,6 +121,7 @@ const formattedTitle = computed(() => {
background: transparent !important;
position: relative;
z-index: 1;
min-height: 60px; /* 与 balance-header 保持一致,防止切换抖动 */
}
.header-content {

View File

@@ -0,0 +1,180 @@
<!--
PopupContainer V2 - 通用底部弹窗组件采用 TransactionDetailSheet 样式风格
## V1 的区别
- V1 (PopupContainer.vue): 使用 Vant 主题变量标准化布局默认高度 80%
- V2 (PopupContainerV2.vue): 使用 Inter 字体16px 圆角纯白背景更现代化的视觉风格
## 基础用法
<PopupContainerV2 v-model:show="show" title="标题">
<div class="content">内容区域</div>
<template #footer>
<van-button type="primary">确定</van-button>
</template>
</PopupContainerV2>
## Props
- modelValue (Boolean, required): 控制弹窗显示/隐藏
- title (String, required): 标题文本
- height (String, default: 'auto'): 弹窗高度支持 'auto', '80%', '500px'
- maxHeight (String, default: '85%'): 最大高度
## Slots
- default: 可滚动的内容区域不提供默认 padding由使用方控制
- footer: 固定底部区域操作按钮等
## Events
- update:modelValue: 弹窗显示/隐藏状态变更
-->
<template>
<van-popup
v-model:show="visible"
position="bottom"
:style="{
height: height === 'auto' ? maxHeight : height,
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}"
teleport="body"
@close="handleClose"
>
<div class="popup-container-v2">
<!-- 固定头部 -->
<div class="popup-header">
<h3 class="popup-title">
{{ title }}
</h3>
<van-icon
name="cross"
class="popup-close"
@click="handleClose"
/>
</div>
<!-- 可滚动内容区域 -->
<div class="popup-content">
<slot />
</div>
<!-- 固定底部 -->
<div
v-if="hasFooter"
class="popup-footer"
>
<slot name="footer" />
</div>
</div>
</van-popup>
</template>
<script setup>
import { computed, useSlots } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
required: true
},
title: {
type: String,
required: true
},
height: {
type: String,
default: 'auto'
},
maxHeight: {
type: String,
default: '85%'
}
})
const emit = defineEmits(['update:modelValue'])
const slots = useSlots()
// 双向绑定
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 判断是否有 footer 插槽
const hasFooter = computed(() => !!slots.footer)
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.popup-container-v2 {
background: #ffffff;
height: 100%;
display: flex;
flex-direction: column;
}
// 固定头部
.popup-header {
flex-shrink: 0;
padding: 24px;
padding-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
.popup-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
color: #09090b;
margin: 0;
}
.popup-close {
font-size: 24px;
color: #71717a;
cursor: pointer;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
}
// 可滚动内容区域
.popup-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
// 不提供默认 padding由使用方控制
}
// 固定底部
.popup-footer {
flex-shrink: 0;
padding: 24px;
padding-top: 16px;
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.popup-container-v2 {
background: #18181b;
}
.popup-header {
.popup-title {
color: #fafafa;
}
.popup-close {
color: #a1a1aa;
}
}
}
</style>

View File

@@ -1,152 +1,134 @@
<template>
<van-popup
<PopupContainerV2
v-model:show="visible"
position="bottom"
:style="{
height: 'auto',
maxHeight: '85%',
borderTopLeftRadius: '16px',
borderTopRightRadius: '16px'
}"
teleport="body"
@close="handleClose"
title="交易详情"
height="85%"
>
<div class="transaction-detail-sheet">
<!-- 头部 -->
<div class="sheet-header">
<div class="header-title">
交易详情
</div>
<van-icon
name="cross"
class="header-close"
@click="handleClose"
/>
<!-- 金额区域 -->
<div class="amount-section">
<div class="amount-label">
金额
</div>
<!-- 金额区域 -->
<div class="amount-section">
<div class="amount-label">
金额
</div>
<!-- 只读显示模式 -->
<div
v-if="!isEditingAmount"
class="amount-value"
@click="startEditAmount"
>
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div
v-else
class="amount-input-wrapper"
>
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"
v-model="editForm.amount"
type="number"
inputmode="decimal"
class="amount-input"
placeholder="0.00"
step="0.01"
min="0"
@blur="finishEditAmount"
>
</div>
</div>
<!-- 表单字段 -->
<div class="form-section">
<div class="form-row">
<div class="form-label">
时间
</div>
<div
class="form-value clickable"
@click="showDatePicker = true"
>
{{ formatDateTime(editForm.occurredAt) }}
</div>
</div>
<div class="form-row no-border">
<div class="form-label">
备注
</div>
<div class="form-value">
<input
v-model="editForm.reason"
type="text"
class="reason-input"
placeholder="请输入备注"
>
</div>
</div>
<div class="form-row">
<div class="form-label">
类型
</div>
<div class="form-value">
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio
:name="0"
class="type-radio"
>
支出
</van-radio>
<van-radio
:name="1"
class="type-radio"
>
收入
</van-radio>
<van-radio
:name="2"
class="type-radio"
>
不计
</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-row">
<div class="form-label">
分类
</div>
<div
class="form-value clickable"
@click="showClassifySelector = !showClassifySelector"
>
<span v-if="editForm.classify">{{ editForm.classify }}</span>
<span
v-else
class="placeholder"
>请选择分类</span>
</div>
</div>
</div>
<!-- 分类选择器展开/收起 -->
<!-- 只读显示模式 -->
<div
v-if="showClassifySelector"
class="classify-section"
v-if="!isEditingAmount"
class="amount-value"
@click="startEditAmount"
>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
:show-add="false"
:show-clear="false"
:show-all="false"
@change="handleClassifyChange"
/>
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
</div>
<!-- 编辑模式 -->
<div
v-else
class="amount-input-wrapper"
>
<span class="currency-symbol">¥</span>
<input
ref="amountInputRef"
v-model="editForm.amount"
type="number"
inputmode="decimal"
class="amount-input"
placeholder="0.00"
step="0.01"
min="0"
@blur="finishEditAmount"
>
</div>
</div>
<!-- 表单字段 -->
<div class="form-section">
<div class="form-row">
<div class="form-label">
时间
</div>
<div
class="form-value clickable"
@click="showDatePicker = true"
>
{{ formatDateTime(editForm.occurredAt) }}
</div>
</div>
<!-- 操作按钮 -->
<div class="form-row no-border">
<div class="form-label">
备注
</div>
<div class="form-value">
<input
v-model="editForm.reason"
type="text"
class="reason-input"
placeholder="请输入备注"
>
</div>
</div>
<div class="form-row">
<div class="form-label">
类型
</div>
<div class="form-value">
<van-radio-group
v-model="editForm.type"
direction="horizontal"
@change="handleTypeChange"
>
<van-radio
:name="0"
class="type-radio"
>
支出
</van-radio>
<van-radio
:name="1"
class="type-radio"
>
收入
</van-radio>
<van-radio
:name="2"
class="type-radio"
>
不计
</van-radio>
</van-radio-group>
</div>
</div>
<div class="form-row">
<div class="form-label">
分类
</div>
<div
class="form-value clickable"
@click="showClassifySelector = !showClassifySelector"
>
<span v-if="editForm.classify">{{ editForm.classify }}</span>
<span
v-else
class="placeholder"
>请选择分类</span>
</div>
</div>
</div>
<!-- 分类选择器展开/收起 -->
<div
v-if="showClassifySelector"
class="classify-section"
>
<ClassifySelector
v-model="editForm.classify"
:type="editForm.type"
:show-add="false"
:show-clear="false"
:show-all="false"
@change="handleClassifyChange"
/>
</div>
<!-- 操作按钮固定底部 -->
<template #footer>
<div class="actions-section">
<van-button
class="delete-btn"
@@ -164,31 +146,32 @@
保存
</van-button>
</div>
</div>
</template>
</PopupContainerV2>
<!-- 日期时间选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-datetime-picker
v-model="currentDateTime"
type="datetime"
title="选择日期时间"
:min-date="minDate"
:max-date="maxDate"
@confirm="handleDateTimeConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
<!-- 日期时间选择器 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-datetime-picker
v-model="currentDateTime"
type="datetime"
title="选择日期时间"
:min-date="minDate"
:max-date="maxDate"
@confirm="handleDateTimeConfirm"
@cancel="showDatePicker = false"
/>
</van-popup>
</template>
<script setup>
import { ref, reactive, watch, computed } from 'vue'
import { ref, reactive, watch } from 'vue'
import { showToast, showDialog } from 'vant'
import dayjs from 'dayjs'
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
import ClassifySelector from '@/components/ClassifySelector.vue'
import { updateTransaction, deleteTransaction } from '@/api/transactionRecord'
@@ -399,291 +382,249 @@ const handleDelete = async () => {
// 用户取消删除
})
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.transaction-detail-sheet {
background: #ffffff;
padding: 24px;
// 金额区域
.amount-section {
display: flex;
flex-direction: column;
gap: 24px;
align-items: center;
gap: 8px;
padding: 0 24px 24px;
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
.amount-label {
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: normal;
color: #71717a;
}
.header-title {
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
color: #09090b;
}
.amount-value {
font-family: Inter, sans-serif;
font-size: 32px;
font-weight: 700;
color: #09090b;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
.header-close {
font-size: 24px;
color: #71717a;
cursor: pointer;
&:active {
opacity: 0.7;
}
}
.amount-section {
.amount-input-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
.amount-label {
.currency-symbol {
font-size: 32px;
font-weight: 700;
color: #09090b;
}
.amount-input {
max-width: 200px;
font-size: 32px;
font-weight: 700;
color: #09090b;
border: none;
outline: none;
background: transparent;
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #e4e4e7;
transition: border-color 0.3s;
&:focus {
border-bottom-color: #6366f1;
}
&::placeholder {
color: #a1a1aa;
}
// 移除 number 类型的上下箭头
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
// Firefox
&[type='number'] {
-moz-appearance: textfield;
}
}
}
}
// 表单区域
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 24px 16px;
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
border-bottom: 1px solid #e4e4e7;
&.no-border {
border-bottom: none;
}
.form-label {
font-family: Inter, sans-serif;
font-size: 14px;
font-size: 16px;
font-weight: normal;
color: #71717a;
}
.amount-value {
.form-value {
font-family: Inter, sans-serif;
font-size: 32px;
font-weight: 700;
font-size: 16px;
font-weight: normal;
color: #09090b;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
text-align: right;
flex: 1;
margin-left: 16px;
&:active {
opacity: 0.7;
}
}
&.clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
.amount-input-wrapper {
display: flex;
align-items: center;
gap: 8px;
.currency-symbol {
font-size: 32px;
font-weight: 700;
color: #09090b;
&:active {
opacity: 0.7;
}
}
.amount-input {
max-width: 200px;
font-size: 32px;
font-weight: 700;
color: #09090b;
.placeholder {
color: #a1a1aa;
}
.reason-input {
width: 100%;
border: none;
outline: none;
text-align: right;
font-family: Inter, sans-serif;
font-size: 16px;
color: #09090b;
background: transparent;
text-align: center;
padding: 8px 0;
border-bottom: 2px solid #e4e4e7;
transition: border-color 0.3s;
&:focus {
border-bottom-color: #6366f1;
}
&::placeholder {
color: #a1a1aa;
}
}
// 移除 number 类型的上下箭头
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
:deep(.van-radio-group) {
display: flex;
gap: 16px;
justify-content: flex-end;
}
// Firefox
&[type='number'] {
-moz-appearance: textfield;
:deep(.van-radio) {
margin: 0;
}
:deep(.van-radio__label) {
margin-left: 4px;
}
}
}
}
// 分类选择器
.classify-section {
padding: 16px 24px;
background: #f4f4f5;
border-radius: 8px;
margin: 0 24px 16px;
}
// 操作按钮
.actions-section {
display: flex;
gap: 16px;
.delete-btn {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid #ef4444;
background: transparent;
color: #ef4444;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 8px;
background: #6366f1;
color: #fafafa;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.amount-section {
.amount-label {
color: #a1a1aa;
}
.amount-value {
color: #fafafa;
}
.amount-input-wrapper {
.currency-symbol {
color: #fafafa;
}
.amount-input {
color: #fafafa;
border-bottom-color: #27272a;
&:focus {
border-bottom-color: #6366f1;
}
}
}
}
.form-section {
display: flex;
flex-direction: column;
gap: 16px;
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
border-bottom: 1px solid #e4e4e7;
&.no-border {
border-bottom: none;
}
border-bottom-color: #27272a;
.form-label {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #71717a;
color: #a1a1aa;
}
.form-value {
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: normal;
color: #09090b;
text-align: right;
flex: 1;
margin-left: 16px;
&.clickable {
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
&:active {
opacity: 0.7;
}
}
.placeholder {
color: #a1a1aa;
}
color: #fafafa;
.reason-input {
width: 100%;
border: none;
outline: none;
text-align: right;
font-family: Inter, sans-serif;
font-size: 16px;
color: #09090b;
background: transparent;
&::placeholder {
color: #a1a1aa;
}
}
:deep(.van-radio-group) {
display: flex;
gap: 16px;
justify-content: flex-end;
}
:deep(.van-radio) {
margin: 0;
}
:deep(.van-radio__label) {
margin-left: 4px;
color: #fafafa;
}
}
}
}
.classify-section {
padding: 16px;
background: #f4f4f5;
border-radius: 8px;
margin-top: -8px;
}
.actions-section {
display: flex;
gap: 16px;
width: 100%;
.delete-btn {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid #ef4444;
background: transparent;
color: #ef4444;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
.save-btn {
flex: 1;
height: 48px;
border-radius: 8px;
background: #6366f1;
color: #fafafa;
font-family: Inter, sans-serif;
font-size: 16px;
font-weight: 500;
}
}
}
// 暗色模式
@media (prefers-color-scheme: dark) {
.transaction-detail-sheet {
background: #18181b;
.sheet-header {
.header-title {
color: #fafafa;
}
.header-close {
color: #a1a1aa;
}
}
.amount-section {
.amount-label {
color: #a1a1aa;
}
.amount-value {
color: #fafafa;
}
.amount-input-wrapper {
.currency-symbol {
color: #fafafa;
}
.amount-input {
color: #fafafa;
border-bottom-color: #27272a;
&:focus {
border-bottom-color: #6366f1;
}
}
}
}
.form-section {
.form-row {
border-bottom-color: #27272a;
.form-label {
color: #a1a1aa;
}
.form-value {
color: #fafafa;
.reason-input {
color: #fafafa;
}
}
}
}
.classify-section {
background: #27272a;
}
background: #27272a;
}
}
</style>

View File

@@ -110,6 +110,7 @@ const messageViewRef = ref(null)
background: transparent;
position: relative;
z-index: 1;
min-height: 60px; /* 与 calendar-header 保持一致,防止切换抖动 */
}
.header-title {

View File

@@ -1,9 +1,12 @@
<template>
<div class="page-container-flex">
<van-nav-bar
title="设置"
placeholder
/>
<!-- 自定义头部 -->
<header class="setting-header">
<h1 class="header-title">
设置
</h1>
</header>
<div class="scroll-content">
<div
class="detail-header"
@@ -384,12 +387,30 @@ const handleScheduledTasks = () => {
}
</script>
<style scoped>
/* 页面背景色 */
:deep(body) {
background-color: var(--van-background);
<style scoped lang="scss">
@import '@/assets/theme.css';
/* ========== 自定义头部 ========== */
.setting-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 24px;
background: transparent;
position: relative;
z-index: 1;
min-height: 60px; /* 与其他 header 保持一致,防止切换抖动 */
}
.header-title {
font-family: var(--font-primary);
font-size: var(--font-2xl);
font-weight: var(--font-medium);
color: var(--text-primary);
margin: 0;
}
/* ========== 页面内容 ========== */
/* 增加卡片对比度 */
:deep(.van-cell-group--inset) {
background-color: var(--van-background-2);
@@ -407,9 +428,4 @@ const handleScheduledTasks = () => {
color: var(--van-text-color-2);
font-weight: normal;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,107 @@
## Context
项目中已有 `PopupContainer.vue` 通用弹窗组件(位于 `Web/src/components/PopupContainer.vue`),但其样式设计与 `TransactionDetailSheet.vue` 不同:
- PopupContainer: 使用 Vant 主题变量,默认高度 80%,标准化的布局
- TransactionDetailSheet: 使用 Inter 字体16px 圆角,纯白背景(#ffffff / #18181b),更现代化的视觉风格
`TransactionDetailSheet.vue` 当前实现(位于 `Web/src/components/Transaction/TransactionDetailSheet.vue`
- 直接使用 `van-popup` + 自定义样式
- 头部:自定义标题 + 关闭按钮(`.sheet-header`
- 内容区域:金额、表单字段、分类选择器(无固定滚动容器)
- 底部操作按钮:删除、保存(`.actions-section`
- 样式特点:`borderTopLeftRadius: 16px`, Inter 字体, `#ffffff` 背景, `gap: 24px`
当前问题:头部和底部未固定,内容较多时滚动体验不佳。
## Goals / Non-Goals
**Goals:**
- 创建 `PopupContainerV2.vue` 通用组件,采用 TransactionDetailSheet 的样式风格
- 提供固定头部(标题 + 可选关闭按钮)、可滚动内容、固定底部的布局能力
- 支持暗色模式(`#18181b` 背景)
- 将 TransactionDetailSheet 重构为使用 PopupContainerV2
- 保持 TransactionDetailSheet 所有现有功能和对外 API 不变
**Non-Goals:**
- 不修改现有的 `PopupContainer.vue`v1 版本保持不变)
- 不强制项目中其他组件迁移到 v2自愿迁移
- 不改变 TransactionDetailSheet 的业务逻辑
- 不引入新的 UI 库或依赖
## Decisions
### 决策 1创建新组件 PopupContainerV2 而不是修改 PopupContainer
**选择**: 创建新的 `PopupContainerV2.vue` 组件
**理由**:
- PopupContainer 已在项目中广泛使用CategoryBillPopup 等),修改可能影响现有组件
- TransactionDetailSheet 的样式风格更现代,适合作为新版本
- v1 和 v2 可以并存,逐步迁移,降低风险
- **替代方案**: 直接修改 PopupContainer → 不采用,破坏性太大,需要验证所有使用方
### 决策 2PopupContainerV2 的样式来源
**选择**: 完全采用 TransactionDetailSheet 的样式风格
**理由**:
- TransactionDetailSheet 的样式已经过验证,用户体验良好
- Inter 字体、16px 圆角、纯白背景是现代化设计趋势
- 保持样式一致性,避免混合不同的设计语言
- **替代方案**: 混合 PopupContainer 和 TransactionDetailSheet 的样式 → 不采用,会导致样式不统一
### 决策 3PopupContainerV2 的 API 设计
**选择**: 提供 title prop、default 插槽、footer 插槽,关闭按钮默认显示
**理由**:
- title prop 简化使用,覆盖 80% 场景
- default 插槽放可滚动内容,最大灵活性
- footer 插槽放固定底部操作按钮
- 关闭按钮默认显示,符合 TransactionDetailSheet 的行为
- **替代方案**: 提供 header 插槽替代 title prop → 不采用,增加使用复杂度,大多数场景只需简单标题
### 决策 4内容区域的 padding 处理
**选择**: PopupContainerV2 的内容插槽不提供默认 padding由使用方控制
**理由**:
- TransactionDetailSheet 的不同区域有不同的 padding 需求(金额区域 16px 16px 24px表单区域 0 16px 16px
- 组件不应预设 padding保持灵活性
- 使用方可以根据内容自行调整间距
- **替代方案**: 提供统一的 padding → 不采用,会限制布局灵活性
### 决策 5TransactionDetailSheet 的迁移策略
**选择**: 完全移除原有的头部和外层布局代码,使用 PopupContainerV2 的插槽
**理由**:
- 避免代码重复,减少维护成本
- PopupContainerV2 已提供所有需要的布局能力
- 保持 TransactionDetailSheet 的职责单一(业务逻辑)
- **替代方案**: 保留部分原有代码 → 不采用,会造成样式冲突和维护混乱
## Risks / Trade-offs
### 风险 1新增 PopupContainerV2 组件增加项目复杂度
**缓解措施**:
- 在组件文件顶部添加清晰的文档注释,说明 v1 和 v2 的区别
- v2 组件设计简洁API 清晰,易于理解和使用
- 在使用 TransactionDetailSheet 时验证 v2 的稳定性后,再考虑推广到其他组件
### 风险 2样式迁移可能遗漏细节
**缓解措施**:
- 仔细对比 TransactionDetailSheet 的原有样式
- 使用 Chrome DevTools 对比重构前后的渲染效果
- 验证暗色模式的样式一致性
### 风险 3日期选择器van-datetime-picker的嵌套弹窗可能存在 z-index 冲突
**缓解措施**: van-datetime-picker 使用 `teleport="body"`,应与 PopupContainerV2 的弹窗层级独立,测试时重点验证
### Trade-off 1创建 v2 而不是统一到一个组件
**影响**: 项目中会同时存在两个弹窗组件,增加学习成本
**权衡**: 保护现有代码稳定性的收益大于维护两个组件的成本,且 v2 可以逐步替代 v1
### Trade-off 2PopupContainerV2 不提供默认 padding
**影响**: 使用方需要自行管理内容区域的间距
**权衡**: 灵活性优于便利性,避免样式冲突

View File

@@ -0,0 +1,34 @@
## Why
`TransactionDetailSheet.vue` 组件有良好的样式设计圆角、Inter 字体、清晰的布局层次),但头部标题和底部操作按钮没有固定定位,当内容较多时会随着滚动而移动,影响用户体验。同时,项目中其他弹窗可能也需要类似的固定头部/底部布局。因此需要将 TransactionDetailSheet 的样式和布局模式抽取为通用的 PopupContainer v2 组件,提供固定头部和底部的能力,方便在项目中复用。
## What Changes
- 创建新的 `PopupContainerV2.vue` 通用弹窗组件
- 使用 TransactionDetailSheet 的样式风格(`borderTopLeftRadius: 16px`, Inter 字体, `#ffffff` / `#18181b` 背景)
- 提供固定头部区域(标题 + 可选关闭按钮)
- 提供可滚动内容区域default 插槽)
- 提供固定底部区域footer 插槽)
- 支持暗色模式
- 重构 `TransactionDetailSheet.vue` 使用新的 PopupContainerV2 组件
- 移除原有的头部和布局代码
- 将内容和操作按钮迁移到对应插槽
- 保持所有业务逻辑和功能不变
## Capabilities
### New Capabilities
- `popup-container-v2`: 新的通用弹窗组件,提供固定头部/底部布局,采用 TransactionDetailSheet 的样式风格
### Modified Capabilities
- `transaction-detail-ui`: TransactionDetailSheet 的 UI 布局从直接使用 van-popup 改为使用 PopupContainerV2头部和底部固定内容区域可滚动
## Impact
- **新增文件**: `Web/src/components/PopupContainerV2.vue`
- **受影响文件**: `Web/src/components/Transaction/TransactionDetailSheet.vue`
- **用户影响**: 改善交易详情弹窗的交互体验,头部和操作按钮始终可见
- **可复用性**: 新组件可用于项目中其他需要固定头部/底部的弹窗场景
- **兼容性**: TransactionDetailSheet 对外 APIprops、events保持不变不影响调用方

View File

@@ -0,0 +1,119 @@
## ADDED Requirements
### Requirement: PopupContainerV2 提供固定布局能力
PopupContainerV2 组件 SHALL 提供固定头部、可滚动内容、固定底部的布局模式,并采用 TransactionDetailSheet 的样式风格16px 圆角、Inter 字体、纯白背景)。
#### Scenario: PopupContainerV2 基础结构
- **WHEN** 使用 PopupContainerV2 组件
- **THEN** 组件基于 van-popup 实现底部弹窗
- **THEN** 弹窗顶部圆角为 16pxborderTopLeftRadius 和 borderTopRightRadius
- **THEN** 背景色为 #ffffff(亮色模式)或 #18181b(暗色模式)
#### Scenario: PopupContainerV2 固定头部
- **WHEN** 传入 title prop
- **THEN** 头部显示标题文本,字体为 Inter18pxfont-weight 600
- **THEN** 头部右侧显示关闭按钮van-icon cross
- **THEN** 头部固定在顶部,不随内容滚动
#### Scenario: PopupContainerV2 可滚动内容区域
- **WHEN** 在 default 插槽中放置内容
- **THEN** 内容区域可独立滚动
- **THEN** 滚动时头部和底部保持固定
- **THEN** 内容区域不提供默认 padding由使用方控制
#### Scenario: PopupContainerV2 固定底部
- **WHEN** 在 footer 插槽中放置操作按钮
- **THEN** 底部区域固定在底部,不随内容滚动
- **THEN** 底部区域保持与内容区域的视觉分隔
#### Scenario: PopupContainerV2 暗色模式支持
- **WHEN** 系统启用暗色模式prefers-color-scheme: dark
- **THEN** 背景色切换为 #18181b
- **THEN** 标题文本颜色切换为 #fafafa
- **THEN** 关闭按钮颜色切换为 #a1a1aa
## MODIFIED Requirements
### Requirement: TransactionDetailSheet 使用 PopupContainerV2 实现固定布局
TransactionDetailSheet 组件 SHALL 使用 PopupContainerV2 组件实现底部弹窗,并确保头部标题和底部操作按钮固定不随内容滚动。
#### Scenario: 打开交易详情弹窗
- **WHEN** 用户点击交易记录打开详情弹窗
- **THEN** 弹窗从底部弹出,使用 PopupContainerV2 组件
- **THEN** 弹窗顶部圆角为 16px
- **THEN** 头部显示固定标题"交易详情",字体为 Inter 18px
- **THEN** 底部固定显示"删除"和"保存"按钮
- **THEN** 中间内容区域(金额、时间、备注、类型、分类)可独立滚动
#### Scenario: 滚动交易详情内容
- **WHEN** 用户在交易详情弹窗中滚动内容
- **THEN** 头部标题始终固定在顶部可见
- **THEN** 底部操作按钮(删除、保存)始终固定在底部可见
- **THEN** 只有中间内容区域发生滚动
#### Scenario: 展开分类选择器后滚动
- **WHEN** 用户点击"分类"字段展开分类选择器
- **THEN** 分类选择器在内容区域内展开
- **THEN** 如果内容超出可视区域,用户可以滚动查看所有分类选项
- **THEN** 头部标题和底部按钮仍然保持固定
### Requirement: 保持现有功能和交互不变
TransactionDetailSheet 组件在重构后 SHALL 保持所有现有的功能和交互逻辑不变,包括但不限于金额编辑、日期选择、分类选择、保存、删除等操作。
#### Scenario: 金额编辑功能保持不变
- **WHEN** 用户点击金额数值进入编辑模式
- **THEN** 金额输入框自动聚焦并选中当前值
- **THEN** 用户输入新金额后失焦时自动格式化为两位小数
#### Scenario: 日期时间选择功能保持不变
- **WHEN** 用户点击"时间"字段
- **THEN** 弹出 van-datetime-picker 日期时间选择器
- **THEN** 选择器显示在最顶层(不受 PopupContainerV2 层级影响)
- **THEN** 确认后时间更新为选择的值
#### Scenario: 分类选择功能保持不变
- **WHEN** 用户点击"分类"字段
- **THEN** 展开 ClassifySelector 组件
- **THEN** 用户可以选择分类
- **THEN** 选择后自动触发保存
#### Scenario: 保存和删除功能保持不变
- **WHEN** 用户点击"保存"按钮
- **THEN** 验证必填字段(金额、分类、时间)
- **THEN** 调用 updateTransaction API 更新数据
- **THEN** 成功后显示提示并关闭弹窗
- **WHEN** 用户点击"删除"按钮
- **THEN** 显示确认对话框
- **THEN** 确认后调用 deleteTransaction API 删除数据
- **THEN** 成功后显示提示并关闭弹窗
### Requirement: 组件对外 API 兼容
TransactionDetailSheet 组件的 props 和 events SHALL 保持与重构前完全一致,确保零破坏性变更。
#### Scenario: Props 保持不变
- **WHEN** 父组件传递 `show``transaction` props
- **THEN** TransactionDetailSheet 正常接收并响应这些 props
- **THEN** 不需要修改任何调用方代码
#### Scenario: Events 保持不变
- **WHEN** 用户保存或删除交易
- **THEN** TransactionDetailSheet 触发 `update:show`, `save`, `delete` 事件
- **THEN** 事件参数格式与重构前完全一致

View File

@@ -0,0 +1,60 @@
## 1. 创建 PopupContainerV2.vue 组件
- [x] 1.1 创建文件 `Web/src/components/PopupContainerV2.vue`
- [x] 1.2 实现基础结构(基于 van-popup16px 圆角,#ffffff / #18181b 背景)
- [x] 1.3 实现固定头部区域(标题 + 关闭按钮Inter 18px font-weight 600
- [x] 1.4 实现可滚动内容区域default 插槽flex: 1, overflow-y: auto
- [x] 1.5 实现固定底部区域footer 插槽border-top 分隔线)
- [x] 1.6 实现暗色模式支持(@media prefers-color-scheme: dark
- [x] 1.7 添加组件文档注释(说明 API、使用示例、与 v1 的区别)
## 2. 重构 TransactionDetailSheet.vue 使用 PopupContainerV2
- [x] 2.1 引入 PopupContainerV2 组件
- [x] 2.2 将 van-popup 替换为 PopupContainerV2
- [x] 2.3 移除自定义 `.sheet-header` 头部区域(使用 PopupContainerV2 的 title prop
- [x] 2.4 将金额区域、表单区域、分类选择器保留在 default 插槽中
- [x] 2.5 将操作按钮区域(删除、保存)移入 PopupContainerV2 的 `#footer` 插槽
- [x] 2.6 移除 handleClose 方法PopupContainerV2 自动处理)
## 3. 调整 TransactionDetailSheet 样式
- [x] 3.1 移除 `.transaction-detail-sheet` 外层容器样式
- [x] 3.2 调整各内容区域的 padding金额区域、表单区域、分类选择器
- [x] 3.3 移除 `.sheet-header` 相关样式
- [x] 3.4 调整暗色模式样式(移除已由 PopupContainerV2 处理的部分)
## 4. 功能验证
- [x] 4.1 测试弹窗打开/关闭功能
- [x] 4.2 测试金额编辑功能(点击进入编辑模式、输入、失焦格式化)
- [x] 4.3 测试日期时间选择功能(弹出选择器、确认更新)
- [x] 4.4 测试类型切换功能(支出/收入/不计)
- [x] 4.5 测试分类选择功能(展开选择器、选择分类、自动保存)
- [x] 4.6 测试备注输入功能
- [x] 4.7 测试保存功能验证、API 调用、成功提示)
- [x] 4.8 测试删除功能确认对话框、API 调用、成功提示)
## 5. 布局验证
- [x] 5.1 验证头部标题固定不滚动
- [x] 5.2 验证底部操作按钮固定不滚动
- [x] 5.3 验证内容区域可独立滚动
- [x] 5.4 验证分类选择器展开后的滚动体验
- [x] 5.5 验证不同内容长度下的布局表现
- [x] 5.6 验证弹窗圆角为 16px
- [x] 5.7 验证标题字体为 Inter 18px font-weight 600
## 6. 兼容性验证
- [x] 6.1 验证 Props 接口不变show, transaction
- [x] 6.2 验证 Events 接口不变update:show, save, delete
- [x] 6.3 验证调用方无需修改代码
- [x] 6.4 测试日期选择器弹窗的 z-index 层级(确保在 PopupContainerV2 之上)
- [x] 6.5 验证暗色模式下的样式表现
## 7. 清理和文档
- [x] 7.1 移除不再使用的代码和注释
- [x] 7.2 确保代码符合项目 ESLint 规范(`pnpm lint`
- [x] 7.3 在本地运行前端项目验证无报错(`pnpm build`

View File

@@ -0,0 +1,119 @@
## ADDED Requirements
### Requirement: PopupContainerV2 提供固定布局能力
PopupContainerV2 组件 SHALL 提供固定头部、可滚动内容、固定底部的布局模式,并采用 TransactionDetailSheet 的样式风格16px 圆角、Inter 字体、纯白背景)。
#### Scenario: PopupContainerV2 基础结构
- **WHEN** 使用 PopupContainerV2 组件
- **THEN** 组件基于 van-popup 实现底部弹窗
- **THEN** 弹窗顶部圆角为 16pxborderTopLeftRadius 和 borderTopRightRadius
- **THEN** 背景色为 #ffffff(亮色模式)或 #18181b(暗色模式)
#### Scenario: PopupContainerV2 固定头部
- **WHEN** 传入 title prop
- **THEN** 头部显示标题文本,字体为 Inter18pxfont-weight 600
- **THEN** 头部右侧显示关闭按钮van-icon cross
- **THEN** 头部固定在顶部,不随内容滚动
#### Scenario: PopupContainerV2 可滚动内容区域
- **WHEN** 在 default 插槽中放置内容
- **THEN** 内容区域可独立滚动
- **THEN** 滚动时头部和底部保持固定
- **THEN** 内容区域不提供默认 padding由使用方控制
#### Scenario: PopupContainerV2 固定底部
- **WHEN** 在 footer 插槽中放置操作按钮
- **THEN** 底部区域固定在底部,不随内容滚动
- **THEN** 底部区域保持与内容区域的视觉分隔
#### Scenario: PopupContainerV2 暗色模式支持
- **WHEN** 系统启用暗色模式prefers-color-scheme: dark
- **THEN** 背景色切换为 #18181b
- **THEN** 标题文本颜色切换为 #fafafa
- **THEN** 关闭按钮颜色切换为 #a1a1aa
## MODIFIED Requirements
### Requirement: TransactionDetailSheet 使用 PopupContainerV2 实现固定布局
TransactionDetailSheet 组件 SHALL 使用 PopupContainerV2 组件实现底部弹窗,并确保头部标题和底部操作按钮固定不随内容滚动。
#### Scenario: 打开交易详情弹窗
- **WHEN** 用户点击交易记录打开详情弹窗
- **THEN** 弹窗从底部弹出,使用 PopupContainerV2 组件
- **THEN** 弹窗顶部圆角为 16px
- **THEN** 头部显示固定标题"交易详情",字体为 Inter 18px
- **THEN** 底部固定显示"删除"和"保存"按钮
- **THEN** 中间内容区域(金额、时间、备注、类型、分类)可独立滚动
#### Scenario: 滚动交易详情内容
- **WHEN** 用户在交易详情弹窗中滚动内容
- **THEN** 头部标题始终固定在顶部可见
- **THEN** 底部操作按钮(删除、保存)始终固定在底部可见
- **THEN** 只有中间内容区域发生滚动
#### Scenario: 展开分类选择器后滚动
- **WHEN** 用户点击"分类"字段展开分类选择器
- **THEN** 分类选择器在内容区域内展开
- **THEN** 如果内容超出可视区域,用户可以滚动查看所有分类选项
- **THEN** 头部标题和底部按钮仍然保持固定
### Requirement: 保持现有功能和交互不变
TransactionDetailSheet 组件在重构后 SHALL 保持所有现有的功能和交互逻辑不变,包括但不限于金额编辑、日期选择、分类选择、保存、删除等操作。
#### Scenario: 金额编辑功能保持不变
- **WHEN** 用户点击金额数值进入编辑模式
- **THEN** 金额输入框自动聚焦并选中当前值
- **THEN** 用户输入新金额后失焦时自动格式化为两位小数
#### Scenario: 日期时间选择功能保持不变
- **WHEN** 用户点击"时间"字段
- **THEN** 弹出 van-datetime-picker 日期时间选择器
- **THEN** 选择器显示在最顶层(不受 PopupContainerV2 层级影响)
- **THEN** 确认后时间更新为选择的值
#### Scenario: 分类选择功能保持不变
- **WHEN** 用户点击"分类"字段
- **THEN** 展开 ClassifySelector 组件
- **THEN** 用户可以选择分类
- **THEN** 选择后自动触发保存
#### Scenario: 保存和删除功能保持不变
- **WHEN** 用户点击"保存"按钮
- **THEN** 验证必填字段(金额、分类、时间)
- **THEN** 调用 updateTransaction API 更新数据
- **THEN** 成功后显示提示并关闭弹窗
- **WHEN** 用户点击"删除"按钮
- **THEN** 显示确认对话框
- **THEN** 确认后调用 deleteTransaction API 删除数据
- **THEN** 成功后显示提示并关闭弹窗
### Requirement: 组件对外 API 兼容
TransactionDetailSheet 组件的 props 和 events SHALL 保持与重构前完全一致,确保零破坏性变更。
#### Scenario: Props 保持不变
- **WHEN** 父组件传递 `show``transaction` props
- **THEN** TransactionDetailSheet 正常接收并响应这些 props
- **THEN** 不需要修改任何调用方代码
#### Scenario: Events 保持不变
- **WHEN** 用户保存或删除交易
- **THEN** TransactionDetailSheet 触发 `update:show`, `save`, `delete` 事件
- **THEN** 事件参数格式与重构前完全一致