85
packages/app/src/components/ContractDocumentView.vue
Normal file
85
packages/app/src/components/ContractDocumentView.vue
Normal 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>
|
||||
41
packages/app/src/components/ContractPendingBanner.vue
Normal file
41
packages/app/src/components/ContractPendingBanner.vue
Normal 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>
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
185
packages/app/src/components/SignaturePad.vue
Normal file
185
packages/app/src/components/SignaturePad.vue
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
289
packages/app/src/pages/admin/contract-settings.vue
Normal file
289
packages/app/src/pages/admin/contract-settings.vue
Normal 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>
|
||||
228
packages/app/src/pages/admin/contracts.vue
Normal file
228
packages/app/src/pages/admin/contracts.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
135
packages/app/src/pages/contract/detail.vue
Normal file
135
packages/app/src/pages/contract/detail.vue
Normal 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>
|
||||
120
packages/app/src/pages/contract/index.vue
Normal file
120
packages/app/src/pages/contract/index.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<view class="contracts-page" :style="{ paddingTop: navBarHeight + 'px' }">
|
||||
<CustomNavBar title="我的合同" show-back />
|
||||
|
||||
<view v-if="state === 'loading'" class="state">
|
||||
<view class="skeleton" /><view class="skeleton" />
|
||||
</view>
|
||||
<view v-else-if="state === 'error'" class="state state-card">
|
||||
<text class="state-title">暂时无法读取合同</text>
|
||||
<text class="state-copy">{{ error }}</text>
|
||||
<button class="secondary" @tap="load">重新加载</button>
|
||||
</view>
|
||||
<view v-else-if="!contracts.length" class="state state-card">
|
||||
<text class="state-title">还没有会员卡合同</text>
|
||||
<text class="state-copy">购买月卡或多次卡后,会在这里生成服务合同,签署后可随时查看和导出。</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<text class="intro">月卡、多次卡均需签署服务合同。合同签署后不可修改,可随时导出 PDF 留存。</text>
|
||||
<view
|
||||
v-for="item in contracts"
|
||||
:key="item.id"
|
||||
class="contract-card"
|
||||
:class="'contract-card--' + item.status.toLowerCase()"
|
||||
hover-class="contract-card--hover"
|
||||
@tap="open(item)"
|
||||
>
|
||||
<view class="contract-card__top">
|
||||
<text class="contract-card__name">{{ item.cardName }}</text>
|
||||
<text class="contract-card__status">{{ statusLabel(item) }}</text>
|
||||
</view>
|
||||
<text class="contract-card__meta">合同编号 {{ item.contractNo }}</text>
|
||||
<view class="contract-card__facts">
|
||||
<text>¥{{ formatPrice(item.amount) }}</text>
|
||||
<text>有效期至 {{ formatChinaDate(item.expireDate) }}</text>
|
||||
</view>
|
||||
<view class="contract-card__foot">
|
||||
<text v-if="item.status === MembershipContractStatus.SIGNED">签署于 {{ formatDateTimeFull(item.signedAt!) }}</text>
|
||||
<text v-else-if="item.status === MembershipContractStatus.VOIDED">{{ item.voidReason || '合同已作废' }}</text>
|
||||
<text v-else>{{ item.requireBeforeBooking ? '签署后即可预约课程' : '存量会员卡补签' }}</text>
|
||||
<text class="contract-card__action">{{ item.status === MembershipContractStatus.PENDING_SIGN ? '去签署 ›' : '查看 ›' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { MembershipContractSource, MembershipContractStatus, type MembershipContractSummary } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { CONTRACT_ROUTES, CONTRACT_STATUS_LABEL, fetchMyContracts } from '../../utils/contract'
|
||||
import { formatChinaDate, formatDateTimeFull, formatPrice } from '../../utils/format'
|
||||
import { getErrorMessage } from '../../utils/auth'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
|
||||
const navBarHeight = getSystemLayout().navBarHeight
|
||||
const contracts = ref<MembershipContractSummary[]>([])
|
||||
const state = ref<'loading' | 'ready' | 'error'>('loading')
|
||||
const error = ref('')
|
||||
let sequence = 0
|
||||
|
||||
function statusLabel(item: MembershipContractSummary) {
|
||||
const label = CONTRACT_STATUS_LABEL[item.status]
|
||||
return item.source === MembershipContractSource.LEGACY && item.status === MembershipContractStatus.PENDING_SIGN ? `${label} · 补签` : label
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const seq = ++sequence
|
||||
if (!contracts.value.length) state.value = 'loading'
|
||||
try {
|
||||
const result = await fetchMyContracts()
|
||||
if (seq !== sequence) return
|
||||
contracts.value = result
|
||||
state.value = 'ready'
|
||||
} catch (err) {
|
||||
if (seq !== sequence) return
|
||||
error.value = getErrorMessage(err, '请检查网络后重试')
|
||||
state.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function open(item: MembershipContractSummary) {
|
||||
uni.navigateTo({
|
||||
url: item.status === MembershipContractStatus.PENDING_SIGN
|
||||
? `${CONTRACT_ROUTES.SIGN}?id=${item.id}`
|
||||
: `${CONTRACT_ROUTES.DETAIL}?id=${item.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
onShow(() => { void load() })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contracts-page { min-height: 100vh; box-sizing: border-box; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); background: #fbf9f6; color: #514943; }
|
||||
button::after { border: 0; }
|
||||
.intro { display: block; margin: 28rpx 32rpx 8rpx; font-size: 23rpx; line-height: 1.8; color: #9a8e83; }
|
||||
.contract-card {
|
||||
margin: 20rpx 32rpx 0; padding: 28rpx; border-radius: 24rpx; background: #fff; border: 1rpx solid #eee6dd;
|
||||
&--hover { background: #f8f4ef; }
|
||||
&__top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
|
||||
&__name { font-size: 30rpx; font-weight: 500; color: #4a4035; }
|
||||
&__status { flex-shrink: 0; padding: 4rpx 16rpx; border-radius: 999rpx; font-size: 22rpx; background: #eef2ec; color: #617d73; }
|
||||
&__meta { display: block; margin-top: 8rpx; font-size: 22rpx; color: #a39689; }
|
||||
&__facts { display: flex; justify-content: space-between; margin-top: 20rpx; font-size: 25rpx; color: #6f655e; }
|
||||
&__foot { display: flex; justify-content: space-between; gap: 16rpx; margin-top: 18rpx; padding-top: 18rpx; border-top: 1rpx solid #f2ece5; font-size: 22rpx; color: #9a8e83; }
|
||||
&__action { flex-shrink: 0; color: #9c7a6e; }
|
||||
&--pending_sign { border-color: #ecd9cb; }
|
||||
&--pending_sign &__status { background: #f6ece4; color: #9c7a6e; }
|
||||
&--voided { opacity: 0.6; }
|
||||
&--voided &__status { background: #f0eeeb; color: #9a948e; }
|
||||
}
|
||||
.state { margin: 36rpx 32rpx; }
|
||||
.state-card { margin-top: 90rpx; padding: 50rpx 32rpx; background: #f4f1ec; border-radius: 28rpx; text-align: center; }
|
||||
.state-title { display: block; font-family: 'Songti SC', 'STSong', serif; font-size: 32rpx; color: #635b4f; }
|
||||
.state-copy { display: block; margin: 18rpx 0 30rpx; font-size: 24rpx; line-height: 1.9; color: #8d8271; }
|
||||
.secondary { width: 100%; margin: 0; border-radius: 20rpx; line-height: 84rpx; font-size: 27rpx; background: #eaece3; color: #60745a; }
|
||||
.skeleton { height: 220rpx; background: #efebe5; border-radius: 24rpx; margin-bottom: 24rpx; animation: breathe 1.2s infinite alternate; }
|
||||
@keyframes breathe { to { opacity: .45 } }
|
||||
</style>
|
||||
331
packages/app/src/pages/contract/sign.vue
Normal file
331
packages/app/src/pages/contract/sign.vue
Normal 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>
|
||||
@@ -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' })
|
||||
|
||||
@@ -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() {
|
||||
|
||||
92
packages/app/src/utils/contract.ts
Normal file
92
packages/app/src/utils/contract.ts
Normal 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 || '合同下载失败')),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user