chore: initial import
This commit is contained in:
787
pkgService/application_detail/application_detail.vue
Normal file
787
pkgService/application_detail/application_detail.vue
Normal file
@@ -0,0 +1,787 @@
|
||||
<template>
|
||||
<view class="application-detail-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 tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '48rpx' }">
|
||||
<view class="hero-panel tn-margin-bottom-lg" :class="heroThemeClass">
|
||||
<view class="hero-top-row">
|
||||
<view class="hero-badge" :class="heroBadgeClass">{{ applicationTypeText }}</view>
|
||||
<view class="hero-status" :class="['hero-status--' + statusType]">{{ statusText }}</view>
|
||||
</view>
|
||||
<view class="hero-title tn-margin-top">{{ displayTitle }}</view>
|
||||
<view class="hero-meta-row tn-margin-top">
|
||||
<view class="hero-meta-chip">
|
||||
<text class="tn-icon-time tn-margin-right-xs"></text>
|
||||
<text>{{ formatDateTime(detail.createTime) }}</text>
|
||||
</view>
|
||||
<view class="hero-meta-chip">
|
||||
<text class="tn-icon-location tn-margin-right-xs"></text>
|
||||
<text>{{ detail.intendedAreaName || '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="statusReminderText"
|
||||
class="status-reminder-panel tn-margin-bottom-lg"
|
||||
:class="{
|
||||
'status-reminder-panel--warning': shouldPayDeposit(detail),
|
||||
'status-reminder-panel--info': isWaitingRoomAssignment(detail)
|
||||
}"
|
||||
>
|
||||
<view class="status-reminder-panel__icon">
|
||||
<text :class="shouldPayDeposit(detail) ? 'tn-icon-warning-fill' : 'tn-icon-time-fill'"></text>
|
||||
</view>
|
||||
<view class="status-reminder-panel__content">
|
||||
<view class="status-reminder-panel__title">{{ t('statusReminderTitle') }}</view>
|
||||
<view class="status-reminder-panel__text">{{ statusReminderText }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-panel tn-margin-bottom-lg">
|
||||
<view class="panel-title">{{ t('basicInfoTitle') }}</view>
|
||||
<view class="meta-row tn-margin-top">
|
||||
<text class="meta-label">{{ t('applicationCodeLabel') }}</text>
|
||||
<view class="meta-action-wrap">
|
||||
<text class="meta-value">{{ detail.detailCode || '--' }}</text>
|
||||
<view v-if="detail.detailCode" class="copy-btn" @click="copyCode">
|
||||
<text class="tn-text-xs tn-text-bold">{{ t('copy') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ t('applicationTypeLabel') }}</text>
|
||||
<text class="meta-value">{{ applicationTypeText }}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ t('statusLabel') }}</text>
|
||||
<text class="meta-value">{{ statusText }}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ t('applicantLabel') }}</text>
|
||||
<text class="meta-value">{{ detail.userName || detail.phone || '--' }}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ t('apartmentLabel') }}</text>
|
||||
<text class="meta-value">{{ detail.intendedAreaName || '--' }}</text>
|
||||
</view>
|
||||
<view class="meta-row" v-if="detail.intendedRoomTypeName">
|
||||
<text class="meta-label">{{ isViewingApplication ? t('intendedRoomTypeLabel') : t('appliedRoomTypeLabel') }}</text>
|
||||
<text class="meta-value">{{ detail.intendedRoomTypeName }}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ isViewingApplication ? t('viewingTimeLabel') : t('leaseTermLabel') }}</text>
|
||||
<text class="meta-value">{{ displayDateRange }}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ t('submittedAtLabel') }}</text>
|
||||
<text class="meta-value">{{ formatDateTime(detail.createTime) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-panel tn-margin-bottom-lg" v-if="viewingMeta.length">
|
||||
<view class="panel-title">{{ t('viewingInfoTitle') }}</view>
|
||||
<view
|
||||
v-for="item in viewingMeta"
|
||||
:key="item.label"
|
||||
class="meta-row tn-margin-top"
|
||||
>
|
||||
<text class="meta-label">{{ item.label }}</text>
|
||||
<text class="meta-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-panel tn-margin-bottom-lg">
|
||||
<view class="panel-title">{{ isViewingApplication ? t('applicationRemarkTitle') : t('remarkTitle') }}</view>
|
||||
<view class="remark-panel tn-margin-top">
|
||||
<view
|
||||
v-for="(line, index) in remarkLines"
|
||||
:key="`${line}-${index}`"
|
||||
class="remark-line"
|
||||
>
|
||||
{{ line }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="ghost-btn" @click="goBackList">
|
||||
<text class="tn-text-bold">{{ t('backList') }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="canCancelOrder"
|
||||
class="danger-btn"
|
||||
:class="{ 'danger-btn--loading': canceling }"
|
||||
@tap.stop="handleCancelOrder"
|
||||
>
|
||||
<text class="tn-text-bold">{{ canceling ? t('cancelOrderLoading') : t('cancelOrder') }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="canViewBill"
|
||||
class="action-btn"
|
||||
@tap.stop="goToBillPage"
|
||||
>
|
||||
<text class="tn-text-bold">{{ t('viewBill') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { userDetailCancel } from '@/api/index.js'
|
||||
import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
|
||||
|
||||
function normalizeApplicationList(list = []) {
|
||||
if (!Array.isArray(list)) {
|
||||
return []
|
||||
}
|
||||
return list
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map((item) => {
|
||||
const remark = item && item.remark ? String(item.remark) : ''
|
||||
const isViewingApplication = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i.test(remark)
|
||||
return {
|
||||
...item,
|
||||
isViewingApplication
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '申请详情',
|
||||
basicInfoTitle: '基础信息',
|
||||
applicationCodeLabel: '申请编号',
|
||||
copy: '复制',
|
||||
applicationTypeLabel: '申请类型',
|
||||
statusLabel: '当前状态',
|
||||
applicantLabel: '申请人',
|
||||
apartmentLabel: '所属公寓',
|
||||
intendedRoomTypeLabel: '意向房型',
|
||||
appliedRoomTypeLabel: '申请房型',
|
||||
viewingTimeLabel: '预约时间',
|
||||
leaseTermLabel: '预计租期',
|
||||
submittedAtLabel: '提交时间',
|
||||
viewingInfoTitle: '带看信息',
|
||||
applicationRemarkTitle: '申请说明',
|
||||
remarkTitle: '补充说明',
|
||||
backList: '返回列表',
|
||||
viewBill: '查看关联账单',
|
||||
viewingApplicationTitle: '预约看房申请',
|
||||
stayApplicationTitle: '入住申请',
|
||||
viewingApplicationType: '预约看房',
|
||||
stayApplicationType: '入住申请',
|
||||
statusPending: '待审核',
|
||||
statusApproved: '已通过',
|
||||
statusRejected: '已拒绝',
|
||||
statusFinished: '已结束',
|
||||
statusProcessing: '处理中',
|
||||
statusPendingPayment: '请支付定金',
|
||||
statusWaitingRoomAssign: '等待预订中心分配房间',
|
||||
statusReminderTitle: '当前进度提醒',
|
||||
emptyRemark: '暂无补充说明',
|
||||
codeCopied: '申请编号已复制',
|
||||
missingBillCode: '申请单编号缺失',
|
||||
cancelOrder: '取消订单',
|
||||
cancelOrderLoading: '取消中...',
|
||||
cancelOrderTitle: '取消订单',
|
||||
cancelOrderContent: '取消后将关闭当前未支付的定金订单,是否继续?',
|
||||
cancelOrderSuccess: '订单已取消',
|
||||
cancelOrderFailed: '取消订单失败',
|
||||
viewingTimeKey: '看房时间',
|
||||
leaseDurationKey: '预计租住时长',
|
||||
budgetKey: '租金预算',
|
||||
commuteKey: '工作/通勤地点',
|
||||
preferredAreaKey: '意向区域',
|
||||
preferredRoomTypeKey: '意向房型',
|
||||
paymentMethodKey: '支付方式',
|
||||
dateRangeJoiner: ' 至 '
|
||||
},
|
||||
en: {
|
||||
navTitle: 'Application Detail',
|
||||
basicInfoTitle: 'Basic Information',
|
||||
applicationCodeLabel: 'Application No.',
|
||||
copy: 'Copy',
|
||||
applicationTypeLabel: 'Application Type',
|
||||
statusLabel: 'Status',
|
||||
applicantLabel: 'Applicant',
|
||||
apartmentLabel: 'Apartment',
|
||||
intendedRoomTypeLabel: 'Preferred Room Type',
|
||||
appliedRoomTypeLabel: 'Requested Room Type',
|
||||
viewingTimeLabel: 'Viewing Time',
|
||||
leaseTermLabel: 'Lease Term',
|
||||
submittedAtLabel: 'Submitted At',
|
||||
viewingInfoTitle: 'Viewing Details',
|
||||
applicationRemarkTitle: 'Application Notes',
|
||||
remarkTitle: 'Remarks',
|
||||
backList: 'Back to List',
|
||||
viewBill: 'View Related Bill',
|
||||
viewingApplicationTitle: 'Viewing Request',
|
||||
stayApplicationTitle: 'Stay Application',
|
||||
viewingApplicationType: 'Viewing Request',
|
||||
stayApplicationType: 'Stay Application',
|
||||
statusPending: 'Pending',
|
||||
statusApproved: 'Approved',
|
||||
statusRejected: 'Rejected',
|
||||
statusFinished: 'Closed',
|
||||
statusProcessing: 'Processing',
|
||||
statusPendingPayment: 'Please Pay Deposit',
|
||||
statusWaitingRoomAssign: 'Waiting for Room Assignment',
|
||||
statusReminderTitle: 'Current Progress',
|
||||
emptyRemark: 'No additional remarks',
|
||||
codeCopied: 'Application number copied',
|
||||
missingBillCode: 'Missing application bill number',
|
||||
cancelOrder: 'Cancel Order',
|
||||
cancelOrderLoading: 'Canceling...',
|
||||
cancelOrderTitle: 'Cancel Order',
|
||||
cancelOrderContent: 'This will close the current unpaid deposit order. Continue?',
|
||||
cancelOrderSuccess: 'Order canceled',
|
||||
cancelOrderFailed: 'Failed to cancel order',
|
||||
viewingTimeKey: 'Viewing Time',
|
||||
leaseDurationKey: 'Expected Stay Duration',
|
||||
budgetKey: 'Budget',
|
||||
commuteKey: 'Work / Commute Location',
|
||||
preferredAreaKey: 'Preferred Area',
|
||||
preferredRoomTypeKey: 'Preferred Room Type',
|
||||
paymentMethodKey: 'Payment Method',
|
||||
dateRangeJoiner: ' to '
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
detailCode: '',
|
||||
detail: {},
|
||||
canceling: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isViewingApplication() {
|
||||
return !!(this.detail && this.detail.isViewingApplication)
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.isViewingApplication) {
|
||||
return this.detail.intendedRoomTypeName || this.t('viewingApplicationTitle')
|
||||
}
|
||||
return this.detail.intendedRoomTypeName || this.t('stayApplicationTitle')
|
||||
},
|
||||
applicationTypeText() {
|
||||
return this.isViewingApplication ? this.t('viewingApplicationType') : this.t('stayApplicationType')
|
||||
},
|
||||
statusText() {
|
||||
if (this.language !== 'en' && this.detail && this.detail.statusDesc) {
|
||||
return this.detail.statusDesc
|
||||
}
|
||||
const map = {
|
||||
0: this.t('statusPending'),
|
||||
10: this.t('statusApproved'),
|
||||
20: this.t('statusRejected'),
|
||||
99: this.t('statusFinished')
|
||||
}
|
||||
return map[this.detail.status] || this.t('statusProcessing')
|
||||
},
|
||||
statusType() {
|
||||
if (this.detail.status === 10) {
|
||||
return 'approved'
|
||||
}
|
||||
if (this.detail.status === 20) {
|
||||
return 'rejected'
|
||||
}
|
||||
if (this.detail.status === 99) {
|
||||
return 'finished'
|
||||
}
|
||||
return 'pending'
|
||||
},
|
||||
heroThemeClass() {
|
||||
return this.isViewingApplication ? 'hero-viewing' : 'hero-stay'
|
||||
},
|
||||
heroBadgeClass() {
|
||||
return this.isViewingApplication ? 'badge-viewing' : 'badge-stay'
|
||||
},
|
||||
displayDateRange() {
|
||||
if (!this.detail.start && !this.detail.end) {
|
||||
return '--'
|
||||
}
|
||||
if (this.isViewingApplication) {
|
||||
return `${this.formatDateTime(this.detail.start)}${this.t('dateRangeJoiner')}${this.formatDateTime(this.detail.end)}`
|
||||
}
|
||||
return `${this.formatDate(this.detail.start)}${this.t('dateRangeJoiner')}${this.formatDate(this.detail.end)}`
|
||||
},
|
||||
remarkLines() {
|
||||
const text = this.detail && this.detail.remark ? String(this.detail.remark).trim() : ''
|
||||
if (!text) {
|
||||
return [this.t('emptyRemark')]
|
||||
}
|
||||
return text.split(/\r?\n/).filter(Boolean)
|
||||
},
|
||||
viewingMeta() {
|
||||
if (!this.isViewingApplication) {
|
||||
return []
|
||||
}
|
||||
return this.parseViewingRemark()
|
||||
},
|
||||
statusReminderText() {
|
||||
if (this.shouldPayDeposit(this.detail)) {
|
||||
return this.t('statusPendingPayment')
|
||||
}
|
||||
if (this.isWaitingRoomAssignment(this.detail)) {
|
||||
return this.t('statusWaitingRoomAssign')
|
||||
}
|
||||
return ''
|
||||
},
|
||||
canViewBill() {
|
||||
return this.shouldPayDeposit(this.detail)
|
||||
&& !this.isViewingApplication
|
||||
&& this.detail
|
||||
&& this.detail.detailCode
|
||||
&& this.statusType === 'pending'
|
||||
},
|
||||
canCancelOrder() {
|
||||
return this.shouldPayDeposit(this.detail)
|
||||
&& !this.isViewingApplication
|
||||
&& this.detail
|
||||
&& this.detail.detailCode
|
||||
&& !this.canceling
|
||||
}
|
||||
},
|
||||
onLoad(options = {}) {
|
||||
this.detailCode = options.code || ''
|
||||
this.restoreDetail(options.payload || '')
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.application_detail.application_detail', key, params)
|
||||
},
|
||||
isPaidApplication(item = {}) {
|
||||
return !item.isViewingApplication && Number(item.payStatus) === 10
|
||||
},
|
||||
shouldPayDeposit(item = {}) {
|
||||
return !item.isViewingApplication
|
||||
&& Number(item.status) === 0
|
||||
&& Number(item.payStatus) !== 10
|
||||
},
|
||||
isWaitingRoomAssignment(item = {}) {
|
||||
return !item.isViewingApplication
|
||||
&& Number(item.status) === 0
|
||||
&& Number(item.payStatus) === 10
|
||||
},
|
||||
restoreDetail(payload = '') {
|
||||
if (payload) {
|
||||
try {
|
||||
const parsed = JSON.parse(decodeURIComponent(payload))
|
||||
this.detail = parsed && typeof parsed === 'object' ? parsed : {}
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('parse application detail payload error', error)
|
||||
}
|
||||
}
|
||||
|
||||
const list = normalizeApplicationList(getMyApplicationList())
|
||||
const matched = list.find(item => String(item.detailCode || '') === String(this.detailCode || ''))
|
||||
this.detail = matched || {
|
||||
detailCode: this.detailCode,
|
||||
isViewingApplication: false
|
||||
}
|
||||
},
|
||||
parseViewingRemark() {
|
||||
const lines = this.remarkLines
|
||||
const targets = [
|
||||
{ zh: '看房时间', en: 'Viewing Time' },
|
||||
{ zh: '预计租住时长', en: 'Expected Stay Duration' },
|
||||
{ zh: '租金预算', en: 'Budget' },
|
||||
{ zh: '工作/通勤地点', en: 'Work / Commute Location' },
|
||||
{ zh: '意向区域', en: 'Preferred Area' },
|
||||
{ zh: '意向房型', en: 'Preferred Room Type' },
|
||||
{ zh: '支付方式', en: 'Payment Method' }
|
||||
]
|
||||
return lines
|
||||
.map((line) => {
|
||||
const matched = targets.find(item => {
|
||||
return line.startsWith(`${item.zh}:`)
|
||||
|| line.startsWith(`${item.zh}:`)
|
||||
|| line.startsWith(`${item.en}:`)
|
||||
|| line.startsWith(`${item.en}:`)
|
||||
})
|
||||
if (!matched) {
|
||||
return null
|
||||
}
|
||||
const localizedLabelMap = {
|
||||
'看房时间': this.t('viewingTimeKey'),
|
||||
'预计租住时长': this.t('leaseDurationKey'),
|
||||
'租金预算': this.t('budgetKey'),
|
||||
'工作/通勤地点': this.t('commuteKey'),
|
||||
'意向区域': this.t('preferredAreaKey'),
|
||||
'意向房型': this.t('preferredRoomTypeKey'),
|
||||
'支付方式': this.t('paymentMethodKey')
|
||||
}
|
||||
const rawValue = line
|
||||
.replace(`${matched.zh}:`, '')
|
||||
.replace(`${matched.zh}:`, '')
|
||||
.replace(`${matched.en}:`, '')
|
||||
.replace(`${matched.en}:`, '')
|
||||
return {
|
||||
label: localizedLabelMap[matched.zh] || matched.en,
|
||||
value: rawValue || '--'
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
},
|
||||
formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
const text = String(value).replace('T', ' ')
|
||||
return text.length > 16 ? text.slice(0, 16) : text
|
||||
},
|
||||
formatDate(value) {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
return String(value).slice(0, 10)
|
||||
},
|
||||
copyCode() {
|
||||
if (!this.detail.detailCode) {
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: String(this.detail.detailCode),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: this.t('codeCopied'),
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
confirmCancelOrder() {
|
||||
return new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: this.t('cancelOrderTitle'),
|
||||
content: this.t('cancelOrderContent'),
|
||||
confirmColor: '#ef4444',
|
||||
success: (res) => resolve(!!(res && res.confirm)),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
},
|
||||
syncCanceledDetailToCache() {
|
||||
const list = normalizeApplicationList(getMyApplicationList())
|
||||
if (!list.length) {
|
||||
return
|
||||
}
|
||||
const nextList = list.map((item) => {
|
||||
if (String(item.detailCode || '') !== String(this.detail.detailCode || '')) {
|
||||
return item
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
status: 99,
|
||||
statusDesc: this.t('statusFinished')
|
||||
}
|
||||
})
|
||||
saveMyApplicationList(nextList)
|
||||
},
|
||||
handleCancelOrder() {
|
||||
if (!this.canCancelOrder || !this.detail.detailCode) {
|
||||
return
|
||||
}
|
||||
this.confirmCancelOrder()
|
||||
.then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
this.canceling = true
|
||||
return userDetailCancel({
|
||||
detailCode: this.detail.detailCode,
|
||||
code: this.detail.detailCode,
|
||||
referenceTypeNo: this.detail.detailCode
|
||||
}, this.language)
|
||||
.then(() => {
|
||||
this.detail = {
|
||||
...this.detail,
|
||||
status: 99,
|
||||
statusDesc: this.t('statusFinished')
|
||||
}
|
||||
this.syncCanceledDetailToCache()
|
||||
uni.showToast({
|
||||
title: this.t('cancelOrderSuccess'),
|
||||
icon: 'success'
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
uni.showToast({
|
||||
title: (error && (error.message || error.msg)) || this.t('cancelOrderFailed'),
|
||||
icon: 'none'
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.canceling = false
|
||||
})
|
||||
},
|
||||
goBackList() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
goToBillPage() {
|
||||
if (!this.detail.detailCode) {
|
||||
uni.showToast({
|
||||
title: this.t('missingBillCode'),
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
saveActiveApplicationCode(this.detail.detailCode)
|
||||
uni.navigateTo({
|
||||
url: `/pkgService/utility_recharge/utility_recharge?referenceTypeNo=${encodeURIComponent(this.detail.detailCode)}`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.application-detail-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.content-panel {
|
||||
background: #FFFFFF;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.hero-viewing {
|
||||
background: linear-gradient(135deg, rgba($brand-primary, 0.12) 0%, rgba($brand-primary, 0.04) 100%);
|
||||
}
|
||||
|
||||
.hero-stay {
|
||||
background: linear-gradient(135deg, rgba(64, 203, 242, 0.12) 0%, rgba($brand-primary, 0.04) 100%);
|
||||
}
|
||||
|
||||
.status-reminder-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 32rpx;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.status-reminder-panel--warning {
|
||||
background: linear-gradient(180deg, rgba(255, 247, 237, 0.96) 0%, rgba(255, 251, 245, 0.98) 100%);
|
||||
border: 1px solid rgba(249, 115, 22, 0.16);
|
||||
}
|
||||
|
||||
.status-reminder-panel--info {
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.14);
|
||||
}
|
||||
|
||||
.status-reminder-panel__icon {
|
||||
width: 68rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.status-reminder-panel--warning .status-reminder-panel__icon {
|
||||
color: #C2410C;
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
}
|
||||
|
||||
.status-reminder-panel--info .status-reminder-panel__icon {
|
||||
color: #1D4ED8;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.status-reminder-panel__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-reminder-panel__title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.status-reminder-panel__text {
|
||||
margin-top: 8rpx;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.hero-top-row,
|
||||
.hero-meta-row,
|
||||
.meta-row,
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-top-row,
|
||||
.meta-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-meta-row,
|
||||
.action-row {
|
||||
gap: 16rpx;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-badge,
|
||||
.hero-status,
|
||||
.hero-meta-chip,
|
||||
.copy-btn,
|
||||
.ghost-btn,
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-badge,
|
||||
.hero-status {
|
||||
padding: 10rpx 22rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-viewing,
|
||||
.badge-stay {
|
||||
background: rgba($brand-primary, 0.14);
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.hero-status--pending {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #B45309;
|
||||
}
|
||||
|
||||
.hero-status--approved {
|
||||
background: rgba($brand-primary, 0.12);
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.hero-status--rejected {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.hero-status--finished {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.hero-title,
|
||||
.panel-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #1A2233;
|
||||
}
|
||||
|
||||
.hero-meta-chip {
|
||||
height: 56rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: #51607A;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
padding: 18rpx 0;
|
||||
border-bottom: 1px dashed rgba($brand-primary, 0.14);
|
||||
}
|
||||
|
||||
.meta-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #6A7A99;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
max-width: 62%;
|
||||
text-align: right;
|
||||
color: #1A2233;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-action-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
height: 48rpx;
|
||||
padding: 0 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba($brand-primary, 0.1);
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.remark-panel {
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba($brand-primary, 0.03);
|
||||
border: 1rpx solid rgba($brand-primary, 0.12);
|
||||
}
|
||||
|
||||
.remark-line {
|
||||
color: #1A2233;
|
||||
line-height: 1.8;
|
||||
font-size: 27rpx;
|
||||
}
|
||||
|
||||
.ghost-btn,
|
||||
.danger-btn,
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-height: 92rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: #FFFFFF;
|
||||
border: 1rpx solid rgba($brand-primary, 0.2);
|
||||
color: $brand-primary;
|
||||
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.06);
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: #FFFFFF;
|
||||
border: 1rpx solid rgba(239, 68, 68, 0.22);
|
||||
color: #DC2626;
|
||||
box-shadow: 0 6rpx 16rpx rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.danger-btn--loading {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
|
||||
}
|
||||
</style>
|
||||
2170
pkgService/components/CurrentApplicationBillPanel.vue
Normal file
2170
pkgService/components/CurrentApplicationBillPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
376
pkgService/contract_detail/contract_detail.vue
Normal file
376
pkgService/contract_detail/contract_detail.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<view class="contract-detail-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-read"></text>
|
||||
<text>{{ Math.round(scale * 100) }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="overview-tips">
|
||||
<view class="tip-chip">
|
||||
<text class="tn-icon-safe-fill"></text>
|
||||
<text>{{ t('signedChip') }}</text>
|
||||
</view>
|
||||
<view class="tip-chip">
|
||||
<text class="tn-icon-charts-ring"></text>
|
||||
<text>{{ t('zoomSupportedChip') }}</text>
|
||||
</view>
|
||||
<view class="tip-chip" v-if="imageUrl">
|
||||
<text class="tn-icon-image-text-fill"></text>
|
||||
<text>{{ t('imageModeChip') }}</text>
|
||||
</view>
|
||||
<view class="tip-chip" v-else-if="contractContent">
|
||||
<text class="tn-icon-text-new"></text>
|
||||
<text>{{ t('textModeChip') }}</text>
|
||||
</view>
|
||||
</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="imageUrl || 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 center' }">
|
||||
<view class="a4-paper">
|
||||
<image
|
||||
v-if="imageUrl"
|
||||
class="contract-image"
|
||||
:src="imageUrl"
|
||||
mode="widthFix"
|
||||
@click="previewImage"
|
||||
></image>
|
||||
<rich-text v-else class="contract-rich-text" :nodes="contractContent"></rich-text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="zoom-controls">
|
||||
<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>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addFilePrefix } from '@/utils/common.js'
|
||||
import { contractPreview } from '@/api/index.js'
|
||||
import { normalizeRichTextImages } from '../../utils/rich-text'
|
||||
|
||||
function resolvePreviewPayload(response) {
|
||||
const payload = response && response.data ? response.data : response
|
||||
return {
|
||||
content: (payload && payload.content) || (payload && payload.data && payload.data.content) || '',
|
||||
imageUrl: (payload && payload.contentImageUrl) || (payload && payload.data && payload.data.contentImageUrl) || ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRichTextContent(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;'
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '合同详情',
|
||||
overviewTitle: '合同影像',
|
||||
contractNoLabel: '合同编号:',
|
||||
notAvailable: '未获取',
|
||||
signedChip: '已签合同',
|
||||
zoomSupportedChip: '支持缩放',
|
||||
imageModeChip: 'A4 影像模式',
|
||||
textModeChip: '正文预览模式',
|
||||
loadingTitle: '合同加载中',
|
||||
loadingDesc: '正在获取合同影像与正文,请稍候。',
|
||||
emptyTitle: '暂无合同内容',
|
||||
emptyDesc: '页面未带入影像地址,且通过合同编号也未获取到可预览的合同内容。'
|
||||
},
|
||||
en: {
|
||||
navTitle: 'Contract Detail',
|
||||
overviewTitle: 'Contract Preview',
|
||||
contractNoLabel: 'Contract No.: ',
|
||||
notAvailable: 'N/A',
|
||||
signedChip: 'Signed Contract',
|
||||
zoomSupportedChip: 'Zoom Supported',
|
||||
imageModeChip: 'A4 Image Mode',
|
||||
textModeChip: 'Text Preview Mode',
|
||||
loadingTitle: 'Loading contract',
|
||||
loadingDesc: 'Fetching contract images and content. Please wait.',
|
||||
emptyTitle: 'No contract content',
|
||||
emptyDesc: 'No previewable contract content was provided by image URL or contract number.'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
contractNo: '',
|
||||
orderNo: '',
|
||||
loading: false,
|
||||
imageUrl: '',
|
||||
contractContent: '',
|
||||
scale: 1
|
||||
}
|
||||
},
|
||||
onLoad(options = {}) {
|
||||
this.contractNo = decodeURIComponent(options.no || '')
|
||||
this.orderNo = decodeURIComponent(options.orderNo || '')
|
||||
this.imageUrl = addFilePrefix(decodeURIComponent(options.contentImageUrl || ''))
|
||||
this.fetchContractDetail()
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.contract_detail.contract_detail', key, params)
|
||||
},
|
||||
fetchContractDetail() {
|
||||
if (!this.contractNo || (this.imageUrl && this.contractContent)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.scale = 1
|
||||
|
||||
return contractPreview({ contractNo: this.contractNo })
|
||||
.then((response) => {
|
||||
const payload = resolvePreviewPayload(response)
|
||||
this.contractContent = normalizeRichTextContent(payload.content || '')
|
||||
if (!this.imageUrl) {
|
||||
this.imageUrl = addFilePrefix(payload.imageUrl || '')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('fetch contract detail failed', error)
|
||||
this.contractContent = ''
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
previewImage() {
|
||||
if (!this.imageUrl) {
|
||||
return
|
||||
}
|
||||
uni.previewImage({
|
||||
urls: [this.imageUrl],
|
||||
current: this.imageUrl
|
||||
})
|
||||
},
|
||||
zoomIn() {
|
||||
if (this.scale < 3) {
|
||||
this.scale = Number((this.scale + 0.2).toFixed(1))
|
||||
}
|
||||
},
|
||||
zoomOut() {
|
||||
if (this.scale > 0.6) {
|
||||
this.scale = Number((this.scale - 0.2).toFixed(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.contract-detail-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: 28rpx;
|
||||
}
|
||||
|
||||
.overview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.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: 10rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-primary;
|
||||
background: rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.overview-tips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.tip-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: #61758a;
|
||||
background: #f7fafd;
|
||||
border: 1px solid rgba($brand-primary, 0.08);
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 24rpx;
|
||||
min-height: 980rpx;
|
||||
background: linear-gradient(180deg, #e8eef5 0%, #e0e8f1 100%);
|
||||
}
|
||||
|
||||
.scroll-area {
|
||||
width: 100%;
|
||||
height: 980rpx;
|
||||
}
|
||||
|
||||
.a4-wrapper {
|
||||
padding: 40rpx 24rpx 120rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.a4-paper {
|
||||
width: 650rpx;
|
||||
min-height: 900rpx;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16rpx 42rpx rgba(28, 53, 90, 0.1);
|
||||
}
|
||||
|
||||
.contract-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.contract-rich-text {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #334155;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
right: 32rpx;
|
||||
bottom: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 999rpx;
|
||||
padding: 10rpx 16rpx;
|
||||
border: 1px solid rgba($brand-primary, 0.08);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.empty-panel {
|
||||
margin-top: 24rpx;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
1065
pkgService/device_control/device_control.vue
Normal file
1065
pkgService/device_control/device_control.vue
Normal file
File diff suppressed because it is too large
Load Diff
583
pkgService/message_detail/message_detail.vue
Normal file
583
pkgService/message_detail/message_detail.vue
Normal file
@@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<view class="message-detail-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 tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '48rpx' }">
|
||||
<view class="hero-panel tn-margin-bottom-lg" :class="heroThemeClass">
|
||||
<view class="hero-top-row">
|
||||
<view class="hero-badge" :class="heroBadgeClass">{{ categoryText }}</view>
|
||||
<view class="hero-icon-wrap">
|
||||
<text :class="categoryIcon"></text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-title tn-margin-top">{{ localizedTitle || t('messageFallbackTitle') }}</view>
|
||||
<view class="hero-meta-row tn-margin-top">
|
||||
<view class="hero-meta-chip">
|
||||
<text class="tn-icon-time tn-margin-right-xs"></text>
|
||||
<text>{{ detail.time || '--' }}</text>
|
||||
</view>
|
||||
<view class="hero-meta-chip">
|
||||
<text class="tn-icon-my tn-margin-right-xs"></text>
|
||||
<text>{{ sourceText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="article-header tn-margin-bottom-lg">
|
||||
<view class="article-title">{{ localizedTitle || t('messageFallbackTitle') }}</view>
|
||||
<view class="article-meta tn-margin-top">
|
||||
<view class="article-tag">{{ categoryText }}</view>
|
||||
<view class="article-time">{{ detail.time || '--' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-panel tn-margin-bottom-lg">
|
||||
<view class="panel-title">{{ t('contentTitle') }}</view>
|
||||
<rich-text class="article-content tn-margin-top" :nodes="contentNodes"></rich-text>
|
||||
</view>
|
||||
|
||||
<view v-if="localizedDesc && localizedDesc !== localizedContent" class="content-panel tn-margin-bottom-lg">
|
||||
<view class="panel-title">{{ t('summaryTitle') }}</view>
|
||||
<view class="panel-desc tn-margin-top">{{ localizedDesc }}</view>
|
||||
</view>
|
||||
|
||||
<view class="meta-panel tn-margin-bottom-lg">
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ t('sourceLabel') }}</text>
|
||||
<text class="meta-value">{{ sourceText }}</text>
|
||||
</view>
|
||||
<view class="meta-row">
|
||||
<text class="meta-label">{{ t('categoryLabel') }}</text>
|
||||
<text class="meta-value">{{ categoryText }}</text>
|
||||
</view>
|
||||
<view v-if="detail.code" class="meta-row">
|
||||
<text class="meta-label">{{ t('codeLabel') }}</text>
|
||||
<view class="meta-action-wrap">
|
||||
<text class="meta-value">{{ detail.code }}</text>
|
||||
<view class="copy-btn" @click="copyCode">
|
||||
<text class="tn-text-xs tn-text-bold">{{ t('copy') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="detail.statusHint" class="meta-row">
|
||||
<text class="meta-label">{{ t('statusLabel') }}</text>
|
||||
<text class="meta-value">{{ detail.statusHint }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-row">
|
||||
<view class="ghost-btn" @click="goBackList">
|
||||
<text class="tn-text-bold">{{ t('backList') }}</text>
|
||||
</view>
|
||||
<view v-if="detail.path" class="action-btn" @click="handleGoBusiness">
|
||||
<text class="tn-text-bold">{{ actionButtonText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
clearMessageDetailPayload,
|
||||
findMessageDetailPayload,
|
||||
getMessageDetailPayload
|
||||
} from '../../utils/messages'
|
||||
import { normalizeRichTextImages } from '../../utils/rich-text'
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function normalizeContentToRichText(value = '', emptyText = '') {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return `<p>${escapeHtml(emptyText || '')}</p>`
|
||||
}
|
||||
if (/<[a-z][\s\S]*>/i.test(text)) {
|
||||
return normalizeRichTextImages(
|
||||
text,
|
||||
'display:block;width:100%;max-width:100%;height:auto;box-sizing:border-box;border-radius:16rpx;margin:16rpx 0;vertical-align:top;'
|
||||
)
|
||||
}
|
||||
return `<div style="white-space:pre-wrap;line-height:1.9;color:#334155;font-size:30rpx;">${escapeHtml(text)}</div>`
|
||||
}
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '消息详情',
|
||||
messageFallbackTitle: '消息通知',
|
||||
contentTitle: '通知内容',
|
||||
summaryTitle: '摘要',
|
||||
sourceLabel: '消息来源',
|
||||
categoryLabel: '消息分类',
|
||||
codeLabel: '通知编码',
|
||||
copy: '复制',
|
||||
statusLabel: '当前状态',
|
||||
backList: '返回消息页',
|
||||
categorySystem: '系统通知',
|
||||
categoryPayment: '缴费账单',
|
||||
categoryButler: '管家消息',
|
||||
actionSystem: '前往相关页面',
|
||||
actionPayment: '去处理账单',
|
||||
actionButler: '去查看服务',
|
||||
actionDefault: '继续前往业务页',
|
||||
remoteSource: '系统通知接口',
|
||||
localSource: '本地业务消息',
|
||||
expiredDesc: '当前消息详情已失效,请返回消息中心重新打开。',
|
||||
expiredHint: '消息缓存已失效',
|
||||
codeCopied: '通知编号已复制',
|
||||
emptyContent: '暂无详细内容'
|
||||
},
|
||||
en: {
|
||||
navTitle: 'Message Detail',
|
||||
messageFallbackTitle: 'Notification',
|
||||
contentTitle: 'Content',
|
||||
summaryTitle: 'Summary',
|
||||
sourceLabel: 'Source',
|
||||
categoryLabel: 'Category',
|
||||
codeLabel: 'Notice Code',
|
||||
copy: 'Copy',
|
||||
statusLabel: 'Status',
|
||||
backList: 'Back to Messages',
|
||||
categorySystem: 'System Notice',
|
||||
categoryPayment: 'Payment Bill',
|
||||
categoryButler: 'Butler Message',
|
||||
actionSystem: 'Open Related Page',
|
||||
actionPayment: 'Handle Bill',
|
||||
actionButler: 'View Service',
|
||||
actionDefault: 'Continue to Page',
|
||||
remoteSource: 'Remote Notification API',
|
||||
localSource: 'Local Business Message',
|
||||
expiredDesc: 'This message detail has expired. Please return to the message center and open it again.',
|
||||
expiredHint: 'Message cache expired',
|
||||
codeCopied: 'Notice code copied',
|
||||
emptyContent: 'No detailed content available'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lookupId: '',
|
||||
lookupCode: '',
|
||||
detail: {
|
||||
id: '',
|
||||
title: '',
|
||||
desc: '',
|
||||
content: '',
|
||||
time: '',
|
||||
category: 'system',
|
||||
source: 'local',
|
||||
code: '',
|
||||
path: '',
|
||||
statusHint: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
language() {
|
||||
return this.$getLanguage()
|
||||
},
|
||||
localizedTitle() {
|
||||
if (this.language === 'en') {
|
||||
return this.detail.titleEn || this.detail.title || this.detail.titleZh || ''
|
||||
}
|
||||
return this.detail.titleZh || this.detail.title || this.detail.titleEn || ''
|
||||
},
|
||||
localizedDesc() {
|
||||
if (this.language === 'en') {
|
||||
return this.detail.descEn || this.detail.desc || this.detail.descZh || ''
|
||||
}
|
||||
return this.detail.descZh || this.detail.desc || this.detail.descEn || ''
|
||||
},
|
||||
localizedContent() {
|
||||
if (this.language === 'en') {
|
||||
return this.detail.contentEn || this.detail.content || this.detail.contentZh || this.localizedDesc
|
||||
}
|
||||
return this.detail.contentZh || this.detail.content || this.detail.contentEn || this.localizedDesc
|
||||
},
|
||||
categoryText() {
|
||||
const map = {
|
||||
system: this.t('categorySystem'),
|
||||
payment: this.t('categoryPayment'),
|
||||
butler: this.t('categoryButler')
|
||||
}
|
||||
return map[this.detail.category] || this.t('messageFallbackTitle')
|
||||
},
|
||||
contentNodes() {
|
||||
return normalizeContentToRichText(this.localizedContent || this.localizedDesc || '', this.t('emptyContent'))
|
||||
},
|
||||
categoryIcon() {
|
||||
const map = {
|
||||
system: 'tn-icon-notice-fill',
|
||||
payment: 'tn-icon-coupon-fill',
|
||||
butler: 'tn-icon-service-fill'
|
||||
}
|
||||
return map[this.detail.category] || 'tn-icon-notice-fill'
|
||||
},
|
||||
heroThemeClass() {
|
||||
const map = {
|
||||
system: 'hero-system',
|
||||
payment: 'hero-payment',
|
||||
butler: 'hero-butler'
|
||||
}
|
||||
return map[this.detail.category] || 'hero-system'
|
||||
},
|
||||
heroBadgeClass() {
|
||||
const map = {
|
||||
system: 'badge-system',
|
||||
payment: 'badge-payment',
|
||||
butler: 'badge-butler'
|
||||
}
|
||||
return map[this.detail.category] || 'badge-system'
|
||||
},
|
||||
actionButtonText() {
|
||||
const map = {
|
||||
system: this.t('actionSystem'),
|
||||
payment: this.t('actionPayment'),
|
||||
butler: this.t('actionButler')
|
||||
}
|
||||
return map[this.detail.category] || this.t('actionDefault')
|
||||
},
|
||||
sourceText() {
|
||||
return this.detail.source === 'remote' ? this.t('remoteSource') : this.t('localSource')
|
||||
}
|
||||
},
|
||||
onLoad(options = {}) {
|
||||
this.lookupId = options.id || ''
|
||||
this.lookupCode = options.code || ''
|
||||
this.restoreDetail()
|
||||
},
|
||||
watch: {
|
||||
language() {
|
||||
this.restoreDetail()
|
||||
}
|
||||
},
|
||||
onUnload() {
|
||||
clearMessageDetailPayload(this.language)
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.message_detail.message_detail', key, params)
|
||||
},
|
||||
restoreDetail() {
|
||||
const payload = getMessageDetailPayload(this.language)
|
||||
const fallbackPayload = findMessageDetailPayload(this.lookupId, this.lookupCode, this.language)
|
||||
const resolvedPayload = payload && Object.keys(payload).length ? payload : fallbackPayload
|
||||
|
||||
if (resolvedPayload && typeof resolvedPayload === 'object' && Object.keys(resolvedPayload).length) {
|
||||
this.detail = {
|
||||
...this.detail,
|
||||
...resolvedPayload
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.detail = {
|
||||
...this.detail,
|
||||
id: this.lookupId || this.lookupCode || '',
|
||||
code: this.lookupCode || '',
|
||||
titleZh: this.t('messageFallbackTitle'),
|
||||
titleEn: this.t('messageFallbackTitle'),
|
||||
descZh: this.t('expiredDesc'),
|
||||
descEn: this.t('expiredDesc'),
|
||||
contentZh: this.t('expiredDesc'),
|
||||
contentEn: this.t('expiredDesc'),
|
||||
title: this.t('messageFallbackTitle'),
|
||||
desc: this.t('expiredDesc'),
|
||||
content: this.t('expiredDesc'),
|
||||
source: 'remote',
|
||||
statusHint: this.t('expiredHint')
|
||||
}
|
||||
},
|
||||
goBackList() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
copyCode() {
|
||||
if (!this.detail.code) {
|
||||
return
|
||||
}
|
||||
uni.setClipboardData({
|
||||
data: String(this.detail.code),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: this.t('codeCopied'),
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
handleGoBusiness() {
|
||||
if (!this.detail.path) {
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: this.detail.path
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-detail-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(145deg, #F4F7FA 0%, #EAEFF5 100%);
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.content-panel,
|
||||
.meta-panel {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-radius: 32rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(15, 23, 42, 0.04), inset 0 2rpx 0 rgba(255, 255, 255, 1);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-panel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -60rpx;
|
||||
top: -80rpx;
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 50%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.hero-system {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.16) 0%, rgba(255, 255, 255, 0.88) 56%);
|
||||
}
|
||||
|
||||
.hero-system::after {
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.42) 0%, rgba(59, 130, 246, 0) 70%);
|
||||
}
|
||||
|
||||
.hero-payment {
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.16) 0%, rgba(255, 255, 255, 0.88) 56%);
|
||||
}
|
||||
|
||||
.hero-payment::after {
|
||||
background: radial-gradient(circle, rgba(245, 158, 11, 0.34) 0%, rgba(245, 158, 11, 0) 70%);
|
||||
}
|
||||
|
||||
.hero-butler {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.16) 0%, rgba(255, 255, 255, 0.88) 56%);
|
||||
}
|
||||
|
||||
.hero-butler::after {
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.34) 0%, rgba(16, 185, 129, 0) 70%);
|
||||
}
|
||||
|
||||
.hero-top-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-system {
|
||||
color: #1d4ed8;
|
||||
background: rgba(29, 78, 216, 0.12);
|
||||
}
|
||||
|
||||
.badge-payment {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.badge-butler {
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
}
|
||||
|
||||
.hero-icon-wrap {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40rpx;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #475569 100%);
|
||||
box-shadow: 0 16rpx 32rpx rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
line-height: 1.4;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-meta,
|
||||
.panel-desc,
|
||||
.meta-label,
|
||||
.meta-value {
|
||||
font-size: 24rpx;
|
||||
line-height: 1.8;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.hero-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
color: #475569;
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.article-header {
|
||||
padding: 40rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 16rpx 48rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 40rpx;
|
||||
line-height: 1.5;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.article-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 10rpx;
|
||||
color: #1d4ed8;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
background: rgba(29, 78, 216, 0.1);
|
||||
}
|
||||
|
||||
.article-time {
|
||||
font-size: 24rpx;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.meta-row + .meta-row {
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.meta-action-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #111827;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72rpx;
|
||||
height: 44rpx;
|
||||
padding: 0 14rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #1d4ed8;
|
||||
background: rgba(29, 78, 216, 0.1);
|
||||
}
|
||||
|
||||
.article-content {
|
||||
font-size: 30rpx;
|
||||
color: #334155;
|
||||
line-height: 1.9;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 92rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #0f172a;
|
||||
background: linear-gradient(135deg, $brand-primary 0%, #c7eb65 100%);
|
||||
box-shadow: 0 16rpx 32rpx rgba($brand-primary, 0.25);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.action-row > view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 92rpx;
|
||||
border-radius: 999rpx;
|
||||
color: #0f172a;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
</style>
|
||||
674
pkgService/my_application/my_application.vue
Normal file
674
pkgService/my_application/my_application.vue
Normal file
@@ -0,0 +1,674 @@
|
||||
<template>
|
||||
<view class="my-application-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 tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '48rpx' }">
|
||||
<scroll-view class="filter-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="filter-row tn-margin-bottom-lg">
|
||||
<view
|
||||
class="filter-chip"
|
||||
v-for="item in statusOptions"
|
||||
:key="item.value"
|
||||
:class="{ 'filter-chip--active': status === item.value }"
|
||||
@click="changeStatus(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="toolbar-row tn-margin-bottom-lg">
|
||||
<view class="toolbar-summary">{{ t('toolbarSummary', { total }) }}</view>
|
||||
<view
|
||||
class="refresh-btn"
|
||||
:class="{ 'refresh-btn--loading': loading }"
|
||||
@click="refreshStatus"
|
||||
>
|
||||
<text
|
||||
class="tn-text-bold tn-flex tn-flex-row-center tn-flex-col-center"
|
||||
>
|
||||
<text v-if="loading" class="tn-icon-loading tn-margin-right-xs spin"></text>
|
||||
{{ loading ? t('refreshing') : t('refresh') }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="list.length">
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="getApplicationKey(item)"
|
||||
class="application-card tn-margin-bottom-lg"
|
||||
@click="goToApplicationDetail(item)"
|
||||
>
|
||||
<view class="card-head tn-flex tn-flex-row-between tn-flex-col-center">
|
||||
<view class="card-title-wrap">
|
||||
<view class="card-title">{{ getApplicationTitle(item) }}</view>
|
||||
<view class="card-code tn-margin-top-xs">{{ getApplicationCode(item) }}</view>
|
||||
</view>
|
||||
<view :class="['status-badge', 'status-' + resolveStatusType(item.status)]">
|
||||
{{ getDisplayStatusText(item) }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card-body tn-margin-top">
|
||||
<view class="info-row tn-flex tn-flex-row-between">
|
||||
<text class="info-label">{{ t('apartmentLabel') }}</text>
|
||||
<text class="info-value">{{ item.intendedAreaName || '--' }}</text>
|
||||
</view>
|
||||
<view class="info-row tn-flex tn-flex-row-between">
|
||||
<text class="info-label">{{ t('occupantLabel') }}</text>
|
||||
<text class="info-value">{{ item.userName || item.phone || '--' }}</text>
|
||||
</view>
|
||||
<view class="info-row tn-flex tn-flex-row-between">
|
||||
<text class="info-label">{{ item.isViewingApplication ? t('viewingTimeLabel') : t('leaseTermLabel') }}</text>
|
||||
<text class="info-value">{{ formatDateRange(item.start, item.end) }}</text>
|
||||
</view>
|
||||
<view class="info-row tn-flex tn-flex-row-between">
|
||||
<text class="info-label">{{ t('submittedAtLabel') }}</text>
|
||||
<text class="info-value">{{ formatDateTime(item.createTime) }}</text>
|
||||
</view>
|
||||
<view v-if="item.remark" class="remark-panel tn-margin-top">
|
||||
<view class="remark-label">{{ t('remarkLabel') }}</view>
|
||||
<view class="remark-text tn-margin-top-xs">{{ item.remark }}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="canViewBill(item)" class="card-footer tn-margin-top-lg">
|
||||
<view
|
||||
class="card-action-btn card-action-btn--ghost"
|
||||
:data-reference-type-no="resolveReferenceTypeNo(item)"
|
||||
@click="goToBillPage"
|
||||
>
|
||||
<text class="tn-text-bold">{{ t('viewBill') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="load-state">
|
||||
<text v-if="loadingMore">{{ t('loadingMore') }}</text>
|
||||
<text v-else-if="!hasMore">{{ t('noMore') }}</text>
|
||||
<text v-else>{{ t('pullUpMore') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!loading" class="empty-panel tn-padding-lg">
|
||||
<view class="empty-title">{{ t('emptyTitle') }}</view>
|
||||
<view class="empty-desc tn-margin-top-sm">{{ t('emptyDesc') }}</view>
|
||||
<view class="action-btn tn-margin-top-lg" @click="goSelectRoom">
|
||||
<text class="tn-text-bold">{{ t('goSelectRoom') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && !list.length" class="empty-panel tn-padding-lg">
|
||||
<view class="empty-title">{{ t('loadingTitle') }}</view>
|
||||
<view class="empty-desc tn-margin-top-sm">{{ t('loadingDesc') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { applicationFormMePage } from '@/api/index.js'
|
||||
import { saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
|
||||
|
||||
function formatPageResponse(response) {
|
||||
const payload = response && response.data ? response.data : response
|
||||
return payload && typeof payload === 'object' ? payload : {}
|
||||
}
|
||||
|
||||
function normalizeApplicationList(list = []) {
|
||||
if (!Array.isArray(list)) {
|
||||
return []
|
||||
}
|
||||
return list
|
||||
.filter(item => item && typeof item === 'object')
|
||||
.map((item) => {
|
||||
const remark = item && item.remark ? String(item.remark) : ''
|
||||
const isViewingApplication = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i.test(remark)
|
||||
return {
|
||||
...item,
|
||||
isViewingApplication
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '我的申请',
|
||||
toolbarSummary: '共 {total} 条申请',
|
||||
refreshing: '刷新中...',
|
||||
refresh: '刷新状态',
|
||||
apartmentLabel: '所属公寓',
|
||||
occupantLabel: '入住人',
|
||||
viewingTimeLabel: '预约时间',
|
||||
leaseTermLabel: '预计租期',
|
||||
submittedAtLabel: '提交时间',
|
||||
remarkLabel: '补充说明',
|
||||
viewBill: '查看关联账单',
|
||||
loadingMore: '加载中...',
|
||||
noMore: '没有更多申请了',
|
||||
pullUpMore: '上拉加载更多',
|
||||
emptyTitle: '暂无申请记录',
|
||||
emptyDesc: '您还没有提交任何申请,去选房后可发起入住申请或预约看房。',
|
||||
goSelectRoom: '去选房',
|
||||
loadingTitle: '申请列表加载中',
|
||||
loadingDesc: '正在同步您的入住申请记录,请稍候。',
|
||||
statusAll: '全部',
|
||||
statusPending: '待审核',
|
||||
statusApproved: '已通过',
|
||||
statusRejected: '已拒绝',
|
||||
statusFinished: '已结束',
|
||||
statusProcessing: '处理中',
|
||||
refreshSuccess: '申请状态已刷新',
|
||||
loadError: '申请记录加载失败',
|
||||
viewingApplicationTitle: '预约看房申请',
|
||||
stayApplicationTitle: '入住申请',
|
||||
missingBillCode: '申请单编号缺失',
|
||||
missingApplicationCode: '申请编号缺失',
|
||||
dateRangeJoiner: ' 至 '
|
||||
},
|
||||
en: {
|
||||
navTitle: 'My Applications',
|
||||
toolbarSummary: '{total} applications',
|
||||
refreshing: 'Refreshing...',
|
||||
refresh: 'Refresh Status',
|
||||
apartmentLabel: 'Apartment',
|
||||
occupantLabel: 'Resident',
|
||||
viewingTimeLabel: 'Viewing Time',
|
||||
leaseTermLabel: 'Lease Term',
|
||||
submittedAtLabel: 'Submitted At',
|
||||
remarkLabel: 'Remarks',
|
||||
viewBill: 'View Related Bill',
|
||||
loadingMore: 'Loading...',
|
||||
noMore: 'No more applications',
|
||||
pullUpMore: 'Pull up to load more',
|
||||
emptyTitle: 'No applications yet',
|
||||
emptyDesc: 'You have not submitted any application yet. Go select a room to start a stay or viewing application.',
|
||||
goSelectRoom: 'Select Room',
|
||||
loadingTitle: 'Loading applications',
|
||||
loadingDesc: 'Syncing your application records. Please wait.',
|
||||
statusAll: 'All',
|
||||
statusPending: 'Pending',
|
||||
statusApproved: 'Approved',
|
||||
statusRejected: 'Rejected',
|
||||
statusFinished: 'Closed',
|
||||
statusProcessing: 'Processing',
|
||||
refreshSuccess: 'Application status refreshed',
|
||||
loadError: 'Failed to load applications',
|
||||
viewingApplicationTitle: 'Viewing Request',
|
||||
stayApplicationTitle: 'Stay Application',
|
||||
missingBillCode: 'Missing application bill number',
|
||||
missingApplicationCode: 'Missing application number',
|
||||
dateRangeJoiner: ' to '
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
status: '',
|
||||
pageIndex: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
list: [],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: true,
|
||||
statusOptions: []
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.reloadList()
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.reloadList().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
},
|
||||
onReachBottom() {
|
||||
this.loadMore()
|
||||
},
|
||||
created() {
|
||||
this.syncStatusOptions()
|
||||
},
|
||||
watch: {
|
||||
language() {
|
||||
this.syncStatusOptions()
|
||||
this.reloadList(true)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
language() {
|
||||
return this.$getLanguage()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.my_application.my_application', key, params)
|
||||
},
|
||||
syncStatusOptions() {
|
||||
this.statusOptions = [
|
||||
{ label: this.t('statusAll'), value: '' },
|
||||
{ label: this.t('statusPending'), value: 0 },
|
||||
{ label: this.t('statusApproved'), value: 10 },
|
||||
{ label: this.t('statusRejected'), value: 20 },
|
||||
{ label: this.t('statusFinished'), value: 99 }
|
||||
]
|
||||
},
|
||||
getStatusText(status) {
|
||||
const map = {
|
||||
0: this.t('statusPending'),
|
||||
10: this.t('statusApproved'),
|
||||
20: this.t('statusRejected'),
|
||||
99: this.t('statusFinished')
|
||||
}
|
||||
return map[status] || this.t('statusProcessing')
|
||||
},
|
||||
getDisplayStatusText(item = {}) {
|
||||
if (this.language !== 'en' && item.statusDesc) {
|
||||
return item.statusDesc
|
||||
}
|
||||
return this.getStatusText(item.status)
|
||||
},
|
||||
resolveStatusType(status) {
|
||||
if (status === 10) {
|
||||
return 'approved'
|
||||
}
|
||||
if (status === 20) {
|
||||
return 'rejected'
|
||||
}
|
||||
if (status === 99) {
|
||||
return 'finished'
|
||||
}
|
||||
return 'pending'
|
||||
},
|
||||
formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
const text = String(value).replace('T', ' ')
|
||||
return text.length > 16 ? text.slice(0, 16) : text
|
||||
},
|
||||
formatDate(value) {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
return String(value).slice(0, 10)
|
||||
},
|
||||
formatDateRange(start, end) {
|
||||
if (!start && !end) {
|
||||
return '--'
|
||||
}
|
||||
if (start && end) {
|
||||
const startText = this.formatDateTime(start)
|
||||
const endText = this.formatDateTime(end)
|
||||
if (String(start).includes('T') || String(end).includes('T')) {
|
||||
return `${startText}${this.t('dateRangeJoiner')}${endText}`
|
||||
}
|
||||
}
|
||||
return `${this.formatDate(start)}${this.t('dateRangeJoiner')}${this.formatDate(end)}`
|
||||
},
|
||||
async refreshStatus() {
|
||||
if (this.loading || this.loadingMore) {
|
||||
return
|
||||
}
|
||||
await this.reloadList()
|
||||
if (this.list.length || this.total === 0) {
|
||||
uni.showToast({
|
||||
title: this.t('refreshSuccess'),
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
},
|
||||
async reloadList() {
|
||||
this.pageIndex = 1
|
||||
this.hasMore = true
|
||||
this.list = []
|
||||
await this.fetchList(true)
|
||||
},
|
||||
async loadMore() {
|
||||
if (!this.hasMore || this.loading || this.loadingMore) {
|
||||
return
|
||||
}
|
||||
this.pageIndex += 1
|
||||
await this.fetchList(false)
|
||||
},
|
||||
async fetchList(isReload = false) {
|
||||
if (isReload) {
|
||||
this.loading = true
|
||||
} else {
|
||||
this.loadingMore = true
|
||||
}
|
||||
|
||||
const params = {
|
||||
PageIndex: this.pageIndex,
|
||||
PageSize: this.pageSize
|
||||
}
|
||||
if (this.status !== '' && this.status !== null && typeof this.status !== 'undefined') {
|
||||
params.Status = this.status
|
||||
}
|
||||
|
||||
await applicationFormMePage(params, this.language)
|
||||
.then((response) => {
|
||||
const pageData = formatPageResponse(response)
|
||||
console.log('pageData', pageData)
|
||||
const nextList = normalizeApplicationList(pageData.data || [])
|
||||
const total = Number(pageData.total || 0)
|
||||
|
||||
this.total = total
|
||||
this.list = normalizeApplicationList(isReload ? nextList : this.list.concat(nextList))
|
||||
this.hasMore = this.list.length < total && nextList.length > 0
|
||||
saveMyApplicationList(this.list)
|
||||
|
||||
const activeCode = this.resolvePreferredReferenceTypeNo(this.list)
|
||||
if (activeCode) {
|
||||
saveActiveApplicationCode(activeCode)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isReload) {
|
||||
this.pageIndex = Math.max(1, this.pageIndex - 1)
|
||||
}
|
||||
uni.showToast({
|
||||
title: (error && (error.message || error.msg)) || this.t('loadError'),
|
||||
icon: 'none'
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
this.loadingMore = false
|
||||
})
|
||||
},
|
||||
changeStatus(value) {
|
||||
if (this.status === value) {
|
||||
return
|
||||
}
|
||||
this.status = value
|
||||
this.reloadList()
|
||||
},
|
||||
getApplicationCode(item = {}) {
|
||||
return item.detailCode || '--'
|
||||
},
|
||||
getApplicationKey(item = {}) {
|
||||
return item.detailCode || item.formId || `${item.createTime || 'application'}-${item.phone || item.userName || 'unknown'}`
|
||||
},
|
||||
getApplicationTitle(item = {}) {
|
||||
if (item.isViewingApplication) {
|
||||
return this.t('viewingApplicationTitle')
|
||||
}
|
||||
return item.intendedRoomTypeName || this.t('stayApplicationTitle')
|
||||
},
|
||||
resolveReferenceTypeNo(item = {}) {
|
||||
return item.detailCode || ''
|
||||
},
|
||||
canViewBill(item = {}) {
|
||||
return !item.isViewingApplication && this.resolveReferenceTypeNo(item) && this.resolveStatusType(item.status) === 'pending'
|
||||
},
|
||||
resolvePreferredReferenceTypeNo(list = []) {
|
||||
const sortedList = [...list]
|
||||
.filter(item => !item.isViewingApplication && this.resolveReferenceTypeNo(item))
|
||||
.sort((a, b) => new Date(b.createTime || 0).getTime() - new Date(a.createTime || 0).getTime())
|
||||
|
||||
if (!sortedList.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const preferredItem = sortedList.find(item => Number(item.status) !== 99) || sortedList[0]
|
||||
return this.resolveReferenceTypeNo(preferredItem)
|
||||
},
|
||||
goToBillPage(event) {
|
||||
const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {}
|
||||
const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0
|
||||
? String(dataset.referenceTypeNo)
|
||||
: ''
|
||||
if (!referenceTypeNo) {
|
||||
uni.showToast({
|
||||
title: this.t('missingBillCode'),
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saveActiveApplicationCode(referenceTypeNo)
|
||||
uni.navigateTo({
|
||||
url: `/pkgService/utility_recharge/utility_recharge?referenceTypeNo=${encodeURIComponent(referenceTypeNo)}`
|
||||
})
|
||||
},
|
||||
goToApplicationDetail(item = {}) {
|
||||
const code = item.detailCode || ''
|
||||
if (!code) {
|
||||
uni.showToast({
|
||||
title: this.t('missingApplicationCode'),
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pkgService/application_detail/application_detail?code=${encodeURIComponent(code)}&payload=${encodeURIComponent(JSON.stringify(item))}`
|
||||
})
|
||||
},
|
||||
goSelectRoom() {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?index=0&pageId=select-room'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.my-application-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
|
||||
}
|
||||
|
||||
.application-card,
|
||||
.empty-panel {
|
||||
background: #FFFFFF;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.empty-title,
|
||||
.card-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1A2233;
|
||||
}
|
||||
|
||||
.empty-desc,
|
||||
.card-code,
|
||||
.info-label,
|
||||
.remark-label,
|
||||
.load-state {
|
||||
color: #6A7A99;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 16rpx;
|
||||
padding: 4rpx;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.toolbar-summary {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #6A7A99;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
min-width: 188rpx;
|
||||
height: 72rpx;
|
||||
padding: 0 24rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: #FFFFFF;
|
||||
border: 1rpx solid rgba($brand-primary, 0.18);
|
||||
color: $brand-primary;
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.06);
|
||||
}
|
||||
|
||||
.refresh-btn--loading {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
flex-shrink: 0;
|
||||
padding: 14rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #FFFFFF;
|
||||
border: 1rpx solid rgba($brand-primary, 0.15);
|
||||
color: #6A7A99;
|
||||
font-size: 24rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-chip--active {
|
||||
background: #FFFFFF;
|
||||
border-color: $brand-primary;
|
||||
color: $brand-primary;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.card-code {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: #B45309;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background: rgba($brand-primary, 0.12);
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.status-finished {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
padding: 16rpx 0;
|
||||
border-bottom: 1px dashed rgba($brand-primary, 0.15);
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
max-width: 62%;
|
||||
text-align: right;
|
||||
color: #1A2233;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remark-panel {
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba($brand-primary, 0.02);
|
||||
border: 1rpx solid rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.remark-text {
|
||||
color: #1A2233;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-action-btn {
|
||||
min-width: 220rpx;
|
||||
height: 84rpx;
|
||||
padding: 0 28rpx;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 18rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.card-action-btn--ghost {
|
||||
background: #ffffff;
|
||||
color: $brand-primary;
|
||||
border: 1rpx solid rgba($brand-primary, 0.3);
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
|
||||
}
|
||||
|
||||
.load-state {
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
padding: 12rpx 0 24rpx;
|
||||
color: #6A7A99;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 隐藏 scroll-view 滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
753
pkgService/my_contract/my_contract.vue
Normal file
753
pkgService/my_contract/my_contract.vue
Normal file
@@ -0,0 +1,753 @@
|
||||
<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">{{ 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('overviewSubtitle') }}</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">{{ t('pendingMetricLabel') }}</view>
|
||||
<view class="metric-value">{{ summaryStats.pending }}</view>
|
||||
</view>
|
||||
<view class="metric-item is-primary">
|
||||
<view class="metric-label">{{ t('displayMetricLabel') }}</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">{{ t('loadingTitle') }}</view>
|
||||
<view class="empty-desc">{{ t('loadingDesc') }}</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">{{ t('contractTitle') }}</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">{{ t('relatedOrderLabel') }}</view>
|
||||
<view class="meta-chip-value">{{ item.orderNo || '--' }}</view>
|
||||
</view>
|
||||
<view class="meta-chip">
|
||||
<view class="meta-chip-label">{{ t('signStatusLabel') }}</view>
|
||||
<view class="meta-chip-value">{{ item.signImageUrl ? t('signed') : t('unsigned') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="detail-panel">
|
||||
<view class="info-row">
|
||||
<text class="info-label">{{ t('contractPeriodLabel') }}</text>
|
||||
<text class="info-value">{{ formatDateRange(item.start, item.end) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">{{ t('recordTipLabel') }}</text>
|
||||
<text class="info-value">{{ getStatusTip(item) }}</text>
|
||||
</view>
|
||||
<view v-if="item.signImageUrl" class="sign-row">
|
||||
<text class="info-label">{{ t('mySignatureLabel') }}</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">{{ t('emptyTitle') }}</view>
|
||||
<view class="empty-desc">{{ t('emptyDesc') }}</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 {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '我的合同',
|
||||
overviewTitle: '合同概览',
|
||||
overviewSubtitle: '查看住宿合同、签约状态与有效期限',
|
||||
pendingMetricLabel: '待签约/审批',
|
||||
displayMetricLabel: '当前展示',
|
||||
loadingTitle: '合同记录加载中',
|
||||
loadingDesc: '正在同步您的合同数据,请稍候。',
|
||||
contractTitle: '住宿合同',
|
||||
relatedOrderLabel: '关联订单',
|
||||
signStatusLabel: '签署状态',
|
||||
signed: '已签名',
|
||||
unsigned: '待签名',
|
||||
contractPeriodLabel: '合同期限',
|
||||
recordTipLabel: '记录说明',
|
||||
mySignatureLabel: '我的签名',
|
||||
emptyTitle: '暂无对应的合同记录',
|
||||
emptyDesc: '切换筛选条件,或等待系统生成新的合同数据。',
|
||||
tabAll: '全部',
|
||||
tabPending: '待签约',
|
||||
statusRejected: '已拒绝',
|
||||
statusPending: '待审批',
|
||||
statusApproved: '已通过',
|
||||
statusProcessing: '处理中',
|
||||
dateRangeJoiner: ' 至 ',
|
||||
tipApproved: '合同已生效,可查看签约结果。',
|
||||
tipNeedSign: '待您完成签名后进入审批流程。',
|
||||
tipPending: '已提交签约信息,等待审批结果。',
|
||||
tipRejected: '合同未通过审批,请联系管理员处理。',
|
||||
tipDefault: '请关注合同最新状态。',
|
||||
actionSign: '去签约',
|
||||
actionViewDetail: '查看详情'
|
||||
},
|
||||
en: {
|
||||
navTitle: 'My Contracts',
|
||||
overviewTitle: 'Contract Overview',
|
||||
overviewSubtitle: 'Check stay contracts, signing status, and effective period',
|
||||
pendingMetricLabel: 'Pending Sign / Review',
|
||||
displayMetricLabel: 'Displayed',
|
||||
loadingTitle: 'Loading contracts',
|
||||
loadingDesc: 'Syncing your contract data. Please wait.',
|
||||
contractTitle: 'Stay Contract',
|
||||
relatedOrderLabel: 'Related Order',
|
||||
signStatusLabel: 'Signing Status',
|
||||
signed: 'Signed',
|
||||
unsigned: 'Unsigned',
|
||||
contractPeriodLabel: 'Contract Period',
|
||||
recordTipLabel: 'Notes',
|
||||
mySignatureLabel: 'My Signature',
|
||||
emptyTitle: 'No contract record found',
|
||||
emptyDesc: 'Switch filters or wait for new contract data to be generated.',
|
||||
tabAll: 'All',
|
||||
tabPending: 'Pending Sign',
|
||||
statusRejected: 'Rejected',
|
||||
statusPending: 'Pending Review',
|
||||
statusApproved: 'Approved',
|
||||
statusProcessing: 'Processing',
|
||||
dateRangeJoiner: ' to ',
|
||||
tipApproved: 'The contract is effective now. You can view the signing result.',
|
||||
tipNeedSign: 'Please complete your signature to enter the review process.',
|
||||
tipPending: 'Signing information has been submitted and is waiting for review.',
|
||||
tipRejected: 'The contract did not pass review. Please contact the administrator.',
|
||||
tipDefault: 'Please pay attention to the latest contract status.',
|
||||
actionSign: 'Sign Now',
|
||||
actionViewDetail: 'View Detail'
|
||||
}
|
||||
},
|
||||
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: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.my_contract.my_contract', key, params)
|
||||
},
|
||||
syncTabs() {
|
||||
this.tabs = [
|
||||
{ label: this.t('tabAll'), value: 'all' },
|
||||
{ label: this.t('tabPending'), 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.t('statusRejected'),
|
||||
'0': this.t('statusPending'),
|
||||
'1': this.t('statusApproved')
|
||||
}
|
||||
return map[String(status)] || this.t('statusProcessing')
|
||||
},
|
||||
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.t('dateRangeJoiner')}${this.formatDate(end)}`
|
||||
},
|
||||
getStatusTip(item = {}) {
|
||||
if (Number(item.approvalStatus) === 1) {
|
||||
return this.t('tipApproved')
|
||||
}
|
||||
if (Number(item.approvalStatus) === 0 && !item.signImageUrl) {
|
||||
return this.t('tipNeedSign')
|
||||
}
|
||||
if (Number(item.approvalStatus) === 0) {
|
||||
return this.t('tipPending')
|
||||
}
|
||||
if (Number(item.approvalStatus) === -1) {
|
||||
return this.t('tipRejected')
|
||||
}
|
||||
return this.t('tipDefault')
|
||||
},
|
||||
resolveActionText(item = {}) {
|
||||
if (this.shouldShowSignAction(item)) {
|
||||
return this.t('actionSign')
|
||||
}
|
||||
return this.t('actionViewDetail')
|
||||
},
|
||||
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>
|
||||
382
pkgService/service_stub/service_stub.vue
Normal file
382
pkgService/service_stub/service_stub.vue
Normal file
@@ -0,0 +1,382 @@
|
||||
<template>
|
||||
<view class="service-stub-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">{{ resolvedPageTitle }}</view>
|
||||
</tn-nav-bar>
|
||||
|
||||
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px' }">
|
||||
|
||||
<!-- 门锁授权 Mockup -->
|
||||
<view v-if="pageKey === 'lock'">
|
||||
<view class="hero-card tn-flex tn-flex-direction-column tn-flex-col-center">
|
||||
<view class="tn-bg-gray--light tn-padding-xl tn-border-radius-circle tn-margin-bottom">
|
||||
<text class="tn-icon-lock tn-color-gray" style="font-size: 80rpx;"></text>
|
||||
</view>
|
||||
<view class="hero-title tn-text-bold">{{ t('lockNoAccessTitle') }}</view>
|
||||
<view class="hero-desc tn-margin-top-sm tn-text-center">{{ t('lockNoAccessDesc') }}</view>
|
||||
<view class="tn-flex tn-margin-top-xl tn-width-full tn-flex-row-between">
|
||||
<view class="btn-light tn-flex-1 tn-margin-right-sm tn-flex tn-flex-col-center tn-flex-row-center tn-padding-sm" style="border-radius: 12rpx; opacity: 0.6;">
|
||||
<text class="tn-icon-key tn-margin-right-xs"></text> {{ t('tempPassword') }}
|
||||
</view>
|
||||
<view class="btn-primary tn-flex-1 tn-margin-left-sm tn-flex tn-flex-col-center tn-flex-row-center tn-padding-sm" style="border-radius: 12rpx; opacity: 0.6;">
|
||||
<text class="tn-icon-unlock tn-margin-right-xs"></text> {{ t('oneTapUnlock') }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="record-card tn-margin-top-lg">
|
||||
<view class="block-title">{{ t('authRecordTitle') }}</view>
|
||||
<view class="tn-margin-top tn-flex tn-flex-row-center tn-padding-xl">
|
||||
<tn-empty icon="tn-icon-empty-data" :text="t('lockEmptyRecord')"></tn-empty>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 积分管理 Mockup -->
|
||||
<view v-else-if="pageKey === 'points'">
|
||||
<view class="hero-card" style="background: linear-gradient(135deg, #FFB703 0%, #FF8A00 100%); color: #fff; border: none;">
|
||||
<view class="tn-flex tn-flex-row-between tn-flex-col-center">
|
||||
<view class="tn-text-md">{{ t('pointsAvailable') }}</view>
|
||||
<view class="tn-text-sm" style="background: rgba(255,255,255,0.2); padding: 4rpx 16rpx; border-radius: 100rpx;">{{ t('redeemRules') }}</view>
|
||||
</view>
|
||||
<view class="tn-margin-top-lg tn-text-bold" style="font-size: 80rpx;">--</view>
|
||||
<view class="tn-margin-top tn-flex tn-flex-row-between">
|
||||
<view class="tn-flex tn-flex-col-center">
|
||||
<text class="tn-icon-calendar tn-margin-right-xs"></text> {{ t('dailyPoints') }}
|
||||
</view>
|
||||
<view class="tn-flex tn-flex-col-center" style="opacity: 0.8;">
|
||||
{{ t('goCheckIn') }} <text class="tn-icon-right tn-margin-left-xs"></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tn-flex tn-flex-row-between tn-margin-top-lg">
|
||||
<view class="btn-light tn-flex-1 tn-margin-right-sm tn-flex tn-flex-direction-column tn-flex-col-center tn-padding-lg" style="border-radius: 20rpx;">
|
||||
<text class="tn-icon-shop tn-color-orange tn-margin-bottom-sm" style="font-size: 60rpx;"></text>
|
||||
<text class="tn-text-bold tn-color-black">{{ t('pointsMall') }}</text>
|
||||
<text class="tn-text-xs tn-color-gray tn-margin-top-xs">{{ t('pointsMallDesc') }}</text>
|
||||
</view>
|
||||
<view class="btn-light tn-flex-1 tn-margin-left-sm tn-flex tn-flex-direction-column tn-flex-col-center tn-padding-lg" style="border-radius: 20rpx;">
|
||||
<text class="tn-icon-coupon tn-color-purplered tn-margin-bottom-sm" style="font-size: 60rpx;"></text>
|
||||
<text class="tn-text-bold tn-color-black">{{ t('coupon') }}</text>
|
||||
<text class="tn-text-xs tn-color-gray tn-margin-top-xs">{{ t('couponDesc') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="record-card tn-margin-top-lg">
|
||||
<view class="block-title">{{ t('pointsRecordTitle') }}</view>
|
||||
<view class="tn-margin-top tn-flex tn-flex-row-center tn-padding-xl">
|
||||
<tn-empty icon="tn-icon-empty-data" :text="t('pointsEmpty')"></tn-empty>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 一码通管理 Mockup -->
|
||||
<view v-else-if="pageKey === 'code'">
|
||||
<view class="hero-card tn-flex tn-flex-direction-column tn-flex-col-center">
|
||||
<view class="tn-text-md tn-color-black tn-text-bold">{{ t('accessCodeTitle') }}</view>
|
||||
<view class="tn-text-xs tn-color-gray tn-margin-top-xs">{{ t('accessCodeDesc') }}</view>
|
||||
|
||||
<view class="qr-code-box tn-margin-top-xl tn-margin-bottom-lg tn-flex tn-flex-col-center tn-flex-row-center">
|
||||
<view class="qr-placeholder tn-flex tn-flex-col-center tn-flex-row-center">
|
||||
<text class="tn-icon-qr-code tn-color-gray" style="font-size: 200rpx; opacity: 0.2;"></text>
|
||||
</view>
|
||||
<view class="qr-mask tn-flex tn-flex-col-center tn-flex-row-center">
|
||||
<text class="tn-color-white tn-text-bold tn-text-lg">{{ t('noAccessPermission') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="tn-text-xs tn-color-gray tn-text-center tn-margin-bottom">{{ t('accessAssignDesc') }}</view>
|
||||
</view>
|
||||
|
||||
<view class="record-card tn-margin-top-lg">
|
||||
<view class="tn-flex tn-flex-row-between tn-flex-col-center">
|
||||
<view class="block-title">{{ t('visitorAccessTitle') }}</view>
|
||||
<view class="tn-color-gray tn-text-sm">{{ t('visitorAccessAction') }} <text class="tn-icon-right"></text></view>
|
||||
</view>
|
||||
<view class="tn-margin-top tn-flex tn-flex-row-center tn-padding-xl">
|
||||
<tn-empty icon="tn-icon-empty-data" :text="t('visitorEmpty')"></tn-empty>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 默认通用占位 -->
|
||||
<view v-else>
|
||||
<view class="hero-card">
|
||||
<view class="hero-badge">{{ t('heroBadge') }}</view>
|
||||
<view class="hero-title tn-margin-top">{{ resolvedPageTitle || t('comingSoonTitle') }}</view>
|
||||
<view class="hero-desc tn-margin-top-sm">{{ resolvedPageDesc || resolvedDefaultDesc }}</view>
|
||||
</view>
|
||||
|
||||
<view class="record-card tn-margin-top-xl">
|
||||
<view class="block-title">{{ t('currentStatusTitle') }}</view>
|
||||
<view class="empty-text tn-margin-top">
|
||||
{{ t('currentStatusDesc') }}
|
||||
</view>
|
||||
<view class="tip-card tn-margin-top-lg">
|
||||
<view class="tip-title">{{ t('noteTitle') }}</view>
|
||||
<view class="tip-desc tn-margin-top-xs">{{ t('noteDesc') }}</view>
|
||||
</view>
|
||||
<view class="submit-btn btn-primary tn-margin-top-xl" @click="goBack">
|
||||
<text class="tn-text-bold">{{ t('backPrev') }}</text>
|
||||
</view>
|
||||
<view class="submit-btn btn-light tn-margin-top" @click="goHome">
|
||||
<text class="tn-text-bold">{{ t('backHome') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function safeDecodeText(value, fallback = '') {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(String(value))
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
zh: {
|
||||
defaultPageTitle: '业务办理',
|
||||
defaultPageDesc: '该能力正在完善中,开放后会同步到个人中心的对应入口。',
|
||||
lockNoAccessTitle: '暂无门锁权限',
|
||||
lockNoAccessDesc: '您当前暂未分配智能门锁,或门锁暂未联网。\n入住后即可使用一键开门与临时密码下发功能。',
|
||||
tempPassword: '临时密码',
|
||||
oneTapUnlock: '一键开锁',
|
||||
authRecordTitle: '授权记录',
|
||||
lockEmptyRecord: '暂无门锁操作记录',
|
||||
pointsAvailable: '当前可用积分',
|
||||
redeemRules: '兑换规则',
|
||||
dailyPoints: '每日签到领积分',
|
||||
goCheckIn: '去签到',
|
||||
pointsMall: '积分商城',
|
||||
pointsMallDesc: '超值好物兑换',
|
||||
coupon: '优惠券',
|
||||
couponDesc: '租金水电抵扣',
|
||||
pointsRecordTitle: '积分明细',
|
||||
pointsEmpty: '暂无积分明细',
|
||||
accessCodeTitle: '我的通行二维码',
|
||||
accessCodeDesc: '可用于社区大门、单元门及电梯通行',
|
||||
noAccessPermission: '未获取通行权限',
|
||||
accessAssignDesc: '请在完成入住签约并录入人脸/实名信息后\n系统将自动为您分配通行权限',
|
||||
visitorAccessTitle: '访客通行',
|
||||
visitorAccessAction: '临时访客授权',
|
||||
visitorEmpty: '暂无访客通行记录',
|
||||
heroBadge: '即将开放',
|
||||
comingSoonTitle: '功能建设中',
|
||||
currentStatusTitle: '当前状态',
|
||||
currentStatusDesc: '该功能当前仍在建设中,暂未开放正式办理入口。你可以先返回上一页,或回到首页继续查看已开放功能。',
|
||||
noteTitle: '说明',
|
||||
noteDesc: '后续接入真实能力后,这里会展示正式的业务入口、状态数据和办理记录。',
|
||||
backPrev: '返回上一页',
|
||||
backHome: '返回首页'
|
||||
},
|
||||
en: {
|
||||
defaultPageTitle: 'Service Center',
|
||||
defaultPageDesc: 'This capability is being improved and will appear in the related personal center entry when available.',
|
||||
lockNoAccessTitle: 'No door lock access',
|
||||
lockNoAccessDesc: 'No smart lock is assigned yet, or the lock is currently offline.\nAfter check-in, one-tap unlock and temporary password features will be available.',
|
||||
tempPassword: 'Temporary Code',
|
||||
oneTapUnlock: 'Unlock Now',
|
||||
authRecordTitle: 'Access Records',
|
||||
lockEmptyRecord: 'No door lock records',
|
||||
pointsAvailable: 'Available Points',
|
||||
redeemRules: 'Redeem Rules',
|
||||
dailyPoints: 'Daily check-in rewards',
|
||||
goCheckIn: 'Check In',
|
||||
pointsMall: 'Points Mall',
|
||||
pointsMallDesc: 'Redeem for selected items',
|
||||
coupon: 'Coupons',
|
||||
couponDesc: 'Offset rent and utilities',
|
||||
pointsRecordTitle: 'Points History',
|
||||
pointsEmpty: 'No points record',
|
||||
accessCodeTitle: 'My Access QR Code',
|
||||
accessCodeDesc: 'Use it for community gates, unit doors, and elevators',
|
||||
noAccessPermission: 'No access permission',
|
||||
accessAssignDesc: 'After completing check-in signing and face / identity enrollment,\nthe system will automatically assign your access permission.',
|
||||
visitorAccessTitle: 'Visitor Access',
|
||||
visitorAccessAction: 'Temporary visitor authorization',
|
||||
visitorEmpty: 'No visitor access records',
|
||||
heroBadge: 'Coming Soon',
|
||||
comingSoonTitle: 'Coming Soon',
|
||||
currentStatusTitle: 'Current Status',
|
||||
currentStatusDesc: 'This capability is still under development and is not yet officially available. You can go back or return to the home page to browse available features.',
|
||||
noteTitle: 'Notes',
|
||||
noteDesc: 'After the real capability is connected, this page will display the formal service entry, status data, and processing records.',
|
||||
backPrev: 'Go Back',
|
||||
backHome: 'Back Home'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
pageTitleZh: '',
|
||||
pageTitleEn: '',
|
||||
pageDescZh: '',
|
||||
pageDescEn: '',
|
||||
pageKey: '',
|
||||
rawPageTitle: '',
|
||||
rawPageDesc: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resolvedPageTitle() {
|
||||
if (this.$getLanguage() === 'en') {
|
||||
return this.pageTitleEn || this.pageTitleZh || this.rawPageTitle || this.t('defaultPageTitle')
|
||||
}
|
||||
return this.pageTitleZh || this.pageTitleEn || this.rawPageTitle || this.t('defaultPageTitle')
|
||||
},
|
||||
resolvedPageDesc() {
|
||||
if (this.$getLanguage() === 'en') {
|
||||
return this.pageDescEn || this.pageDescZh || this.rawPageDesc || ''
|
||||
}
|
||||
return this.pageDescZh || this.pageDescEn || this.rawPageDesc || ''
|
||||
},
|
||||
resolvedDefaultDesc() {
|
||||
return this.t('defaultPageDesc')
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.pageTitleZh = safeDecodeText(options && (options.titleZh || options.title), '')
|
||||
this.pageTitleEn = safeDecodeText(options && options.titleEn, '')
|
||||
this.pageDescZh = safeDecodeText(options && (options.descZh || options.desc), '')
|
||||
this.pageDescEn = safeDecodeText(options && options.descEn, '')
|
||||
this.rawPageTitle = safeDecodeText(options && options.title, '')
|
||||
this.rawPageDesc = safeDecodeText(options && options.desc, '')
|
||||
this.pageKey = safeDecodeText(options && options.key, '')
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.service_stub.service_stub', key, params)
|
||||
},
|
||||
goBack() {
|
||||
uni.navigateBack()
|
||||
},
|
||||
goHome() {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.service-stub-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.record-card {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
|
||||
border: 1rpx solid rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba($brand-primary, 0.1);
|
||||
color: $brand-primary;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-title,
|
||||
.block-title,
|
||||
.record-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #1A2233;
|
||||
}
|
||||
|
||||
.hero-desc,
|
||||
.empty-text {
|
||||
color: #6A7A99;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.tip-card {
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba($brand-primary, 0.02);
|
||||
border: 1rpx solid rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
color: #1A2233;
|
||||
}
|
||||
|
||||
.tip-desc {
|
||||
font-size: 24rpx;
|
||||
color: #6A7A99;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.submit-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background: #ffffff;
|
||||
color: $brand-primary;
|
||||
border: 1rpx solid rgba($brand-primary, 0.3);
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.qr-code-box {
|
||||
position: relative;
|
||||
width: 400rpx;
|
||||
height: 400rpx;
|
||||
border-radius: 32rpx;
|
||||
background-color: #F8FAFF;
|
||||
border: 1rpx dashed rgba($brand-primary, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.qr-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
</style>
|
||||
715
pkgService/smart_control/smart_control.vue
Normal file
715
pkgService/smart_control/smart_control.vue
Normal file
@@ -0,0 +1,715 @@
|
||||
<template>
|
||||
<view class="smart-page">
|
||||
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent" fontColor="#ffffff">
|
||||
<view class="tn-text-bold tn-text-xl">{{ t('navTitle') }}</view>
|
||||
</tn-nav-bar>
|
||||
|
||||
<view class="header-section" :style="{ paddingTop: (vuex_custom_bar_height + 20) + 'px' }">
|
||||
<view class="room-info">
|
||||
<text class="room-name">{{ displayRoomName }}</text>
|
||||
<view class="room-badge">Smart Home</view>
|
||||
</view>
|
||||
<view class="env-status">{{ summaryText }}</view>
|
||||
</view>
|
||||
|
||||
<view class="toolbar">
|
||||
<view class="toolbar-tip">{{ t('toolbarTip') }}</view>
|
||||
<view class="toolbar-action" @click="reloadDevices">
|
||||
<text class="tn-icon-refresh" :class="{'spin-anim': isLoading}" style="margin-right: 8rpx;"></text>
|
||||
<text>{{ isLoading ? t('loadingShort') : t('refreshStatus') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Feedback States -->
|
||||
<view v-if="isLoading && !devices.length" class="empty-state">
|
||||
<view class="loading-ring"></view>
|
||||
<view class="empty-title">{{ t('loadingTitle') }}</view>
|
||||
<view class="empty-desc">{{ t('loadingDesc') }}</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="loadError && !devices.length" class="empty-state error-state">
|
||||
<text class="tn-icon-warning empty-icon"></text>
|
||||
<view class="empty-title">{{ t('loadFailedTitle') }}</view>
|
||||
<view class="empty-desc">{{ loadError }}</view>
|
||||
<view class="retry-btn" @click="reloadDevices">{{ t('retry') }}</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!devices.length" class="empty-state">
|
||||
<text class="tn-icon-empty empty-icon"></text>
|
||||
<view class="empty-title">{{ t('emptyTitle') }}</view>
|
||||
<view class="empty-desc">{{ t('emptyDesc') }}</view>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="device-scroll">
|
||||
<view class="device-grid">
|
||||
<view
|
||||
class="device-card"
|
||||
:class="{'is-active': item.active}"
|
||||
v-for="item in devices"
|
||||
:key="item.renderKey"
|
||||
@click="goToDetail(item)"
|
||||
>
|
||||
<view class="card-glow" v-if="item.active"></view>
|
||||
<view class="card-top">
|
||||
<view class="icon-wrapper" :class="{'is-active': item.active}" @click.stop="toggleDevicePower(item)">
|
||||
<text :class="[item.iconClass || 'tn-icon-my-computer']"></text>
|
||||
</view>
|
||||
<view class="status-dot" :class="{ 'is-on': item.active }"></view>
|
||||
</view>
|
||||
<view class="card-bottom">
|
||||
<view class="device-name">{{ item.deviceName }}</view>
|
||||
<view class="device-state" :class="{'is-active': item.active}">{{ item.stateText || (item.active ? t('deviceRunning') : t('deviceOff')) }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { gatewayRoomDeviceList, gatewayExecuteDeviceCommand } from '../../api/index.js'
|
||||
import { getSessionSnapshot } from '../../utils/app-state'
|
||||
import {
|
||||
normalizeSmartDeviceList,
|
||||
getDeviceFunctions,
|
||||
getControlTypeCategory,
|
||||
buildCommandSchemaFields,
|
||||
patchDeviceStatus
|
||||
} from '../../utils/smart-device'
|
||||
|
||||
const DEFAULT_BRAND_ID = 'Tuya'
|
||||
|
||||
function pickFirstFilledValue(...values) {
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = values[index]
|
||||
if (value === undefined || value === null) {
|
||||
continue
|
||||
}
|
||||
const text = String(value).trim()
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildDeviceRenderKey(item = {}, index = 0) {
|
||||
return pickFirstFilledValue(
|
||||
item.deviceId,
|
||||
item.platformDeviceId,
|
||||
item.deviceNo,
|
||||
`device-${index}`
|
||||
)
|
||||
}
|
||||
|
||||
function buildDeviceCardModel(item = {}, index = 0) {
|
||||
return {
|
||||
...item,
|
||||
renderKey: buildDeviceRenderKey(item, index)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRefreshIntervalMs(value, fallback = 1000) {
|
||||
const seconds = Number(value)
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return fallback
|
||||
}
|
||||
return Math.round(seconds * 1000)
|
||||
}
|
||||
|
||||
function resolveDeviceListPollingInterval(deviceList = [], fallback = 1000) {
|
||||
const intervalList = (Array.isArray(deviceList) ? deviceList : [])
|
||||
.map(item => resolveRefreshIntervalMs(item && item.refreshInterval, 0))
|
||||
.filter(value => value > 0)
|
||||
|
||||
return intervalList.length ? Math.min(...intervalList) : fallback
|
||||
}
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '全屋智能',
|
||||
toolbarTip: '点击设备进入详情并下发控制指令',
|
||||
loadingShort: '加载中',
|
||||
refreshStatus: '刷新状态',
|
||||
loadingTitle: '正在连接设备',
|
||||
loadingDesc: '正在查询当前房间的可控设备...',
|
||||
loadFailedTitle: '设备加载失败',
|
||||
retry: '重新加载',
|
||||
emptyTitle: '暂无可控设备',
|
||||
emptyDesc: '当前房间未绑定智能设备',
|
||||
deviceSummary: '共 {count} 台设备,可进入详情页控制',
|
||||
deviceRunning: '运行中',
|
||||
deviceOff: '已关闭',
|
||||
currentRoom: '当前房间',
|
||||
missingRoomCode: '缺少房间编码',
|
||||
syncingStatus: '正在同步设备状态',
|
||||
unsupportedQuickControl: '设备暂不支持快捷控制',
|
||||
unsupportedSwitch: '该设备不支持快捷开关',
|
||||
missingFunctionCode: '功能编码缺失',
|
||||
sending: '发送中...',
|
||||
enabled: '已开启',
|
||||
disabled: '已关闭',
|
||||
commandFailed: '指令下发失败',
|
||||
requestFailed: '设备列表请求失败'
|
||||
},
|
||||
en: {
|
||||
navTitle: 'Smart Home',
|
||||
toolbarTip: 'Tap a device to open details and send control commands',
|
||||
loadingShort: 'Loading',
|
||||
refreshStatus: 'Refresh Status',
|
||||
loadingTitle: 'Connecting devices',
|
||||
loadingDesc: 'Checking controllable devices in the current room...',
|
||||
loadFailedTitle: 'Failed to load devices',
|
||||
retry: 'Retry',
|
||||
emptyTitle: 'No controllable devices',
|
||||
emptyDesc: 'No smart devices are bound to this room',
|
||||
deviceSummary: '{count} devices available. Tap to control in detail view',
|
||||
deviceRunning: 'Running',
|
||||
deviceOff: 'Off',
|
||||
currentRoom: 'Current Room',
|
||||
missingRoomCode: 'Missing room code',
|
||||
syncingStatus: 'Syncing device status',
|
||||
unsupportedQuickControl: 'Quick control is not supported for this device',
|
||||
unsupportedSwitch: 'This device does not support quick switching',
|
||||
missingFunctionCode: 'Missing function code',
|
||||
sending: 'Sending...',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
commandFailed: 'Failed to send command',
|
||||
requestFailed: 'Failed to request device list'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
brandId: DEFAULT_BRAND_ID,
|
||||
roomCode: '',
|
||||
roomName: '',
|
||||
devices: [],
|
||||
isLoading: false,
|
||||
isSyncing: false,
|
||||
isSubmitting: false,
|
||||
hasLoadedOnce: false,
|
||||
loadError: '',
|
||||
pollingEnabled: false,
|
||||
pollingTimer: null,
|
||||
pendingReloadPromise: null,
|
||||
pollingIntervalMs: 1000
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
language() {
|
||||
return this.$getLanguage()
|
||||
},
|
||||
displayRoomName() {
|
||||
return this.roomName || this.roomCode || this.t('currentRoom')
|
||||
},
|
||||
summaryText() {
|
||||
if (!this.roomCode) {
|
||||
return this.t('missingRoomCode')
|
||||
}
|
||||
if (this.isLoading) {
|
||||
return this.t('syncingStatus')
|
||||
}
|
||||
if (this.devices.length) {
|
||||
return this.t('deviceSummary', { count: this.devices.length })
|
||||
}
|
||||
return this.t('emptyTitle')
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.resolvePageContext(options || {})
|
||||
},
|
||||
onShow() {
|
||||
this.startPolling(true)
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
this.reloadDevices({
|
||||
stopRefresh: true,
|
||||
forceLoading: !this.devices.length
|
||||
})
|
||||
},
|
||||
onHide() {
|
||||
this.stopPolling()
|
||||
},
|
||||
onUnload() {
|
||||
this.stopPolling()
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.smart_control.smart_control', key, params)
|
||||
},
|
||||
resolvePageContext(options = {}) {
|
||||
const session = getSessionSnapshot()
|
||||
const roomData = (session && session.myRoomData) || {}
|
||||
|
||||
this.brandId = pickFirstFilledValue(
|
||||
options.brandId,
|
||||
roomData.brandId,
|
||||
roomData.platform,
|
||||
roomData.brand,
|
||||
DEFAULT_BRAND_ID
|
||||
)
|
||||
this.roomCode = pickFirstFilledValue(
|
||||
options.roomCode,
|
||||
roomData.roomCode
|
||||
)
|
||||
this.roomName = pickFirstFilledValue(
|
||||
options.roomName,
|
||||
roomData.roomName,
|
||||
roomData.roomLabel,
|
||||
roomData.roomNo
|
||||
)
|
||||
},
|
||||
clearPollingTimer() {
|
||||
if (this.pollingTimer) {
|
||||
clearTimeout(this.pollingTimer)
|
||||
this.pollingTimer = null
|
||||
}
|
||||
},
|
||||
startPolling(immediate = false) {
|
||||
if (this.pollingEnabled) {
|
||||
if (immediate) {
|
||||
this.pollNext(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.pollingEnabled = true
|
||||
this.pollNext(immediate)
|
||||
},
|
||||
stopPolling() {
|
||||
this.pollingEnabled = false
|
||||
this.clearPollingTimer()
|
||||
this.isSyncing = false
|
||||
},
|
||||
scheduleNextPoll(delay = this.pollingIntervalMs) {
|
||||
if (!this.pollingEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
this.clearPollingTimer()
|
||||
this.pollingTimer = setTimeout(() => {
|
||||
this.pollNext(true)
|
||||
}, delay)
|
||||
},
|
||||
pollNext(immediate = false) {
|
||||
if (!this.pollingEnabled) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const requestPromise = this.reloadDevices({
|
||||
silent: this.hasLoadedOnce,
|
||||
forceLoading: !this.hasLoadedOnce,
|
||||
keepErrorDevices: true
|
||||
})
|
||||
|
||||
return requestPromise.finally(() => {
|
||||
if (!this.pollingEnabled) {
|
||||
return
|
||||
}
|
||||
this.scheduleNextPoll(immediate ? this.pollingIntervalMs : 0)
|
||||
})
|
||||
},
|
||||
reloadDevices(options = {}) {
|
||||
const {
|
||||
stopRefresh = false,
|
||||
silent = false,
|
||||
forceLoading = false,
|
||||
keepErrorDevices = false
|
||||
} = options
|
||||
|
||||
if (!this.roomCode) {
|
||||
this.devices = []
|
||||
this.loadError = this.t('missingRoomCode')
|
||||
if (stopRefresh) {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (this.pendingReloadPromise) {
|
||||
return this.pendingReloadPromise.finally(() => {
|
||||
if (stopRefresh) {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.isLoading = forceLoading || (!this.hasLoadedOnce && !silent)
|
||||
this.isSyncing = silent && this.hasLoadedOnce
|
||||
if (!silent) {
|
||||
this.loadError = ''
|
||||
}
|
||||
|
||||
const requestPromise = gatewayRoomDeviceList(this.brandId, this.roomCode)
|
||||
.then((response) => {
|
||||
const deviceList = normalizeSmartDeviceList(response).map((item, index) => buildDeviceCardModel(item, index))
|
||||
this.devices = deviceList
|
||||
this.pollingIntervalMs = resolveDeviceListPollingInterval(deviceList)
|
||||
this.hasLoadedOnce = true
|
||||
if (!silent) {
|
||||
this.loadError = ''
|
||||
}
|
||||
if (!this.roomName && deviceList.length) {
|
||||
this.roomName = deviceList[0].roomName || this.roomName
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('load smart devices failed', error)
|
||||
if (!keepErrorDevices) {
|
||||
this.devices = []
|
||||
}
|
||||
if (!silent) {
|
||||
this.loadError = (error && (error.message || error.msg)) || this.t('requestFailed')
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false
|
||||
this.isSyncing = false
|
||||
this.pendingReloadPromise = null
|
||||
if (stopRefresh) {
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
this.pendingReloadPromise = requestPromise
|
||||
return requestPromise
|
||||
},
|
||||
goToDetail(item) {
|
||||
const payload = encodeURIComponent(JSON.stringify(item || {}))
|
||||
uni.navigateTo({
|
||||
url: `/pkgService/device_control/device_control?brandId=${encodeURIComponent(this.brandId)}&roomCode=${encodeURIComponent(this.roomCode)}&roomName=${encodeURIComponent(this.roomName)}&device=${payload}`
|
||||
})
|
||||
},
|
||||
resolveCommandFieldName(func, candidates = [], fallback = 'command') {
|
||||
if (!func || !func.commandSchema) return fallback
|
||||
const schemaFields = buildCommandSchemaFields(func.commandSchema)
|
||||
const fieldNames = schemaFields.map(field => String(field.name || '').trim()).filter(Boolean)
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
if (fieldNames.includes(candidates[i])) return candidates[i]
|
||||
}
|
||||
return fieldNames[0] || fallback
|
||||
},
|
||||
toggleDevicePower(item) {
|
||||
if (this.isSubmitting) return
|
||||
|
||||
const functions = getDeviceFunctions(item)
|
||||
if (!functions || !functions.length) {
|
||||
uni.showToast({ title: this.t('unsupportedQuickControl'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// Find the first switch-like function
|
||||
let targetFunc = functions.find(f => getControlTypeCategory(f.controlType) === 'switch')
|
||||
if (!targetFunc) {
|
||||
targetFunc = functions[0] // fallback to first function
|
||||
}
|
||||
|
||||
const category = getControlTypeCategory(targetFunc.controlType)
|
||||
if (category !== 'switch') {
|
||||
uni.showToast({ title: this.t('unsupportedSwitch'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const functionCode = String((targetFunc && targetFunc.functionCode) || '').trim()
|
||||
if (!functionCode) {
|
||||
uni.showToast({ title: this.t('missingFunctionCode'), icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const schemaText = (typeof targetFunc.commandSchema === 'string' ? targetFunc.commandSchema : JSON.stringify(targetFunc.commandSchema || '')).toLowerCase()
|
||||
const fieldName = this.resolveCommandFieldName(targetFunc, ['command', 'action', 'value'], 'command')
|
||||
const hasSchemaForm = buildCommandSchemaFields(targetFunc.commandSchema).length > 0
|
||||
|
||||
const enabled = !item.active
|
||||
let commandPayload = {}
|
||||
|
||||
if (!hasSchemaForm && schemaText.includes('on/off')) {
|
||||
commandPayload = { command: enabled ? 'on' : 'off' }
|
||||
} else {
|
||||
commandPayload = { [fieldName]: enabled ? 'on' : 'off' }
|
||||
}
|
||||
|
||||
this.isSubmitting = true
|
||||
uni.showLoading({ title: this.t('sending') })
|
||||
|
||||
gatewayExecuteDeviceCommand(this.brandId, item.deviceNo, functionCode, commandPayload)
|
||||
.then(() => {
|
||||
const patchedDevice = patchDeviceStatus(item, commandPayload, targetFunc.controlType)
|
||||
|
||||
// Update device in list
|
||||
const index = this.devices.findIndex(d => d.deviceId === item.deviceId)
|
||||
if (index !== -1) {
|
||||
this.$set(this.devices, index, {
|
||||
...patchedDevice,
|
||||
renderKey: this.devices[index].renderKey
|
||||
})
|
||||
}
|
||||
uni.showToast({ title: enabled ? this.t('enabled') : this.t('disabled'), icon: 'success' })
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('execute device command failed', error)
|
||||
uni.showToast({ title: (error && (error.message || error.msg)) || this.t('commandFailed'), icon: 'none' })
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSubmitting = false
|
||||
uni.hideLoading()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0% { opacity: 0.5; transform: scale(0.95); }
|
||||
50% { opacity: 0.8; transform: scale(1.05); }
|
||||
100% { opacity: 0.5; transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.spin-anim {
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.smart-page {
|
||||
min-height: 100vh;
|
||||
background-color: #0F1015;
|
||||
background-image:
|
||||
radial-gradient(circle at 80% 0%, rgba(70, 83, 255, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 20% 100%, rgba(70, 83, 255, 0.05) 0%, transparent 50%);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-section {
|
||||
padding: 40rpx 40rpx 20rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 60rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2rpx;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #a5a5b0 100%);
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.room-badge {
|
||||
background: rgba(70, 83, 255, 0.2);
|
||||
color: #7281ff;
|
||||
font-size: 20rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid rgba(70, 83, 255, 0.3);
|
||||
}
|
||||
|
||||
.env-status {
|
||||
font-size: 28rpx;
|
||||
color: #888996;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 40rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.toolbar-tip {
|
||||
font-size: 24rpx;
|
||||
color: #666775;
|
||||
}
|
||||
|
||||
.toolbar-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12rpx 24rpx;
|
||||
border-radius: 30rpx;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-size: 24rpx;
|
||||
color: #e0e0e6;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.toolbar-action:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 40rpx;
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 100rpx;
|
||||
color: #444554;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #e0e0e6;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.empty-desc {
|
||||
font-size: 26rpx;
|
||||
color: #666775;
|
||||
text-align: center;
|
||||
}
|
||||
.retry-btn {
|
||||
margin-top: 40rpx;
|
||||
padding: 16rpx 48rpx;
|
||||
border-radius: 40rpx;
|
||||
background: #4653ff;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 24rpx rgba(70, 83, 255, 0.3);
|
||||
}
|
||||
|
||||
.loading-ring {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border: 6rpx solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #4653ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.device-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 30rpx;
|
||||
padding: 10rpx 40rpx 60rpx;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 40rpx;
|
||||
padding: 36rpx;
|
||||
height: 280rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.device-card:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.device-card.is-active {
|
||||
background: rgba(70, 83, 255, 0.1);
|
||||
border-color: rgba(70, 83, 255, 0.4);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
top: -20%;
|
||||
right: -20%;
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
background: rgba(70, 83, 255, 0.4);
|
||||
filter: blur(40rpx);
|
||||
border-radius: 50%;
|
||||
animation: pulseGlow 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.icon-wrapper text {
|
||||
font-size: 44rpx;
|
||||
color: #888996;
|
||||
}
|
||||
.icon-wrapper.is-active {
|
||||
background: #4653ff;
|
||||
box-shadow: 0 8rpx 20rpx rgba(70, 83, 255, 0.3);
|
||||
}
|
||||
.icon-wrapper.is-active text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
margin-top: 12rpx;
|
||||
margin-right: 4rpx;
|
||||
}
|
||||
.status-dot.is-on {
|
||||
background: #00D37A;
|
||||
box-shadow: 0 0 12rpx rgba(0, 211, 122, 0.6);
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.device-state {
|
||||
font-size: 24rpx;
|
||||
color: #666775;
|
||||
}
|
||||
.device-state.is-active {
|
||||
color: #a3abff;
|
||||
}
|
||||
</style>
|
||||
137
pkgService/submit_request/components/DateRangeSelector.vue
Normal file
137
pkgService/submit_request/components/DateRangeSelector.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<view class="date-range-selector">
|
||||
<view class="date-item">
|
||||
<view class="date-label">{{ resolvedStartLabel }}</view>
|
||||
<picker mode="date" @change="onStartChange">
|
||||
<view class="date-input" :class="{ 'is-empty': !start }">
|
||||
{{ start || resolvedStartPlaceholder }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="date-divider">{{ t('dateDivider') }}</view>
|
||||
<view class="date-item">
|
||||
<view class="date-label">{{ resolvedEndLabel }}</view>
|
||||
<picker mode="date" @change="onEndChange">
|
||||
<view class="date-input" :class="{ 'is-empty': !end }">
|
||||
{{ end || resolvedEndPlaceholder }}
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DateRangeSelector',
|
||||
i18n: {
|
||||
zh: {
|
||||
startLabel: '预计入住',
|
||||
endLabel: '预计离开',
|
||||
startPlaceholder: '请选择入住日期',
|
||||
endPlaceholder: '请选择离开日期',
|
||||
dateDivider: '至'
|
||||
},
|
||||
en: {
|
||||
startLabel: 'Expected Check-in',
|
||||
endLabel: 'Expected Check-out',
|
||||
startPlaceholder: 'Select check-in date',
|
||||
endPlaceholder: 'Select check-out date',
|
||||
dateDivider: 'to'
|
||||
}
|
||||
},
|
||||
props: {
|
||||
start: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
end: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
endLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
startPlaceholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
endPlaceholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resolvedStartLabel() {
|
||||
return this.startLabel || this.t('startLabel')
|
||||
},
|
||||
resolvedEndLabel() {
|
||||
return this.endLabel || this.t('endLabel')
|
||||
},
|
||||
resolvedStartPlaceholder() {
|
||||
return this.startPlaceholder || this.t('startPlaceholder')
|
||||
},
|
||||
resolvedEndPlaceholder() {
|
||||
return this.endPlaceholder || this.t('endPlaceholder')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t(key) {
|
||||
return this.$tm('pkgService.submit_request.components.DateRangeSelector', key)
|
||||
},
|
||||
onStartChange(e) {
|
||||
this.$emit('update:start', e.detail.value)
|
||||
},
|
||||
onEndChange(e) {
|
||||
this.$emit('update:end', e.detail.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.date-range-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
border: 2rpx solid rgba(226, 232, 240, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.date-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date-divider {
|
||||
margin: 0 24rpx;
|
||||
color: #94A3B8;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-size: 22rpx;
|
||||
color: #64748B;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.date-input {
|
||||
font-size: 28rpx;
|
||||
color: #1A2B4C;
|
||||
font-weight: 600;
|
||||
|
||||
&.is-empty {
|
||||
color: #CBD5E1;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
326
pkgService/submit_request/components/RoomTypeSelector.vue
Normal file
326
pkgService/submit_request/components/RoomTypeSelector.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<view class="room-type-selector">
|
||||
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="showPopup = true">
|
||||
<view class="selected-info">
|
||||
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
|
||||
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : t('selectRoomType') }}
|
||||
</view>
|
||||
<view class="selected-area tn-text-ellipsis" v-if="selectedArea">
|
||||
{{ getAreaDisplayName(selectedArea) }}
|
||||
</view>
|
||||
</view>
|
||||
<text class="tn-icon-right tn-color-gray"></text>
|
||||
</view>
|
||||
|
||||
<tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true">
|
||||
<view class="popup-content">
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">{{ t('popupTitle') }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="area-list-scroll">
|
||||
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
|
||||
<tn-loading mode="circle"></tn-loading>
|
||||
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingRoomTypes') }}</view>
|
||||
</view>
|
||||
|
||||
<view v-else-if="!areaList.length" class="empty-state tn-padding-xl tn-text-center">
|
||||
<text class="tn-color-gray">{{ t('emptyRoomTypes') }}</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="area-group" v-for="(area, index) in areaList" :key="area.code">
|
||||
<view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)">
|
||||
<view class="tn-flex tn-flex-col-center">
|
||||
<text class="area-dot"></text>
|
||||
<text>{{ getAreaDisplayName(area) }}</text>
|
||||
</view>
|
||||
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
|
||||
</view>
|
||||
<view class="room-type-grid" v-show="!collapsedAreas[index]">
|
||||
<view
|
||||
class="room-type-item"
|
||||
v-for="room in area.roomTypes"
|
||||
:key="room.code"
|
||||
:class="{ 'is-active': selectedRoomType && selectedRoomType.code === room.code }"
|
||||
@click="handleSelect(area, room)"
|
||||
>
|
||||
<view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view>
|
||||
<view class="room-price" v-if="room.price">¥{{ getPriceValue(room.price) }}{{ t('priceUnit') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</tn-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { areaPagination } from '@/api/index.js'
|
||||
import { normalizeAreaItem } from '../../../utils/select-room-normalizers'
|
||||
|
||||
export default {
|
||||
name: 'RoomTypeSelector',
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '' // roomTypeCode
|
||||
},
|
||||
initialAreaCode: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showPopup: false,
|
||||
isLoading: false,
|
||||
areaList: [],
|
||||
selectedArea: null,
|
||||
selectedRoomType: null,
|
||||
collapsedAreas: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
language() {
|
||||
return this.$getLanguage ? this.$getLanguage() : 'zh'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.syncSelection()
|
||||
},
|
||||
language() {
|
||||
this.loadData(true)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadData()
|
||||
},
|
||||
methods: {
|
||||
t(key) {
|
||||
return this.$translate(this.i18n, key)
|
||||
},
|
||||
loadData(force = false) {
|
||||
if (!force && this.areaList.length > 0) return
|
||||
|
||||
this.isLoading = true
|
||||
return areaPagination({ PageIndex: 1, PageSize: 100 }, this.language)
|
||||
.then((res) => {
|
||||
const rawList = Array.isArray(res?.data?.data) ? res.data.data : (Array.isArray(res?.data) ? res.data : [])
|
||||
|
||||
this.areaList = rawList
|
||||
.map(item => normalizeAreaItem(item))
|
||||
.filter(area => area.roomTypes && area.roomTypes.length > 0)
|
||||
|
||||
// 初始化全部不折叠
|
||||
const collapsed = {}
|
||||
this.areaList.forEach((_, index) => {
|
||||
collapsed[index] = false
|
||||
})
|
||||
this.collapsedAreas = collapsed
|
||||
|
||||
this.syncSelection()
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('load room types error', e)
|
||||
uni.showToast({ title: this.t('loadFailed'), icon: 'none' })
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
},
|
||||
syncSelection() {
|
||||
if (!this.areaList.length) return
|
||||
|
||||
let foundArea = null
|
||||
let foundRoom = null
|
||||
|
||||
// Try to find by room type code
|
||||
if (this.value) {
|
||||
for (const area of this.areaList) {
|
||||
const room = area.roomTypes.find(r => r.code === this.value)
|
||||
if (room) {
|
||||
foundArea = area
|
||||
foundRoom = room
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by room, try to find by initial area code
|
||||
if (!foundRoom && this.initialAreaCode) {
|
||||
foundArea = this.areaList.find(a => a.code === this.initialAreaCode)
|
||||
}
|
||||
|
||||
if (foundArea) {
|
||||
this.selectedArea = foundArea
|
||||
if (foundRoom) {
|
||||
this.selectedRoomType = foundRoom
|
||||
}
|
||||
|
||||
// If we found the room, or we just want to initialize the area but no room yet
|
||||
if (foundRoom && this.value !== foundRoom.code) {
|
||||
this.$emit('input', foundRoom.code)
|
||||
this.$emit('change', { area: foundArea, roomType: foundRoom })
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSelect(area, roomType) {
|
||||
this.selectedArea = area
|
||||
this.selectedRoomType = roomType
|
||||
this.$emit('input', roomType.code)
|
||||
this.$emit('change', { area, roomType })
|
||||
this.showPopup = false
|
||||
},
|
||||
getAreaDisplayName(area = {}) {
|
||||
if (this.language === 'en') {
|
||||
return area.nameEnDisplay || area.nameEn || area.name || ''
|
||||
}
|
||||
return area.name || area.nameEn || ''
|
||||
},
|
||||
getRoomTypeDisplayName(roomType = {}) {
|
||||
if (this.language === 'en') {
|
||||
return roomType.nameEnDisplay || roomType.nameEn || roomType.name || ''
|
||||
}
|
||||
return roomType.name || roomType.nameEn || ''
|
||||
},
|
||||
toggleArea(index) {
|
||||
this.$set(this.collapsedAreas, index, !this.collapsedAreas[index])
|
||||
},
|
||||
getPriceValue(priceText) {
|
||||
if (!priceText) return '--'
|
||||
const matched = String(priceText).match(/(\d+(\.\d+)?)/)
|
||||
return matched ? matched[1] : '--'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.room-type-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selector-header {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
padding: 26rpx 32rpx;
|
||||
border: 1rpx solid rgba($brand-primary, 0.15);
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
|
||||
min-height: 100rpx;
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.selected-name {
|
||||
font-size: 28rpx;
|
||||
color: #1A2233;
|
||||
font-weight: 600;
|
||||
|
||||
&.is-empty {
|
||||
color: #A0B0C0;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-area {
|
||||
font-size: 22rpx;
|
||||
color: #6A7A99;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #F8FAFF;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
padding: 40rpx 30rpx 20rpx;
|
||||
text-align: center;
|
||||
border-bottom: 1px dashed rgba($brand-primary, 0.15);
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1A2233;
|
||||
}
|
||||
|
||||
.area-list-scroll {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
padding: 20rpx 30rpx 60rpx;
|
||||
}
|
||||
|
||||
.area-group {
|
||||
margin-bottom: 30rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04);
|
||||
border: 1rpx solid rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.area-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1A2233;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.area-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: $brand-primary;
|
||||
margin-right: 12rpx;
|
||||
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
|
||||
}
|
||||
|
||||
.room-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.room-type-item {
|
||||
padding: 20rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #FFFFFF;
|
||||
border: 1rpx solid rgba($brand-primary, 0.15);
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.is-active {
|
||||
background: #FFFFFF;
|
||||
border-color: $brand-primary;
|
||||
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1);
|
||||
|
||||
.room-name {
|
||||
color: $brand-primary;
|
||||
}
|
||||
.room-price {
|
||||
color: $brand-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #1A2233;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.room-price {
|
||||
font-size: 22rpx;
|
||||
color: #6A7A99;
|
||||
}
|
||||
</style>
|
||||
105
pkgService/submit_request/components/TagSelector.vue
Normal file
105
pkgService/submit_request/components/TagSelector.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<view class="tag-selector">
|
||||
<view v-if="options.length" class="option-row">
|
||||
<view
|
||||
v-for="item in options"
|
||||
:key="item.key"
|
||||
class="option-chip"
|
||||
:class="{ 'option-chip--active': value.includes(item.key) }"
|
||||
@click="toggleTag(item.key)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-tips">{{ loading ? t('loadingText') : resolvedEmptyText }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TagSelector',
|
||||
i18n: {
|
||||
zh: {
|
||||
loadingText: '标签加载中...',
|
||||
emptyText: '暂无标签'
|
||||
},
|
||||
en: {
|
||||
loadingText: 'Loading tags...',
|
||||
emptyText: 'No tags available'
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
resolvedEmptyText() {
|
||||
return this.emptyText || this.t('emptyText')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.submit_request.components.TagSelector', key, params)
|
||||
},
|
||||
toggleTag(key) {
|
||||
const currentList = [...this.value]
|
||||
const index = currentList.indexOf(key)
|
||||
if (index > -1) {
|
||||
currentList.splice(index, 1)
|
||||
} else {
|
||||
currentList.push(key)
|
||||
}
|
||||
this.$emit('input', currentList)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.option-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.option-chip {
|
||||
padding: 16rpx 28rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: #64748B;
|
||||
font-size: 24rpx;
|
||||
line-height: 1;
|
||||
border: 2rpx solid rgba(226, 232, 240, 0.8);
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.02);
|
||||
backdrop-filter: blur(8px);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.option-chip--active {
|
||||
background: #1A2B4C;
|
||||
border-color: #1A2B4C;
|
||||
color: #FFFFFF;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8rpx 24rpx rgba(26, 43, 76, 0.2);
|
||||
}
|
||||
|
||||
.empty-tips {
|
||||
color: #94A3B8;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
1304
pkgService/submit_request/submit_request.vue
Normal file
1304
pkgService/submit_request/submit_request.vue
Normal file
File diff suppressed because it is too large
Load Diff
77
pkgService/utility_recharge/utility_recharge.vue
Normal file
77
pkgService/utility_recharge/utility_recharge.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<view class="bill-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="bill-body tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '48rpx' }">
|
||||
<current-application-bill-panel
|
||||
ref="billPanel"
|
||||
:reference-type-no="referenceTypeNo"
|
||||
@reference-resolved="handleReferenceResolved"
|
||||
@all-paid="handleAllPaid"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { fetchEnums } from '@/utils/enum-store.js'
|
||||
import { saveActiveApplicationCode, saveHomePageTabId } from '@/utils/app-state'
|
||||
import CurrentApplicationBillPanel from '../components/CurrentApplicationBillPanel.vue'
|
||||
|
||||
export default {
|
||||
i18n: {
|
||||
zh: {
|
||||
navTitle: '账单管理'
|
||||
},
|
||||
en: {
|
||||
navTitle: 'Bill Management'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
CurrentApplicationBillPanel
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
referenceTypeNo: ''
|
||||
}
|
||||
},
|
||||
async onLoad(options) {
|
||||
await fetchEnums(false, this.$getLanguage())
|
||||
this.referenceTypeNo = options && options.referenceTypeNo ? decodeURIComponent(options.referenceTypeNo) : ''
|
||||
if (this.referenceTypeNo) {
|
||||
saveActiveApplicationCode(this.referenceTypeNo)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
t(key, params) {
|
||||
return this.$tm('pkgService.utility_recharge.utility_recharge', key, params)
|
||||
},
|
||||
handleReferenceResolved(referenceTypeNo) {
|
||||
this.referenceTypeNo = referenceTypeNo || ''
|
||||
if (this.referenceTypeNo) {
|
||||
saveActiveApplicationCode(this.referenceTypeNo)
|
||||
}
|
||||
},
|
||||
goStayServiceHome() {
|
||||
saveHomePageTabId('stay-service')
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index?pageId=stay-service'
|
||||
})
|
||||
},
|
||||
handleAllPaid() {
|
||||
setTimeout(() => {
|
||||
this.goStayServiceHome()
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bill-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user