Files
QSY-lvjuxcx/pkgService/contract_sign/contract_sign.vue
liangjian 9fee2c3a74 feat: 优化首页服务入口与租住业务流程
补齐首页待办跳转、押金支付引导和多类租住服务页面能力,统一租客端关键业务流程体验。
2026-08-25 13:53:12 +08:00

619 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="contract-sign-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ $tr('合同签约') }}</view>
</tn-nav-bar>
<view class="page-main" :style="{ paddingTop: vuex_custom_bar_height + 'px' }">
<view class="overview-panel">
<view class="overview-header">
<view>
<view class="overview-title">{{ contractDisplayName || $tr('签约前确认') }}</view>
<view class="overview-subtitle">{{ $tr('合同编号:') }}{{ contractNo || $tr('未获取') }}</view>
</view>
<view class="overview-badge">
<text class="tn-icon-edit-write-fill"></text>
<text>{{ Math.round(scale * 100) }}%</text>
</view>
</view>
<view class="overview-meta">
<text class="tn-icon-safe-fill"></text>
<text>{{ $tr('阅读合同正文后完成签名提交,签约结果会同步到我的合同。') }}</text>
</view>
</view>
<view v-if="loading" class="empty-panel">
<view class="empty-title">{{ $tr('合同加载中') }}</view>
<view class="empty-desc">{{ $tr('正在获取合同正文,请稍候。') }}</view>
</view>
<view v-else-if="contractContent" class="preview-panel">
<scroll-view scroll-y scroll-x class="scroll-area" :enable-flex="true">
<view class="a4-wrapper" :style="{ transform: `scale(${scale})`, transformOrigin: 'top left' }">
<view class="a4-paper preview-paper">
<rich-text class="contract-rich-text" :nodes="contractContent"></rich-text>
</view>
</view>
</scroll-view>
<view class="preview-tip" @click="openFullscreenPreview">
<text class="tn-icon-fullscreen"></text>
<text>{{ $tr('点击全屏查看') }}</text>
</view>
<view class="zoom-controls" @click.stop>
<view class="zoom-btn" @click="zoomOut">
<text class="tn-icon-reduce-circle"></text>
</view>
<view class="zoom-text">{{ Math.round(scale * 100) }}%</view>
<view class="zoom-btn" @click="zoomIn">
<text class="tn-icon-add-circle"></text>
</view>
</view>
</view>
<view v-else class="empty-panel">
<view class="empty-icon">
<text class="tn-icon-folder"></text>
</view>
<view class="empty-title">{{ $tr('暂无合同正文') }}</view>
<view class="empty-desc">{{ $tr('当前合同暂未返回可签约正文,请稍后再试。') }}</view>
</view>
<view v-if="contractContent" class="footer-bar">
<view class="footer-tip">
<text class="tn-icon-safe-fill"></text>
<text>{{ $tr('签名前请确认您已完整阅读合同内容') }}</text>
</view>
<view class="action-btn" @click="openSignBoard">
{{ isSubmitting ? $tr('提交中...') : $tr('我已阅读并同意,去签名') }}
</view>
</view>
</view>
<ContractSignDialog
:show="showSignBoard"
:signer-name="signerName"
@save="handleSignSave"
@close="closeSignBoard"
/>
<view v-if="showFullscreenPreview" class="fullscreen-mask" @click="closeFullscreenPreview">
<view class="fullscreen-panel" @click.stop>
<view class="fullscreen-header">
<view class="fullscreen-back" @click="closeFullscreenPreview">
<text class="tn-icon-left"></text>
<text>{{ $tr('返回') }}</text>
</view>
<view class="fullscreen-title">{{ $tr('合同全文') }}</view>
<view class="fullscreen-placeholder"></view>
</view>
<scroll-view scroll-y scroll-x class="fullscreen-scroll" :enable-flex="true">
<view class="fullscreen-wrapper">
<view class="a4-paper fullscreen-paper">
<rich-text class="contract-rich-text" :nodes="contractContent"></rich-text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
import { getAuthToken, patchStoredContractSign } from '../../utils/app-state'
import { contractPreview, contractSign, uploadCommonFile } from '@/api/index.js'
import { syncLoginStayState } from '../../utils/session-sync'
import ContractSignDialog from '../../components/ContractSignDialog.vue'
import { normalizeRichTextImages } from '../../utils/rich-text'
import { fetchContractDisplayInfo } from '../../utils/contract-display'
function normalizeContractContent(content = '') {
return normalizeRichTextImages(
content,
'display:block;width:100%;max-width:100%;height:auto;box-sizing:border-box;border-radius:12rpx;margin:16rpx 0;vertical-align:top;'
)
}
function resolvePreviewContent(response) {
const payload = response && response.data ? response.data : response
const content = (payload && payload.content) || (payload && payload.data && payload.data.content) || ''
return normalizeContractContent(content)
}
function resolveUploadedFileUrl(uploadResult) {
const candidates = [
uploadResult && uploadResult.data && uploadResult.data.url,
uploadResult && uploadResult.data && uploadResult.data.fileUrl,
uploadResult && uploadResult.data && uploadResult.data.path,
uploadResult && uploadResult.data && uploadResult.data.filePath,
uploadResult && uploadResult.data,
uploadResult && uploadResult.url,
uploadResult && uploadResult.fileUrl,
uploadResult && uploadResult.path,
uploadResult && uploadResult.filePath,
typeof uploadResult === 'string' ? uploadResult : ''
]
return candidates.find(item => typeof item === 'string' && item) || ''
}
export default {
components: {
ContractSignDialog
},
data() {
return {
contractNo: '',
contractDisplayName: '',
orderNo: '',
contractContent: '',
loading: false,
showSignBoard: false,
showFullscreenPreview: false,
isSubmitting: false,
scale: 0.5,
signerName: ''
}
},
onLoad(options = {}) {
this.contractNo = decodeURIComponent(options.no || '')
this.orderNo = decodeURIComponent(options.orderNo || '')
this.signerName = this.getSignerName()
this.fetchContractDisplayName()
this.fetchContractContent()
},
methods: {
fetchContractDisplayName() {
if (!this.contractNo) {
return Promise.resolve()
}
return fetchContractDisplayInfo(this.contractNo, this.$getLanguage())
.then(({ contractDisplayName }) => {
this.contractDisplayName = contractDisplayName || ''
})
.catch((error) => {
console.error('fetch contract display name failed', error)
this.contractDisplayName = ''
})
},
fetchContractContent() {
if (!this.contractNo) {
return Promise.resolve()
}
this.loading = true
this.scale = 0.5
return contractPreview({ contractNo: this.contractNo })
.then((response) => {
this.contractContent = resolvePreviewContent(response)
})
.catch((error) => {
console.error('fetch contract preview failed', error)
this.contractContent = ''
})
.finally(() => {
this.loading = false
})
},
openSignBoard() {
if (this.isSubmitting) {
return
}
if (!this.signerName) {
uni.showToast({
title: this.$tr('未获取到签名人姓名'),
icon: 'none'
})
return
}
if (!this.contractNo) {
uni.showToast({
title: this.$tr('缺少合同编号'),
icon: 'none'
})
return
}
this.showSignBoard = true
},
closeSignBoard() {
this.showSignBoard = false
},
getSignerName() {
const user = (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
return this.pickFirstValue(
user.name,
user.realName,
user.userName,
user.nickname,
user.nickName,
user.fullName,
user.account,
''
)
},
pickFirstValue() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (value !== undefined && value !== null) {
const normalized = String(value).trim()
if (normalized) {
return normalized
}
}
}
return ''
},
openFullscreenPreview() {
this.showFullscreenPreview = true
},
closeFullscreenPreview() {
this.showFullscreenPreview = false
},
zoomIn() {
if (this.scale < 3) {
this.scale = Number((this.scale + 0.2).toFixed(1))
}
},
zoomOut() {
if (this.scale > 0.5) {
this.scale = Number((this.scale - 0.2).toFixed(1))
}
},
handleSignSave(tempFilePath) {
this.closeSignBoard()
this.submitSign(tempFilePath)
},
submitSign(tempFilePath) {
if (!tempFilePath || this.isSubmitting) {
return
}
this.isSubmitting = true
uni.showLoading({
title: this.$tr('签约提交中...'),
mask: true
})
return uploadCommonFile(
tempFilePath,
{},
'file',
{
...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {})
}
)
.then((uploadResult) => {
const signImageUrl = resolveUploadedFileUrl(uploadResult)
if (!signImageUrl) {
return Promise.reject(new Error((uploadResult && uploadResult.msg) || this.$tr('签名上传失败')))
}
return contractSign({
contractNo: this.contractNo,
signImageUrl
}).then(() => signImageUrl)
})
.then((signImageUrl) => {
return syncLoginStayState({ force: true })
.catch((error) => {
console.error('sync login stay state after sign failed', error)
})
.then(() => {
patchStoredContractSign(this.contractNo, signImageUrl)
return signImageUrl
})
})
.then(() => {
uni.$emit('contractSignSuccess', {
contractNo: this.contractNo
})
uni.$emit('refresh-shell-state')
})
.then(() => {
uni.hideLoading()
uni.showToast({
title: this.$tr('签约成功'),
icon: 'success'
})
setTimeout(() => {
uni.navigateBack({
delta: 1
})
}, 600)
})
.catch((error) => {
console.error('contract sign failed', error)
uni.hideLoading()
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('签约失败'),
icon: 'none'
})
})
.finally(() => {
this.isSubmitting = false
})
}
}
}
</script>
<style scoped lang="scss">
.contract-sign-page {
min-height: 100vh;
background: linear-gradient(180deg, #f0f6ff 0%, #f8faff 100%);
}
.page-main {
padding: 0 24rpx calc(40rpx + env(safe-area-inset-bottom));
}
.overview-panel,
.preview-panel,
.empty-panel {
background: rgba(255, 255, 255, 0.96);
border-radius: 28rpx;
border: 1px solid rgba($brand-primary, 0.08);
box-shadow: 0 18rpx 48rpx rgba(25, 60, 104, 0.1);
}
.overview-panel {
padding: 24rpx 26rpx;
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
}
.overview-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2d3d;
}
.overview-subtitle {
margin-top: 6rpx;
font-size: 20rpx;
color: #7c8da0;
}
.overview-badge {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
color: $brand-primary;
background: rgba($brand-primary, 0.1);
}
.overview-meta {
display: inline-flex;
align-items: center;
gap: 8rpx;
font-size: 20rpx;
line-height: 1.5;
color: #61758a;
}
.preview-panel {
position: relative;
overflow: hidden;
margin-top: 18rpx;
min-height: 980rpx;
background: linear-gradient(180deg, #e8eef5 0%, #e0e8f1 100%);
}
.scroll-area {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.a4-wrapper {
min-width: max-content;
padding: 24rpx 24rpx 110rpx;
display: flex;
justify-content: flex-start;
transition: transform 0.2s ease-out;
}
.a4-paper {
width: 794px;
min-height: 1123px;
padding: 40px;
box-sizing: border-box;
background: #ffffff;
border-radius: 20rpx;
box-shadow: 0 16rpx 42rpx rgba(28, 53, 90, 0.1);
overflow: hidden;
}
.contract-rich-text {
display: block;
width: 100%;
font-size: 14px;
line-height: 1.7;
color: #333333;
word-break: break-all;
}
.preview-tip {
position: absolute;
top: 16rpx;
right: 16rpx;
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: #ffffff;
background: rgba(31, 45, 61, 0.55);
backdrop-filter: blur(8px);
z-index: 2;
}
.zoom-controls {
position: absolute;
right: 24rpx;
bottom: 24rpx;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.96);
border-radius: 999rpx;
padding: 8rpx 12rpx;
border: 1px solid rgba($brand-primary, 0.08);
z-index: 2;
}
.zoom-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
color: $brand-primary;
}
.zoom-text {
min-width: 72rpx;
text-align: center;
font-size: 24rpx;
font-weight: 700;
color: #334155;
}
.footer-bar {
padding-top: 18rpx;
}
.footer-tip {
display: inline-flex;
align-items: center;
gap: 8rpx;
margin-bottom: 16rpx;
font-size: 22rpx;
color: #61758a;
}
.action-btn {
height: 84rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
background: linear-gradient(135deg, rgba(0, 187, 232, 0.9) 0%, #00BBE8 100%);
box-shadow: 0 14rpx 30rpx rgba(42, 92, 170, 0.22);
}
.empty-panel {
margin-top: 18rpx;
padding: 54rpx 32rpx;
text-align: center;
}
.empty-icon {
width: 132rpx;
height: 132rpx;
margin: 0 auto 22rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 70rpx;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: #233244;
}
.empty-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: #8a99a8;
}
.fullscreen-mask {
position: fixed;
inset: 0;
z-index: 999;
background: #ffffff;
}
.fullscreen-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #ffffff;
}
.fullscreen-header {
height: calc(96rpx + env(safe-area-inset-top));
padding: env(safe-area-inset-top) 28rpx 0;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
border-bottom: 1px solid #e2e8f0;
}
.fullscreen-back,
.fullscreen-placeholder {
width: 120rpx;
height: 64rpx;
}
.fullscreen-back {
display: inline-flex;
align-items: center;
gap: 8rpx;
font-size: 28rpx;
color: #334155;
}
.fullscreen-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2d3d;
}
.fullscreen-scroll {
flex: 1;
width: 100%;
height: 100%;
background: #f8fafc;
}
.fullscreen-wrapper {
min-width: max-content;
min-height: 100%;
padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
display: flex;
justify-content: center;
box-sizing: border-box;
}
.fullscreen-paper {
margin: 0 auto;
}
</style>