Files
EmailBill/Web/src/components/Budget/BudgetCard.vue
孙诚 343570d4bc
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
feat: 更新预算卡片组件,添加预算描述功能并优化状态标签显示
2026-01-08 16:03:20 +08:00

425 lines
9.0 KiB
Vue

<template>
<div class="common-card budget-card" @click="$emit('click')">
<div class="budget-content-wrapper">
<Transition :name="transitionName">
<div :key="budget.period" class="budget-inner-card">
<div class="card-header">
<div class="budget-info">
<h3 class="card-title">{{ budget.name }}</h3>
<slot name="tag">
<van-tag
:type="budget.type === BudgetPeriodType.Year ? 'warning' : 'primary'"
plain
class="status-tag"
>
{{ budget.type === BudgetPeriodType.Year ? '年度预算' : '月度预算' }}
</van-tag>
</slot>
</div>
<div class="header-actions">
<slot name="actions">
<van-button
v-if="budget.description"
:icon="showDescription ? 'info' : 'info-o'"
size="mini"
:type="showDescription ? 'primary' : 'default'"
plain
round
style="margin-right: 4px;"
@click.stop="showDescription = !showDescription"
/>
<van-button
v-if="budget.category !== 2"
:icon="budget.isStopped ? 'play' : 'pause'"
size="mini"
plain
round
@click.stop="$emit('toggle-stop', budget)"
/>
</slot>
</div>
</div>
<div class="budget-body">
<div class="amount-info">
<slot name="amount-info"></slot>
</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="#969799"
:show-pivot="false"
/>
<span class="percent">{{ timePercentage }}%</span>
</div>
<van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content" v-html="budget.description"></div>
</div>
</van-collapse-transition>
</div>
<div class="card-footer">
<div class="period-navigation" @click.stop>
<van-button
icon="arrow-left"
class="nav-icon"
plain
size="small"
style="width: 50px;"
@click="handleSwitch(-1)"
/>
<span class="period-text">{{ budget.period }}</span>
<van-button
icon="arrow"
class="nav-icon"
plain
size="small"
style="width: 50px;"
@click="handleSwitch(1)"
/>
</div>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { BudgetPeriodType } from '@/constants/enums'
const props = defineProps({
budget: {
type: Object,
required: true
},
progressColor: {
type: String,
default: '#1989fa'
},
percentClass: {
type: [String, Object],
default: ''
},
periodLabel: {
type: String,
default: ''
}
})
const emit = defineEmits(['toggle-stop', 'switch-period', 'click'])
const transitionName = ref('slide-left')
const showDescription = ref(false)
const handleSwitch = (direction) => {
transitionName.value = direction > 0 ? 'slide-left' : 'slide-right'
emit('switch-period', direction)
}
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)
})
</script>
<style scoped>
.budget-card {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
padding-bottom: 8px;
overflow: hidden;
position: relative;
}
.budget-content-wrapper {
position: relative;
width: 100%;
}
.budget-inner-card {
width: 100%;
}
/* 切换动画 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-leave-to {
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-active,
.slide-right-leave-active {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.budget-info {
display: flex;
align-items: center;
gap: 8px;
}
.card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.amount-info {
display: flex;
justify-content: space-between;
margin: 12px 0;
text-align: center;
}
:deep(.info-item) .label {
font-size: 12px;
color: #969799;
margin-bottom: 2px;
}
:deep(.info-item) .value {
font-size: 15px;
font-weight: 600;
}
:deep(.value.expense) {
color: #ee0a24;
}
:deep(.value.income) {
color: #07c160;
}
.progress-section {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 13px;
color: #646566;
}
.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: #ff976a;
font-weight: bold;
}
.percent.income {
color: #07c160;
font-weight: bold;
}
.time-progress {
margin-top: -8px;
opacity: 0.8;
}
.time-progress .period-type,
.time-progress .percent {
font-size: 11px;
}
.budget-description {
margin-top: 8px;
background-color: #f7f8fa;
border-radius: 4px;
padding: 8px;
}
.description-content {
font-size: 11px;
color: #646566;
line-height: 1.4;
}
.description-content :deep(h3) {
margin: 12px 0 6px;
font-size: 13px;
color: #323233;
border-left: 3px solid #1989fa;
padding-left: 8px;
}
.description-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
background: #fff;
border-radius: 4px;
overflow: hidden;
}
.description-content :deep(th),
.description-content :deep(td) {
text-align: left;
padding: 6px 4px;
border-bottom: 1px solid #f2f3f5;
}
.description-content :deep(th) {
background-color: #f7f8fa;
color: #969799;
font-weight: normal;
font-size: 10px;
}
.description-content :deep(p) {
margin: 4px 0;
}
.description-content :deep(.income-value) {
color: #07c160;
}
.description-content :deep(.expense-value) {
color: #ee0a24;
}
.description-content :deep(.highlight) {
background-color: #fffbe6;
color: #ed6a0c;
padding: 2px 6px;
border-radius: 4px;
font-weight: bold;
border: 1px solid #ffe58f;
}
@media (prefers-color-scheme: dark) {
.description-content :deep(h3) {
color: #f5f5f5;
}
.description-content :deep(table) {
background: #1a1a1a;
}
.description-content :deep(th) {
background-color: #242424;
}
.description-content :deep(td) {
border-bottom-color: #2c2c2c;
}
.description-content :deep(.highlight) {
background-color: #3e371a;
color: #ff976a;
border-color: #594a1a;
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
color: #969799;
padding: 12px 12px 0;
padding-top: 8px;
border-top: 1px solid #ebedf0;
}
.period-navigation {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.period-text {
font-size: 14px;
font-weight: 500;
color: #323233;
}
.nav-icon {
padding: 4px;
font-size: 12px;
color: #1989fa;
}
@media (prefers-color-scheme: dark) {
.card-footer {
border-top-color: #2c2c2c;
}
.period-text {
color: #f5f5f5;
}
.budget-description {
background-color: #2c2c2c;
}
.description-content {
color: #969799;
}
}
</style>