121 lines
6.0 KiB
Vue
121 lines
6.0 KiB
Vue
<template>
|
||
<view class="contracts-page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||
<CustomNavBar title="我的合同" show-back />
|
||
|
||
<view v-if="state === 'loading'" class="state">
|
||
<view class="skeleton" /><view class="skeleton" />
|
||
</view>
|
||
<view v-else-if="state === 'error'" class="state state-card">
|
||
<text class="state-title">暂时无法读取合同</text>
|
||
<text class="state-copy">{{ error }}</text>
|
||
<button class="secondary" @tap="load">重新加载</button>
|
||
</view>
|
||
<view v-else-if="!contracts.length" class="state state-card">
|
||
<text class="state-title">还没有会员卡合同</text>
|
||
<text class="state-copy">购买月卡或多次卡后,会在这里生成服务合同,签署后可随时查看和导出。</text>
|
||
</view>
|
||
<template v-else>
|
||
<text class="intro">月卡、多次卡均需签署服务合同。合同签署后不可修改,可随时导出 PDF 留存。</text>
|
||
<view
|
||
v-for="item in contracts"
|
||
:key="item.id"
|
||
class="contract-card"
|
||
:class="'contract-card--' + item.status.toLowerCase()"
|
||
hover-class="contract-card--hover"
|
||
@tap="open(item)"
|
||
>
|
||
<view class="contract-card__top">
|
||
<text class="contract-card__name">{{ item.cardName }}</text>
|
||
<text class="contract-card__status">{{ statusLabel(item) }}</text>
|
||
</view>
|
||
<text class="contract-card__meta">合同编号 {{ item.contractNo }}</text>
|
||
<view class="contract-card__facts">
|
||
<text>¥{{ formatPrice(item.amount) }}</text>
|
||
<text>有效期至 {{ formatChinaDate(item.expireDate) }}</text>
|
||
</view>
|
||
<view class="contract-card__foot">
|
||
<text v-if="item.status === MembershipContractStatus.SIGNED">签署于 {{ formatDateTimeFull(item.signedAt!) }}</text>
|
||
<text v-else-if="item.status === MembershipContractStatus.VOIDED">{{ item.voidReason || '合同已作废' }}</text>
|
||
<text v-else>{{ item.requireBeforeBooking ? '签署后即可预约课程' : '存量会员卡补签' }}</text>
|
||
<text class="contract-card__action">{{ item.status === MembershipContractStatus.PENDING_SIGN ? '去签署 ›' : '查看 ›' }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import { MembershipContractSource, MembershipContractStatus, type MembershipContractSummary } from '@mp-pilates/shared'
|
||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||
import { CONTRACT_ROUTES, CONTRACT_STATUS_LABEL, fetchMyContracts } from '../../utils/contract'
|
||
import { formatChinaDate, formatDateTimeFull, formatPrice } from '../../utils/format'
|
||
import { getErrorMessage } from '../../utils/auth'
|
||
import { getSystemLayout } from '../../utils/system'
|
||
|
||
const navBarHeight = getSystemLayout().navBarHeight
|
||
const contracts = ref<MembershipContractSummary[]>([])
|
||
const state = ref<'loading' | 'ready' | 'error'>('loading')
|
||
const error = ref('')
|
||
let sequence = 0
|
||
|
||
function statusLabel(item: MembershipContractSummary) {
|
||
const label = CONTRACT_STATUS_LABEL[item.status]
|
||
return item.source === MembershipContractSource.LEGACY && item.status === MembershipContractStatus.PENDING_SIGN ? `${label} · 补签` : label
|
||
}
|
||
|
||
async function load() {
|
||
const seq = ++sequence
|
||
if (!contracts.value.length) state.value = 'loading'
|
||
try {
|
||
const result = await fetchMyContracts()
|
||
if (seq !== sequence) return
|
||
contracts.value = result
|
||
state.value = 'ready'
|
||
} catch (err) {
|
||
if (seq !== sequence) return
|
||
error.value = getErrorMessage(err, '请检查网络后重试')
|
||
state.value = 'error'
|
||
}
|
||
}
|
||
|
||
function open(item: MembershipContractSummary) {
|
||
uni.navigateTo({
|
||
url: item.status === MembershipContractStatus.PENDING_SIGN
|
||
? `${CONTRACT_ROUTES.SIGN}?id=${item.id}`
|
||
: `${CONTRACT_ROUTES.DETAIL}?id=${item.id}`,
|
||
})
|
||
}
|
||
|
||
onShow(() => { void load() })
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.contracts-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
|
||
button::after { border: 0; }
|
||
.intro { display: block; margin: 28rpx 32rpx 8rpx; font-size: 23rpx; line-height: 1.8; color: #9a8e83; }
|
||
.contract-card {
|
||
margin: 20rpx 32rpx 0; padding: 28rpx; border-radius: 24rpx; background: #fff; border: 1rpx solid #eee6dd;
|
||
&--hover { background: #f8f4ef; }
|
||
&__top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
|
||
&__name { font-size: 30rpx; font-weight: 500; color: #4a4035; }
|
||
&__status { flex-shrink: 0; padding: 4rpx 16rpx; border-radius: 999rpx; font-size: 22rpx; background: #eef2ec; color: #617d73; }
|
||
&__meta { display: block; margin-top: 8rpx; font-size: 22rpx; color: #a39689; }
|
||
&__facts { display: flex; justify-content: space-between; margin-top: 20rpx; font-size: 25rpx; color: #6f655e; }
|
||
&__foot { display: flex; justify-content: space-between; gap: 16rpx; margin-top: 18rpx; padding-top: 18rpx; border-top: 1rpx solid #f2ece5; font-size: 22rpx; color: #9a8e83; }
|
||
&__action { flex-shrink: 0; color: #9c7a6e; }
|
||
&--pending_sign { border-color: #ecd9cb; }
|
||
&--pending_sign &__status { background: #f6ece4; color: #9c7a6e; }
|
||
&--voided { opacity: 0.6; }
|
||
&--voided &__status { background: #f0eeeb; color: #9a948e; }
|
||
}
|
||
.state { margin: 36rpx 32rpx; }
|
||
.state-card { margin-top: 90rpx; padding: 50rpx 32rpx; background: #f4f1ec; border-radius: 28rpx; text-align: center; }
|
||
.state-title { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 32rpx; color: #635b4f; }
|
||
.state-copy { display: block; margin: 18rpx 0 30rpx; font-size: 24rpx; line-height: 1.9; color: #8d8271; }
|
||
.secondary { width: 100%; margin: 0; border-radius: 20rpx; line-height: 84rpx; font-size: 27rpx; background: #eaece3; color: #60745a; }
|
||
.skeleton { height: 220rpx; background: #efebe5; border-radius: 24rpx; margin-bottom: 24rpx; animation: breathe 1.2s infinite alternate; }
|
||
@keyframes breathe { to { opacity: .45 } }
|
||
</style>
|