Files
QSY-lvjuxcx/pkgService/ticket_workbench/ticket_workbench.vue
liangjian 1111ac148b feat: 重构核心租住流程页面与交互体验
统一注册、看房、申请与入住等核心页面的视觉和流程衔接,补齐支付、签约与状态流转闭环,提升移动端操作连贯性。
2026-07-29 09:05:38 +08:00

2125 lines
72 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<view class="ticket-page tn-safe-area-inset-bottom">
<tn-nav-bar
:isBack="true"
:bottomShadow="false"
:backgroundColor="navBarBackgroundColor"
:customBackStyle="navBarBackStyle"
:fixed="true"
>
<view class="tn-text-bold tn-text-xl tn-color-black" :style="{ opacity: navTitleOpacity }">{{ pageTitle }}</view>
</tn-nav-bar>
<scroll-view class="page-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll">
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '220rpx' }">
<view class="hero-header tn-margin-bottom-xl">
<view class="hero-badge">
<text class="badge-dot"></text>
<text>{{ $tr('WORK ORDER') }}</text>
</view>
<view class="hero-title">{{ pageTitle }}</view>
<view class="hero-desc">{{ pageDesc }}</view>
</view>
<view class="segment-card tn-margin-bottom-lg">
<view
class="segment-item"
:class="{ 'segment-item--active': activeTab === 'submit' }"
@click="switchTab('submit')"
>
{{ $tr('提交申请') }}
</view>
<view
class="segment-item"
:class="{ 'segment-item--active': activeTab === 'history' }"
@click="switchTab('history')"
>
{{ $tr('记录查询') }}
</view>
</view>
<view v-if="activeTab === 'submit'" class="context-card tn-margin-bottom-lg">
<view class="block-title">{{ $tr('当前入住信息') }}</view>
<view class="block-subtitle">{{ currentOrderStatusText }}</view>
<view class="context-grid tn-margin-top">
<view class="context-item">
<view class="context-label">{{ $tr('当前房间') }}</view>
<view class="context-value">{{ currentRoomText }}</view>
</view>
<view v-if="currentAreaText" class="context-item">
<view class="context-label">{{ $tr('所属区域') }}</view>
<view class="context-value">{{ currentAreaText }}</view>
</view>
<view v-if="currentLeaseText" class="context-item">
<view class="context-label">{{ $tr('租期') }}</view>
<view class="context-value">{{ currentLeaseText }}</view>
</view>
<view v-if="currentRoomTypeText" class="context-item">
<view class="context-label">{{ $tr('当前房型') }}</view>
<view class="context-value">{{ currentRoomTypeText }}</view>
</view>
</view>
</view>
<view v-if="modeBannerText && activeTab === 'submit'" class="status-card tn-margin-bottom-lg" :class="statusCardClass">
<view class="status-card__title">{{ modeBannerTitle }}</view>
<view class="status-card__desc">{{ modeBannerText }}</view>
<view v-if="fixOnholdMode" class="status-card__action" @click="cancelFixOnhold">{{ $tr('取消补充') }}</view>
<view v-else-if="transitActionMode" class="status-card__action" @click="cancelTransitAction">{{ $tr('退出处理') }}</view>
</view>
<view v-if="activeTab === 'submit'" class="form-card">
<view class="block-title">{{ submitSectionTitle }}</view>
<view class="block-subtitle">{{ submitSectionDesc }}</view>
<view v-if="isChangeService" class="form-item tn-margin-top">
<view class="form-label">{{ $tr('意向房型') }}</view>
<room-type-selector
v-model="switchForm.roomTypeCode"
:initial-area-code="switchForm.areaCode"
@change="handleSwitchRoomTypeChange"
/>
</view>
<view v-if="isCheckOutService" class="form-item tn-margin-top">
<view class="form-label">{{ $tr('预计退宿时间') }}</view>
<view class="picker-row">
<picker mode="date" :value="form.checkOutDate" @change="onDateChange('checkOutDate', $event)">
<view class="form-picker">{{ form.checkOutDate || $tr('请选择日期') }}</view>
</picker>
<picker mode="time" :value="form.checkOutTimePart" @change="onTimeChange('checkOutTimePart', $event)">
<view class="form-picker form-picker--time">{{ form.checkOutTimePart || $tr('请选择时间') }}</view>
</picker>
</view>
</view>
<view v-if="isRenewalService" class="form-item tn-margin-top">
<view class="form-label">{{ $tr('续住至') }}</view>
<view class="picker-row">
<picker mode="date" :value="form.renewalDate" @change="onDateChange('renewalDate', $event)">
<view class="form-picker">{{ form.renewalDate || $tr('请选择日期') }}</view>
</picker>
<picker mode="time" :value="form.renewalTimePart" @change="onTimeChange('renewalTimePart', $event)">
<view class="form-picker form-picker--time">{{ form.renewalTimePart || $tr('请选择时间') }}</view>
</picker>
</view>
</view>
<view v-if="isCleaningService" class="form-item tn-margin-top">
<view class="form-label">{{ $tr('预约清洁时间') }}</view>
<view class="picker-row">
<picker mode="date" :value="form.cleaningDate" @change="onDateChange('cleaningDate', $event)">
<view class="form-picker">{{ form.cleaningDate || $tr('请选择日期') }}</view>
</picker>
<picker mode="time" :value="form.cleaningTimePart" @change="onTimeChange('cleaningTimePart', $event)">
<view class="form-picker form-picker--time">{{ form.cleaningTimePart || $tr('请选择时间') }}</view>
</picker>
</view>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ contentFieldLabel }}</view>
<textarea
class="form-textarea"
:placeholder="contentPlaceholder"
:maxlength="500"
v-model="form.content"
></textarea>
<view class="field-counter">{{ (form.content || '').length }}/500</view>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ $tr('补充说明') }}</view>
<textarea
class="form-textarea form-textarea--remark"
:placeholder="$tr('请输入补充说明,帮助工作人员更快处理')"
:maxlength="300"
v-model="form.remark"
></textarea>
</view>
<view v-if="showTransitAssigneeSelector" class="form-item tn-margin-top">
<view class="form-label">{{ $tr('处理目标') }}</view>
<picker mode="selector" :range="transitAssigneePickerLabels" @change="onTransitAssigneeChange">
<view class="form-picker">
{{ transitSelectedAssigneeName || $tr('请选择处理目标') }}
</view>
</picker>
</view>
<view v-if="fixOnholdMode && fixOnholdFields.length" class="form-item tn-margin-top">
<view class="form-label">{{ $tr('补充业务信息') }}</view>
<view
v-for="(item, index) in fixOnholdFields"
:key="`${item.key}-${index}`"
class="supplement-item"
>
<view class="supplement-title">{{ item.title }}</view>
<textarea
v-if="shouldUseTextarea(item.value)"
class="form-textarea form-textarea--small"
v-model="item.value"
:placeholder="$tr('请输入{label}', { label: item.title })"
></textarea>
<input
v-else
class="form-input"
type="text"
v-model="item.value"
:placeholder="$tr('请输入{label}', { label: item.title })"
/>
</view>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ $tr('附件图片') }}<text v-if="isRepairService" class="required-mark"> *</text></view>
<tn-image-upload
:key="uploadInstanceKey"
:fileList="form.photos"
:action="uploadAction"
:header="uploadHeaders"
:formData="uploadFormData"
:max-count="6"
:max-size="5 * 1024 * 1024"
:uploadText="''"
:customBtn="true"
@on-success="onUploadSuccess"
@on-list-change="onPhotoListChange"
@on-remove="onRemoveImage"
@on-error="onUploadError"
@on-oversize="onUploadOversize"
@on-exceed="onUploadExceed"
>
<template #addBtn>
<view class="upload-trigger">
<text class="tn-icon-camera-fill upload-trigger__icon"></text>
<view class="upload-trigger__text">{{ $tr('上传图片') }}</view>
</view>
</template>
</tn-image-upload>
<view class="upload-tip">{{ uploadTipText }}</view>
</view>
</view>
<view v-else class="history-panel">
<view class="history-toolbar tn-margin-bottom-lg">
<view class="search-box">
<text class="tn-icon-search search-box__icon"></text>
<input
v-model="searchKeyword"
class="search-box__input"
:placeholder="$tr('搜索工单标题或内容')"
confirm-type="search"
@confirm="refreshHistory"
/>
</view>
<view class="refresh-mini-btn" @click="refreshHistory">
{{ historyLoading ? $tr('刷新中...') : $tr('刷新') }}
</view>
</view>
<scroll-view class="filter-scroll" scroll-x :show-scrollbar="false">
<view class="filter-row tn-margin-bottom-lg">
<view
v-for="item in historyStatusOptions"
:key="item.value"
class="filter-chip"
:class="{ 'filter-chip--active': selectedStatus === item.value }"
@click="changeStatusFilter(item.value)"
>
{{ item.label }}
</view>
</view>
</scroll-view>
<view v-if="historyList.length">
<view
v-for="item in historyList"
:key="item.no || item.orderNo"
class="history-card tn-margin-bottom-lg"
@click="openFlows(item)"
>
<view class="history-card__head tn-flex tn-flex-row-between tn-flex-col-center">
<view class="history-card__title-wrap">
<view class="history-card__title">{{ getHistoryTitle(item) }}</view>
<view class="history-card__code tn-margin-top-xs">{{ item.no || '--' }}</view>
</view>
<view :class="['status-badge', `status-${resolveHistoryStatusType(item)}`]">
{{ getHistoryStatusText(item) }}
</view>
</view>
<view class="history-card__body tn-margin-top">
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ $tr('提交时间') }}</text>
<text class="info-value">{{ formatDateTime(item.createTime) }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ $tr('申请内容') }}</text>
<text class="info-value info-value--multiline">{{ item.content || item.remark || '--' }}</text>
</view>
</view>
</view>
<view class="load-state">
<text v-if="historyLoading">{{ $tr('加载中...') }}</text>
<text v-else-if="!hasNext">{{ $tr('没有更多了') }}</text>
<text v-else @click="loadMoreHistory">{{ $tr('加载更多') }}</text>
</view>
</view>
<view v-else-if="!historyLoading" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ $tr('暂无工单记录') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ $tr('当前类型还没有工单记录。') }}</view>
</view>
</view>
</view>
</scroll-view>
<view v-if="activeTab === 'submit'" class="floating-submit-bar">
<view class="floating-submit-bar__inner">
<view class="floating-submit-tip">{{ submitFooterTip }}</view>
<view class="submit-btn btn-primary" :class="{ 'submit-btn--disabled': submitting }" @click="submitForm">
<text class="tn-text-bold">{{ submitting ? $tr('提交中...') : submitButtonText }}</text>
</view>
</view>
</view>
<tn-popup v-model="showFlowPopup" mode="bottom" length="84%" :borderRadius="28" :closeBtn="true">
<view class="flow-popup">
<view class="flow-popup__title">{{ currentFlowTitle || pageTitle }}</view>
<scroll-view scroll-y class="flow-popup__scroll">
<view
v-for="(item, index) in flowList"
:key="item.flowNo || item.no || index"
class="flow-card tn-margin-bottom"
:class="{ 'flow-card--current': isCurrentFlowItem(index) }"
>
<view class="tn-flex tn-flex-row-between tn-flex-col-center">
<view>
<view class="flow-card__status">{{ getFlowStatusText(item) }}</view>
<view class="flow-card__time tn-margin-top-xs">{{ formatDateTime(item.createTime) }}</view>
</view>
<view v-if="isCurrentFlowItem(index)" class="flow-card__tag">{{ $tr('当前节点') }}</view>
</view>
<view class="flow-card__content tn-margin-top">{{ item.content || item.remark || $tr('暂无处理内容') }}</view>
<view v-if="item.attachments && item.attachments.length" class="flow-attachments tn-margin-top">
<image
v-for="(attachment, attachIndex) in item.attachments"
:key="attachIndex"
class="flow-attachment-image"
:src="getAttachmentPreview(attachment)"
mode="aspectFill"
@click.stop="previewAttachment(item.attachments, attachIndex)"
></image>
</view>
</view>
<view v-if="transitionOptions.length" class="flow-actions">
<view class="block-title">{{ $tr('可执行操作') }}</view>
<view class="flow-action-list tn-margin-top">
<view
v-for="(item, index) in transitionOptions"
:key="`${item.nextStatus}-${index}`"
class="flow-action-btn"
@click="startTransitAction(item, getCurrentFlowItem())"
>
{{ item.nextStatusDesc || $tr('继续处理') }}
</view>
</view>
</view>
<view v-if="canFixOnhold(currentFlowOrder)" class="flow-actions tn-margin-top">
<view class="flow-action-btn flow-action-btn--ghost" @click="startFixOnhold(currentFlowOrder)">
{{ $tr('补充信息') }}
</view>
</view>
</scroll-view>
</view>
</tn-popup>
</view>
</template>
<script>
import { API_BASE_URL } from '@/utils/request'
import { getAuthToken, getSessionSnapshot } from '@/utils/app-state'
import { addFilePrefix } from '@/utils/common'
import {
orderSearchedDetail,
ticketFlowTransitionStepRuleSelectTypeItems,
ticketOrderFixonhold,
ticketOrderFlows,
ticketOrderMyPage,
ticketOrderSubmitChangeroom,
ticketOrderSubmitCheckout,
ticketOrderSubmitCleaning,
ticketOrderSubmitRenewal,
ticketOrderSubmitRepair,
ticketOrderSubmitSuggestion,
ticketOrderSubmitWaterdelivery,
ticketOrderTransitionOptions,
ticketOrderTransit
} from '@/api/index.js'
import { normalizeBookingDetail, parseOrderDetailResponse } from '@/utils/booking-helper'
import RoomTypeSelector from '../submit_request/components/RoomTypeSelector.vue'
const IMAGE_MEDIA_TYPE = 1
const USER_ASSIGNEE_TYPE_KEY = '1'
const SERVICE_CONFIGS = {
TicketRepairApply: {
key: 'repair',
title: '报修申请',
titleEn: 'Repair Request',
desc: '提交报修内容并跟踪后续处理进度。',
descEn: 'Submit repair details and track the ticket progress.'
},
TicketSuggestionApply: {
key: 'suggestion',
title: '意见建议',
titleEn: 'Suggestion',
desc: '提交建议与反馈,后续可查看工单流转记录。',
descEn: 'Submit feedback and check the ticket timeline later.'
},
TicketChangeApply: {
key: 'change',
title: '调宿申请',
titleEn: 'Room Change',
desc: '提交调宿意向和补充说明,后续由工作人员继续处理。',
descEn: 'Submit your room change request and follow the process later.'
},
TicketCheckOutApply: {
key: 'checkout',
title: '退宿申请',
titleEn: 'Checkout',
desc: '提交退宿时间与原因,并跟踪办理进度。',
descEn: 'Submit checkout time and reason, then track the progress.'
},
TicketRenewalApply: {
key: 'renewal',
title: '续住申请',
titleEn: 'Renewal',
desc: '提交续住诉求与时间安排,后续可查看审批进展。',
descEn: 'Submit renewal details and check the handling status later.'
},
TicketWaterDeliveryApply: {
key: 'water',
title: '送水申请',
titleEn: 'Water Delivery',
desc: '填写送水需求后提交工单。',
descEn: 'Submit a ticket for your water delivery request.'
},
TicketCleaningApply: {
key: 'cleaning',
title: '清洁服务',
titleEn: 'Cleaning Service',
desc: '填写清洁需求或预约时间后提交工单。',
descEn: 'Submit your cleaning request or appointment time.'
}
}
function pickReadableValue(...values) {
for (let index = 0; index < values.length; index += 1) {
const text = String(values[index] || '').trim()
if (text) return text
}
return ''
}
function isOpaqueCodeValue(value = '') {
const text = String(value || '').trim()
if (!text) {
return false
}
return /^[A-Z0-9-]{12,}$/i.test(text) && !/[\u4e00-\u9fa5\s]/.test(text)
}
function pickReadableLabel(...values) {
for (let index = 0; index < values.length; index += 1) {
const text = String(values[index] || '').trim()
if (!text || isOpaqueCodeValue(text)) {
continue
}
return text
}
return ''
}
function safeJsonParse(value, fallback = null) {
if (typeof value !== 'string') {
return value || fallback
}
try {
return JSON.parse(value)
} catch (error) {
return fallback
}
}
function normalizePageResponse(response) {
const payload = response && response.data !== undefined ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
function normalizeListResponse(response) {
const payload = normalizePageResponse(response)
if (Array.isArray(payload)) return payload
if (Array.isArray(payload.data)) return payload.data
if (payload.data && Array.isArray(payload.data.data)) return payload.data.data
return []
}
export default {
name: 'TicketWorkbenchPage',
components: {
RoomTypeSelector
},
data() {
return {
serviceType: 'TicketRepairApply',
activeTab: 'submit',
scrollTop: 0,
loadingBase: false,
submitting: false,
historyLoading: false,
hasNext: false,
historyPageIndex: 1,
historyPageSize: 10,
historyList: [],
searchKeyword: '',
selectedStatus: '',
showFlowPopup: false,
flowLoading: false,
flowList: [],
currentFlowOrder: null,
currentFlowTitle: '',
transitionOptions: [],
transitActionMode: false,
transitActionOption: null,
transitActionFlow: null,
transitActionFlowNo: '',
transitActionTicket: null,
transitAssigneeGroups: [],
transitSelectedAssigneeType: '',
transitSelectedAssigneeValue: '',
transitSelectedAssigneeName: '',
transitAssigneeCode: '',
fixOnholdMode: false,
fixOnholdTicket: null,
fixOnholdFlowNo: '',
fixOnholdInfoMap: {},
fixOnholdFields: [],
currentOrderDetail: {},
uploadInstanceKey: 0,
form: {
content: '',
remark: '',
checkOutDate: '',
checkOutTimePart: '',
renewalDate: '',
renewalTimePart: '',
cleaningDate: '',
cleaningTimePart: '',
photos: [],
attachments: []
},
switchForm: {
areaCode: '',
roomTypeCode: ''
}
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
pageConfig() {
return SERVICE_CONFIGS[this.serviceType] || SERVICE_CONFIGS.TicketRepairApply
},
pageTitle() {
return this.language === 'en'
? (this.pageConfig.titleEn || this.pageConfig.title)
: this.pageConfig.title
},
pageDesc() {
return this.language === 'en'
? (this.pageConfig.descEn || this.pageConfig.desc)
: this.pageConfig.desc
},
navBarBackgroundColor() {
if (this.scrollTop < 40) return 'rgba(255, 255, 255, 0)'
return 'rgba(255, 255, 255, 0.96)'
},
navBarBackStyle() {
return {
transition: 'all 0.2s ease',
background: this.scrollTop < 40 ? 'rgba(255, 255, 255, 0.82)' : 'rgba(255, 255, 255, 0.96)',
border: '1rpx solid rgba(43, 142, 255, 0.08)',
boxShadow: '0 10rpx 28rpx rgba(31, 76, 145, 0.10)',
backdropFilter: 'blur(18rpx)',
borderRadius: '20rpx',
width: '72rpx',
height: '72rpx',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '12rpx',
color: '#1A2233'
}
},
navTitleOpacity() {
return Math.min(Math.max((this.scrollTop - 40) / 50, 0), 1)
},
isRepairService() {
return this.serviceType === 'TicketRepairApply'
},
isChangeService() {
return this.serviceType === 'TicketChangeApply'
},
isCheckOutService() {
return this.serviceType === 'TicketCheckOutApply'
},
isRenewalService() {
return this.serviceType === 'TicketRenewalApply'
},
isCleaningService() {
return this.serviceType === 'TicketCleaningApply'
},
currentRoomText() {
return pickReadableValue(
this.currentOrderDetail.roomNo,
this.currentOrderDetail.roomLabel,
this.currentOrderDetail.roomName,
this.currentOrderDetail.roomCode
) || '--'
},
currentAreaText() {
return pickReadableLabel(
this.currentOrderDetail.areaText,
this.currentOrderDetail.areaName,
this.currentOrderDetail.areaLabel,
this.currentOrderDetail.campusName
)
},
currentLeaseText() {
const start = String(this.currentOrderDetail.startDate || this.currentOrderDetail.start || '').slice(0, 10)
const end = String(this.currentOrderDetail.endDate || this.currentOrderDetail.end || '').slice(0, 10)
return [start, end].filter(Boolean).join(' - ')
},
currentRoomTypeText() {
return pickReadableValue(this.currentOrderDetail.roomTypeName, this.currentOrderDetail.titleZh, this.currentOrderDetail.titleEn)
},
currentOrderStatusText() {
const status = Number(this.currentOrderDetail.orderStatus)
if (Number.isNaN(status)) return this.$tr('当前入住订单')
if ([0, 1, 2].includes(status)) return this.$tr('待处理')
if ([3, 4, 7].includes(status)) return this.$tr('处理中')
if ([5, 6].includes(status)) return this.$tr('已解决')
return this.$tr('已结束')
},
contentFieldLabel() {
if (this.isChangeService) return this.language === 'en' ? 'Change Reason' : '调宿说明'
if (this.isCheckOutService) return this.language === 'en' ? 'Checkout Reason' : '退宿原因'
if (this.isRenewalService) return this.language === 'en' ? 'Renewal Note' : '续住说明'
if (this.isCleaningService) return this.language === 'en' ? 'Cleaning Request' : '清洁说明'
if (this.serviceType === 'TicketWaterDeliveryApply') return this.language === 'en' ? 'Water Delivery Note' : '送水说明'
return this.language === 'en' ? 'Repair / Suggestion Detail' : '申请内容'
},
contentPlaceholder() {
if (this.isChangeService) return this.language === 'en' ? 'Describe the reason and expected arrangement' : '请填写调宿原因和期望安排'
if (this.isCheckOutService) return this.language === 'en' ? 'Describe the reason for checkout' : '请填写退宿原因'
if (this.isRenewalService) return this.language === 'en' ? 'Describe the renewal request' : '请填写续住诉求'
if (this.isCleaningService) return this.language === 'en' ? 'Describe the cleaning range or special needs' : '请填写清洁范围或重点需求'
if (this.serviceType === 'TicketWaterDeliveryApply') return this.language === 'en' ? 'Describe bottle count and delivery note' : '请填写桶数、送达时段等说明'
if (this.isRepairService) return this.language === 'en' ? 'Describe the issue and location' : '请填写报修位置和问题描述'
return this.language === 'en' ? 'Describe your suggestion or request' : '请尽量详细描述您的意见或诉求'
},
uploadAction() {
return `${String(API_BASE_URL || '').replace(/\/$/, '')}/file/common/upload`
},
uploadHeaders() {
const token = getAuthToken()
return {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Requested-Language': this.language === 'en' ? 'en' : 'zh'
}
},
uploadFormData() {
return {
ProviderType: 'WechatMiniProgram'
}
},
uploadTipText() {
return this.isRepairService ? this.$tr('支持上传 JPG、PNG 等图片,单张不超过 5MB,最多 6 张。报修至少上传 1 张图片。') : this.$tr('支持上传 JPG、PNG 等图片,单张不超过 5MB,最多 6 张。')
},
submitButtonText() {
if (this.fixOnholdMode) {
return this.language === 'en' ? 'Submit Supplement' : '提交补充信息'
}
if (this.transitActionMode) {
return this.transitActionOption && this.transitActionOption.nextStatusDesc
? this.transitActionOption.nextStatusDesc
: this.$tr('继续处理')
}
return this.$tr('提交工单')
},
submitSectionTitle() {
if (this.fixOnholdMode) return this.language === 'en' ? 'Supplement Information' : '补充工单信息'
if (this.transitActionMode) return this.language === 'en' ? 'Continue Current Ticket' : '继续处理当前工单'
return this.pageTitle
},
submitSectionDesc() {
if (this.fixOnholdMode) return this.$tr('当前工单处于挂起状态,补充信息后会继续流转。')
if (this.transitActionMode) return this.$tr('当前工单正在处理中,提交后会继续流转。')
return this.$tr('以下内容将通过工单接口提交并进入后续流转。')
},
modeBannerTitle() {
if (this.fixOnholdMode) return this.language === 'en' ? 'Supplement Required' : '当前工单需要补充信息'
if (this.transitActionMode) return this.language === 'en' ? 'Ticket In Progress' : '当前工单正在继续处理'
return ''
},
modeBannerText() {
if (this.fixOnholdMode) {
return this.fixOnholdTicket && this.fixOnholdTicket.no
? `${this.fixOnholdTicket.no} ${this.language === 'en' ? 'needs more information before continuing.' : '需要补充信息后继续流转。'}`
: this.$tr('当前工单处于挂起状态,补充信息后会继续流转。')
}
if (this.transitActionMode) {
return this.transitActionOption && this.transitActionOption.nextStatusDesc
? `${this.language === 'en' ? 'Current action:' : '当前操作:'}${this.transitActionOption.nextStatusDesc}`
: this.$tr('当前工单正在处理中,提交后会继续流转。')
}
return ''
},
statusCardClass() {
return this.fixOnholdMode ? 'status-card--warning' : 'status-card--info'
},
submitFooterTip() {
if (this.fixOnholdMode) return this.language === 'en' ? 'Supplement the required information and continue the ticket.' : '补充所需信息后会继续流转当前工单'
if (this.transitActionMode) return this.language === 'en' ? 'After submission, the ticket will move to the next step.' : '提交后工单会流转到下一处理节点'
return this.language === 'en' ? 'You can check the handling progress in history after submission.' : '提交后可在记录查询中查看处理进度'
},
historyStatusOptions() {
return [
{ value: '', label: this.$tr('全部') },
{ value: '0', label: this.$tr('待处理') },
{ value: '3', label: this.$tr('处理中') },
{ value: '5', label: this.$tr('已解决') },
{ value: '9', label: this.$tr('已结束') }
]
},
transitAssigneePickerRange() {
return this.transitAssigneeGroups.reduce((result, group) => {
const list = Array.isArray(group.items) ? group.items : []
list.forEach((item) => {
result.push({
assigneeType: group.assigneeType,
assigneeValue: item.value,
assigneeName: item.name
})
})
return result
}, [])
},
transitAssigneePickerLabels() {
return this.transitAssigneePickerRange.map(item => item.assigneeName)
},
showTransitAssigneeSelector() {
return this.transitActionMode
&& this.resolveActionCommand(this.transitActionOption) === 'transfer'
&& this.transitAssigneePickerRange.length > 0
}
},
onLoad(options) {
const serviceType = String(options && options.serviceType || '').trim()
if (SERVICE_CONFIGS[serviceType]) {
this.serviceType = serviceType
}
this.resetForm()
this.refreshContext()
},
onShow() {
this.refreshContext()
},
methods: {
handleScroll(e) {
this.scrollTop = e.detail.scrollTop
},
switchTab(tab) {
this.activeTab = tab
if (tab === 'history') {
this.refreshHistory()
}
},
refreshContext() {
const session = getSessionSnapshot()
const storeState = (this.$store && this.$store.state) || {}
const currentOrder = storeState.vuex_current_order || {}
const roomData = session.myRoomData || {}
this.currentOrderDetail = {
...roomData,
...currentOrder
}
this.applyDefaultSchedule()
const orderNo = this.getSubmitOrderNo()
if (!orderNo) {
return
}
orderSearchedDetail(orderNo)
.then((response) => {
const detail = normalizeBookingDetail(parseOrderDetailResponse(response))
if (detail) {
this.currentOrderDetail = {
...this.currentOrderDetail,
...detail
}
}
})
.catch(() => {
uni.showToast({ title: this.$tr('入住信息加载失败'), icon: 'none' })
})
},
resetForm() {
this.uploadInstanceKey += 1
this.form = {
content: '',
remark: '',
checkOutDate: '',
checkOutTimePart: '',
renewalDate: '',
renewalTimePart: '',
cleaningDate: '',
cleaningTimePart: '',
photos: [],
attachments: []
}
this.switchForm = {
areaCode: '',
roomTypeCode: ''
}
},
getCurrentUserName() {
const user = (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
return pickReadableValue(user.name, user.realName, user.userName, user.account) || (this.language === 'en' ? 'User' : '用户')
},
getSubmitOrderNo() {
return String(this.currentOrderDetail.orderNo || '').trim()
},
getSubmitRoomCode() {
return String(this.currentOrderDetail.roomCode || '').trim()
},
getSubmitSlotCode() {
return String(this.currentOrderDetail.slotCode || this.currentOrderDetail.slot || '').trim()
},
getDefaultExpectedHandleDateTime() {
const target = new Date()
target.setDate(target.getDate() + 1)
target.setSeconds(0, 0)
return target
},
getDefaultExpectedDeadlineDateTime(startDate) {
const target = new Date(startDate || this.getDefaultExpectedHandleDateTime())
target.setFullYear(target.getFullYear() + 10)
return target
},
formatDateToYmd(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
},
formatHourMinute(date) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
},
applyDefaultSchedule() {
const start = this.getDefaultExpectedHandleDateTime()
const defaultTime = this.formatHourMinute(start)
if (this.isCheckOutService && !this.form.checkOutDate) {
this.form.checkOutDate = this.formatDateToYmd(start)
this.form.checkOutTimePart = defaultTime
}
if (this.isRenewalService && !this.form.renewalDate) {
this.form.renewalDate = this.formatDateToYmd(start)
this.form.renewalTimePart = defaultTime
}
if (this.isCleaningService && !this.form.cleaningDate) {
this.form.cleaningDate = this.formatDateToYmd(start)
this.form.cleaningTimePart = defaultTime
}
},
onDateChange(field, event) {
this.form[field] = event && event.detail ? event.detail.value : ''
},
onTimeChange(field, event) {
this.form[field] = event && event.detail ? event.detail.value : ''
},
handleSwitchRoomTypeChange({ area, roomType }) {
this.switchForm.areaCode = area && area.code ? area.code : ''
this.switchForm.roomTypeCode = roomType && roomType.code ? roomType.code : ''
},
buildDateTimeString(dateText, timeText) {
const date = String(dateText || '').trim()
const time = String(timeText || '').trim()
if (!date || !time) return ''
return `${date} ${time}:00`
},
resolveCheckOutTime() {
return this.buildDateTimeString(this.form.checkOutDate, this.form.checkOutTimePart) || null
},
resolveRenewalTime() {
return this.buildDateTimeString(this.form.renewalDate, this.form.renewalTimePart) || null
},
resolveCleaningTime() {
return this.buildDateTimeString(this.form.cleaningDate, this.form.cleaningTimePart) || null
},
buildAutoGeneratedTitle() {
const now = new Date()
const dateText = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
return `${this.getCurrentUserName()}-${this.pageTitle}-${dateText}`
},
getSubmitValidationMessage() {
if (this.fixOnholdMode && !this.fixOnholdFlowNo) {
return this.$tr('工单提交失败')
}
if (this.transitActionMode && !this.transitActionFlowNo) {
return this.$tr('工单提交失败')
}
if (!this.fixOnholdMode && !this.transitActionMode && !this.getSubmitOrderNo() && !this.getSubmitRoomCode()) {
return this.$tr('当前缺少入住订单或房间信息,暂无法提交工单')
}
if (this.isRepairService && !(Array.isArray(this.form.attachments) && this.form.attachments.length)) {
return this.$tr('请上传报修附件图片')
}
if (this.isChangeService && !this.switchForm.roomTypeCode) {
return this.$tr('请选择意向房型')
}
if (this.isCheckOutService && !this.resolveCheckOutTime()) {
return this.$tr('请选择退宿时间')
}
if (this.isCheckOutService && !String(this.form.content || '').trim()) {
return this.$tr('请填写退宿原因')
}
if (this.showTransitAssigneeSelector && !this.transitSelectedAssigneeValue) {
return this.$tr('请选择处理目标')
}
return ''
},
resolveSubmitApi() {
if (this.isRepairService) return ticketOrderSubmitRepair
if (this.serviceType === 'TicketSuggestionApply') return ticketOrderSubmitSuggestion
if (this.isChangeService) return ticketOrderSubmitChangeroom
if (this.isCheckOutService) return ticketOrderSubmitCheckout
if (this.isRenewalService) return ticketOrderSubmitRenewal
if (this.serviceType === 'TicketWaterDeliveryApply') return ticketOrderSubmitWaterdelivery
if (this.isCleaningService) return ticketOrderSubmitCleaning
return ticketOrderSubmitSuggestion
},
buildAttachmentPayload() {
return (Array.isArray(this.form.attachments) ? this.form.attachments : []).map(item => ({
mediaType: item.mediaType || IMAGE_MEDIA_TYPE,
resourceUrl: item.rawResourceUrl || item.resourceUrl || item.previewUrl || ''
})).filter(item => item.resourceUrl)
},
buildCommonPayload(linkageMode = 'auto') {
const orderNo = this.getSubmitOrderNo()
const roomCode = this.getSubmitRoomCode()
const payload = {
orderNo: null,
roomCode: null,
slotCode: this.getSubmitSlotCode() || null,
title: this.buildAutoGeneratedTitle(),
expectedHandleTime: this.buildDateTimeString(this.formatDateToYmd(this.getDefaultExpectedHandleDateTime()), '09:00'),
expectedDeadlineTime: this.buildDateTimeString(this.formatDateToYmd(this.getDefaultExpectedDeadlineDateTime(this.getDefaultExpectedHandleDateTime())), '09:00'),
content: String(this.form.content || '').trim(),
remark: String(this.form.remark || '').trim() || null,
attachments: this.buildAttachmentPayload()
}
if (linkageMode === 'both' || (linkageMode === 'auto' && orderNo && roomCode)) {
payload.orderNo = orderNo || null
payload.roomCode = roomCode || null
} else if (linkageMode === 'order' || (linkageMode === 'auto' && orderNo)) {
payload.orderNo = orderNo || null
} else {
payload.roomCode = roomCode || null
}
return payload
},
buildSubmitPayload(linkageMode = 'auto') {
const payload = this.buildCommonPayload(linkageMode)
if (this.isCheckOutService) {
const checkOutTime = this.resolveCheckOutTime()
const reason = String(this.form.content || '').trim() || null
payload.checkOutTime = checkOutTime
payload.CheckOutTime = checkOutTime
payload.reason = reason
payload.Reason = reason
payload.bizInfos = {
CheckOutTime: checkOutTime || '',
Reason: reason || '',
Remark: String(this.form.remark || '').trim() || ''
}
}
if (this.isRenewalService) {
const renewalTime = this.resolveRenewalTime()
if (renewalTime) {
payload.newExpectedCheckOutTime = renewalTime
payload.NewExpectedCheckOutTime = renewalTime
payload.bizInfos = {
...(payload.bizInfos || {}),
NewExpectedCheckOutTime: renewalTime
}
}
}
if (this.isCleaningService) {
const serviceDate = this.resolveCleaningTime()
if (serviceDate) {
payload.serviceDate = serviceDate
payload.ServiceDate = serviceDate
payload.bizInfos = {
...(payload.bizInfos || {}),
ServiceDate: serviceDate
}
}
}
if (this.isChangeService) {
payload.areaCode = this.switchForm.areaCode || null
payload.roomTypeCode = this.switchForm.roomTypeCode || null
}
return payload
},
getSubmitErrorText(error) {
const data = error && (error.data || error.msg || error.message)
if (Array.isArray(data) && data.length) {
return data[0].msg || data[0].message || ''
}
if (data && Array.isArray(data.data) && data.data.length) {
return data.data[0].msg || data.data[0].message || ''
}
if (data && typeof data.msg === 'string') {
return data.msg
}
return typeof data === 'string' ? data : ''
},
resolveCompatibilityFallbackPayload(error, currentPayload) {
const message = String(this.getSubmitErrorText(error) || '').toLowerCase()
const roomCode = this.getSubmitRoomCode()
const orderNo = this.getSubmitOrderNo()
if (orderNo && roomCode && (!currentPayload.orderNo || !currentPayload.roomCode) && /(roomcode|room code|房间|orderno|order no|订单)/.test(message)) {
return this.buildSubmitPayload('both')
}
if (roomCode && !currentPayload.roomCode && /(roomcode|room code|房间)/.test(message)) {
return this.buildSubmitPayload('room')
}
if (orderNo && !currentPayload.orderNo && /(orderno|order no|订单)/.test(message)) {
return this.buildSubmitPayload('order')
}
return null
},
submitWithCompatibilityFallback(api, payload) {
return api(payload)
.catch((error) => {
const fallbackPayload = this.resolveCompatibilityFallbackPayload(error, payload)
if (!fallbackPayload) {
return Promise.reject(error)
}
return api(fallbackPayload)
})
},
buildTransitActionFlowBizInfos() {
const infos = this.transitActionFlow && this.transitActionFlow.infos && typeof this.transitActionFlow.infos === 'object'
? this.transitActionFlow.infos
: {}
return Object.keys(infos).reduce((result, key) => {
const rawValue = infos[key] && typeof infos[key] === 'object' && infos[key].value !== undefined ? infos[key].value : infos[key]
if (rawValue !== undefined && rawValue !== null && rawValue !== '') {
result[key] = typeof rawValue === 'string' ? rawValue : String(rawValue)
}
return result
}, {})
},
buildTransitActionOperateBizInfos() {
const result = {}
if (this.isCheckOutService) {
result.CheckOutTime = this.resolveCheckOutTime() || null
result.Remark = String(this.form.remark || '').trim() || null
result.Reason = String(this.form.content || '').trim() || null
}
if (this.isRenewalService) {
result.NewExpectedCheckOutTime = this.resolveRenewalTime() || null
}
if (this.isCleaningService) {
result.ServiceDate = this.resolveCleaningTime() || null
}
if (this.isChangeService) {
result.AreaCode = this.switchForm.areaCode || null
result.RoomTypeCode = this.switchForm.roomTypeCode || null
}
return result
},
buildTransitActionPayload() {
const payload = {
flowNo: this.transitActionFlowNo,
nextStatus: Number(this.transitActionOption && this.transitActionOption.nextStatus),
content: String(this.form.content || '').trim(),
remark: String(this.form.remark || '').trim() || '',
bizInfos: {
...this.buildTransitActionFlowBizInfos(),
...this.buildTransitActionOperateBizInfos()
}
}
const assigneeType = String(this.transitSelectedAssigneeType || this.transitActionOption && this.transitActionOption.assigneeType || '').trim()
const assigneeValue = String(this.transitSelectedAssigneeValue || this.transitActionOption && this.transitActionOption.assigneeValue || '').trim()
if (assigneeType) {
payload.assigneeType = Number(assigneeType)
}
if (assigneeValue) {
payload.assigneeValue = assigneeValue
}
if (assigneeType === USER_ASSIGNEE_TYPE_KEY && assigneeValue) {
payload.userCode = this.transitAssigneeCode || assigneeValue
}
if (this.form.attachments.length) {
payload.attachments = this.buildAttachmentPayload()
}
return payload
},
extractFixInfoValue(infoMap, keys) {
const target = infoMap && typeof infoMap === 'object' ? infoMap : {}
for (let index = 0; index < keys.length; index += 1) {
const key = keys[index]
const source = target[key]
const value = source && typeof source === 'object' && source.value !== undefined ? source.value : source
if (value !== undefined && value !== null && value !== '') {
return value
}
}
return ''
},
buildFixOnholdBizInfos() {
const infoMap = this.fixOnholdInfoMap && typeof this.fixOnholdInfoMap === 'object' ? this.fixOnholdInfoMap : {}
const customFieldMap = this.fixOnholdFields.reduce((result, item) => {
if (item && item.key) {
result[item.key] = String(item.value || '').trim() || null
}
return result
}, {})
const valueMap = {
checkouttime: this.resolveCheckOutTime() || null,
newexpectedcheckouttime: this.resolveRenewalTime() || null,
servicedate: this.resolveCleaningTime() || null,
remark: String(this.form.remark || '').trim() || null,
content: String(this.form.content || '').trim() || null,
reason: String(this.form.content || '').trim() || null,
areacode: this.switchForm.areaCode || null,
roomtypecode: this.switchForm.roomTypeCode || null
}
return Object.keys(infoMap).reduce((result, key) => {
const normalizedKey = String(key || '').toLowerCase()
const original = infoMap[key] && typeof infoMap[key] === 'object' && infoMap[key].value !== undefined
? infoMap[key].value
: infoMap[key]
if (Object.prototype.hasOwnProperty.call(customFieldMap, key)) {
result[key] = customFieldMap[key]
} else if (Object.prototype.hasOwnProperty.call(valueMap, normalizedKey)) {
result[key] = valueMap[normalizedKey]
} else {
result[key] = original === undefined ? null : original
}
return result
}, {})
},
buildFixOnholdPayload() {
const payload = {
flowNo: this.fixOnholdFlowNo,
content: String(this.form.content || '').trim(),
remark: String(this.form.remark || '').trim() || '',
bizInfos: this.buildFixOnholdBizInfos()
}
if (this.form.attachments.length) {
payload.attachments = this.buildAttachmentPayload()
}
return payload
},
submitForm() {
if (this.submitting) return
const validationMessage = this.getSubmitValidationMessage()
if (validationMessage) {
uni.showToast({ title: validationMessage, icon: 'none' })
return
}
new Promise((resolve) => {
uni.showModal({
title: this.$tr('确认提交'),
content: this.$tr('确认提交当前工单吗?'),
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false)
})
})
.then((confirmed) => {
if (!confirmed) {
return Promise.reject({ __cancelled: true })
}
this.submitting = true
if (this.fixOnholdMode) {
return ticketOrderFixonhold(this.buildFixOnholdPayload(), this.language)
}
if (this.transitActionMode) {
return ticketOrderTransit(this.buildTransitActionPayload(), this.language)
}
const api = this.resolveSubmitApi()
return this.submitWithCompatibilityFallback(api, this.buildSubmitPayload())
})
.then(() => {
uni.showToast({ title: this.$tr('工单提交成功'), icon: 'success' })
this.cancelFixOnhold()
this.cancelTransitAction()
this.resetForm()
this.applyDefaultSchedule()
this.switchTab('history')
})
.catch((error) => {
if (error && error.__cancelled) {
return
}
uni.showToast({
title: this.getSubmitErrorText(error) || this.$tr('工单提交失败'),
icon: 'none'
})
})
.finally(() => {
this.submitting = false
})
},
refreshHistory() {
this.historyPageIndex = 1
this.fetchHistoryList(true)
},
loadMoreHistory() {
if (!this.hasNext || this.historyLoading) return
this.historyPageIndex += 1
this.fetchHistoryList(false)
},
changeStatusFilter(value) {
this.selectedStatus = value
this.refreshHistory()
},
fetchHistoryList(reset) {
this.historyLoading = true
const params = {
Type: this.serviceType,
PageIndex: this.historyPageIndex,
PageSize: this.historyPageSize
}
if (this.getSubmitOrderNo()) {
params.OrderNo = this.getSubmitOrderNo()
}
if (String(this.selectedStatus) !== '') {
params.Status = Number(this.selectedStatus)
}
if (String(this.searchKeyword || '').trim()) {
params.Search = String(this.searchKeyword || '').trim()
}
ticketOrderMyPage(params, this.language)
.then((response) => {
const payload = normalizePageResponse(response)
const list = Array.isArray(payload.data) ? payload.data : []
this.historyList = reset ? list : this.historyList.concat(list)
this.hasNext = !!payload.hasNext
})
.catch(() => {
if (reset) {
this.historyList = []
}
this.hasNext = false
uni.showToast({ title: this.$tr('工单记录加载失败'), icon: 'none' })
})
.finally(() => {
this.historyLoading = false
})
},
getHistoryTitle(item = {}) {
return pickReadableValue(item.title, item.subject, this.pageTitle)
},
getHistoryStatusText(item = {}) {
return pickReadableValue(item.currentStatusDesc, item.statusDesc, item.nextStatusDesc) || this.statusText(Number(item.currentStatus !== undefined ? item.currentStatus : item.status))
},
resolveHistoryStatusType(item = {}) {
const status = Number(item.currentStatus !== undefined ? item.currentStatus : item.status)
if ([0, 1, 2].includes(status)) return 'pending'
if ([3, 4, 7].includes(status)) return 'processing'
if ([5, 6].includes(status)) return 'resolved'
return 'closed'
},
statusText(status) {
if ([0, 1, 2].includes(status)) return this.$tr('待处理')
if ([3, 4, 7].includes(status)) return this.$tr('处理中')
if ([5, 6].includes(status)) return this.$tr('已解决')
return this.$tr('已结束')
},
openFlows(item) {
this.currentFlowOrder = item
this.currentFlowTitle = this.getHistoryTitle(item)
this.showFlowPopup = true
this.flowLoading = true
this.flowList = []
this.transitionOptions = []
ticketOrderFlows(item.no, this.language)
.then((response) => {
this.flowList = normalizeListResponse(response).sort((a, b) => new Date(a.createTime).getTime() - new Date(b.createTime).getTime())
return this.loadTransitionOptions(this.getCurrentFlowItem())
})
.catch(() => {
uni.showToast({ title: this.$tr('工单流转加载失败'), icon: 'none' })
})
.finally(() => {
this.flowLoading = false
})
},
loadTransitionOptions(flow) {
const targetFlow = flow || this.getCurrentFlowItem()
const flowNo = String(targetFlow && (targetFlow.flowNo || targetFlow.no || targetFlow.code) || '').trim()
if (!flowNo) {
this.transitionOptions = []
return Promise.resolve([])
}
return ticketOrderTransitionOptions(flowNo, this.language)
.then((response) => {
const list = normalizeListResponse(response)
this.transitionOptions = list.filter(item => Number(item && item.triggerMode) === 1 || String(item && item.triggerMode || '').toLowerCase() === 'manual')
return this.transitionOptions
})
.catch(() => {
this.transitionOptions = []
uni.showToast({ title: this.$tr('工单操作项加载失败'), icon: 'none' })
return []
})
},
getTicketCurrentFlowNo(item) {
return String(item && (item.currentFlowNo || item.flowNo || item.no) || '').trim()
},
findActiveFlowItem(list, item) {
const source = Array.isArray(list) ? list : []
if (!source.length) return null
const pendingItem = source.slice().reverse().find(flow => flow && flow.iNeedToHandle === true)
if (pendingItem) return pendingItem
const currentFlowNo = this.getTicketCurrentFlowNo(item)
if (currentFlowNo) {
const matched = source.find(flow => String(flow && (flow.flowNo || flow.no || flow.code) || '').trim() === currentFlowNo)
if (matched) return matched
}
return source[source.length - 1]
},
getCurrentFlowItem() {
return this.findActiveFlowItem(this.flowList, this.currentFlowOrder)
},
isCurrentFlowItem(index) {
const current = this.getCurrentFlowItem()
const currentFlowNo = String(current && (current.flowNo || current.no || current.code) || '').trim()
const target = this.flowList[index]
const targetFlowNo = String(target && (target.flowNo || target.no || target.code) || '').trim()
return !!currentFlowNo && currentFlowNo === targetFlowNo
},
getFlowStatusText(item) {
return pickReadableValue(item.statusDesc, item.currentStatusDesc, item.nextStatusDesc) || this.statusText(Number(item.status))
},
resolveActionCommand(item) {
const text = String(item && item.nextStatusDesc || '').trim()
if (text.includes('转交')) return 'transfer'
if (text.includes('挂起') || text.includes('等待')) return 'onhold'
if (text.includes('关闭')) return 'close'
if (text.includes('验证') || text.includes('核验')) return 'verify'
if (text.includes('解决')) return 'resolve'
if (text.includes('处理')) return 'inprogress'
const map = {
2: 'transfer',
3: 'inprogress',
4: 'onhold',
5: 'resolve',
6: 'verify',
8: 'cancel',
10: 'close'
}
return map[Number(item && item.nextStatus)] || 'transit'
},
startTransitAction(action, flow) {
this.showFlowPopup = false
this.resetForm()
this.applyDefaultSchedule()
this.fixOnholdMode = false
this.fixOnholdTicket = null
this.fixOnholdFlowNo = ''
this.fixOnholdInfoMap = {}
this.fixOnholdFields = []
this.transitActionMode = true
this.transitActionOption = action
this.transitActionFlow = flow
this.transitActionFlowNo = String(flow && (flow.flowNo || flow.no || flow.code) || '').trim()
this.transitActionTicket = this.currentFlowOrder ? { ...this.currentFlowOrder } : null
this.transitAssigneeGroups = []
this.transitSelectedAssigneeType = ''
this.transitSelectedAssigneeValue = ''
this.transitSelectedAssigneeName = ''
this.transitAssigneeCode = ''
if (this.resolveActionCommand(action) === 'transfer') {
this.loadTransitAssigneeGroups()
}
this.activeTab = 'submit'
},
canFixOnhold(item) {
return Number(item && item.currentStatus) === 4
},
extractFlowInfos(flow) {
return flow && flow.infos && typeof flow.infos === 'object' && !Array.isArray(flow.infos)
? flow.infos
: {}
},
normalizeFixOnholdFields(infoMap) {
const reservedKeys = ['remark', 'content', 'reason', 'checkouttime', 'newexpectedcheckouttime', 'servicedate', 'areacode', 'roomtypecode']
return Object.keys(infoMap || {}).reduce((result, key) => {
const normalizedKey = String(key || '').trim().toLowerCase()
if (!normalizedKey || reservedKeys.includes(normalizedKey)) {
return result
}
const source = infoMap[key]
const title = pickReadableValue(source && source.title, source && source.label, key)
result.push({
key,
title,
value: ''
})
return result
}, [])
},
applyTransitPrefill(flow) {
if (!flow) return
const infos = this.extractFlowInfos(flow)
const getInfoValue = (key) => {
const source = Object.keys(infos).find(item => String(item || '').toLowerCase() === String(key || '').toLowerCase())
const value = source ? infos[source] : null
return value && typeof value === 'object' && value.value !== undefined ? value.value : value
}
if (this.isChangeService) {
this.switchForm.areaCode = getInfoValue('AreaCode') || this.switchForm.areaCode
this.switchForm.roomTypeCode = getInfoValue('RoomTypeCode') || this.switchForm.roomTypeCode
}
if (this.isCheckOutService && getInfoValue('CheckOutTime')) {
const text = String(getInfoValue('CheckOutTime')).trim().replace('T', ' ')
const parts = text.split(' ')
this.form.checkOutDate = parts[0] || this.form.checkOutDate
this.form.checkOutTimePart = (parts[1] || '').slice(0, 5) || this.form.checkOutTimePart
}
if (this.isRenewalService && getInfoValue('NewExpectedCheckOutTime')) {
const text = String(getInfoValue('NewExpectedCheckOutTime')).trim().replace('T', ' ')
const parts = text.split(' ')
this.form.renewalDate = parts[0] || this.form.renewalDate
this.form.renewalTimePart = (parts[1] || '').slice(0, 5) || this.form.renewalTimePart
}
if (this.isCleaningService && getInfoValue('ServiceDate')) {
const text = String(getInfoValue('ServiceDate')).trim().replace('T', ' ')
const parts = text.split(' ')
this.form.cleaningDate = parts[0] || this.form.cleaningDate
this.form.cleaningTimePart = (parts[1] || '').slice(0, 5) || this.form.cleaningTimePart
}
if (!this.form.content && getInfoValue('Reason')) {
this.form.content = String(getInfoValue('Reason'))
}
if (!this.form.remark && getInfoValue('Remark')) {
this.form.remark = String(getInfoValue('Remark'))
}
},
startFixOnhold(item) {
if (!item || !item.no || !this.canFixOnhold(item)) return
this.loadingBase = true
ticketOrderFlows(item.no, this.language)
.then((response) => {
const flowList = normalizeListResponse(response)
const activeFlow = this.findActiveFlowItem(flowList, item)
this.resetForm()
this.applyDefaultSchedule()
this.fixOnholdMode = true
this.fixOnholdTicket = item
this.fixOnholdFlowNo = this.getTicketCurrentFlowNo(item) || String(activeFlow && (activeFlow.flowNo || activeFlow.no || activeFlow.code) || '').trim()
this.fixOnholdInfoMap = this.extractFlowInfos(activeFlow)
this.fixOnholdFields = this.normalizeFixOnholdFields(this.fixOnholdInfoMap)
this.transitActionMode = false
this.transitActionOption = null
this.transitActionFlow = null
this.transitActionFlowNo = ''
this.transitActionTicket = null
this.showFlowPopup = false
this.activeTab = 'submit'
this.applyTransitPrefill(activeFlow)
})
.finally(() => {
this.loadingBase = false
})
},
cancelFixOnhold() {
this.fixOnholdMode = false
this.fixOnholdTicket = null
this.fixOnholdFlowNo = ''
this.fixOnholdInfoMap = {}
this.fixOnholdFields = []
},
cancelTransitAction() {
this.transitActionMode = false
this.transitActionOption = null
this.transitActionFlow = null
this.transitActionFlowNo = ''
this.transitActionTicket = null
this.transitAssigneeGroups = []
this.transitSelectedAssigneeType = ''
this.transitSelectedAssigneeValue = ''
this.transitSelectedAssigneeName = ''
this.transitAssigneeCode = ''
},
normalizeTransitAssigneeGroups(response) {
return normalizeListResponse(response).map((group) => {
const items = Array.isArray(group && group.items) ? group.items : []
if (!items.length) return null
return {
assigneeType: String(group.assigneeType || '').trim(),
items: items.map(item => ({
value: String(item && item.value || item && item.name || '').trim(),
name: String(item && item.name || item && item.value || '').trim()
})).filter(item => item.value)
}
}).filter(Boolean)
},
loadTransitAssigneeGroups() {
return ticketFlowTransitionStepRuleSelectTypeItems({
flowNo: this.transitActionFlowNo,
nextStatus: this.transitActionOption && this.transitActionOption.nextStatus
}, this.language)
.then((response) => {
this.transitAssigneeGroups = this.normalizeTransitAssigneeGroups(response)
})
.catch(() => {
uni.showToast({ title: this.$tr('处理目标加载失败'), icon: 'none' })
})
},
onTransitAssigneeChange(event) {
const index = Number(event && event.detail ? event.detail.value : 0)
const target = this.transitAssigneePickerRange[index]
if (!target) return
this.transitSelectedAssigneeType = target.assigneeType
this.transitSelectedAssigneeValue = target.assigneeValue
this.transitSelectedAssigneeName = target.assigneeName
this.transitAssigneeCode = target.assigneeType === USER_ASSIGNEE_TYPE_KEY ? target.assigneeValue : ''
},
shouldUseTextarea(value) {
return String(value || '').length > 40
},
resolveUploadedFileUrl(uploadResult) {
return pickReadableValue(
uploadResult && uploadResult.data && uploadResult.data.url,
uploadResult && uploadResult.data && uploadResult.data.fileUrl,
uploadResult && uploadResult.data && uploadResult.data.path,
uploadResult && uploadResult.data,
uploadResult && uploadResult.url,
uploadResult && uploadResult.fileUrl,
uploadResult && uploadResult.path,
typeof uploadResult === 'string' ? uploadResult : ''
)
},
onUploadSuccess(data, index, list) {
const rawResourceUrl = this.resolveUploadedFileUrl(safeJsonParse(data, data))
if (!rawResourceUrl) {
return
}
this.form.photos = Array.isArray(list) ? list : this.form.photos
this.form.attachments.splice(index, 1, {
mediaType: IMAGE_MEDIA_TYPE,
rawResourceUrl,
resourceUrl: rawResourceUrl,
previewUrl: addFilePrefix(rawResourceUrl)
})
},
onPhotoListChange(list) {
this.form.photos = Array.isArray(list) ? list : []
},
onRemoveImage(index) {
this.form.attachments.splice(index, 1)
},
onUploadError() {
uni.showToast({ title: this.$tr('图片上传失败,请重试'), icon: 'none' })
},
onUploadOversize() {
uni.showToast({ title: this.$tr('图片不能超过 5MB'), icon: 'none' })
},
onUploadExceed() {
uni.showToast({ title: this.$tr('最多上传 6 张图片'), icon: 'none' })
},
getAttachmentPreview(attachment) {
return addFilePrefix(pickReadableValue(attachment && attachment.resourceUrl, attachment && attachment.url, attachment && attachment.path))
},
previewAttachment(attachments, index) {
const urls = (Array.isArray(attachments) ? attachments : []).map(item => this.getAttachmentPreview(item)).filter(Boolean)
if (!urls.length) return
uni.previewImage({
urls,
current: urls[index] || urls[0]
})
},
formatDateTime(value) {
const text = String(value || '').trim()
if (!text) return '--'
const date = new Date(text.replace('T', ' ').replace(/-/g, '/'))
if (Number.isNaN(date.getTime())) {
return text
}
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
}
}
</script>
<style scoped lang="scss">
.ticket-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.page-scroll {
height: 100vh;
}
.hero-header {
padding: 24rpx 8rpx 0;
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
background: rgba($brand-primary, 0.08);
border-radius: 999rpx;
color: $brand-primary;
font-size: 22rpx;
font-weight: 700;
}
.badge-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 10rpx;
}
.hero-title {
margin-top: 22rpx;
font-size: 52rpx;
line-height: 1.2;
color: #1A2233;
font-weight: 800;
}
.hero-desc {
margin-top: 16rpx;
color: #6A7A99;
font-size: 26rpx;
line-height: 1.6;
}
.segment-card,
.context-card,
.form-card,
.history-card,
.status-card,
.empty-panel {
background: #FFFFFF;
border-radius: 28rpx;
box-shadow: 0 12rpx 36rpx rgba(43, 142, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.8);
}
.segment-card {
display: flex;
padding: 8rpx;
}
.segment-item {
flex: 1;
height: 84rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #74829C;
font-weight: 600;
}
.segment-item--active {
background: linear-gradient(135deg, rgba($brand-primary, 0.12) 0%, rgba($brand-primary, 0.06) 100%);
color: $brand-primary;
}
.context-card,
.form-card,
.status-card,
.empty-panel {
padding: 30rpx;
}
.block-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.block-subtitle {
margin-top: 10rpx;
font-size: 24rpx;
color: #7C8AA5;
line-height: 1.6;
}
.context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.context-item {
padding: 22rpx;
border-radius: 22rpx;
background: #F7FAFF;
}
.context-label {
font-size: 22rpx;
color: #7A879F;
}
.context-value {
margin-top: 10rpx;
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
word-break: break-word;
}
.status-card--warning {
background: linear-gradient(135deg, rgba(255, 182, 84, 0.12) 0%, #FFFFFF 100%);
}
.status-card--info {
background: linear-gradient(135deg, rgba($brand-primary, 0.10) 0%, #FFFFFF 100%);
}
.status-card__title {
font-size: 30rpx;
font-weight: 700;
color: #1A2233;
}
.status-card__desc {
margin-top: 10rpx;
font-size: 24rpx;
color: #6A7A99;
line-height: 1.6;
}
.status-card__action {
margin-top: 18rpx;
display: inline-flex;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
font-size: 24rpx;
font-weight: 700;
}
.form-item {
position: relative;
}
.form-label {
margin-bottom: 16rpx;
font-size: 28rpx;
color: #1A2233;
font-weight: 700;
}
.form-textarea,
.form-input,
.form-picker {
width: 100%;
background: #F8FAFF;
border-radius: 22rpx;
border: 1rpx solid rgba($brand-primary, 0.08);
color: #1A2233;
}
.form-textarea {
min-height: 220rpx;
padding: 26rpx;
font-size: 28rpx;
line-height: 1.6;
}
.form-textarea--remark {
min-height: 160rpx;
}
.form-textarea--small {
min-height: 140rpx;
}
.form-input,
.form-picker {
min-height: 96rpx;
padding: 0 26rpx;
display: flex;
align-items: center;
font-size: 28rpx;
}
.picker-row {
display: flex;
gap: 16rpx;
}
.picker-row picker {
flex: 1;
}
.form-picker--time {
justify-content: center;
}
.field-counter {
margin-top: 12rpx;
text-align: right;
font-size: 22rpx;
color: #9AA6BC;
}
.required-mark {
color: #FF6B6B;
}
.supplement-item + .supplement-item {
margin-top: 18rpx;
}
.supplement-title {
margin-bottom: 12rpx;
font-size: 24rpx;
color: #4E5D78;
font-weight: 600;
}
.upload-trigger {
width: 200rpx;
height: 200rpx;
border-radius: 22rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.08) 0%, rgba($brand-primary, 0.03) 100%);
border: 1rpx dashed rgba($brand-primary, 0.26);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-trigger__icon {
font-size: 40rpx;
color: $brand-primary;
}
.upload-trigger__text {
margin-top: 12rpx;
font-size: 24rpx;
color: $brand-primary;
font-weight: 600;
}
.upload-tip {
margin-top: 16rpx;
font-size: 22rpx;
color: #7C8AA5;
line-height: 1.6;
}
.history-toolbar {
display: flex;
gap: 16rpx;
}
.search-box {
flex: 1;
height: 84rpx;
border-radius: 22rpx;
background: #FFFFFF;
box-shadow: 0 8rpx 24rpx rgba(43, 142, 255, 0.06);
display: flex;
align-items: center;
padding: 0 24rpx;
}
.search-box__icon {
color: #8B98AF;
font-size: 28rpx;
}
.search-box__input {
flex: 1;
margin-left: 14rpx;
font-size: 26rpx;
color: #1A2233;
}
.refresh-mini-btn {
padding: 0 26rpx;
min-width: 144rpx;
height: 84rpx;
border-radius: 22rpx;
background: #FFFFFF;
color: $brand-primary;
box-shadow: 0 8rpx 24rpx rgba(43, 142, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 700;
}
.filter-scroll {
white-space: nowrap;
}
.filter-row {
display: inline-flex;
}
.filter-chip {
margin-right: 16rpx;
padding: 14rpx 26rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.06);
color: #74829C;
font-size: 24rpx;
}
.filter-chip--active {
background: $brand-primary;
color: #FFFFFF;
}
.history-card {
padding: 28rpx;
}
.history-card__title {
font-size: 30rpx;
color: #1A2233;
font-weight: 700;
}
.history-card__code {
font-size: 22rpx;
color: #90A0B8;
}
.status-badge {
padding: 10rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.status-pending {
background: rgba(255, 184, 77, 0.14);
color: #B36A00;
}
.status-processing {
background: rgba($brand-primary, 0.12);
color: $brand-primary;
}
.status-resolved {
background: rgba(46, 204, 113, 0.14);
color: #169B55;
}
.status-closed {
background: rgba(148, 163, 184, 0.14);
color: #66758D;
}
.info-row + .info-row {
margin-top: 12rpx;
}
.info-label {
color: #8B98AF;
font-size: 24rpx;
}
.info-value {
max-width: 58%;
text-align: right;
color: #1A2233;
font-size: 24rpx;
}
.info-value--multiline {
text-align: right;
word-break: break-word;
}
.load-state {
padding: 18rpx 0 30rpx;
text-align: center;
font-size: 24rpx;
color: #8B98AF;
}
.empty-title {
font-size: 30rpx;
color: #1A2233;
font-weight: 700;
text-align: center;
}
.empty-desc {
font-size: 24rpx;
color: #7C8AA5;
text-align: center;
line-height: 1.6;
}
.floating-submit-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(248, 250, 255, 0) 0%, rgba(248, 250, 255, 0.98) 36%, rgba(248, 250, 255, 1) 100%);
}
.floating-submit-bar__inner {
background: rgba(255, 255, 255, 0.96);
border-radius: 28rpx;
padding: 22rpx;
box-shadow: 0 12rpx 36rpx rgba(43, 142, 255, 0.08);
}
.floating-submit-tip {
margin-bottom: 16rpx;
font-size: 22rpx;
color: #7C8AA5;
}
.submit-btn {
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-primary {
background: linear-gradient(135deg, rgba($brand-primary, 0.92) 0%, $brand-primary 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba($brand-primary, 0.24);
}
.submit-btn--disabled {
opacity: 0.7;
}
.flow-popup {
height: 100%;
background: #F7FAFF;
padding: 34rpx 28rpx 0;
}
.flow-popup__title {
text-align: center;
font-size: 32rpx;
color: #1A2233;
font-weight: 700;
}
.flow-popup__scroll {
height: calc(100% - 72rpx);
margin-top: 26rpx;
}
.flow-card {
background: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
border: 1rpx solid rgba($brand-primary, 0.06);
}
.flow-card--current {
border-color: rgba($brand-primary, 0.22);
box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.08);
}
.flow-card__status {
font-size: 28rpx;
font-weight: 700;
color: #1A2233;
}
.flow-card__time {
font-size: 22rpx;
color: #8B98AF;
}
.flow-card__tag {
padding: 8rpx 16rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.10);
color: $brand-primary;
font-size: 22rpx;
font-weight: 700;
}
.flow-card__content {
font-size: 24rpx;
color: #4E5D78;
line-height: 1.6;
}
.flow-attachments {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}
.flow-attachment-image {
width: 144rpx;
height: 144rpx;
border-radius: 18rpx;
background: #EDF2F8;
}
.flow-action-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.flow-action-btn {
padding: 16rpx 24rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.12) 0%, rgba($brand-primary, 0.06) 100%);
color: $brand-primary;
font-size: 24rpx;
font-weight: 700;
}
.flow-action-btn--ghost {
display: inline-flex;
}
</style>