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,85 @@
<template>
<view class="contract-doc">
<text class="contract-doc__title">{{ document.title }}</text>
<text class="contract-doc__no">合同编号:{{ document.contractNo }}</text>
<text class="contract-doc__notice">{{ document.notice }}</text>
<view v-for="group in groups" :key="group.title" class="contract-doc__group">
<text class="contract-doc__heading">{{ group.title }}</text>
<view v-for="row in group.rows" :key="row.label" class="contract-doc__row">
<text class="contract-doc__label">{{ row.label }}</text>
<text class="contract-doc__value">{{ row.value }}</text>
</view>
</view>
<view v-for="section in document.sections" :key="section.title" class="contract-doc__section">
<text class="contract-doc__heading">{{ section.title }}</text>
<text
v-for="clause in section.clauses"
:key="clause.text"
class="contract-doc__clause"
:class="{ 'contract-doc__clause--bold': clause.emphasis }"
>{{ clause.text }}</text>
</view>
<text class="contract-doc__commitment">{{ document.commitment }}</text>
<view v-if="signature" class="contract-doc__signatures">
<view class="contract-doc__sign">
<text class="contract-doc__sign-label">甲方(签名)</text>
<image class="contract-doc__sign-image" :src="signature.memberSignature" mode="aspectFit" />
<text class="contract-doc__sign-meta">{{ signature.signerName }}</text>
<text class="contract-doc__sign-meta">{{ signedAtText }}</text>
</view>
<view class="contract-doc__sign">
<text class="contract-doc__sign-label">乙方(签章)</text>
<image v-if="signature.partyBSignature" class="contract-doc__sign-image" :src="signature.partyBSignature" mode="aspectFit" />
<text class="contract-doc__sign-meta">{{ signature.partyBName }}</text>
<text class="contract-doc__sign-meta">{{ signedAtText }}</text>
</view>
</view>
<text v-if="signature" class="contract-doc__digest">内容摘要(SHA-256):{{ signature.contentSha256 }}</text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ContractDocument, ContractSignatureInfo } from '@mp-pilates/shared'
import { formatDateTimeFull } from '../utils/format'
const props = defineProps<{
document: ContractDocument
signature?: ContractSignatureInfo | null
}>()
const groups = computed(() => [
{ title: '甲方(会员)', rows: props.document.partyA },
{ title: '乙方(经营者)', rows: props.document.partyB },
{ title: '会员卡信息', rows: props.document.cardRows },
])
const signedAtText = computed(() => props.signature ? `签署于 ${formatDateTimeFull(props.signature.signedAt)}` : '')
</script>
<style lang="scss" scoped>
.contract-doc {
color: #3f3832;
&__title { display: block; text-align: center; font-family: 'Songti SC', 'STSong', serif; font-size: 36rpx; font-weight: 600; letter-spacing: 2rpx; }
&__no { display: block; text-align: center; margin-top: 10rpx; font-size: 22rpx; color: #9a8e83; }
&__notice { display: block; margin: 24rpx 0 8rpx; padding: 20rpx 22rpx; border-radius: 16rpx; background: #f4efe8; font-size: 23rpx; line-height: 1.8; color: #7b6f65; }
&__group, &__section { margin-top: 28rpx; }
&__heading { display: block; font-size: 28rpx; font-weight: 600; color: #4a4035; margin-bottom: 12rpx; }
&__row { display: flex; gap: 20rpx; padding: 14rpx 0; border-bottom: 1rpx solid #efe8e0; }
&__label { flex-shrink: 0; width: 170rpx; font-size: 24rpx; color: #9a8e83; line-height: 1.7; }
&__value { flex: 1; font-size: 25rpx; line-height: 1.7; overflow-wrap: anywhere; }
&__clause { display: block; font-size: 25rpx; line-height: 1.9; margin-bottom: 12rpx; text-align: justify; }
&__clause--bold { font-weight: 600; color: #2f2924; }
&__commitment { display: block; margin-top: 30rpx; font-size: 25rpx; line-height: 1.9; font-weight: 600; }
&__signatures { display: flex; gap: 20rpx; margin-top: 32rpx; }
&__sign { flex: 1; min-width: 0; padding: 20rpx; border-radius: 16rpx; background: #faf6f1; }
&__sign-label { display: block; font-size: 23rpx; color: #8b7f74; }
&__sign-image { display: block; width: 100%; height: 140rpx; margin: 10rpx 0; }
&__sign-meta { display: block; font-size: 21rpx; line-height: 1.7; color: #8b7f74; overflow-wrap: anywhere; }
&__digest { display: block; margin-top: 20rpx; font-size: 19rpx; line-height: 1.6; color: #b0a498; word-break: break-all; }
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<view v-if="summary && summary.pendingCount > 0" class="contract-banner" hover-class="contract-banner--hover" @tap="open">
<view class="contract-banner__mark">签</view>
<view class="contract-banner__body">
<text class="contract-banner__title">{{ summary.pendingCount }} 份会员卡合同待签署</text>
<text class="contract-banner__copy">{{ summary.blockingCount ? '签署后即可预约课程' : '请核对会员卡信息并完成补签' }}</text>
</view>
<text class="contract-banner__action">去签署 ›</text>
</view>
</template>
<script setup lang="ts">
import type { ContractPendingSummary } from '@mp-pilates/shared'
import { CONTRACT_ROUTES } from '../utils/contract'
const props = defineProps<{ summary: ContractPendingSummary | null }>()
function open() {
const summary = props.summary
if (!summary) return
uni.navigateTo({
url: summary.pendingCount === 1 && summary.firstPendingId
? `${CONTRACT_ROUTES.SIGN}?id=${summary.firstPendingId}`
: CONTRACT_ROUTES.LIST,
})
}
</script>
<style lang="scss" scoped>
.contract-banner {
display: flex; align-items: center; gap: 20rpx;
margin: 24rpx 32rpx 0; padding: 24rpx 26rpx;
border-radius: 24rpx; background: #f6ece4; border: 1rpx solid #ecdccf;
&--hover { opacity: 0.85; }
&__mark { width: 60rpx; height: 60rpx; flex-shrink: 0; border-radius: 50%; background: #9c7a6e; color: #fff; font-size: 26rpx; display: flex; align-items: center; justify-content: center; font-family: 'Songti SC', 'STSong', serif; }
&__body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6rpx; }
&__title { font-size: 27rpx; color: #5a4a40; font-weight: 500; }
&__copy { font-size: 22rpx; color: #9a8478; }
&__action { flex-shrink: 0; font-size: 24rpx; color: #9c7a6e; }
}
</style>

View File

@@ -8,6 +8,7 @@
<view v-else class="profile-menu__item" :class="{ 'profile-menu__item--admin': item.isAdmin }"
hover-class="profile-menu__item--hover" hover-stay-time="150" @tap="handleTap(item)">
<text class="profile-menu__title">{{ item.title }}</text>
<text v-if="item.badge" class="profile-menu__badge">{{ item.badge }}</text>
<text class="profile-menu__arrow">›</text>
</view>
</template>
@@ -26,11 +27,13 @@ interface MenuItem {
isAdmin?: boolean
action?: 'clear' | 'notifications'
requireAuth?: boolean
badge?: string
}
const props = defineProps<{
isAdmin: boolean
requireAuth?: boolean
contractPendingCount?: number
}>()
const emit = defineEmits<{
@@ -61,6 +64,14 @@ const menuItems = computed<MenuItem[]>(() => {
path: '/pages/profile/bookings',
requireAuth: true,
},
{
key: 'contracts',
type: 'item',
title: '我的合同',
path: '/pages/contract/index',
requireAuth: true,
badge: props.contractPendingCount ? `${props.contractPendingCount} 份待签` : undefined,
},
{
key: 'info',
type: 'item',
@@ -124,6 +135,7 @@ function handleTap(item: MenuItem) {
&__item--hover { background: #f4f1ec; }
&__item--admin { background: #eff3ed; .profile-menu__title { color: #617d73; } }
&__title { flex: 1; font-size: 27rpx; font-weight: 400; color: #6f655e; }
&__badge { flex-shrink: 0; padding: 4rpx 14rpx; border-radius: 999rpx; background: #f6ece4; color: #9c7a6e; font-size: 21rpx; }
&__arrow { font-size: 30rpx; color: #b2a79e; line-height: 1; }
&__separator { height: 12rpx; background: #fbf9f6; }
}

View File

@@ -0,0 +1,185 @@
<template>
<view class="signature-pad" :style="{ height: height + 'rpx' }">
<canvas
:id="canvasId"
type="2d"
class="signature-pad__canvas"
disable-scroll
@touchstart="handleStart"
@touchmove="handleMove"
@touchend="handleEnd"
@touchcancel="handleEnd"
/>
<view class="signature-pad__baseline" />
<text v-if="empty" class="signature-pad__hint">{{ placeholder }}</text>
</view>
</template>
<script setup lang="ts">
import { getCurrentInstance, nextTick, onMounted, ref } from 'vue'
interface Point { x: number; y: number }
interface CanvasNode {
width: number
height: number
getContext(type: '2d'): CanvasContext2D
}
interface CanvasContext2D {
scale(x: number, y: number): void
clearRect(x: number, y: number, w: number, h: number): void
beginPath(): void
moveTo(x: number, y: number): void
quadraticCurveTo(cx: number, cy: number, x: number, y: number): void
arc(x: number, y: number, r: number, start: number, end: number): void
fill(): void
stroke(): void
lineWidth: number
lineCap: string
lineJoin: string
strokeStyle: string
fillStyle: string
}
interface CanvasTouch { x: number; y: number }
const props = withDefaults(defineProps<{ height?: number; placeholder?: string }>(), {
height: 380,
placeholder: '请在此区域用正楷手写签名',
})
const emit = defineEmits<{ (e: 'change', empty: boolean): void }>()
const MIN_STROKE_LENGTH = 60
const instance = getCurrentInstance()
const canvasId = `signature-${Math.random().toString(36).slice(2, 10)}`
const empty = ref(true)
let canvas: CanvasNode | null = null
let ctx: CanvasContext2D | null = null
let size = { width: 0, height: 0 }
let last: Point | null = null
let lastMid: Point | null = null
let lastWidth = 3
let strokeLength = 0
function init() {
uni.createSelectorQuery()
.in(instance?.proxy)
.select(`#${canvasId}`)
.fields({ node: true, size: true }, () => {})
.exec((result: Array<{ node?: CanvasNode; width: number; height: number } | null>) => {
const info = result?.[0]
if (!info?.node) return
const ratio = uni.getWindowInfo().pixelRatio || 2
canvas = info.node
size = { width: info.width, height: info.height }
canvas.width = Math.round(info.width * ratio)
canvas.height = Math.round(info.height * ratio)
ctx = canvas.getContext('2d')
ctx.scale(ratio, ratio)
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = '#1f1a17'
ctx.fillStyle = '#1f1a17'
})
}
// 2d canvas 的触点坐标(x、y)相对画布左上角。
function touchPoint(event: TouchEvent): Point | null {
const { touches, changedTouches } = event as unknown as { touches?: CanvasTouch[]; changedTouches?: CanvasTouch[] }
const touch = touches?.[0] ?? changedTouches?.[0]
return touch ? { x: touch.x, y: touch.y } : null
}
function handleStart(event: TouchEvent) {
const point = touchPoint(event)
if (!point || !ctx) return
last = point
lastMid = point
lastWidth = 3.2
ctx.beginPath()
ctx.arc(point.x, point.y, 1.4, 0, Math.PI * 2)
ctx.fill()
}
function handleMove(event: TouchEvent) {
const point = touchPoint(event)
if (!point || !ctx || !last || !lastMid) return
const distance = Math.hypot(point.x - last.x, point.y - last.y)
if (distance < 1) return
// 快速书写时笔画变细,模拟真实笔锋。
const width = Math.max(1.8, Math.min(4.2, 4.4 - distance * 0.12))
const mid = { x: (last.x + point.x) / 2, y: (last.y + point.y) / 2 }
ctx.lineWidth = (lastWidth + width) / 2
ctx.beginPath()
ctx.moveTo(lastMid.x, lastMid.y)
ctx.quadraticCurveTo(last.x, last.y, mid.x, mid.y)
ctx.stroke()
strokeLength += distance
last = point
lastMid = mid
lastWidth = width
if (empty.value && strokeLength >= MIN_STROKE_LENGTH) {
empty.value = false
emit('change', false)
}
}
function handleEnd() {
last = null
lastMid = null
}
function clear() {
if (ctx) ctx.clearRect(0, 0, size.width, size.height)
strokeLength = 0
if (!empty.value) {
empty.value = true
emit('change', true)
}
}
/** 导出透明底 PNG(data URI),用于合同签名。 */
function exportPng(): Promise<string> {
return new Promise((resolve, reject) => {
if (!canvas || empty.value) {
reject(new Error('请先完成签名'))
return
}
// type="2d" 画布按节点导出,不使用 canvasId。
const options = {
canvas,
fileType: 'png',
destWidth: Math.round(size.width * 2),
destHeight: Math.round(size.height * 2),
success: (res: UniApp.CanvasToTempFilePathRes) => {
uni.getFileSystemManager().readFile({
filePath: res.tempFilePath,
encoding: 'base64',
success: (file) => resolve(`data:image/png;base64,${file.data as string}`),
fail: (err) => reject(new Error(err.errMsg || '签名读取失败')),
})
},
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg || '签名导出失败')),
}
uni.canvasToTempFilePath(options as unknown as UniApp.CanvasToTempFilePathOptions, instance?.proxy)
})
}
defineExpose({ clear, exportPng, isEmpty: () => empty.value })
onMounted(() => {
nextTick(() => setTimeout(init, 60))
})
</script>
<style lang="scss" scoped>
.signature-pad {
position: relative;
width: 100%;
border-radius: 24rpx;
border: 2rpx dashed #cdbfb2;
background: #fffdf9;
overflow: hidden;
&__canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; }
&__baseline { position: absolute; left: 48rpx; right: 48rpx; bottom: 30%; border-bottom: 1rpx solid #e6ddd3; }
&__hint { position: absolute; left: 0; right: 0; top: 42%; text-align: center; color: #b9ab9e; font-size: 26rpx; letter-spacing: 2rpx; pointer-events: none; }
}
</style>

View File

@@ -272,6 +272,42 @@
"style": {
"navigationStyle": "custom"
}
},
{
"path": "contracts",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "contract-settings",
"style": {
"navigationStyle": "custom"
}
}
]
},
{
"root": "pages/contract",
"pages": [
{
"path": "index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "sign",
"style": {
"navigationStyle": "custom",
"disableScroll": true
}
},
{
"path": "detail",
"style": {
"navigationStyle": "custom"
}
}
]
},

View File

@@ -0,0 +1,289 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="合同签署设置" show-back />
<view v-if="loading" class="state"><view class="skeleton" /><view class="skeleton" /></view>
<view v-else-if="error" class="state">
<text class="state-title">暂时无法读取设置</text>
<text class="state-copy">{{ error }}</text>
<button class="retry-button" @tap="load">重新加载</button>
</view>
<view v-else class="content">
<view v-if="missingFields.length" class="alert">
<text class="alert-title">以下信息未完善,学员暂时无法签署合同</text>
<text class="alert-copy">{{ missingFields.join('、') }}</text>
</view>
<view v-else class="alert alert--ok">
<text class="alert-title">合同信息已完善,学员可正常签署</text>
<text class="alert-copy">修改后,尚未签署的合同会按新信息生成;已签署的合同内容不受影响。</text>
</view>
<view class="panel">
<view class="panel-heading"><text class="section-number">01</text><text class="section-title">乙方(经营者)信息</text></view>
<text class="hint">工作室为个体工商户,合同中显示为「经营者」而非法定代表人。请与营业执照保持一致。</text>
<view class="field">
<text class="field-label">经营者名称</text>
<input v-model="form.businessName" class="field-input" maxlength="100" placeholder="营业执照上的「名称」;无字号时填经营者姓名" />
</view>
<view class="field">
<text class="field-label">统一社会信用代码</text>
<input v-model="form.creditCode" class="field-input" maxlength="18" placeholder="18 位,营业执照左上角" />
</view>
<view class="field">
<text class="field-label">经营者姓名</text>
<input v-model="form.operatorName" class="field-input" maxlength="30" placeholder="营业执照上的「经营者」" />
</view>
<view class="field">
<text class="field-label">门店名称(选填)</text>
<input v-model="form.brandName" class="field-input" maxlength="60" placeholder="对外品牌名,如 MP Pilates Studio" />
</view>
<view class="field">
<text class="field-label">联系电话</text>
<input v-model="form.phone" class="field-input" maxlength="20" placeholder="用于退费、通知等联系" />
</view>
<view class="field">
<text class="field-label">经营场所</text>
<textarea v-model="form.address" class="field-textarea" maxlength="200" auto-height placeholder="营业执照登记的经营场所,即授课地点" :show-confirm-bar="false" />
</view>
<view class="field field--row">
<text class="field-label">场所性质</text>
<view class="segment">
<text :class="{ selected: form.premisesType === 'LEASED' }" @tap="form.premisesType = 'LEASED'">租赁</text>
<text :class="{ selected: form.premisesType === 'OWNED' }" @tap="form.premisesType = 'OWNED'">自有</text>
</view>
</view>
<picker v-if="form.premisesType === 'LEASED'" mode="date" :value="form.leaseEndDate" @change="onLeaseDateChange">
<view class="field field--row">
<text class="field-label">租期至(选填)</text>
<text class="field-value">{{ form.leaseEndDate || '选择日期' }} ›</text>
</view>
</picker>
<text class="field-note">如实披露场所租期,是预付式消费的常见监管要求;会员卡有效期不宜超过租期。</text>
</view>
<view class="panel">
<view class="panel-heading"><text class="section-number">02</text><text class="section-title">退费结算价</text></view>
<text class="hint">学员自身原因退卡时,已上课时按「单节标准课价」扣除。应为对外公示的单次课价格。</text>
<view class="field">
<text class="field-label">单节标准课价(元)</text>
<input v-model="form.standardLessonPriceYuan" class="field-input" type="digit" :placeholder="fallbackPriceHint" />
</view>
<text class="field-note">{{ priceNote }}</text>
</view>
<view class="panel">
<view class="panel-heading"><text class="section-number">03</text><text class="section-title">经营者签名</text></view>
<text class="hint">签名会作为乙方签章出现在每份合同中,签署时自动加载。建议由经营者本人书写。</text>
<template v-if="signing">
<SignaturePad ref="pad" :height="360" placeholder="请经营者本人手写签名" @change="onSignatureChange" />
<view class="sign-actions">
<button class="ghost-button" :disabled="savingSignature" @tap="cancelSigning">取消</button>
<button class="ghost-button" :disabled="savingSignature" @tap="pad?.clear()">清除</button>
<button class="save-button" :loading="savingSignature" :disabled="savingSignature || signatureEmpty" @tap="saveSignature">保存签名</button>
</view>
</template>
<template v-else>
<view class="signature-preview">
<image v-if="signatureImage" :src="signatureImage" mode="aspectFit" class="signature-image" />
<text v-else class="field-note">尚未设置签名</text>
</view>
<button class="add-button" @tap="signing = true">{{ signatureImage ? '重新签名' : '手写签名' }}</button>
</template>
</view>
<view class="save-bar">
<text class="save-status">{{ isDirty ? '有未保存的修改' : '信息已同步' }}</text>
<button class="save-button" :loading="saving" :disabled="saving || !isDirty" @tap="save">保存</button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { ContractPartyConfig, ContractPremisesType } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import SignaturePad from '../../components/SignaturePad.vue'
import { useAdminStore } from './stores/admin'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
import { formatPrice } from '../../utils/format'
interface PartyForm {
businessName: string
brandName: string
creditCode: string
operatorName: string
phone: string
address: string
premisesType: ContractPremisesType
leaseEndDate: string
standardLessonPriceYuan: string
}
const navBarHeight = getSystemLayout().navBarHeight
const adminStore = useAdminStore()
const loading = ref(true)
const error = ref('')
const saving = ref(false)
const savingSignature = ref(false)
const signing = ref(false)
const signatureEmpty = ref(true)
const pad = ref<InstanceType<typeof SignaturePad> | null>(null)
const config = ref<ContractPartyConfig | null>(null)
const form = ref<PartyForm>(emptyForm())
const original = ref('')
function emptyForm(): PartyForm {
return { businessName: '', brandName: '', creditCode: '', operatorName: '', phone: '', address: '', premisesType: 'LEASED', leaseEndDate: '', standardLessonPriceYuan: '' }
}
function toForm(data: ContractPartyConfig): PartyForm {
return {
businessName: data.businessName,
brandName: data.brandName,
creditCode: data.creditCode,
operatorName: data.operatorName,
phone: data.phone,
address: data.address,
premisesType: data.premisesType,
leaseEndDate: data.leaseEndDate ? data.leaseEndDate.slice(0, 10) : '',
standardLessonPriceYuan: data.standardLessonPriceConfigured ? formatPrice(data.standardLessonPrice).replace(/\.00$/, '') : '',
}
}
const isDirty = computed(() => original.value !== '' && JSON.stringify(form.value) !== original.value)
const missingFields = computed(() => config.value?.missingFields ?? [])
const signatureImage = computed(() => config.value?.signatureImage ?? null)
const fallbackPriceHint = computed(() => config.value && !config.value.standardLessonPriceConfigured && config.value.standardLessonPrice > 0
? `未填写时按单次卡售价 ¥${formatPrice(config.value.standardLessonPrice)}`
: '例如 450')
const priceNote = computed(() => config.value?.standardLessonPrice
? `当前合同使用 ¥${formatPrice(config.value.standardLessonPrice)}${config.value.standardLessonPriceConfigured ? '' : '(取自单次卡售价)'};扣除总额不超过学员实付金额。`
: '尚无单次卡可参考,请填写单节标准课价。')
function applyConfig(data: ContractPartyConfig) {
config.value = data
form.value = toForm(data)
original.value = JSON.stringify(form.value)
}
async function load() {
loading.value = true
error.value = ''
try {
applyConfig(await adminStore.fetchContractParty())
} catch (err) {
error.value = getErrorMessage(err, '加载失败,请稍后重试')
} finally {
loading.value = false
}
}
async function save() {
if (saving.value || !isDirty.value) return
const priceText = form.value.standardLessonPriceYuan.trim()
const price = priceText ? Math.round(Number(priceText) * 100) : 0
if (priceText && (!Number.isFinite(price) || price < 0)) {
uni.showToast({ title: '请填写正确的课价', icon: 'none' })
return
}
saving.value = true
try {
applyConfig(await adminStore.saveContractParty({
businessName: form.value.businessName,
brandName: form.value.brandName,
creditCode: form.value.creditCode,
operatorName: form.value.operatorName,
phone: form.value.phone,
address: form.value.address,
premisesType: form.value.premisesType,
leaseEndDate: form.value.premisesType === 'LEASED' && form.value.leaseEndDate ? form.value.leaseEndDate : null,
standardLessonPrice: price,
}))
uni.showToast({ title: '已保存', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败,请重试'), icon: 'none' })
} finally {
saving.value = false
}
}
function onLeaseDateChange(event: { detail: { value: string } }) {
form.value.leaseEndDate = event.detail.value
}
function onSignatureChange(isEmpty: boolean) {
signatureEmpty.value = isEmpty
}
function cancelSigning() {
signing.value = false
signatureEmpty.value = true
}
async function saveSignature() {
if (!pad.value || savingSignature.value) return
savingSignature.value = true
try {
const image = await pad.value.exportPng()
const data = await adminStore.saveContractPartySignature(image)
config.value = data
signing.value = false
signatureEmpty.value = true
uni.showToast({ title: '签名已保存', icon: 'success' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '保存失败,请重试'), icon: 'none' })
} finally {
savingSignature.value = false
}
}
onLoad(() => { void load() })
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; box-sizing: border-box; background: #f7f6ef; color: #515747; }
.content { padding: 28rpx 28rpx calc(180rpx + env(safe-area-inset-bottom)); }
button { margin: 0; padding: 0; border: 0; border-radius: 12rpx; font-size: 25rpx; line-height: 1.5; }
button::after { border: 0; }
button[disabled] { opacity: .42; }
.alert { padding: 24rpx 26rpx; margin-bottom: 24rpx; border-radius: 20rpx; background: #fbf0e8; border: 1rpx solid #efdccd; }
.alert--ok { background: #eef2e6; border-color: #dde4d2; }
.alert-title { display: block; font-size: 26rpx; color: #8f6450; }
.alert--ok .alert-title { color: #5b704c; }
.alert-copy { display: block; margin-top: 8rpx; font-size: 23rpx; line-height: 1.7; color: #9a8073; }
.alert--ok .alert-copy { color: #7f8a72; }
.panel { background: #fffdf9; padding: 30rpx 26rpx; margin-bottom: 24rpx; border-radius: 24rpx; border: 1rpx solid #e6e5d9; }
.panel-heading { display: flex; align-items: center; gap: 18rpx; margin-bottom: 14rpx; }
.section-number { color: #a2ad91; font-size: 25rpx; font-family: Georgia, serif; }
.section-title { font-size: 30rpx; font-weight: 500; color: #45513d; }
.hint { display: block; color: #92907d; font-size: 23rpx; line-height: 1.8; margin-bottom: 16rpx; }
.field { padding: 20rpx 0; border-bottom: 1rpx solid #eeece2; }
.field--row { display: flex; align-items: center; justify-content: space-between; gap: 20rpx; }
.field-label { display: block; font-size: 23rpx; color: #8e977f; }
.field-input { margin-top: 8rpx; height: 56rpx; font-size: 28rpx; color: #4a5040; }
.field-textarea { box-sizing: border-box; width: 100%; margin-top: 8rpx; min-height: 56rpx; font-size: 28rpx; line-height: 1.6; color: #4a5040; }
.field-value { font-size: 26rpx; color: #6d7a5d; }
.field-note { display: block; color: #a09c88; font-size: 21rpx; line-height: 1.7; margin-top: 14rpx; }
.segment { display: flex; padding: 6rpx; border-radius: 14rpx; background: #eaece1; }
.segment text { padding: 10rpx 28rpx; border-radius: 10rpx; font-size: 24rpx; color: #858b76; }
.segment .selected { background: #fffdf8; color: #4f6243; }
.signature-preview { height: 200rpx; border-radius: 16rpx; background: #f5f4ec; display: flex; align-items: center; justify-content: center; margin-bottom: 20rpx; }
.signature-image { width: 100%; height: 180rpx; }
.add-button { width: 100%; height: 84rpx; border: 1rpx dashed #bdc6ab; background: #f3f5eb; color: #74835f; }
.sign-actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
.ghost-button { flex: 1; height: 80rpx; background: #eef0e6; color: #6d7a5d; }
.save-bar { position: fixed; z-index: 10; bottom: 0; left: 0; right: 0; display: flex; align-items: center; gap: 24rpx; padding: 24rpx 30rpx calc(24rpx + env(safe-area-inset-bottom)); background: #fffdf9; border-top: 1rpx solid #e6e5d9; }
.save-status { flex: 1; color: #9c9e8b; font-size: 23rpx; }
.save-button { min-width: 220rpx; padding: 22rpx 30rpx; border-radius: 999rpx; color: #fffdf5; background: #657857; font-size: 27rpx; }
.sign-actions .save-button { flex: 2; min-width: 0; }
.state { padding: 70rpx 34rpx; text-align: center; }
.state-title { display: block; font-size: 30rpx; line-height: 1.6; color: #6c775d; }
.state-copy { display: block; color: #96917f; font-size: 25rpx; line-height: 1.8; margin-top: 20rpx; }
.retry-button { padding: 22rpx; margin: 28rpx auto; width: 240rpx; color: #657857; background: #e9edde; }
.skeleton { height: 280rpx; border-radius: 24rpx; margin-bottom: 26rpx; background: #eaece2; animation: breathe 1.2s ease-in-out infinite alternate; }
@keyframes breathe { from { opacity: .45; } to { opacity: .85; } }
</style>

View File

@@ -0,0 +1,228 @@
<template>
<view class="page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="会员合同" show-back />
<view class="toolbar">
<view class="tabs">
<text v-for="tab in TABS" :key="tab.label" class="tab" :class="{ 'tab--active': status === tab.value }" @tap="selectTab(tab.value)">{{ tab.label }}</text>
</view>
<input v-model="search" class="search" placeholder="搜索姓名 / 昵称 / 手机号 / 合同编号" confirm-type="search" @input="onSearchInput" />
</view>
<view v-if="candidates.length" class="legacy">
<text class="legacy-title">{{ candidates.length }} 张存量会员卡需补录实付金额</text>
<text class="legacy-copy">这些会员卡由后台直接开通,没有支付订单。填写学员实际支付的金额后,系统会生成待签署的补签合同。</text>
<view v-for="item in candidates" :key="item.membershipId" class="legacy-item">
<view class="legacy-info">
<text class="legacy-name">{{ item.user.nickname || '未命名学员' }}{{ item.user.phone ? ' · ' + item.user.phone : '' }}</text>
<text class="legacy-meta">{{ item.cardName }} · {{ item.remainingTimes === null ? '不限次' : '剩余 ' + item.remainingTimes + ' 次' }} · 至 {{ formatChinaDate(item.expireDate) }}</text>
</view>
<input v-model="amounts[item.membershipId]" class="legacy-input" type="digit" placeholder="实付(元)" />
<button class="legacy-button" :disabled="creating === item.membershipId" @tap="createLegacy(item)">发起</button>
</view>
</view>
<view v-if="loading && !items.length" class="state"><view class="skeleton" /><view class="skeleton" /></view>
<view v-else-if="error && !items.length" class="state">
<text class="state-copy">{{ error }}</text>
<button class="retry-button" @tap="reload">重新加载</button>
</view>
<view v-else-if="!items.length" class="state"><text class="state-copy">暂无合同</text></view>
<view v-for="item in items" :key="item.id" class="row" hover-class="row--hover" @tap="openDetail(item)">
<view class="row-top">
<text class="row-name">{{ item.partyName || item.user.nickname || '未命名学员' }}</text>
<text class="row-status" :class="'row-status--' + item.status.toLowerCase()">{{ CONTRACT_STATUS_LABEL[item.status] }}</text>
</view>
<text class="row-meta">{{ item.cardName }} · ¥{{ formatPrice(item.amount) }}{{ item.source === MembershipContractSource.LEGACY ? ' · 存量补签' : '' }}</text>
<text class="row-meta">{{ item.contractNo }} · {{ item.signedAt ? '签署于 ' + formatDateTimeFull(item.signedAt) : '生成于 ' + formatDateTimeFull(item.createdAt) }}</text>
<text v-if="item.voidReason" class="row-meta">作废原因:{{ item.voidReason }}</text>
<view v-if="item.status !== MembershipContractStatus.VOIDED" class="row-actions">
<text class="row-action" @tap.stop="voidItem(item)">作废</text>
</view>
</view>
<text v-if="items.length" class="footer">{{ hasMore ? (loading ? '加载中…' : '上拉加载更多') : '没有更多了' }}</text>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onReachBottom, onShow } from '@dcloudio/uni-app'
import {
MembershipContractSource,
MembershipContractStatus,
type AdminMembershipContractRow,
type LegacyContractCandidate,
} from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from './stores/admin'
import { CONTRACT_ROUTES, CONTRACT_STATUS_LABEL } from '../../utils/contract'
import { formatChinaDate, formatDateTimeFull, formatPrice } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
const TABS: Array<{ label: string; value?: MembershipContractStatus }> = [
{ label: '全部' },
{ label: '待签署', value: MembershipContractStatus.PENDING_SIGN },
{ label: '已签署', value: MembershipContractStatus.SIGNED },
{ label: '已作废', value: MembershipContractStatus.VOIDED },
]
const PAGE_SIZE = 20
const navBarHeight = getSystemLayout().navBarHeight
const adminStore = useAdminStore()
const status = ref<MembershipContractStatus | undefined>(undefined)
const search = ref('')
const items = ref<AdminMembershipContractRow[]>([])
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
const error = ref('')
const candidates = ref<LegacyContractCandidate[]>([])
const amounts = ref<Record<string, string>>({})
const creating = ref('')
let sequence = 0
let searchTimer: ReturnType<typeof setTimeout> | undefined
let loaded = false
async function fetchPage(reset: boolean) {
const seq = ++sequence
if (reset) { page.value = 1; hasMore.value = true }
if (!hasMore.value) return
loading.value = true
error.value = ''
try {
const result = await adminStore.fetchAdminContracts({ page: page.value, limit: PAGE_SIZE, status: status.value, search: search.value.trim() || undefined })
if (seq !== sequence) return
items.value = reset ? [...result.items] : [...items.value, ...result.items]
hasMore.value = page.value * PAGE_SIZE < result.total
page.value += 1
} catch (err) {
if (seq === sequence) error.value = getErrorMessage(err, '加载失败,请重试')
} finally {
if (seq === sequence) loading.value = false
}
}
async function syncLegacy() {
try {
const result = await adminStore.syncLegacyContracts()
candidates.value = [...result.needsAmount]
if (result.created) uni.showToast({ title: `已生成 ${result.created} 份存量补签合同`, icon: 'none' })
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '存量会员卡同步失败'), icon: 'none' })
}
}
function reload() {
void fetchPage(true)
}
function selectTab(value?: MembershipContractStatus) {
if (status.value === value) return
status.value = value
reload()
}
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(reload, 300)
}
async function createLegacy(item: LegacyContractCandidate) {
const text = (amounts.value[item.membershipId] ?? '').trim()
const amount = Math.round(Number(text) * 100)
if (!text || !Number.isFinite(amount) || amount < 0) {
uni.showToast({ title: '请填写实付金额', icon: 'none' })
return
}
const confirm = await uni.showModal({ title: '发起补签合同?', content: `${item.user.nickname || '该学员'} · ${item.cardName},实付 ¥${formatPrice(amount)}。学员将在个人中心看到待签署合同。` })
if (!confirm.confirm) return
creating.value = item.membershipId
try {
await adminStore.createLegacyContract({ membershipId: item.membershipId, amount })
candidates.value = candidates.value.filter(candidate => candidate.membershipId !== item.membershipId)
uni.showToast({ title: '已发起补签', icon: 'success' })
reload()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '发起失败,请重试'), icon: 'none' })
} finally {
creating.value = ''
}
}
async function voidItem(item: AdminMembershipContractRow) {
const result = await uni.showModal({
title: '作废合同',
content: '',
editable: true,
placeholderText: item.status === MembershipContractStatus.SIGNED ? '请填写作废原因,如已退费' : '请填写作废原因,如已线下签署纸质合同',
confirmText: '作废',
confirmColor: '#a35645',
})
if (!result.confirm) return
const reason = (result.content ?? '').trim()
if (reason.length < 2) {
uni.showToast({ title: '请填写作废原因', icon: 'none' })
return
}
try {
await adminStore.voidContract(item.id, reason)
uni.showToast({ title: '合同已作废', icon: 'success' })
reload()
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '操作失败,请重试'), icon: 'none' })
}
}
function openDetail(item: AdminMembershipContractRow) {
uni.navigateTo({ url: `${CONTRACT_ROUTES.DETAIL}?id=${item.id}&from=admin` })
}
onLoad(() => {
void syncLegacy().then(reload)
loaded = true
})
onShow(() => {
if (loaded && items.value.length) reload()
})
onReachBottom(() => {
if (!loading.value && hasMore.value) void fetchPage(false)
})
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); background: #f7f6ef; color: #515747; }
button { margin: 0; padding: 0; border: 0; line-height: 1.5; }
button::after { border: 0; }
.toolbar { padding: 20rpx 28rpx 8rpx; }
.tabs { display: flex; padding: 6rpx; border-radius: 16rpx; background: #eaece1; }
.tab { flex: 1; text-align: center; padding: 14rpx 0; border-radius: 12rpx; font-size: 24rpx; color: #858b76; }
.tab--active { background: #fffdf8; color: #4f6243; }
.search { margin-top: 18rpx; height: 76rpx; padding: 0 24rpx; border-radius: 16rpx; background: #fffdf9; border: 1rpx solid #e6e5d9; font-size: 25rpx; }
.legacy { margin: 18rpx 28rpx 0; padding: 26rpx; border-radius: 22rpx; background: #fbf0e8; border: 1rpx solid #efdccd; }
.legacy-title { display: block; font-size: 27rpx; color: #8f6450; }
.legacy-copy { display: block; margin: 8rpx 0 12rpx; font-size: 22rpx; line-height: 1.7; color: #9a8073; }
.legacy-item { display: flex; align-items: center; gap: 14rpx; padding: 16rpx 0; border-top: 1rpx solid #f0e2d7; }
.legacy-info { flex: 1; min-width: 0; }
.legacy-name { display: block; font-size: 25rpx; color: #5a4a40; }
.legacy-meta { display: block; margin-top: 4rpx; font-size: 21rpx; color: #9a8478; }
.legacy-input { width: 150rpx; height: 64rpx; padding: 0 14rpx; border-radius: 12rpx; background: #fff; font-size: 24rpx; }
.legacy-button { width: 96rpx; height: 64rpx; line-height: 64rpx; border-radius: 12rpx; background: #9c7a6e; color: #fff; font-size: 24rpx; }
.row { margin: 18rpx 28rpx 0; padding: 26rpx; border-radius: 22rpx; background: #fffdf9; border: 1rpx solid #e6e5d9; }
.row--hover { background: #f5f4ec; }
.row-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.row-name { font-size: 29rpx; color: #45513d; }
.row-status { flex-shrink: 0; padding: 4rpx 14rpx; border-radius: 999rpx; font-size: 21rpx; background: #eef2e6; color: #5b704c; }
.row-status--pending_sign { background: #f6ece4; color: #9c7a6e; }
.row-status--voided { background: #efedea; color: #9a948e; }
.row-meta { display: block; margin-top: 8rpx; font-size: 22rpx; line-height: 1.6; color: #8e917f; }
.row-actions { display: flex; justify-content: flex-end; margin-top: 10rpx; }
.row-action { padding: 6rpx 0 0 24rpx; font-size: 23rpx; color: #a35645; }
.footer { display: block; text-align: center; padding: 28rpx; font-size: 22rpx; color: #a09c88; }
.state { padding: 60rpx 34rpx; text-align: center; }
.state-copy { display: block; color: #96917f; font-size: 25rpx; line-height: 1.8; }
.retry-button { padding: 22rpx; margin: 28rpx auto; width: 240rpx; border-radius: 12rpx; color: #657857; background: #e9edde; font-size: 25rpx; }
.skeleton { height: 180rpx; border-radius: 22rpx; margin: 0 0 20rpx; background: #eaece2; animation: breathe 1.2s ease-in-out infinite alternate; }
@keyframes breathe { from { opacity: .45; } to { opacity: .85; } }
</style>

View File

@@ -64,6 +64,10 @@
<text class="item-title">订单管理</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/contracts')">
<text class="item-title">会员合同</text>
<text class="arrow-text">›</text>
</view>
</view>
<view class="section-header"><text class="section-title">馆内好物</text></view>
@@ -86,6 +90,10 @@
<text class="item-title">馆主介绍</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="navigate('/pages/admin/contract-settings')">
<text class="item-title">合同签署设置</text>
<text class="arrow-text">›</text>
</view>
<view class="list-item" @tap="handleIncreaseSubscriptionCount">
<text class="item-title">增加订阅次数</text>
<text class="item-extra">剩余 {{ user?.adminBookingSubscriptionCount ?? 0 }} 次</text>

View File

@@ -33,6 +33,13 @@ import type {
CreateProfessionalAssessmentDto,
TrainingPlanRecord,
CreateTrainingPlanDto,
AdminMembershipContractRow,
ContractPartyConfig,
CreateLegacyContractDto,
LegacyContractSyncResult,
MembershipContractStatus,
MembershipContractSummary,
UpdateContractPartyDto,
} from '@mp-pilates/shared'
interface LegacyPaginatedData<T> {
@@ -306,7 +313,22 @@ export const useAdminStore = defineStore('admin', () => {
function createTeachingCaseUpload(fileName: string) { return post<TeachingCaseUpload>('/admin/teaching-cases/assets/credentials', { fileName }) }
function completeTeachingCaseUpload(id: string) { return post<{ id: string; url: string }>(`/admin/teaching-cases/assets/${id}/complete`) }
function fetchContractParty() { return get<ContractPartyConfig>('/admin/contract-party') }
function saveContractParty(dto: UpdateContractPartyDto) { return put<ContractPartyConfig>('/admin/contract-party', { ...dto }) }
function saveContractPartySignature(image: string) { return put<ContractPartyConfig>('/admin/contract-party/signature', { image }) }
async function fetchAdminContracts(params: { page: number; limit: number; status?: MembershipContractStatus; search?: string }) {
const query: Record<string, unknown> = { page: params.page, limit: params.limit }
if (params.status) query.status = params.status
if (params.search) query.search = params.search
return normalizePaginatedData(await get<PaginatedData<AdminMembershipContractRow>>('/admin/contracts', query))
}
function syncLegacyContracts() { return post<LegacyContractSyncResult>('/admin/contracts/legacy-sync') }
function createLegacyContract(dto: CreateLegacyContractDto) { return post<MembershipContractSummary>('/admin/contracts/legacy', { ...dto }) }
function voidContract(id: string, reason: string) { return post<MembershipContractSummary>(`/admin/contracts/${id}/void`, { reason }) }
return {
fetchContractParty, saveContractParty, saveContractPartySignature,
fetchAdminContracts, syncLegacyContracts, createLegacyContract, voidContract,
fetchTeachingCases, fetchTeachingCase, saveTeachingCase, withdrawTeachingCase, createTeachingCaseUpload, completeTeachingCaseUpload,
fetchReviews, fetchReviewTrend,
fetchTeachingAnalytics,

View File

@@ -213,6 +213,7 @@ import { BookingStatus, TimeSlotStatus, SubscriptionMessageScene } from '@mp-pil
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
import { isContractUnsignedError, promptSignContract } from '../../utils/contract'
import { getSystemLayout } from '../../utils/system'
import { isSlotPast, formatDateTimeFull } from '../../utils/format'
import { irisProfile } from '../../utils/teacher'
@@ -562,6 +563,10 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
if (isContractUnsignedError(err)) {
promptSignContract()
return
}
const message = err instanceof Error ? err.message : '预约失败,请重试'
uni.showToast({ title: message, icon: 'none' })
}

View File

@@ -92,6 +92,7 @@ import { BookingStatus, TIME_PERIODS, canUseMembershipForSlot } from '@mp-pilate
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getErrorMessage } from '../../utils/auth'
import { isContractUnsignedError, promptSignContract } from '../../utils/contract'
import { formatDate, isSlotPast } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import DateSelector from '../../components/DateSelector.vue'
@@ -362,6 +363,10 @@ async function onConfirmBooking(payload: { timeSlotId: string; membershipId: str
await loadSlots(selectedDate.value)
} catch (err: unknown) {
uni.hideLoading()
if (isContractUnsignedError(err)) {
promptSignContract()
return
}
const message = err instanceof Error ? err.message : '预约失败,请重试'
uni.showToast({ title: message, icon: 'none' })
}

View File

@@ -311,9 +311,20 @@
<text class="sheet-warning-text">{{ purchaseConfirmationWarning }}</text>
</view>
<view v-if="contractRequired" class="sheet-contract">
<view class="sheet-contract-check" :class="{ 'sheet-contract-check--on': contractAccepted }" @tap="contractAccepted = !contractAccepted">
{{ contractAccepted ? '✓' : '' }}
</view>
<view class="sheet-contract-text">
<text @tap="contractAccepted = !contractAccepted">我已阅读并同意</text>
<text class="sheet-contract-link" @tap="previewContract">《会员卡服务合同》</text>
<text @tap="contractAccepted = !contractAccepted">,支付成功后需填写姓名并手写签署</text>
</view>
</view>
<view class="sheet-actions">
<button class="sheet-cancel" :disabled="buying" @tap="closePurchaseConfirmation">再想想</button>
<button class="sheet-confirm" :disabled="buying" @tap="doPurchase">
<button class="sheet-confirm" :class="{ 'sheet-confirm--blocked': contractRequired && !contractAccepted }" :disabled="buying" @tap="doPurchase">
{{ buying ? '正在唤起支付...' : '确认支付' }}
</button>
</view>
@@ -356,8 +367,10 @@ import {
pickRenewalTarget,
isMembershipExpired,
getMembershipEffectiveState,
requiresMembershipContract,
} from '@mp-pilates/shared'
import { getErrorMessage } from '../../utils/auth'
import { CONTRACT_ROUTES } from '../../utils/contract'
import { get, post } from '../../utils/request'
import { formatPrice, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
@@ -449,6 +462,13 @@ const typeLabel = computed(() => card.value ? getTypeLabel(card.value.type) : ''
const isTrialCard = computed(() => card.value?.type === CardTypeCategory.TRIAL)
const isUnlimitedCard = computed(() => card.value?.totalTimes === null)
const cardData = computed<CardType>(() => card.value as CardType)
const contractRequired = computed(() => !!card.value && requiresMembershipContract(card.value))
const contractAccepted = ref(false)
function previewContract() {
if (!card.value) return
uni.navigateTo({ url: `${CONTRACT_ROUTES.DETAIL}?previewCardTypeId=${card.value.id}` })
}
const heroClass = computed(() => {
if (!card.value) return ''
@@ -590,16 +610,17 @@ const purchaseNotes = computed(() => {
}
if (card.value.type === CardTypeCategory.DURATION) {
notes.push('如需退卡,已上课时按次卡单价计费扣除,不以月卡优惠价结算。')
notes.push('每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。')
} else {
notes.push(card.value.totalTimes === null ? '到期后自动失效。' : '到期或课时用完后自动失效。')
}
notes.push(
card.value.type === CardTypeCategory.DURATION
? '每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。'
: card.value.totalTimes === null
? '到期后自动失效。'
: '到期或课时用完后自动失效,不可退款。',
)
if (requiresMembershipContract(card.value)) {
notes.push('付款 7 日内未上课可全额退款;之后退卡,已上课时按单节标准课价扣除,不收手续费,详见合同。')
notes.push('支付成功后需在线签署《会员卡服务合同》,签署后即可预约课程。')
} else if (card.value.totalTimes !== null) {
notes[notes.length - 1] = '到期或课时用完后自动失效,不可退款。'
}
notes.push('支持微信支付,支付过程由微信安全保障。')
return notes
})
@@ -814,6 +835,7 @@ async function preparePurchaseConfirmation() {
return
}
syncRenewalClock()
contractAccepted.value = false
purchaseConfirmVisible.value = true
} catch (err) {
uni.showToast({ title: getErrorMessage(err, '暂时无法核对优惠,请重试'), icon: 'none' })
@@ -890,6 +912,15 @@ async function settlePaidOrder(orderId: string) {
pendingOrderId.value = ''
paymentRedirecting.value = true
if (contractRequired.value) {
uni.showToast({ title: '支付成功,请签署服务合同', icon: 'none' })
setTimeout(() => {
if (session !== paymentConfirmationSession.value) return
paymentRedirecting.value = false
uni.redirectTo({ url: `${CONTRACT_ROUTES.SIGN}?orderId=${orderId}` })
}, 900)
return
}
const scheduledMonthly = card.value?.type === CardTypeCategory.DURATION && userStore.memberships.some(m => m.cardTypeId === card.value?.id && m.status === MembershipStatus.SCHEDULED)
uni.showToast({ title: scheduledMonthly ? '下期月卡已安排' : isRenewal.value ? '续卡权益已到账' : '会员卡已到账', icon: 'success' })
setTimeout(() => {
@@ -923,6 +954,10 @@ function goToMembership() {
async function doPurchase() {
if (!card.value || buying.value) return
if (contractRequired.value && !contractAccepted.value) {
uni.showToast({ title: '请先阅读并同意《会员卡服务合同》', icon: 'none' })
return
}
buying.value = true
purchaseConfirmVisible.value = false
@@ -941,6 +976,7 @@ async function doPurchase() {
uni.showLoading({ title: '创建订单...' })
const result = await post<CreateOrderResponse>('/payment/create-order', {
cardTypeId: card.value.id,
...(contractRequired.value ? { contractAccepted: contractAccepted.value } : {}),
})
uni.hideLoading()
@@ -1576,7 +1612,13 @@ onUnmounted(() => {
line-height: 1.55;
}
.sheet-contract { margin-top: 20rpx; display: flex; align-items: flex-start; gap: 14rpx; }
.sheet-contract-check { flex-shrink: 0; width: 32rpx; height: 32rpx; margin-top: 2rpx; border-radius: 8rpx; border: 2rpx solid #bfb2a5; color: #fff; font-size: 20rpx; line-height: 32rpx; text-align: center; }
.sheet-contract-check--on { background: #6b8276; border-color: #6b8276; }
.sheet-contract-text { flex: 1; color: #756a61; font-size: 22rpx; line-height: 1.6; }
.sheet-contract-link { color: #9c7a6e; }
.sheet-actions { margin-top: 28rpx; display: flex; gap: 14rpx; }
.sheet-confirm--blocked { opacity: 0.6; }
.sheet-cancel,
.sheet-confirm {

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>

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>

View File

@@ -0,0 +1,331 @@
<template>
<view class="sign-page" :style="{ paddingTop: navBarHeight + 'px' }">
<CustomNavBar title="签署会员卡合同" show-back :before-back="handleBack" />
<view v-if="state === 'loading'" class="state"><view class="skeleton" /><view class="skeleton tall" /></view>
<view v-else-if="state !== 'ready' || !detail" class="state state-card">
<text class="state-title">{{ stateTitle }}</text>
<text class="state-copy">{{ error }}</text>
<button v-if="state === 'error'" class="secondary" @tap="load">重新加载</button>
<button class="text-button" @tap="goList">查看我的合同</button>
</view>
<template v-else>
<view v-if="step !== 'done'" class="steps">
<view v-for="(item, index) in STEPS" :key="item.key" class="steps__item" :class="{ 'steps__item--active': stepIndex >= index }">
<text class="steps__index">{{ index + 1 }}</text>
<text class="steps__label">{{ item.label }}</text>
</view>
</view>
<!-- Step 1: identity -->
<view v-if="step === 'identity'" class="panel">
<view class="card-summary">
<text class="card-summary__name">{{ detail.cardName }}</text>
<text class="card-summary__meta">¥{{ formatPrice(detail.amount) }} · 有效期至 {{ formatChinaDate(detail.expireDate) }}</text>
<text v-if="detail.source === MembershipContractSource.LEGACY" class="card-summary__tag">存量会员卡补签</text>
</view>
<text class="panel__title">确认签约人信息</text>
<text class="panel__copy">请填写与身份证件一致的真实姓名,合同将以此作为甲方信息。</text>
<view class="field">
<text class="field__label">真实姓名</text>
<input v-model="realName" class="field__input" maxlength="30" placeholder="请输入真实姓名" />
</view>
<view class="field">
<text class="field__label">手机号</text>
<input v-model="phone" class="field__input" type="number" maxlength="11" placeholder="请输入 11 位手机号" />
</view>
<text v-if="formError" class="error">{{ formError }}</text>
<view class="dock">
<button class="primary" :loading="busy" :disabled="busy" @tap="submitIdentity">下一步:阅读合同</button>
</view>
</view>
<!-- Step 2: read -->
<template v-else-if="step === 'read'">
<text class="read-tip">{{ readToEnd ? '已阅读至底部,请确认以下事项' : '请完整阅读合同至底部,加粗条款请重点阅读' }}</text>
<scroll-view class="reader" scroll-y lower-threshold="120" @scrolltolower="readToEnd = true">
<view class="reader__paper">
<ContractDocumentView :document="detail.document" />
</view>
</scroll-view>
<view class="dock dock--fixed">
<text v-if="detail.unsignableReason" class="error">{{ detail.unsignableReason }}</text>
<view class="check" @tap="agreedTerms = !agreedTerms">
<view class="check__box" :class="{ 'check__box--on': agreedTerms }">{{ agreedTerms ? '✓' : '' }}</view>
<text class="check__text">我已阅读并理解合同全部条款,特别是加粗显示的有效期、取消预约、退费等条款</text>
</view>
<view class="check" @tap="confirmedHealth = !confirmedHealth">
<view class="check__box" :class="{ 'check__box--on': confirmedHealth }">{{ confirmedHealth ? '✓' : '' }}</view>
<text class="check__text">我确认已如实告知身体健康状况,不存在合同第八条所列不适宜运动的情形</text>
</view>
<button class="primary" :disabled="!canProceedToSign" @tap="step = 'sign'">
{{ readToEnd ? '下一步:手写签名' : '请先阅读至合同底部' }}
</button>
</view>
</template>
<!-- Step 3: handwrite -->
<view v-else-if="step === 'sign'" class="panel">
<text class="panel__title">手写签名</text>
<text class="panel__copy">请在下方区域用正楷书写姓名「{{ detail.partyName }}」。签名将与合同全文一并留存,签署后不可修改。</text>
<SignaturePad ref="pad" :height="420" @change="onSignatureChange" />
<view class="sign-actions">
<button class="secondary sign-actions__clear" :disabled="busy" @tap="clearSignature">清除重写</button>
<button class="primary sign-actions__submit" :loading="busy" :disabled="busy || signatureEmpty" @tap="submitSignature">确认签署</button>
</view>
<text class="legal-note">签署即表示你同意以电子方式订立本合同,手写电子签名与纸质签字具有同等效力。系统将记录签署时间、合同内容摘要及设备信息作为签署凭证。</text>
</view>
<!-- Done -->
<view v-else class="done">
<view class="done__mark">✓</view>
<text class="done__title">合同已签署</text>
<text class="done__copy">合同编号 {{ detail.contractNo }},可在「个人中心 - 我的合同」随时查看和导出。</text>
<button class="primary" @tap="viewContract">查看合同</button>
<button v-if="nextPendingId" class="secondary" @tap="signNext">继续签署下一份</button>
<button class="text-button" @tap="goBooking">去预约课程</button>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { MembershipContractSource, MembershipContractStatus, type MembershipContractDetail } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import ContractDocumentView from '../../components/ContractDocumentView.vue'
import SignaturePad from '../../components/SignaturePad.vue'
import {
CONTRACT_ROUTES,
fetchContractByOrder,
fetchContractDetail,
fetchContractPendingSummary,
saveContractPartyA,
signContract,
} from '../../utils/contract'
import { formatChinaDate, formatPrice } from '../../utils/format'
import { getErrorMessage } from '../../utils/auth'
import { getSystemLayout } from '../../utils/system'
type Step = 'identity' | 'read' | 'sign' | 'done'
const STEPS = [
{ key: 'identity', label: '确认信息' },
{ key: 'read', label: '阅读合同' },
{ key: 'sign', label: '手写签名' },
] as const
const navBarHeight = getSystemLayout().navBarHeight
const contractId = ref('')
const orderId = ref('')
const detail = ref<MembershipContractDetail | null>(null)
const state = ref<'loading' | 'ready' | 'error' | 'voided'>('loading')
const error = ref('')
const step = ref<Step>('identity')
const realName = ref('')
const phone = ref('')
const formError = ref('')
const busy = ref(false)
const readToEnd = ref(false)
const agreedTerms = ref(false)
const confirmedHealth = ref(false)
const signatureEmpty = ref(true)
const nextPendingId = ref<string | null>(null)
const pad = ref<InstanceType<typeof SignaturePad> | null>(null)
const stepIndex = computed(() => STEPS.findIndex(item => item.key === step.value))
const canProceedToSign = computed(() => readToEnd.value && agreedTerms.value && confirmedHealth.value && !!detail.value?.signable)
const stateTitle = computed(() => state.value === 'voided' ? '该合同已作废' : '暂时无法读取合同')
function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function resolveContractId(): Promise<string> {
if (contractId.value) return contractId.value
for (const delay of [0, 800, 1500, 2500]) {
if (delay) await wait(delay)
const summary = await fetchContractByOrder(orderId.value)
if (summary) return summary.id
}
throw new Error('合同正在生成,请稍后在「我的合同」中签署')
}
async function load() {
state.value = 'loading'
error.value = ''
try {
contractId.value = await resolveContractId()
const result = await fetchContractDetail(contractId.value)
if (result.status === MembershipContractStatus.SIGNED) {
uni.redirectTo({ url: `${CONTRACT_ROUTES.DETAIL}?id=${result.id}` })
return
}
if (result.status === MembershipContractStatus.VOIDED) {
error.value = result.voidReason || '请联系馆主了解详情'
state.value = 'voided'
return
}
detail.value = result
realName.value = result.partyName ?? ''
phone.value = result.partyPhone ?? ''
step.value = 'identity'
state.value = 'ready'
} catch (err) {
error.value = getErrorMessage(err, '请检查网络后重试')
state.value = 'error'
}
}
async function submitIdentity() {
if (!detail.value || busy.value) return
const name = realName.value.trim()
const mobile = phone.value.trim()
if (name.length < 2) { formError.value = '请填写真实姓名'; return }
if (!/^1\d{10}$/.test(mobile)) { formError.value = '请填写 11 位手机号'; return }
formError.value = ''
busy.value = true
try {
detail.value = await saveContractPartyA(detail.value.id, name, mobile)
readToEnd.value = false
step.value = 'read'
} catch (err) {
formError.value = getErrorMessage(err, '保存失败,请重试')
} finally {
busy.value = false
}
}
function onSignatureChange(isEmpty: boolean) {
signatureEmpty.value = isEmpty
}
function clearSignature() {
pad.value?.clear()
}
function deviceSummary(): string | undefined {
try {
const device = uni.getDeviceInfo()
const app = uni.getAppBaseInfo()
return `${device.brand ?? ''} ${device.model ?? ''} / ${device.system ?? ''} / 微信 ${app.version ?? ''} / 基础库 ${app.SDKVersion ?? ''}`.slice(0, 200)
} catch {
return undefined
}
}
async function submitSignature() {
if (!detail.value || !pad.value || busy.value) return
busy.value = true
try {
const signatureImage = await pad.value.exportPng()
detail.value = await signContract(detail.value.id, {
contentSha256: detail.value.contentSha256,
signatureImage,
agreedTerms: agreedTerms.value,
confirmedHealth: confirmedHealth.value,
device: deviceSummary(),
})
step.value = 'done'
const summary = await fetchContractPendingSummary().catch(() => null)
nextPendingId.value = summary?.firstPendingId ?? null
} catch (err) {
const message = getErrorMessage(err, '签署失败,请重试')
if (message.includes('合同内容已更新')) {
uni.showToast({ title: '合同内容有更新,请重新阅读', icon: 'none' })
detail.value = await fetchContractDetail(detail.value.id).catch(() => detail.value)
readToEnd.value = false
step.value = 'read'
} else {
uni.showToast({ title: message, icon: 'none', duration: 3000 })
}
} finally {
busy.value = false
}
}
function handleBack(): boolean {
if (state.value !== 'ready') return true
if (step.value === 'sign') { step.value = 'read'; return false }
if (step.value === 'read') { step.value = 'identity'; return false }
return true
}
function viewContract() {
if (detail.value) uni.redirectTo({ url: `${CONTRACT_ROUTES.DETAIL}?id=${detail.value.id}` })
}
function signNext() {
if (nextPendingId.value) uni.redirectTo({ url: `${CONTRACT_ROUTES.SIGN}?id=${nextPendingId.value}` })
}
function goBooking() {
uni.switchTab({ url: '/pages/booking/index' })
}
function goList() {
uni.redirectTo({ url: CONTRACT_ROUTES.LIST })
}
onLoad((query) => {
contractId.value = String(query?.id || '')
orderId.value = String(query?.orderId || '')
if (!contractId.value && !orderId.value) {
state.value = 'error'
error.value = '缺少合同信息'
return
}
void load()
})
</script>
<style lang="scss" scoped>
.sign-page { height: 100vh; box-sizing: border-box; display: flex; flex-direction: column; background: #fbf9f6; color: #514943; overflow: hidden; }
button { font-weight: 400; }
button::after { border: 0; }
.steps { display: flex; justify-content: space-between; gap: 12rpx; padding: 24rpx 32rpx 16rpx; flex-shrink: 0; }
.steps__item { flex: 1; display: flex; align-items: center; gap: 10rpx; opacity: 0.45; }
.steps__item--active { opacity: 1; }
.steps__index { width: 40rpx; height: 40rpx; border-radius: 50%; background: #617d70; color: #fff; font-size: 22rpx; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.steps__label { font-size: 24rpx; color: #5f564e; }
.panel { flex: 1; overflow-y: auto; padding: 12rpx 32rpx calc(200rpx + env(safe-area-inset-bottom)); }
.panel__title { display: block; margin-top: 28rpx; font-family: 'Songti SC', 'STSong', serif; font-size: 34rpx; color: #4a4035; }
.panel__copy { display: block; margin: 12rpx 0 24rpx; font-size: 24rpx; line-height: 1.8; color: #8d8271; }
.card-summary { padding: 26rpx; border-radius: 24rpx; background: #edf1e6; border: 1rpx solid #dfe5d6; }
.card-summary__name { display: block; font-size: 30rpx; color: #4a4035; }
.card-summary__meta { display: block; margin-top: 8rpx; font-size: 24rpx; color: #6f7d68; }
.card-summary__tag { display: inline-block; margin-top: 12rpx; padding: 2rpx 14rpx; border-radius: 999rpx; background: #fff; font-size: 21rpx; color: #7d8b72; }
.field { margin-top: 20rpx; padding: 22rpx 26rpx; border-radius: 20rpx; background: #fff; border: 1rpx solid #ece4db; }
.field__label { display: block; font-size: 22rpx; color: #9a8e83; }
.field__input { margin-top: 8rpx; height: 56rpx; font-size: 30rpx; color: #3f3832; }
.read-tip { flex-shrink: 0; display: block; margin: 0 32rpx 14rpx; padding: 14rpx 20rpx; border-radius: 14rpx; background: #f6ece4; font-size: 22rpx; color: #9c7a6e; }
.reader { flex: 1; height: 0; }
.reader__paper { margin: 0 32rpx 24rpx; 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--fixed { position: relative; flex-shrink: 0; }
.check { display: flex; gap: 16rpx; align-items: flex-start; margin-bottom: 16rpx; }
.check__box { width: 34rpx; height: 34rpx; flex-shrink: 0; margin-top: 4rpx; border-radius: 8rpx; border: 2rpx solid #bfb2a5; color: #fff; font-size: 22rpx; display: flex; align-items: center; justify-content: center; }
.check__box--on { background: #617d70; border-color: #617d70; }
.check__text { flex: 1; font-size: 23rpx; line-height: 1.7; color: #6f655e; }
.primary, .secondary { width: 100%; margin: 0; border-radius: 20rpx; line-height: 88rpx; font-size: 28rpx; background: #617d70; color: #fff; }
.primary[disabled] { opacity: 0.5; background: #617d70; color: #fff; }
.secondary { background: #eaece3; color: #60745a; }
.sign-actions { display: flex; gap: 20rpx; margin-top: 28rpx; }
.sign-actions__clear { flex: 1; }
.sign-actions__submit { flex: 2; }
.legal-note { display: block; margin-top: 24rpx; font-size: 21rpx; line-height: 1.8; color: #a39689; }
.error { display: block; margin: 12rpx 0; font-size: 23rpx; line-height: 1.7; color: #a35645; }
.text-button { margin: 20rpx auto 0; padding: 8rpx 0; background: transparent; color: #8b816f; font-size: 24rpx; line-height: 1.8; }
.done { padding: 80rpx 48rpx; text-align: center; display: flex; flex-direction: column; gap: 22rpx; }
.done__mark { width: 120rpx; height: 120rpx; margin: 0 auto 10rpx; border-radius: 50%; background: #edf1e6; color: #617d70; font-size: 56rpx; display: flex; align-items: center; justify-content: center; }
.done__title { font-family: 'Songti SC', 'STSong', serif; font-size: 38rpx; color: #4a4035; }
.done__copy { font-size: 24rpx; line-height: 1.8; color: #8d8271; margin-bottom: 20rpx; }
.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: 420rpx; }
@keyframes breathe { to { opacity: .45 } }
</style>

View File

@@ -10,12 +10,15 @@
:show-today-lessons="loggedIn && isAdmin" :today-lessons-refresh-key="activityRefreshKey"
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" @refresh-memberships="userStore.fetchMemberships()" />
<ContractPendingBanner v-if="loggedIn" :summary="contractSummary" />
<InviteCard />
<!-- Menu section: always visible -->
<ProfileMenu
:is-admin="isAdmin"
:require-auth="loggedIn"
:contract-pending-count="loggedIn ? contractSummary?.pendingCount ?? 0 : 0"
@clear-cache="handleClearCache"
@require-login="handleLogin"
@open-notifications="showNotificationsModal = true"
@@ -36,7 +39,7 @@
<script setup lang="ts">
import InviteCard from '../../components/InviteCard.vue'
import { useInviteStore } from '../../stores/invite'
import { ref, onMounted } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import { useUserStore } from '../../stores/user'
@@ -46,12 +49,33 @@ import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
import SubscriptionSettingsModal from '../../components/SubscriptionSettingsModal.vue'
import ContractPendingBanner from '../../components/ContractPendingBanner.vue'
import type { ContractPendingSummary } from '@mp-pilates/shared'
import { fetchContractPendingSummary } from '../../utils/contract'
const invite = useInviteStore()
const userStore = useUserStore()
const { loggedIn, hasProfile, user, memberships, membershipsLoading, membershipsLoaded, membershipsError, isAdmin } = storeToRefs(userStore)
const showNotificationsModal = ref(false)
const contractSummary = ref<ContractPendingSummary | null>(null)
let contractSummaryRequest = 0
async function refreshContractSummary() {
const request = ++contractSummaryRequest
const token = userStore.token
try {
const summary = await fetchContractPendingSummary()
if (request === contractSummaryRequest && token === userStore.token) contractSummary.value = summary
} catch {
if (request === contractSummaryRequest) contractSummary.value = null
}
}
watch(() => userStore.token, () => {
contractSummaryRequest++
contractSummary.value = null
})
const activityRefreshKey = ref(0)
const membershipNow = ref(Date.now())
const loginLoading = ref(false)
@@ -87,6 +111,7 @@ onShow(async () => {
invite.refreshActivity().catch(() => {}),
userStore.fetchProfile(),
userStore.fetchMemberships(),
refreshContractSummary(),
])
}
})
@@ -97,7 +122,7 @@ async function handleLogin() {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
await invite.refreshActivity().catch(() => {})
await Promise.all([invite.refreshActivity().catch(() => {}), refreshContractSummary()])
}
} catch (err: unknown) {
uni.showToast({ title: getErrorMessage(err, '登录失败,请重试'), icon: 'none' })

View File

@@ -2,6 +2,7 @@
<view class="membership-page" :style="{ paddingTop: navBarHeight, height: pageHeight }">
<CustomNavBar title="我的会员卡" show-back />
<scroll-view class="scroll" scroll-y refresher-enabled :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<ContractPendingBanner v-if="userStore.loggedIn" :summary="contractSummary" />
<view v-if="userStore.loggedIn" class="practice-summary">
<view class="summary-heading"><text class="summary-kicker">MY PRACTICE</text><text class="summary-title">每一次练习,都在积累。</text></view>
<view class="summary-grid">
@@ -96,8 +97,12 @@ import { post } from '../../utils/request'
import { getErrorMessage } from '../../utils/auth'
import CustomNavBar from '../../components/CustomNavBar.vue'
import OwnedMembershipCard from '../../components/OwnedMembershipCard.vue'
import ContractPendingBanner from '../../components/ContractPendingBanner.vue'
import type { ContractPendingSummary } from '@mp-pilates/shared'
import { fetchContractPendingSummary } from '../../utils/contract'
const userStore = useUserStore()
const contractSummary = ref<ContractPendingSummary | null>(null)
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
@@ -152,7 +157,11 @@ const remainingLabel = computed(() => !userStore.membershipsLoaded || userStore.
async function loadMemberships() {
membershipNow.value = Date.now()
if (!userStore.loggedIn) return
await Promise.all([userStore.fetchMemberships(), userStore.fetchStats()])
await Promise.all([
userStore.fetchMemberships(),
userStore.fetchStats(),
fetchContractPendingSummary().then(summary => { contractSummary.value = summary }).catch(() => {}),
])
}
async function onRefresh() {

View File

@@ -0,0 +1,92 @@
import {
CONTRACT_UNSIGNED_MESSAGE,
MembershipContractStatus,
type ContractDocument,
type ContractPendingSummary,
type MembershipContractDetail,
type MembershipContractSummary,
type SignMembershipContractDto,
} from '@mp-pilates/shared'
import { get, getApiBaseUrl, post, put } from './request'
export const CONTRACT_ROUTES = {
LIST: '/pages/contract/index',
SIGN: '/pages/contract/sign',
DETAIL: '/pages/contract/detail',
} as const
export const CONTRACT_STATUS_LABEL: Record<MembershipContractStatus, string> = {
[MembershipContractStatus.PENDING_SIGN]: '待签署',
[MembershipContractStatus.SIGNED]: '已签署',
[MembershipContractStatus.VOIDED]: '已作废',
}
export function fetchMyContracts() {
return get<MembershipContractSummary[]>('/contracts/mine')
}
export function fetchContractPendingSummary() {
return get<ContractPendingSummary>('/contracts/pending-summary')
}
export function fetchContractByOrder(orderId: string) {
return get<MembershipContractSummary | null>(`/contracts/by-order/${orderId}`)
}
export function fetchContractDetail(id: string) {
return get<MembershipContractDetail>(`/contracts/${id}`)
}
export function fetchContractTemplatePreview(cardTypeId: string) {
return get<{ document: ContractDocument }>('/contracts/template-preview', { cardTypeId })
}
export function saveContractPartyA(id: string, realName: string, phone: string) {
return put<MembershipContractDetail>(`/contracts/${id}/party-a`, { realName, phone })
}
export function signContract(id: string, dto: SignMembershipContractDto) {
return post<MembershipContractDetail>(`/contracts/${id}/sign`, { ...dto })
}
export function isContractUnsignedError(err: unknown): boolean {
return err instanceof Error && err.message.includes(CONTRACT_UNSIGNED_MESSAGE)
}
export function promptSignContract(contractId?: string | null) {
uni.showModal({
title: '请先签署会员卡合同',
content: '你购买的会员卡尚未签署服务合同,签署后即可预约课程。',
confirmText: '去签署',
cancelText: '稍后',
success: (res) => {
if (!res.confirm) return
uni.navigateTo({ url: contractId ? `${CONTRACT_ROUTES.SIGN}?id=${contractId}` : CONTRACT_ROUTES.LIST })
},
})
}
/** 带登录凭证下载 PDF 后用系统文档预览,预览页右上角可转发或保存。 */
export function openContractPdf(id: string): Promise<void> {
const token = (uni.getStorageSync('token') as string) || ''
return new Promise((resolve, reject) => {
uni.downloadFile({
url: `${getApiBaseUrl()}/contracts/${id}/pdf`,
header: token ? { Authorization: `Bearer ${token}` } : {},
success: (res) => {
if (res.statusCode !== 200) {
reject(new Error(res.statusCode === 401 ? 'Unauthorized' : '合同导出失败,请稍后重试'))
return
}
uni.openDocument({
filePath: res.tempFilePath,
fileType: 'pdf',
showMenu: true,
success: () => resolve(),
fail: (err) => reject(new Error(err.errMsg || '无法打开合同文件')),
})
},
fail: (err) => reject(new Error(err.errMsg || '合同下载失败')),
})
})
}

View File

@@ -13,6 +13,10 @@ interface RequestOptions {
readonly header?: Record<string, string>
}
export function getApiBaseUrl(): string {
return BASE_URL
}
export class HttpRequestError extends Error {
constructor(message: string, readonly statusCode: number) {
super(message)