120
packages/app/src/pages/contract/index.vue
Normal file
120
packages/app/src/pages/contract/index.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user