|
|
<template>
|
|
|
<view class="member-card-scroll">
|
|
|
<scroll-view
|
|
|
class="card-scroll"
|
|
|
scroll-x="true"
|
|
|
scroll-with-animation
|
|
|
:show-scrollbar="false"
|
|
|
@scroll="onScroll"
|
|
|
>
|
|
|
<view class="card-scroll-inner" :style="innerStyle">
|
|
|
<view
|
|
|
v-for="(card, index) in cards"
|
|
|
:key="card.id || index"
|
|
|
class="card-item"
|
|
|
@click="onCardClick(card, index)"
|
|
|
>
|
|
|
<member-card
|
|
|
:card="card"
|
|
|
:size="size"
|
|
|
:selectable="selectable"
|
|
|
:selected="selectable && highlightIndex === index"
|
|
|
/>
|
|
|
<!-- v-if="selectable && !isCardUsable(card)"-->
|
|
|
<view class="unusable-tip">
|
|
|
<text v-if=" card.status == 'ACTIVE'" style="color:#00a89b;" >正常</text>
|
|
|
<text v-else style="color: #ff6b6b;">{{ getUnusableReason(card) }}</text>
|
|
|
</view>
|
|
|
</view>
|
|
|
</view>
|
|
|
</scroll-view>
|
|
|
|
|
|
<!-- 指示器 -->
|
|
|
<view v-if="cards && cards.length > 1" class="indicator">
|
|
|
<view
|
|
|
v-for="(card, index) in cards"
|
|
|
:key="index"
|
|
|
class="dot"
|
|
|
:class="{ active: index === currentIndex }"
|
|
|
></view>
|
|
|
</view>
|
|
|
</view>
|
|
|
</template>
|
|
|
|
|
|
<script>
|
|
|
import MemberCard from './member-card.vue';
|
|
|
|
|
|
export default {
|
|
|
name: 'MemberCardScroll',
|
|
|
components: { MemberCard },
|
|
|
props: {
|
|
|
cards: { type: Array, default: () => [] },
|
|
|
size: { type: String, default: 'mini' },
|
|
|
center: { type: Boolean, default: false },
|
|
|
selectable: { type: Boolean, default: false },
|
|
|
deductType: { type: String, default: 'COUNT' },
|
|
|
deductCount: { type: Number, default: 1 },
|
|
|
deductAmount: { type: Number, default: 0 },
|
|
|
// 默认卡片ID(后端可标记某张卡为默认卡)
|
|
|
defaultCardId: { type: [String, Number], default: null },
|
|
|
// 是否自动选中第一张可用卡(弹窗场景设为 false)
|
|
|
autoSelect: { type: Boolean, default: true }
|
|
|
},
|
|
|
data() {
|
|
|
return {
|
|
|
selectedIndex: -1,
|
|
|
currentIndex: 0,
|
|
|
screenWidthRpx: 750
|
|
|
};
|
|
|
},
|
|
|
computed: {
|
|
|
highlightIndex() {
|
|
|
if (this.selectedIndex >= 0) {
|
|
|
return this.selectedIndex;
|
|
|
}
|
|
|
if (this.selectable && this.defaultCard) {
|
|
|
return this.cards.findIndex(c => c.id === this.defaultCard.id);
|
|
|
}
|
|
|
return -1;
|
|
|
},
|
|
|
selectedCard() {
|
|
|
return this.selectedIndex >= 0 ? this.cards[this.selectedIndex] : null;
|
|
|
},
|
|
|
// 默认卡:后端指定的 defaultCardId,或第一张可用卡
|
|
|
defaultCard() {
|
|
|
if (this.defaultCardId != null) {
|
|
|
const card = this.cards.find(c => c.id === this.defaultCardId);
|
|
|
if (card && this.isCardUsable(card)) return card;
|
|
|
}
|
|
|
return this.cards.find(card => this.isCardUsable(card)) || null;
|
|
|
},
|
|
|
// 有效卡:用户选中的卡,没选就用默认卡
|
|
|
effectiveCard() {
|
|
|
return this.selectedCard || this.defaultCard;
|
|
|
},
|
|
|
// 卡片宽度映射(与 member-card.vue 保持一致)
|
|
|
cardWidthRpx() {
|
|
|
const map = { large: 690, small: 440, mini: 380 };
|
|
|
return map[this.size] || 380;
|
|
|
},
|
|
|
// 动态计算首尾 padding
|
|
|
innerStyle() {
|
|
|
if (!this.center) {
|
|
|
return { paddingLeft: '20rpx', paddingRight: '20rpx' };
|
|
|
}
|
|
|
const padding = Math.max(0, (this.screenWidthRpx - this.cardWidthRpx) / 2);
|
|
|
return {
|
|
|
paddingLeft: padding + 'rpx',
|
|
|
paddingRight: padding + 'rpx'
|
|
|
};
|
|
|
}
|
|
|
},
|
|
|
watch: {
|
|
|
cards: {
|
|
|
immediate: true,
|
|
|
handler() {
|
|
|
this.initSelected();
|
|
|
}
|
|
|
},
|
|
|
defaultCardId() {
|
|
|
this.initSelected();
|
|
|
}
|
|
|
},
|
|
|
created() {
|
|
|
this.screenWidthRpx = 750;
|
|
|
},
|
|
|
methods: {
|
|
|
onScroll(e) {
|
|
|
const scrollLeft = e.detail.scrollLeft;
|
|
|
const sysInfo = uni.getSystemInfoSync();
|
|
|
const screenWidth = sysInfo.windowWidth;
|
|
|
const rpxRatio = screenWidth / 750;
|
|
|
const cardWidthPx = this.cardWidthRpx * rpxRatio;
|
|
|
const gapPx = 20 * rpxRatio;
|
|
|
|
|
|
const paddingRpx = this.center
|
|
|
? Math.max(0, (750 - this.cardWidthRpx) / 2)
|
|
|
: 20;
|
|
|
const paddingPx = paddingRpx * rpxRatio;
|
|
|
|
|
|
const centerX = scrollLeft + screenWidth / 2;
|
|
|
let index = Math.round(
|
|
|
(centerX - paddingPx - cardWidthPx / 2) / (cardWidthPx + gapPx)
|
|
|
);
|
|
|
index = Math.max(0, Math.min(index, this.cards.length - 1));
|
|
|
|
|
|
// ✅ 只更新 currentIndex,用于指示器显示当前位置
|
|
|
// ✅ 不再修改 selectedIndex,选中卡只由点击/默认卡决定
|
|
|
this.currentIndex = index;
|
|
|
},
|
|
|
|
|
|
initSelected() {
|
|
|
if (!this.selectable) return;
|
|
|
if (!this.autoSelect) {
|
|
|
this.selectedIndex = -1;
|
|
|
return;
|
|
|
}
|
|
|
if (this.defaultCardId != null) {
|
|
|
const idx = this.cards.findIndex(c => c.id === this.defaultCardId);
|
|
|
if (idx >= 0 && this.isCardUsable(this.cards[idx])) {
|
|
|
this.selectedIndex = idx;
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
const idx = this.cards.findIndex(card => this.isCardUsable(card));
|
|
|
this.selectedIndex = idx;
|
|
|
},
|
|
|
|
|
|
onCardClick(card, index) {
|
|
|
this.currentIndex = index;
|
|
|
if (this.selectable) {
|
|
|
if (!this.isCardUsable(card)) {
|
|
|
uni.showToast({ title: this.getUnusableReason(card), icon: 'none' });
|
|
|
return;
|
|
|
}
|
|
|
this.selectedIndex = index;
|
|
|
this.$emit('select', { card, index });
|
|
|
} else {
|
|
|
this.$emit('card-click', { card, index });
|
|
|
}
|
|
|
},
|
|
|
|
|
|
isCardUsable(card) {
|
|
|
if (!card) return false;
|
|
|
// ✅ 只有 ACTIVE 才可用
|
|
|
if (card.status && card.status !== 'ACTIVE') return false;
|
|
|
if (card.expireDate) {
|
|
|
const now = new Date();
|
|
|
const expire = new Date(card.expireDate.replace(/-/g, '/'));
|
|
|
if (now > expire) return false;
|
|
|
}
|
|
|
if (card.type === 'count') {
|
|
|
const available = (card.remainingCount || 0) - (card.frozenCount || 0);
|
|
|
if (available < this.deductCount) return false;
|
|
|
} else if (card.type === 'total_fee') {
|
|
|
const available = (card.remainingTotalFee || 0) - (card.frozenAmount || 0);
|
|
|
if (available < this.deductAmount) return false;
|
|
|
}
|
|
|
return true;
|
|
|
},
|
|
|
|
|
|
getUnusableReason(card) {
|
|
|
if (card.status && card.status == 'ACTIVE') {
|
|
|
return '卡片已激活';
|
|
|
}
|
|
|
if (card.status && card.status == 'INACTIVE') {
|
|
|
return '卡片未激活';
|
|
|
}
|
|
|
if (card.status && card.status == 'EXPIRED') {
|
|
|
return '卡片已过期';
|
|
|
}
|
|
|
if (card.status && card.status == 'SUSPENDED') {
|
|
|
return '卡片已暂停';
|
|
|
}
|
|
|
if (card.status && card.status == 'DEPLETED') {
|
|
|
return '卡片额度已用完';
|
|
|
}
|
|
|
if (card.expireDate) {
|
|
|
const now = new Date();
|
|
|
const expire = new Date(card.expireDate.replace(/-/g, '/'));
|
|
|
if (now > expire) return '卡片已过期';
|
|
|
}
|
|
|
if (card.type === 'count') {
|
|
|
const available = (card.remainingCount || 0) - (card.frozenCount || 0);
|
|
|
if (available < this.deductCount) return '剩余次数不足';
|
|
|
}
|
|
|
else if (card.type === 'total_fee') {
|
|
|
const available = (card.remainingTotalFee || 0) - (card.frozenAmount || 0);
|
|
|
if (available < this.deductAmount) return '余额不足';
|
|
|
}
|
|
|
|
|
|
return '不可用';
|
|
|
},
|
|
|
|
|
|
// 对外方法:获取有效卡(用于提交预约)
|
|
|
getEffectiveCard() {
|
|
|
return this.effectiveCard;
|
|
|
},
|
|
|
|
|
|
isManuallySelected() {
|
|
|
return this.selectedIndex >= 0;
|
|
|
},
|
|
|
|
|
|
getSelected() {
|
|
|
return { card: this.selectedCard, index: this.selectedIndex };
|
|
|
},
|
|
|
|
|
|
isSelectedValid() {
|
|
|
return this.selectedIndex >= 0 && this.isCardUsable(this.selectedCard);
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
</script>
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
.member-card-scroll {
|
|
|
width: 100%;
|
|
|
}
|
|
|
.card-scroll {
|
|
|
width: 100%;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
.card-scroll-inner {
|
|
|
display: inline-block;
|
|
|
white-space: nowrap;
|
|
|
font-size: 0;
|
|
|
}
|
|
|
.card-item {
|
|
|
display: inline-block;
|
|
|
vertical-align: top;
|
|
|
margin-right: 20rpx;
|
|
|
position: relative;
|
|
|
font-size: 28rpx;
|
|
|
}
|
|
|
.card-item:last-child {
|
|
|
margin-right: 0;
|
|
|
}
|
|
|
.unusable-tip {
|
|
|
margin-top: 8rpx;
|
|
|
font-size: 22rpx;
|
|
|
|
|
|
text-align: center;
|
|
|
}
|
|
|
.indicator {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
align-items: center;
|
|
|
padding: 20rpx 0;
|
|
|
}
|
|
|
.dot {
|
|
|
width: 12rpx;
|
|
|
height: 12rpx;
|
|
|
border-radius: 50%;
|
|
|
background: #ddd;
|
|
|
margin: 0 6rpx;
|
|
|
transition: all 0.3s;
|
|
|
&.active {
|
|
|
width: 24rpx;
|
|
|
border-radius: 6rpx;
|
|
|
background: #00a89b;
|
|
|
}
|
|
|
}
|
|
|
</style>
|