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,135 @@
<template>
<view class="detail-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar :title="previewCardTypeId ? '合同范本' : '合同详情'" show-back />
<view v-if="state === 'loading'" class="state"><view class="skeleton" /><view class="skeleton tall" /></view>
<view v-else-if="state === 'error' || !contractDocument" class="state state-card">
<text class="state-title">暂时无法读取合同</text>
<text class="state-copy">{{ error }}</text>
<button class="secondary" @tap="load">重新加载</button>
</view>
<template v-else>
<view v-if="previewCardTypeId" class="status status--preview">
<text class="status__title">合同范本预览</text>
<text class="status__copy">支付成功后将生成正式合同,填写姓名并手写签名后生效。金额、日期以支付确认后的正式合同为准。</text>
</view>
<view v-else-if="detail" class="status" :class="'status--' + detail.status.toLowerCase()">
<text class="status__title">{{ CONTRACT_STATUS_LABEL[detail.status] }}</text>
<text class="status__copy">{{ statusCopy }}</text>
</view>
<view class="paper">
<ContractDocumentView :document="contractDocument" :signature="detail?.signature ?? null" />
</view>
<view v-if="detail && !previewCardTypeId" class="dock">
<button v-if="detail.status === MembershipContractStatus.SIGNED" class="primary" :loading="exporting" :disabled="exporting" @tap="exportPdf">导出 PDF</button>
<button v-else-if="detail.status === MembershipContractStatus.PENDING_SIGN && !fromAdmin" class="primary" @tap="goSign">去签署</button>
<text v-if="detail.status === MembershipContractStatus.SIGNED" class="dock__hint">打开后可通过右上角菜单转发或保存到手机</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { MembershipContractStatus, type ContractDocument, type MembershipContractDetail } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import ContractDocumentView from '../../components/ContractDocumentView.vue'
import {
CONTRACT_ROUTES,
CONTRACT_STATUS_LABEL,
fetchContractDetail,
fetchContractTemplatePreview,
openContractPdf,
} from '../../utils/contract'
import { formatDateTimeFull } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
const navBarHeight = getSystemLayout().navBarHeight
const contractId = ref('')
const previewCardTypeId = ref('')
const fromAdmin = ref(false)
const detail = ref<MembershipContractDetail | null>(null)
const contractDocument = ref<ContractDocument | null>(null)
const state = ref<'loading' | 'ready' | 'error'>('loading')
const error = ref('')
const exporting = ref(false)
const statusCopy = computed(() => {
const current = detail.value
if (!current) return ''
if (current.status === MembershipContractStatus.SIGNED) return `${current.partyName ?? ''} 于 ${formatDateTimeFull(current.signedAt!)} 签署,合同内容已锁定。`
if (current.status === MembershipContractStatus.VOIDED) return current.voidReason || '该合同已作废。'
return current.unsignableReason || (current.requireBeforeBooking ? '签署后即可预约课程。' : '请核对会员卡信息并完成补签。')
})
async function load() {
state.value = 'loading'
error.value = ''
try {
if (previewCardTypeId.value) {
contractDocument.value = (await fetchContractTemplatePreview(previewCardTypeId.value)).document
} else {
detail.value = await fetchContractDetail(contractId.value)
contractDocument.value = detail.value.document
}
state.value = 'ready'
} catch (err) {
error.value = getErrorMessage(err, '请检查网络后重试')
state.value = 'error'
}
}
async function exportPdf() {
if (!detail.value || exporting.value) return
exporting.value = true
try {
await openContractPdf(detail.value.id)
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '导出失败,请重试'), icon: 'none' })
} finally {
exporting.value = false
}
}
function goSign() {
if (detail.value) uni.redirectTo({ url: `${CONTRACT_ROUTES.SIGN}?id=${detail.value.id}` })
}
onLoad((query) => {
contractId.value = String(query?.id || '')
previewCardTypeId.value = String(query?.previewCardTypeId || '')
fromAdmin.value = query?.from === 'admin'
void load()
})
</script>
<style lang="scss" scoped>
.detail-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(220rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
button { font-weight: 400; }
button::after { border: 0; }
.status { margin: 28rpx 32rpx 0; padding: 26rpx; border-radius: 24rpx; background: #edf1e6; border: 1rpx solid #dfe5d6; }
.status__title { display: block; font-size: 30rpx; color: #4f6a5c; }
.status__copy { display: block; margin-top: 8rpx; font-size: 23rpx; line-height: 1.8; color: #6f7d68; }
.status--pending_sign, .status--preview { background: #f6ece4; border-color: #ecdccf; }
.status--pending_sign .status__title, .status--preview .status__title { color: #9c7a6e; }
.status--pending_sign .status__copy, .status--preview .status__copy { color: #9a8478; }
.status--voided { background: #f0eeeb; border-color: #e5e1dc; }
.status--voided .status__title, .status--voided .status__copy { color: #8f8881; }
.paper { margin: 24rpx 32rpx 0; padding: 32rpx 28rpx; border-radius: 24rpx; background: #fff; border: 1rpx solid #eee6dd; }
.dock { position: fixed; left: 0; right: 0; bottom: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); background: #fbf9f6; border-top: 1rpx solid #ece6de; }
.dock__hint { display: block; margin-top: 12rpx; text-align: center; font-size: 21rpx; color: #a39689; }
.primary, .secondary { width: 100%; margin: 0; border-radius: 20rpx; line-height: 88rpx; font-size: 28rpx; background: #617d70; color: #fff; }
.secondary { background: #eaece3; color: #60745a; }
.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; }
.skeleton { height: 220rpx; background: #efebe5; border-radius: 24rpx; margin-bottom: 24rpx; animation: breathe 1.2s infinite alternate; }
.skeleton.tall { height: 520rpx; }
@keyframes breathe { to { opacity: .45 } }
</style>