Files
mp-pilates/packages/app/src/pages/contract/index.vue
2026-10-28 12:24:22 +08:00

121 lines
6.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>