Files
QSY-lvjuxcx/pkgService/contract_sign/contract_sign.vue
liangjian 6e721ccc7e refactor: 国际化文本迁移至全局 locales/messages.js
将组件级 i18n 对象重构为统一由全局 locales/messages.js 管理,确保中英文语言切换时正确显示对应文本。

- 新增 utils/i18n.js 国际化工具函数
- 更新 store/$t.mixin.js 翻译混入逻辑
- 迁移 pkgService、pkgBooking、pkgAccount、pkgExplore、pages 等共 58 个组件的中英文文本至全局消息表
2026-07-24 13:43:41 +08:00

602 lines
15 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">{{ $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'
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: '',
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.fetchContractContent()
},
methods: {
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>