Files
EmailBill/Web/src/components/Budget/BudgetCard.vue
孙诚 c74ce24727
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 39s
Docker Build & Deploy / Deploy to Production (push) Successful in 12s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
fix
2026-01-16 23:18:04 +08:00

695 lines
17 KiB
Vue

<!-- eslint-disable vue/no-v-html -->
<template>
<!-- 普通预算卡片 -->
<div v-if="!budget.noLimit" class="common-card budget-card" @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>
<van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description" />
</div>
</van-collapse-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" @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>
<van-collapse-transition>
<div v-if="budget.description && showDescription" class="budget-description">
<div class="description-content rich-html-content" v-html="budget.description" />
</div>
</van-collapse-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 = () => {
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)
})
</script>
<style scoped>
.budget-card {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
padding: 8px 12px;
overflow: hidden;
position: relative;
cursor: pointer;
}
.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-description {
margin-top: 8px;
background-color: var(--van-background);
border-radius: 4px;
padding: 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;
}
/* @media (prefers-color-scheme: dark) {
.budget-description {
background-color: var(--van-background-2);
}
.description-content {
color: var(--van-text-color-2);
}
.collapsed-row .value {
color: var(--van-text-color);
}
} */
</style>