Files
QSY-lvjuxcx/pkgService/my_contract/my_contract.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

683 lines
17 KiB
Vue

<template>
<view class="my-contract-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('查看住宿合同、签约状态与有效期限') }}</view>
</view>
<view class="overview-badge">
<text class="tn-icon-edit-form"></text>
<text>{{ summaryStats.total }}</text>
</view>
</view>
<view class="overview-metrics">
<view class="metric-item">
<view class="metric-label">{{ $tr('待签约/审批') }}</view>
<view class="metric-value">{{ summaryStats.pending }}</view>
</view>
<view class="metric-item is-primary">
<view class="metric-label">{{ $tr('当前展示') }}</view>
<view class="metric-value">{{ displayList.length }}</view>
</view>
</view>
</view>
<view class="filter-row">
<view
v-for="item in tabs"
:key="item.value"
class="filter-chip"
:class="{ 'filter-chip--active': currentTab === item.value }"
@click="changeTab(item.value)"
>
{{ item.label }}
</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="displayList.length" class="list-wrapper">
<view
v-for="(item, index) in displayList"
:key="item.no || item.contractNo || item.orderNo || index"
class="contract-card"
:style="{ animationDelay: `${index * 0.08}s` }"
>
<view class="card-header">
<view class="card-header-left">
<view class="card-icon">
<text class="tn-icon-edit-write-fill"></text>
</view>
<view class="card-title-group">
<view class="card-title">{{ $tr('住宿合同') }}</view>
<view class="card-code">{{ item.no || item.contractNo || '--' }}</view>
</view>
</view>
<view :class="['status-badge', `status-${resolveStatusType(item.approvalStatus)}`]">
<view class="status-dot"></view>
<text>{{ getDisplayStatusText(item) }}</text>
</view>
</view>
<view class="meta-strip">
<view class="meta-chip">
<view class="meta-chip-label">{{ $tr('关联订单') }}</view>
<view class="meta-chip-value">{{ item.orderNo || '--' }}</view>
</view>
<view class="meta-chip">
<view class="meta-chip-label">{{ $tr('签署状态') }}</view>
<view class="meta-chip-value">{{ item.signImageUrl ? $tr('已签名') : $tr('待签名') }}</view>
</view>
</view>
<view class="detail-panel">
<view class="info-row">
<text class="info-label">{{ $tr('合同期限') }}</text>
<text class="info-value">{{ formatDateRange(item.start, item.end) }}</text>
</view>
<view class="info-row">
<text class="info-label">{{ $tr('记录说明') }}</text>
<text class="info-value">{{ getStatusTip(item) }}</text>
</view>
<view v-if="item.signImageUrl" class="sign-row">
<text class="info-label">{{ $tr('我的签名') }}</text>
<view class="sign-preview">
<image :src="item.signImageUrl" mode="aspectFit"></image>
</view>
</view>
</view>
<view class="card-footer">
<view
class="action-btn"
:class="{ 'action-btn--secondary': !shouldShowSignAction(item) }"
@click="handleContractAction(item)"
>
{{ resolveActionText(item) }}
</view>
</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>
</view>
</template>
<script>
import { addFilePrefix } from '@/utils/common.js'
import { getSessionSnapshot } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync'
function normalizeContract(item = {}) {
return {
...item,
signImageUrl: item.signImageUrl ? addFilePrefix(item.signImageUrl) : '',
contentImageUrl: item.contentImageUrl ? addFilePrefix(item.contentImageUrl) : ''
}
}
export default {
data() {
return {
currentTab: 'all',
loading: false,
pendingContracts: [],
effectiveContracts: [],
tabs: []
}
},
computed: {
displayList() {
if (this.currentTab === 'pending') {
return this.pendingContracts
}
return [...this.pendingContracts, ...this.effectiveContracts]
},
summaryStats() {
return {
total: this.pendingContracts.length + this.effectiveContracts.length,
pending: this.pendingContracts.length,
effective: this.effectiveContracts.length
}
},
language() {
return this.$getLanguage()
}
},
created() {
this.syncTabs()
},
onShow() {
this.refreshContracts()
},
watch: {
language() {
this.syncTabs()
this.refreshContracts()
}
},
methods: {
syncTabs() {
this.tabs = [
{ label: this.$tr('全部'), value: 'all' },
{ label: this.$tr('待签约'), value: 'pending' }
]
},
getStoreUser() {
return (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
},
getStorePendingContracts() {
const list = this.$store && this.$store.state && this.$store.state.vuex_pending_contracts
return Array.isArray(list) ? list : []
},
getStoreEffectiveContracts() {
const list = this.$store && this.$store.state && this.$store.state.vuex_effective_contracts
return Array.isArray(list) ? list : []
},
isCurrentLogin() {
const storeUser = this.getStoreUser()
if (storeUser && Object.keys(storeUser).length) {
return true
}
return !!getSessionSnapshot().isLogin
},
applyContractState({
pendingContracts = [],
effectiveContracts = []
} = {}) {
this.pendingContracts = Array.isArray(pendingContracts)
? pendingContracts.map(normalizeContract)
: []
this.effectiveContracts = Array.isArray(effectiveContracts)
? effectiveContracts.map(normalizeContract)
: []
},
refreshContracts() {
if (!this.isCurrentLogin()) {
this.applyContractState()
this.loading = false
return Promise.resolve()
}
this.loading = true
return syncLoginStayState({ force: true })
.then((result = {}) => {
this.applyContractState({
pendingContracts: result.pendingContracts,
effectiveContracts: result.effectiveContracts
})
})
.catch((error) => {
console.error('refresh contracts failed', error)
const storePendingContracts = this.getStorePendingContracts()
const storeEffectiveContracts = this.getStoreEffectiveContracts()
const session = getSessionSnapshot()
this.applyContractState({
pendingContracts: storePendingContracts.length ? storePendingContracts : session.pendingContracts,
effectiveContracts: storeEffectiveContracts.length ? storeEffectiveContracts : session.effectiveContracts
})
})
.finally(() => {
this.loading = false
})
},
changeTab(value) {
if (this.currentTab === value) {
return
}
this.currentTab = value
},
getStatusText(status) {
const map = {
'-1': this.$tr('已拒绝'),
'0': this.$tr('待审批'),
'1': this.$tr('已通过')
}
return map[String(status)] || this.$tr('处理中')
},
getDisplayStatusText(item = {}) {
if (this.language !== 'en' && item.approvalStatusDesc) {
return item.approvalStatusDesc
}
return this.getStatusText(item.approvalStatus)
},
resolveStatusType(status) {
if (Number(status) === 1) return 'approved'
if (Number(status) === -1) return 'rejected'
return 'pending'
},
formatDate(value) {
return value ? String(value).slice(0, 10) : '--'
},
formatDateRange(start, end) {
if (!start && !end) {
return '--'
}
return `${this.formatDate(start)}${this.$tr(' 至 ')}${this.formatDate(end)}`
},
getStatusTip(item = {}) {
if (Number(item.approvalStatus) === 1) {
return this.$tr('合同已生效,可查看签约结果。')
}
if (Number(item.approvalStatus) === 0 && !item.signImageUrl) {
return this.$tr('待您完成签名后进入审批流程。')
}
if (Number(item.approvalStatus) === 0) {
return this.$tr('已提交签约信息,等待审批结果。')
}
if (Number(item.approvalStatus) === -1) {
return this.$tr('合同未通过审批,请联系管理员处理。')
}
return this.$tr('请关注合同最新状态。')
},
resolveActionText(item = {}) {
if (this.shouldShowSignAction(item)) {
return this.$tr('去签约')
}
return this.$tr('查看详情')
},
shouldShowSignAction(item = {}) {
return Number(item.approvalStatus) === 0 && !item.signImageUrl
},
handleContractAction(item = {}) {
const contractNo = encodeURIComponent(item.no || item.contractNo || '')
const orderNo = encodeURIComponent(item.orderNo || '')
const contentImageUrl = encodeURIComponent(item.contentImageUrl || '')
if (Number(item.approvalStatus) === 0 && !item.signImageUrl) {
uni.navigateTo({
url: `/pkgService/contract_sign/contract_sign?no=${contractNo}&orderNo=${orderNo}`
})
return
}
uni.navigateTo({
url: `/pkgService/contract_detail/contract_detail?no=${contractNo}&orderNo=${orderNo}&contentImageUrl=${contentImageUrl}`
})
}
}
}
</script>
<style lang="scss" scoped>
.my-contract-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,
.empty-panel,
.contract-card {
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba($brand-primary, 0.08);
box-shadow: 0 16rpx 42rpx rgba(28, 53, 90, 0.08);
}
.overview-panel {
padding: 28rpx;
border-radius: 28rpx;
}
.overview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22rpx;
}
.overview-title {
font-size: 34rpx;
font-weight: 700;
color: #1f2d3d;
}
.overview-subtitle {
margin-top: 8rpx;
font-size: 22rpx;
color: #7c8da0;
}
.overview-badge {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 20rpx;
border-radius: 999rpx;
color: $brand-primary;
font-size: 24rpx;
font-weight: 700;
background: rgba($brand-primary, 0.1);
}
.overview-metrics {
display: flex;
gap: 18rpx;
}
.metric-item {
flex: 1;
padding: 20rpx 16rpx;
border-radius: 20rpx;
background: #f7fafd;
text-align: center;
}
.metric-item.is-primary {
background: linear-gradient(135deg, rgba($brand-primary, 0.14) 0%, rgba($brand-primary, 0.05) 100%);
}
.metric-label {
font-size: 22rpx;
color: #7c8da0;
}
.metric-value {
margin-top: 10rpx;
font-size: 40rpx;
line-height: 1;
font-weight: 700;
color: #203248;
}
.filter-row {
display: flex;
gap: 16rpx;
margin: 24rpx 0;
}
.filter-chip {
padding: 14rpx 28rpx;
border-radius: 999rpx;
background: #ffffff;
border: 1px solid rgba($brand-primary, 0.14);
color: #6a7a99;
font-size: 24rpx;
box-shadow: 0 6rpx 14rpx rgba($brand-primary, 0.04);
}
.filter-chip--active {
color: $brand-primary;
border-color: rgba($brand-primary, 0.24);
background: rgba($brand-primary, 0.08);
font-weight: 700;
}
.list-wrapper {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.contract-card {
padding: 26rpx;
border-radius: 30rpx;
animation: slide-up 0.45s ease both;
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.card-header-left {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.card-icon {
width: 64rpx;
height: 64rpx;
margin-right: 16rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
color: $brand-primary;
font-size: 32rpx;
background: rgba($brand-primary, 0.1);
}
.card-title-group {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2d3d;
}
.card-code {
margin-top: 8rpx;
font-size: 22rpx;
color: #8a99a8;
word-break: break-all;
}
.status-badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
border: 1px solid transparent;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: currentColor;
}
.status-pending {
color: #c98512;
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.16);
}
.status-approved {
color: $brand-primary;
background: rgba($brand-primary, 0.1);
border-color: rgba($brand-primary, 0.16);
}
.status-rejected {
color: #cf3c3c;
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.14);
}
.meta-strip {
display: flex;
gap: 18rpx;
margin-top: 22rpx;
}
.meta-chip {
flex: 1;
min-width: 0;
padding: 18rpx 20rpx;
border-radius: 20rpx;
background: linear-gradient(180deg, #f9fbfd 0%, #f2f6fa 100%);
border: 1px solid rgba($brand-primary, 0.08);
}
.meta-chip-label {
font-size: 20rpx;
color: #91a0ae;
}
.meta-chip-value {
margin-top: 10rpx;
font-size: 24rpx;
font-weight: 600;
color: #243447;
word-break: break-all;
}
.detail-panel {
margin-top: 20rpx;
padding: 22rpx;
border-radius: 24rpx;
background: #f8fbfe;
border: 1px solid rgba($brand-primary, 0.08);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24rpx;
font-size: 24rpx;
line-height: 1.7;
}
.info-row + .info-row {
margin-top: 12rpx;
}
.info-label {
flex-shrink: 0;
width: 116rpx;
color: #7c8da0;
}
.info-value {
flex: 1;
text-align: right;
color: #233244;
font-weight: 600;
word-break: break-all;
}
.sign-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1px dashed rgba($brand-primary, 0.14);
}
.sign-preview {
width: 150rpx;
height: 64rpx;
padding: 4rpx 8rpx;
border-radius: 12rpx;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba($brand-primary, 0.08);
}
.sign-preview image {
width: 100%;
height: 100%;
display: block;
}
.card-footer {
display: flex;
justify-content: flex-end;
margin-top: 22rpx;
}
.action-btn {
min-width: 216rpx;
height: 76rpx;
padding: 0 28rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
transition: all 0.3s ease;
color: #ffffff;
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.2);
}
.action-btn:active {
transform: scale(0.96);
box-shadow: 0 4rpx 10rpx rgba($brand-primary, 0.15);
}
.action-btn--secondary {
color: #35506f;
background: #f4f7fa;
box-shadow: none;
}
.empty-panel {
margin-top: 24rpx;
padding: 56rpx 32rpx;
border-radius: 30rpx;
text-align: center;
}
.empty-icon {
width: 132rpx;
height: 132rpx;
margin: 0 auto 22rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: $brand-primary;
font-size: 66rpx;
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;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(32rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>