chore: initial import
This commit is contained in:
651
pkgService/contract_sign/contract_sign.vue
Normal file
651
pkgService/contract_sign/contract_sign.vue
Normal file
@@ -0,0 +1,651 @@
|
||||
<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">{{ t('navTitle') }}</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">{{ t('overviewTitle') }}</view>
|
||||
<view class="overview-subtitle">{{ t('contractNoLabel') }}{{ contractNo || t('notAvailable') }}</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>{{ t('overviewMeta') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading" class="empty-panel">
|
||||
<view class="empty-title">{{ t('loadingTitle') }}</view>
|
||||
<view class="empty-desc">{{ t('loadingDesc') }}</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>{{ t('fullscreenPreview') }}</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">{{ t('emptyTitle') }}</view>
|
||||
<view class="empty-desc">{{ t('emptyDesc') }}</view>
|
||||
</view>
|
||||
|
||||
<view v-if="contractContent" class="footer-bar">
|
||||
<view class="footer-tip">
|
||||
<text class="tn-icon-safe-fill"></text>
|
||||
<text>{{ t('footerTip') }}</text>
|
||||
</view>
|
||||
<view class="action-btn" @click="openSignBoard">
|
||||
{{ isSubmitting ? t('submitting') : t('signAction') }}
|
||||
</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>{{ t('back') }}</text>
|
||||
</view>
|
||||
<view class="fullscreen-title">{{ t('fullscreenTitle') }}</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 {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '合同签约',
|
||||
overviewTitle: '签约前确认',
|
||||
contractNoLabel: '合同编号:',
|
||||
notAvailable: '未获取',
|
||||
overviewMeta: '阅读合同正文后完成签名提交,签约结果会同步到我的合同。',
|
||||
loadingTitle: '合同加载中',
|
||||
loadingDesc: '正在获取合同正文,请稍候。',
|
||||
fullscreenPreview: '点击全屏查看',
|
||||
emptyTitle: '暂无合同正文',
|
||||
emptyDesc: '当前合同暂未返回可签约正文,请稍后再试。',
|
||||
footerTip: '签名前请确认您已完整阅读合同内容',
|
||||
submitting: '提交中...',
|
||||
signAction: '我已阅读并同意,去签名',
|
||||
back: '返回',
|
||||
fullscreenTitle: '合同全文',
|
||||
signerMissing: '未获取到签名人姓名',
|
||||
contractNoMissing: '缺少合同编号',
|
||||
signingLoading: '签约提交中...',
|
||||
uploadFailed: '签名上传失败',
|
||||
signSuccess: '签约成功',
|
||||
signFailed: '签约失败'
|
||||
},
|
||||
en: {
|
||||
navTitle: 'Contract Signing',
|
||||
overviewTitle: 'Before Signing',
|
||||
contractNoLabel: 'Contract No.: ',
|
||||
notAvailable: 'N/A',
|
||||
overviewMeta: 'Read the contract content before submitting your signature. The result will sync to My Contracts.',
|
||||
loadingTitle: 'Loading contract',
|
||||
loadingDesc: 'Fetching contract content. Please wait.',
|
||||
fullscreenPreview: 'Open Fullscreen',
|
||||
emptyTitle: 'No contract content',
|
||||
emptyDesc: 'No signable contract content is available at the moment. Please try again later.',
|
||||
footerTip: 'Please confirm that you have fully read the contract before signing',
|
||||
submitting: 'Submitting...',
|
||||
signAction: 'I have read and agree. Sign now',
|
||||
back: 'Back',
|
||||
fullscreenTitle: 'Full Contract',
|
||||
signerMissing: 'Missing signer name',
|
||||
contractNoMissing: 'Missing contract number',
|
||||
signingLoading: 'Submitting contract...',
|
||||
uploadFailed: 'Failed to upload signature',
|
||||
signSuccess: 'Contract signed successfully',
|
||||
signFailed: 'Failed to sign contract'
|
||||
}
|
||||
},
|
||||
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: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.contract_sign.contract_sign', key, params)
|
||||
},
|
||||
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.t('signerMissing'),
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!this.contractNo) {
|
||||
uni.showToast({
|
||||
title: this.t('contractNoMissing'),
|
||||
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.t('signingLoading'),
|
||||
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.t('uploadFailed')))
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: this.t('signSuccess'),
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({
|
||||
url: '/pkgService/my_contract/my_contract'
|
||||
})
|
||||
}, 600)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('contract sign failed', error)
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: (error && (error.message || error.msg)) || this.t('signFailed'),
|
||||
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>
|
||||
Reference in New Issue
Block a user