feat: 支持会员卡合同在线签署

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-10-28 12:24:22 +08:00
parent c04a9db627
commit ecea65f596
46 changed files with 4313 additions and 232 deletions

View 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>