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
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:
@@ -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>
|
|
||||||
@@ -6,14 +6,15 @@
|
|||||||
<div class="chart-card gauge-card">
|
<div class="chart-card gauge-card">
|
||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
<!-- 月度健康度 -->
|
<span class="chart-title-text">
|
||||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
|
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
|
||||||
|
</span>
|
||||||
<van-icon
|
<van-icon
|
||||||
name="info-o"
|
name="info-o"
|
||||||
size="16"
|
size="16"
|
||||||
color="var(--van-primary-color)"
|
color="var(--van-primary-color)"
|
||||||
style="margin-left: auto; cursor: pointer"
|
class="info-icon"
|
||||||
@click="showDescriptionPopup = true; activeDescTab = 'month'"
|
@click="handleShowDescription('month')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -27,15 +28,15 @@
|
|||||||
/>
|
/>
|
||||||
<div class="gauge-text-overlay">
|
<div class="gauge-text-overlay">
|
||||||
<div class="balance-label">
|
<div class="balance-label">
|
||||||
余额
|
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="balance-value"
|
class="balance-value"
|
||||||
:style="{
|
:style="{
|
||||||
color:
|
color:
|
||||||
overallStats.month.current > overallStats.month.limit
|
activeTab === BudgetCategory.Expense
|
||||||
? 'var(--van-danger-color)'
|
? (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)) }}
|
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
|
||||||
@@ -44,11 +45,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gauge-footer">
|
<div class="gauge-footer">
|
||||||
<div class="gauge-item">
|
<div class="gauge-item">
|
||||||
<span class="label">已用</span>
|
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
|
||||||
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
|
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gauge-item">
|
<div class="gauge-item">
|
||||||
<span class="label">预算</span>
|
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
|
||||||
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
|
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,13 +59,15 @@
|
|||||||
<div class="chart-card gauge-card">
|
<div class="chart-card gauge-card">
|
||||||
<div class="chart-header">
|
<div class="chart-header">
|
||||||
<div class="chart-title">
|
<div class="chart-title">
|
||||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
|
<span class="chart-title-text">
|
||||||
|
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
|
||||||
|
</span>
|
||||||
<van-icon
|
<van-icon
|
||||||
name="info-o"
|
name="info-o"
|
||||||
size="16"
|
size="16"
|
||||||
color="var(--van-primary-color)"
|
color="var(--van-primary-color)"
|
||||||
style="margin-left: auto; cursor: pointer"
|
class="info-icon"
|
||||||
@click="showDescriptionPopup = true; activeDescTab = 'year'"
|
@click="handleShowDescription('year')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,16 +81,15 @@
|
|||||||
/>
|
/>
|
||||||
<div class="gauge-text-overlay">
|
<div class="gauge-text-overlay">
|
||||||
<div class="balance-label">
|
<div class="balance-label">
|
||||||
余额
|
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="balance-value"
|
class="balance-value"
|
||||||
:style="{
|
:style="{
|
||||||
color:
|
color:
|
||||||
activeTab === BudgetCategory.Expense &&
|
activeTab === BudgetCategory.Expense
|
||||||
overallStats.year.current > overallStats.year.limit
|
? (overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '')
|
||||||
? 'var(--van-danger-color)'
|
: (overallStats.year.current < overallStats.year.limit ? 'var(--van-danger-color)' : '')
|
||||||
: ''
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
|
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
|
||||||
@@ -96,11 +98,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="gauge-footer">
|
<div class="gauge-footer">
|
||||||
<div class="gauge-item">
|
<div class="gauge-item">
|
||||||
<span class="label">已用</span>
|
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
|
||||||
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
|
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gauge-item">
|
<div class="gauge-item">
|
||||||
<span class="label">预算</span>
|
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
|
||||||
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
|
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +119,7 @@
|
|||||||
预算进度(月度)
|
预算进度(月度)
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-subtitle">
|
<div class="chart-subtitle">
|
||||||
预算剩余消耗趋势
|
{{ activeTab === BudgetCategory.Expense ? '预算剩余消耗趋势' : '收入积累趋势' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BaseChart
|
<BaseChart
|
||||||
@@ -204,7 +206,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
|
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
|
||||||
import { getCssVar } from '@/utils/theme'
|
import { getCssVar } from '@/utils/theme'
|
||||||
import PopupContainer from '@/components/PopupContainer.vue'
|
import PopupContainer from '@/components/PopupContainer.vue'
|
||||||
@@ -239,6 +241,12 @@ const props = defineProps({
|
|||||||
const showDescriptionPopup = ref(false)
|
const showDescriptionPopup = ref(false)
|
||||||
const activeDescTab = ref('month')
|
const activeDescTab = ref('month')
|
||||||
|
|
||||||
|
// 显示描述弹窗
|
||||||
|
const handleShowDescription = (tab) => {
|
||||||
|
activeDescTab.value = tab
|
||||||
|
showDescriptionPopup.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// Chart.js 相关
|
// Chart.js 相关
|
||||||
const { getChartOptions } = useChartTheme()
|
const { getChartOptions } = useChartTheme()
|
||||||
|
|
||||||
@@ -595,10 +603,19 @@ const varianceChartOptions = computed(() => {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
label: (context) => {
|
label: (context) => {
|
||||||
const item = context.dataset._meta[context.dataIndex]
|
const item = context.dataset._meta[context.dataIndex]
|
||||||
const diffText =
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
||||||
item.value > 0
|
|
||||||
|
let diffText
|
||||||
|
if (isExpense) {
|
||||||
|
diffText = item.value > 0
|
||||||
? `超支: ¥${formatMoney(item.value)}`
|
? `超支: ¥${formatMoney(item.value)}`
|
||||||
: `结余: ¥${formatMoney(Math.abs(item.value))}`
|
: `结余: ¥${formatMoney(Math.abs(item.value))}`
|
||||||
|
} else {
|
||||||
|
diffText = item.value > 0
|
||||||
|
? `超额: ¥${formatMoney(item.value)}`
|
||||||
|
: `未达标: ¥${formatMoney(Math.abs(item.value))}`
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`预算: ¥${formatMoney(item.limit)}`,
|
`预算: ¥${formatMoney(item.limit)}`,
|
||||||
`实际: ¥${formatMoney(item.current)}`,
|
`实际: ¥${formatMoney(item.current)}`,
|
||||||
@@ -1038,9 +1055,24 @@ const yearBurndownChartOptions = computed(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--van-text-color);
|
color: var(--van-text-color);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title-text {
|
||||||
|
flex: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
margin: -4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-subtitle {
|
.chart-subtitle {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ const formattedTitle = computed(() => {
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
min-height: 60px; /* 与 balance-header 保持一致,防止切换抖动 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
|
|||||||
180
Web/src/components/PopupContainerV2.vue
Normal file
180
Web/src/components/PopupContainerV2.vue
Normal 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>
|
||||||
@@ -1,152 +1,134 @@
|
|||||||
<template>
|
<template>
|
||||||
<van-popup
|
<PopupContainerV2
|
||||||
v-model:show="visible"
|
v-model:show="visible"
|
||||||
position="bottom"
|
title="交易详情"
|
||||||
:style="{
|
height="85%"
|
||||||
height: 'auto',
|
|
||||||
maxHeight: '85%',
|
|
||||||
borderTopLeftRadius: '16px',
|
|
||||||
borderTopRightRadius: '16px'
|
|
||||||
}"
|
|
||||||
teleport="body"
|
|
||||||
@close="handleClose"
|
|
||||||
>
|
>
|
||||||
<div class="transaction-detail-sheet">
|
<!-- 金额区域 -->
|
||||||
<!-- 头部 -->
|
<div class="amount-section">
|
||||||
<div class="sheet-header">
|
<div class="amount-label">
|
||||||
<div class="header-title">
|
金额
|
||||||
交易详情
|
|
||||||
</div>
|
|
||||||
<van-icon
|
|
||||||
name="cross"
|
|
||||||
class="header-close"
|
|
||||||
@click="handleClose"
|
|
||||||
/>
|
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="showClassifySelector"
|
v-if="!isEditingAmount"
|
||||||
class="classify-section"
|
class="amount-value"
|
||||||
|
@click="startEditAmount"
|
||||||
>
|
>
|
||||||
<ClassifySelector
|
¥ {{ formatAmount(parseFloat(editForm.amount) || 0) }}
|
||||||
v-model="editForm.classify"
|
</div>
|
||||||
:type="editForm.type"
|
<!-- 编辑模式 -->
|
||||||
:show-add="false"
|
<div
|
||||||
:show-clear="false"
|
v-else
|
||||||
:show-all="false"
|
class="amount-input-wrapper"
|
||||||
@change="handleClassifyChange"
|
>
|
||||||
/>
|
<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>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<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">
|
<div class="actions-section">
|
||||||
<van-button
|
<van-button
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
@@ -164,31 +146,32 @@
|
|||||||
保存
|
保存
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PopupContainerV2>
|
||||||
|
|
||||||
<!-- 日期时间选择器 -->
|
<!-- 日期时间选择器 -->
|
||||||
<van-popup
|
<van-popup
|
||||||
v-model:show="showDatePicker"
|
v-model:show="showDatePicker"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
round
|
round
|
||||||
>
|
>
|
||||||
<van-datetime-picker
|
<van-datetime-picker
|
||||||
v-model="currentDateTime"
|
v-model="currentDateTime"
|
||||||
type="datetime"
|
type="datetime"
|
||||||
title="选择日期时间"
|
title="选择日期时间"
|
||||||
:min-date="minDate"
|
:min-date="minDate"
|
||||||
:max-date="maxDate"
|
:max-date="maxDate"
|
||||||
@confirm="handleDateTimeConfirm"
|
@confirm="handleDateTimeConfirm"
|
||||||
@cancel="showDatePicker = false"
|
@cancel="showDatePicker = false"
|
||||||
/>
|
/>
|
||||||
</van-popup>
|
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, watch, computed } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
import { showToast, showDialog } from 'vant'
|
import { showToast, showDialog } from 'vant'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import PopupContainerV2 from '@/components/PopupContainerV2.vue'
|
||||||
import ClassifySelector from '@/components/ClassifySelector.vue'
|
import ClassifySelector from '@/components/ClassifySelector.vue'
|
||||||
import { updateTransaction, deleteTransaction } from '@/api/transactionRecord'
|
import { updateTransaction, deleteTransaction } from '@/api/transactionRecord'
|
||||||
|
|
||||||
@@ -399,291 +382,249 @@ const handleDelete = async () => {
|
|||||||
// 用户取消删除
|
// 用户取消删除
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭弹窗
|
|
||||||
const handleClose = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.transaction-detail-sheet {
|
// 金额区域
|
||||||
background: #ffffff;
|
.amount-section {
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 24px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
|
||||||
.sheet-header {
|
.amount-label {
|
||||||
display: flex;
|
font-family: Inter, sans-serif;
|
||||||
justify-content: space-between;
|
font-size: 14px;
|
||||||
align-items: center;
|
font-weight: normal;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
.header-title {
|
.amount-value {
|
||||||
font-family: Inter, sans-serif;
|
font-family: Inter, sans-serif;
|
||||||
font-size: 18px;
|
font-size: 32px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #09090b;
|
color: #09090b;
|
||||||
}
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
.header-close {
|
&:active {
|
||||||
font-size: 24px;
|
opacity: 0.7;
|
||||||
color: #71717a;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-section {
|
.amount-input-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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-family: Inter, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: #71717a;
|
color: #71717a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-value {
|
.form-value {
|
||||||
font-family: Inter, sans-serif;
|
font-family: Inter, sans-serif;
|
||||||
font-size: 32px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: normal;
|
||||||
color: #09090b;
|
color: #09090b;
|
||||||
cursor: pointer;
|
text-align: right;
|
||||||
user-select: none;
|
flex: 1;
|
||||||
transition: opacity 0.2s;
|
margin-left: 16px;
|
||||||
|
|
||||||
&:active {
|
&.clickable {
|
||||||
opacity: 0.7;
|
cursor: pointer;
|
||||||
}
|
user-select: none;
|
||||||
}
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
.amount-input-wrapper {
|
&:active {
|
||||||
display: flex;
|
opacity: 0.7;
|
||||||
align-items: center;
|
}
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.currency-symbol {
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #09090b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-input {
|
.placeholder {
|
||||||
max-width: 200px;
|
color: #a1a1aa;
|
||||||
font-size: 32px;
|
}
|
||||||
font-weight: 700;
|
|
||||||
color: #09090b;
|
.reason-input {
|
||||||
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
text-align: right;
|
||||||
|
font-family: Inter, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #09090b;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
text-align: center;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 2px solid #e4e4e7;
|
|
||||||
transition: border-color 0.3s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-bottom-color: #6366f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #a1a1aa;
|
color: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 移除 number 类型的上下箭头
|
:deep(.van-radio-group) {
|
||||||
&::-webkit-outer-spin-button,
|
display: flex;
|
||||||
&::-webkit-inner-spin-button {
|
gap: 16px;
|
||||||
-webkit-appearance: none;
|
justify-content: flex-end;
|
||||||
margin: 0;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Firefox
|
:deep(.van-radio) {
|
||||||
&[type='number'] {
|
margin: 0;
|
||||||
-moz-appearance: textfield;
|
}
|
||||||
|
|
||||||
|
: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 {
|
.form-section {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex;
|
border-bottom-color: #27272a;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
height: 48px;
|
|
||||||
border-bottom: 1px solid #e4e4e7;
|
|
||||||
|
|
||||||
&.no-border {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
.form-label {
|
||||||
font-family: Inter, sans-serif;
|
color: #a1a1aa;
|
||||||
font-size: 16px;
|
|
||||||
font-weight: normal;
|
|
||||||
color: #71717a;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-value {
|
.form-value {
|
||||||
font-family: Inter, sans-serif;
|
color: #fafafa;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reason-input {
|
.reason-input {
|
||||||
width: 100%;
|
color: #fafafa;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.classify-section {
|
.classify-section {
|
||||||
padding: 16px;
|
background: #27272a;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const messageViewRef = ref(null)
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
min-height: 60px; /* 与 calendar-header 保持一致,防止切换抖动 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-container-flex">
|
<div class="page-container-flex">
|
||||||
<van-nav-bar
|
<!-- 自定义头部 -->
|
||||||
title="设置"
|
<header class="setting-header">
|
||||||
placeholder
|
<h1 class="header-title">
|
||||||
/>
|
设置
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="scroll-content">
|
<div class="scroll-content">
|
||||||
<div
|
<div
|
||||||
class="detail-header"
|
class="detail-header"
|
||||||
@@ -384,12 +387,30 @@ const handleScheduledTasks = () => {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
/* 页面背景色 */
|
@import '@/assets/theme.css';
|
||||||
:deep(body) {
|
|
||||||
background-color: var(--van-background);
|
/* ========== 自定义头部 ========== */
|
||||||
|
.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) {
|
:deep(.van-cell-group--inset) {
|
||||||
background-color: var(--van-background-2);
|
background-color: var(--van-background-2);
|
||||||
@@ -407,9 +428,4 @@ const handleScheduledTasks = () => {
|
|||||||
color: var(--van-text-color-2);
|
color: var(--van-text-color-2);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 设置页面容器背景色 */
|
|
||||||
:deep(.van-nav-bar) {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-20
|
||||||
@@ -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 → 不采用,破坏性太大,需要验证所有使用方
|
||||||
|
|
||||||
|
### 决策 2:PopupContainerV2 的样式来源
|
||||||
|
|
||||||
|
**选择**: 完全采用 TransactionDetailSheet 的样式风格
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- TransactionDetailSheet 的样式已经过验证,用户体验良好
|
||||||
|
- Inter 字体、16px 圆角、纯白背景是现代化设计趋势
|
||||||
|
- 保持样式一致性,避免混合不同的设计语言
|
||||||
|
- **替代方案**: 混合 PopupContainer 和 TransactionDetailSheet 的样式 → 不采用,会导致样式不统一
|
||||||
|
|
||||||
|
### 决策 3:PopupContainerV2 的 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 → 不采用,会限制布局灵活性
|
||||||
|
|
||||||
|
### 决策 5:TransactionDetailSheet 的迁移策略
|
||||||
|
|
||||||
|
**选择**: 完全移除原有的头部和外层布局代码,使用 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 2:PopupContainerV2 不提供默认 padding
|
||||||
|
**影响**: 使用方需要自行管理内容区域的间距
|
||||||
|
**权衡**: 灵活性优于便利性,避免样式冲突
|
||||||
@@ -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 对外 API(props、events)保持不变,不影响调用方
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: PopupContainerV2 提供固定布局能力
|
||||||
|
|
||||||
|
PopupContainerV2 组件 SHALL 提供固定头部、可滚动内容、固定底部的布局模式,并采用 TransactionDetailSheet 的样式风格(16px 圆角、Inter 字体、纯白背景)。
|
||||||
|
|
||||||
|
#### Scenario: PopupContainerV2 基础结构
|
||||||
|
|
||||||
|
- **WHEN** 使用 PopupContainerV2 组件
|
||||||
|
- **THEN** 组件基于 van-popup 实现底部弹窗
|
||||||
|
- **THEN** 弹窗顶部圆角为 16px(borderTopLeftRadius 和 borderTopRightRadius)
|
||||||
|
- **THEN** 背景色为 #ffffff(亮色模式)或 #18181b(暗色模式)
|
||||||
|
|
||||||
|
#### Scenario: PopupContainerV2 固定头部
|
||||||
|
|
||||||
|
- **WHEN** 传入 title prop
|
||||||
|
- **THEN** 头部显示标题文本,字体为 Inter,18px,font-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** 事件参数格式与重构前完全一致
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
## 1. 创建 PopupContainerV2.vue 组件
|
||||||
|
|
||||||
|
- [x] 1.1 创建文件 `Web/src/components/PopupContainerV2.vue`
|
||||||
|
- [x] 1.2 实现基础结构(基于 van-popup,16px 圆角,#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`)
|
||||||
119
openspec/specs/transaction-detail-ui/spec.md
Normal file
119
openspec/specs/transaction-detail-ui/spec.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: PopupContainerV2 提供固定布局能力
|
||||||
|
|
||||||
|
PopupContainerV2 组件 SHALL 提供固定头部、可滚动内容、固定底部的布局模式,并采用 TransactionDetailSheet 的样式风格(16px 圆角、Inter 字体、纯白背景)。
|
||||||
|
|
||||||
|
#### Scenario: PopupContainerV2 基础结构
|
||||||
|
|
||||||
|
- **WHEN** 使用 PopupContainerV2 组件
|
||||||
|
- **THEN** 组件基于 van-popup 实现底部弹窗
|
||||||
|
- **THEN** 弹窗顶部圆角为 16px(borderTopLeftRadius 和 borderTopRightRadius)
|
||||||
|
- **THEN** 背景色为 #ffffff(亮色模式)或 #18181b(暗色模式)
|
||||||
|
|
||||||
|
#### Scenario: PopupContainerV2 固定头部
|
||||||
|
|
||||||
|
- **WHEN** 传入 title prop
|
||||||
|
- **THEN** 头部显示标题文本,字体为 Inter,18px,font-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** 事件参数格式与重构前完全一致
|
||||||
Reference in New Issue
Block a user