onShow 静默刷新(首次有loading,后续静默): - budget: 添加 initialLoaded 守卫,避免首次双重请求 - profile: 添加 onShow 刷新概览/预算/账单/群组 - group-manage: 添加 onShow 刷新群组列表 - category-manage: 添加 onShow 刷新分类列表 - add: 添加 onShow 刷新分类(可能新增了分类) 群组模式预算双显: - 服务端 GET /budget 群组模式额外返回 myAmount(本人预算) - 客户端 Budget 类型添加 myAmount 字段 - 预算页群组模式:群组总预算卡片(只读)+ 我的预算卡片(可编辑) - 个人模式保持原样 TransactionItem 群组模式优化: - 主图标始终显示分类图标(不再显示创建者头像) - 他人记录在昵称旁显示小头像(28rpx圆形)
211 lines
5.1 KiB
Vue
211 lines
5.1 KiB
Vue
<template>
|
||
<view class="tx-item-wrap">
|
||
<view
|
||
class="tx-item"
|
||
:style="{ transform: `translateX(${offsetX}rpx)` }"
|
||
@tap="$emit('item-tap', item)"
|
||
@touchstart="!disableSwipe && onTouchStart($event)"
|
||
@touchmove="!disableSwipe && onTouchMove($event)"
|
||
@touchend="!disableSwipe && onTouchEnd()"
|
||
>
|
||
<!-- 始终显示分类图标 -->
|
||
<CategoryIcon
|
||
:label="item.category_name?.[0] || '?'"
|
||
:color="item.category_color || '#BFB3B3'"
|
||
:bg-color="(item.category_color || '#BFB3B3') + '20'"
|
||
size="sm"
|
||
/>
|
||
<view class="info">
|
||
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
|
||
<text class="meta">
|
||
<image v-if="isOtherCreator && creatorAvatarUrl" :src="creatorAvatarUrl" class="creator-mini-avatar" mode="aspectFill" />
|
||
<text v-else-if="isOtherCreator" class="creator-mini-initial">{{ item.creator_nickname?.[0] }}</text>
|
||
<text v-if="isOtherCreator" class="creator-name">{{ item.creator_nickname }} · </text>
|
||
{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}
|
||
</text>
|
||
</view>
|
||
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
|
||
</view>
|
||
<view class="delete-btn" :class="{ visible: showDelete }" @tap="$emit('delete', item)">
|
||
<Icon name="trash" :size="32" color="#FFFFFF" />
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue'
|
||
import { formatAmount, formatDate } from '@/utils/format'
|
||
import { API_BASE } from '@/config'
|
||
import Icon from '@/components/Icon/Icon.vue'
|
||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||
|
||
const props = withDefaults(defineProps<{
|
||
item: {
|
||
id: number
|
||
user_id: number
|
||
amount: number
|
||
type: 'expense' | 'income'
|
||
note?: string
|
||
date: string
|
||
category_name?: string
|
||
category_icon?: string
|
||
category_color?: string
|
||
creator_nickname?: string
|
||
creator_avatar?: string
|
||
}
|
||
hideDate?: boolean
|
||
disableSwipe?: boolean
|
||
/** 是否显示创建者信息(群组模式) */
|
||
showCreator?: boolean
|
||
/** 当前用户 ID,用于判断是否为本人记录 */
|
||
currentUserId?: number
|
||
}>(), { hideDate: false, disableSwipe: false, showCreator: false, currentUserId: 0 })
|
||
|
||
/** 是否为他人的记录(群组模式下,非本人的才显示创建者信息) */
|
||
const isOtherCreator = computed(() => {
|
||
if (!props.showCreator || !props.item.creator_nickname) return false
|
||
return !props.currentUserId || props.item.user_id !== props.currentUserId
|
||
})
|
||
|
||
defineEmits(['item-tap', 'delete'])
|
||
|
||
const creatorAvatarUrl = computed(() => {
|
||
if (!props.item.creator_avatar) return ''
|
||
if (props.item.creator_avatar.startsWith('http')) return props.item.creator_avatar
|
||
// 截取服务端根地址(去掉 /api)
|
||
const base = API_BASE.replace(/\/api$/, '')
|
||
return base + '/api/user/avatar/' + props.item.creator_avatar
|
||
})
|
||
|
||
const startX = ref(0)
|
||
const offsetX = ref(0)
|
||
const showDelete = ref(false)
|
||
|
||
function onTouchStart(e: any) {
|
||
startX.value = e.touches[0].clientX
|
||
}
|
||
|
||
function onTouchMove(e: any) {
|
||
const diff = e.touches[0].clientX - startX.value
|
||
if (diff < 0) {
|
||
offsetX.value = Math.max(diff * 2, -160)
|
||
} else if (showDelete.value) {
|
||
offsetX.value = Math.min(diff * 2 - 160, 0)
|
||
}
|
||
}
|
||
|
||
function onTouchEnd() {
|
||
if (offsetX.value < -80) {
|
||
offsetX.value = -160
|
||
showDelete.value = true
|
||
} else {
|
||
offsetX.value = 0
|
||
showDelete.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.tx-item-wrap {
|
||
position: relative;
|
||
overflow: hidden;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.tx-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 28rpx 24rpx;
|
||
gap: 24rpx;
|
||
border-radius: 20rpx;
|
||
transition: transform 0.2s ease;
|
||
background: #FFFFFF;
|
||
position: relative;
|
||
z-index: 1;
|
||
|
||
&:active {
|
||
background: #FFF0E6;
|
||
}
|
||
}
|
||
|
||
.delete-btn {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 160rpx;
|
||
background: #FF6B6B;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 0 20rpx 20rpx 0;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
z-index: 0;
|
||
|
||
&.visible {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.creator-mini-avatar {
|
||
width: 28rpx;
|
||
height: 28rpx;
|
||
border-radius: 50%;
|
||
vertical-align: middle;
|
||
margin-right: 4rpx;
|
||
}
|
||
|
||
.creator-mini-initial {
|
||
display: inline-flex;
|
||
width: 28rpx;
|
||
height: 28rpx;
|
||
border-radius: 50%;
|
||
background: #FFF0E6;
|
||
font-size: 16rpx;
|
||
color: #FF8C69;
|
||
align-items: center;
|
||
justify-content: center;
|
||
vertical-align: middle;
|
||
margin-right: 4rpx;
|
||
}
|
||
|
||
.info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.name {
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
color: #2D1B1B;
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.meta {
|
||
font-size: 24rpx;
|
||
color: #8B7E7E;
|
||
display: block;
|
||
margin-top: 4rpx;
|
||
}
|
||
|
||
.creator-name {
|
||
color: #FF8C69;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.amount {
|
||
font-family: 'Fredoka', sans-serif;
|
||
font-size: 32rpx;
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
white-space: nowrap;
|
||
|
||
&.expense { color: #2D1B1B; }
|
||
&.income { color: #7BC67E; }
|
||
}
|
||
</style>
|