- 新增工单中心(ticket_center)与工单工作台(ticket_workbench)页面 - 新增房间巡查(room_inspection)与失物招领发布(lost_found_publish)页面 - 预约看样流程新增房型/楼栋/楼层选择器组件 - 新增工具模块: booking-entry、privacy、reservation-flow - api/index.js 补充楼层列表、看样提交等接口 - locales/messages.js 补充多语言文案 - manifest.json 开启微信小程序隐私检查 - 完善登录、注册、申请详情、账单等相关页面交互
2253 lines
77 KiB
Vue
2253 lines
77 KiB
Vue
<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>{{ t('ticketKicker') }}</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')"
|
||
>
|
||
{{ t('submitTab') }}
|
||
</view>
|
||
<view
|
||
class="segment-item"
|
||
:class="{ 'segment-item--active': activeTab === 'history' }"
|
||
@click="switchTab('history')"
|
||
>
|
||
{{ t('historyTab') }}
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="activeTab === 'submit'" class="context-card tn-margin-bottom-lg">
|
||
<view class="block-title">{{ t('currentStayInfo') }}</view>
|
||
<view class="block-subtitle">{{ currentOrderStatusText }}</view>
|
||
<view class="context-grid tn-margin-top">
|
||
<view class="context-item">
|
||
<view class="context-label">{{ t('roomLabel') }}</view>
|
||
<view class="context-value">{{ currentRoomText }}</view>
|
||
</view>
|
||
<view v-if="currentAreaText" class="context-item">
|
||
<view class="context-label">{{ t('areaLabel') }}</view>
|
||
<view class="context-value">{{ currentAreaText }}</view>
|
||
</view>
|
||
<view v-if="currentLeaseText" class="context-item">
|
||
<view class="context-label">{{ t('leaseLabel') }}</view>
|
||
<view class="context-value">{{ currentLeaseText }}</view>
|
||
</view>
|
||
<view v-if="currentRoomTypeText" class="context-item">
|
||
<view class="context-label">{{ t('roomTypeLabel') }}</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">{{ t('cancelSupplement') }}</view>
|
||
<view v-else-if="transitActionMode" class="status-card__action" @click="cancelTransitAction">{{ t('cancelProcess') }}</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">{{ t('changeRoomTypeLabel') }}</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">{{ t('checkoutTimeLabel') }}</view>
|
||
<view class="picker-row">
|
||
<picker mode="date" :value="form.checkOutDate" @change="onDateChange('checkOutDate', $event)">
|
||
<view class="form-picker">{{ form.checkOutDate || t('selectDate') }}</view>
|
||
</picker>
|
||
<picker mode="time" :value="form.checkOutTimePart" @change="onTimeChange('checkOutTimePart', $event)">
|
||
<view class="form-picker form-picker--time">{{ form.checkOutTimePart || t('selectTime') }}</view>
|
||
</picker>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="isRenewalService" class="form-item tn-margin-top">
|
||
<view class="form-label">{{ t('renewalTimeLabel') }}</view>
|
||
<view class="picker-row">
|
||
<picker mode="date" :value="form.renewalDate" @change="onDateChange('renewalDate', $event)">
|
||
<view class="form-picker">{{ form.renewalDate || t('selectDate') }}</view>
|
||
</picker>
|
||
<picker mode="time" :value="form.renewalTimePart" @change="onTimeChange('renewalTimePart', $event)">
|
||
<view class="form-picker form-picker--time">{{ form.renewalTimePart || t('selectTime') }}</view>
|
||
</picker>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="isCleaningService" class="form-item tn-margin-top">
|
||
<view class="form-label">{{ t('cleaningTimeLabel') }}</view>
|
||
<view class="picker-row">
|
||
<picker mode="date" :value="form.cleaningDate" @change="onDateChange('cleaningDate', $event)">
|
||
<view class="form-picker">{{ form.cleaningDate || t('selectDate') }}</view>
|
||
</picker>
|
||
<picker mode="time" :value="form.cleaningTimePart" @change="onTimeChange('cleaningTimePart', $event)">
|
||
<view class="form-picker form-picker--time">{{ form.cleaningTimePart || t('selectTime') }}</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">{{ t('remarkLabel') }}</view>
|
||
<textarea
|
||
class="form-textarea form-textarea--remark"
|
||
:placeholder="t('remarkPlaceholder')"
|
||
:maxlength="300"
|
||
v-model="form.remark"
|
||
></textarea>
|
||
</view>
|
||
|
||
<view v-if="showTransitAssigneeSelector" class="form-item tn-margin-top">
|
||
<view class="form-label">{{ t('assigneeLabel') }}</view>
|
||
<picker mode="selector" :range="transitAssigneePickerLabels" @change="onTransitAssigneeChange">
|
||
<view class="form-picker">
|
||
{{ transitSelectedAssigneeName || t('selectAssignee') }}
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
|
||
<view v-if="fixOnholdMode && fixOnholdFields.length" class="form-item tn-margin-top">
|
||
<view class="form-label">{{ t('supplementLabel') }}</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="t('supplementPlaceholder', { label: item.title })"
|
||
></textarea>
|
||
<input
|
||
v-else
|
||
class="form-input"
|
||
type="text"
|
||
v-model="item.value"
|
||
:placeholder="t('supplementPlaceholder', { label: item.title })"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="form-item tn-margin-top">
|
||
<view class="form-label">{{ t('attachmentsLabel') }}<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=""
|
||
@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">{{ t('uploadImage') }}</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="t('searchPlaceholder')"
|
||
confirm-type="search"
|
||
@confirm="refreshHistory"
|
||
/>
|
||
</view>
|
||
<view class="refresh-mini-btn" @click="refreshHistory">
|
||
{{ historyLoading ? t('refreshing') : t('refresh') }}
|
||
</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">{{ t('submittedAtLabel') }}</text>
|
||
<text class="info-value">{{ formatDateTime(item.createTime) }}</text>
|
||
</view>
|
||
<view class="info-row tn-flex tn-flex-row-between">
|
||
<text class="info-label">{{ t('contentLabel') }}</text>
|
||
<text class="info-value info-value--multiline">{{ item.content || item.remark || '--' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="load-state">
|
||
<text v-if="historyLoading">{{ t('loadingMore') }}</text>
|
||
<text v-else-if="!hasNext">{{ t('noMore') }}</text>
|
||
<text v-else @click="loadMoreHistory">{{ t('loadMore') }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else-if="!historyLoading" class="empty-panel tn-padding-lg">
|
||
<view class="empty-title">{{ t('emptyHistoryTitle') }}</view>
|
||
<view class="empty-desc tn-margin-top-sm">{{ t('emptyHistoryDesc') }}</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 ? t('submitting') : 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">{{ t('currentFlowTag') }}</view>
|
||
</view>
|
||
|
||
<view class="flow-card__content tn-margin-top">{{ item.content || item.remark || t('noFlowContent') }}</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">{{ t('flowActionTitle') }}</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 || t('continueProcess') }}
|
||
</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)">
|
||
{{ t('supplementAction') }}
|
||
</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 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
|
||
},
|
||
i18n: {
|
||
zh: {
|
||
areaLabel: '所属区域',
|
||
assigneeLabel: '处理目标',
|
||
attachmentsLabel: '附件图片',
|
||
cancelProcess: '退出处理',
|
||
cancelSupplement: '取消补充',
|
||
changeRoomTypeLabel: '意向房型',
|
||
cleaningTimeLabel: '预约清洁时间',
|
||
contentLabel: '申请内容',
|
||
currentFlowTag: '当前节点',
|
||
currentStayInfo: '当前入住信息',
|
||
currentStayOrder: '当前入住订单',
|
||
emptyHistoryDesc: '当前类型还没有工单记录。',
|
||
emptyHistoryTitle: '暂无工单记录',
|
||
flowActionTitle: '可执行操作',
|
||
historyTab: '记录查询',
|
||
leaseLabel: '租期',
|
||
loadMore: '加载更多',
|
||
loadingMore: '加载中...',
|
||
loadOrderFailed: '入住信息加载失败',
|
||
noFlowContent: '暂无处理内容',
|
||
noMore: '没有更多了',
|
||
refresh: '刷新',
|
||
refreshing: '刷新中...',
|
||
remarkLabel: '补充说明',
|
||
remarkPlaceholder: '请输入补充说明,帮助工作人员更快处理',
|
||
roomLabel: '当前房间',
|
||
roomTypeLabel: '当前房型',
|
||
searchPlaceholder: '搜索工单标题或内容',
|
||
selectAssignee: '请选择处理目标',
|
||
selectDate: '请选择日期',
|
||
selectTime: '请选择时间',
|
||
submitSectionDefaultDesc: '以下内容将通过工单接口提交并进入后续流转。',
|
||
submitTab: '提交申请',
|
||
submitTicket: '提交工单',
|
||
submitting: '提交中...',
|
||
supplementAction: '补充信息',
|
||
supplementLabel: '补充业务信息',
|
||
supplementPlaceholder: '请输入{label}',
|
||
ticketKicker: 'WORK ORDER',
|
||
uploadImage: '上传图片',
|
||
uploadImageCountExceeded: '最多上传 6 张图片',
|
||
uploadImageFailed: '图片上传失败,请重试',
|
||
uploadImageOversize: '图片不能超过 5MB',
|
||
uploadTip: '支持上传 JPG、PNG 等图片,单张不超过 5MB,最多 6 张。',
|
||
uploadTipRepair: '支持上传 JPG、PNG 等图片,单张不超过 5MB,最多 6 张。报修至少上传 1 张图片。',
|
||
checkoutTimeLabel: '预计退宿时间',
|
||
renewalTimeLabel: '续住至',
|
||
submittedAtLabel: '提交时间',
|
||
submitSuccess: '工单提交成功',
|
||
submitFailed: '工单提交失败',
|
||
confirmSubmitTitle: '确认提交',
|
||
confirmSubmitContent: '确认提交当前工单吗?',
|
||
missingStayContext: '当前缺少入住订单或房间信息,暂无法提交工单',
|
||
repairAttachmentRequired: '请上传报修附件图片',
|
||
changeAreaRequired: '请选择意向房型',
|
||
checkoutTimeRequired: '请选择退宿时间',
|
||
checkoutReasonRequired: '请填写退宿原因',
|
||
loadHistoryFailed: '工单记录加载失败',
|
||
loadFlowsFailed: '工单流转加载失败',
|
||
loadActionsFailed: '工单操作项加载失败',
|
||
continueProcess: '继续处理',
|
||
loadAssigneeFailed: '处理目标加载失败',
|
||
submitSectionActionDesc: '当前工单正在处理中,提交后会继续流转。',
|
||
submitSectionSupplementDesc: '当前工单处于挂起状态,补充信息后会继续流转。',
|
||
currentStatusPending: '待处理',
|
||
currentStatusProcessing: '处理中',
|
||
currentStatusResolved: '已解决',
|
||
currentStatusClosed: '已结束',
|
||
allStatus: '全部',
|
||
pendingStatus: '待处理',
|
||
processingStatus: '处理中',
|
||
resolvedStatus: '已解决',
|
||
closedStatus: '已结束'
|
||
},
|
||
en: {
|
||
areaLabel: 'Area',
|
||
assigneeLabel: 'Assignee',
|
||
attachmentsLabel: 'Attachments',
|
||
cancelProcess: 'Cancel Action',
|
||
cancelSupplement: 'Cancel Supplement',
|
||
changeRoomTypeLabel: 'Preferred Room Type',
|
||
cleaningTimeLabel: 'Cleaning Time',
|
||
contentLabel: 'Content',
|
||
currentFlowTag: 'Current',
|
||
currentStayInfo: 'Current Stay',
|
||
currentStayOrder: 'Current Stay Order',
|
||
emptyHistoryDesc: 'No tickets of this type yet.',
|
||
emptyHistoryTitle: 'No Ticket Records',
|
||
flowActionTitle: 'Available Actions',
|
||
historyTab: 'History',
|
||
leaseLabel: 'Lease',
|
||
loadMore: 'Load More',
|
||
loadingMore: 'Loading...',
|
||
loadOrderFailed: 'Failed to load stay context',
|
||
noFlowContent: 'No handling content',
|
||
noMore: 'No more records',
|
||
refresh: 'Refresh',
|
||
refreshing: 'Refreshing...',
|
||
remarkLabel: 'Notes',
|
||
remarkPlaceholder: 'Enter notes to help the staff handle this ticket',
|
||
roomLabel: 'Room',
|
||
roomTypeLabel: 'Room Type',
|
||
searchPlaceholder: 'Search title or content',
|
||
selectAssignee: 'Select assignee',
|
||
selectDate: 'Select date',
|
||
selectTime: 'Select time',
|
||
submitSectionDefaultDesc: 'The form below will be submitted through the ticket APIs.',
|
||
submitTab: 'Submit',
|
||
submitTicket: 'Submit Ticket',
|
||
submitting: 'Submitting...',
|
||
supplementAction: 'Supplement',
|
||
supplementLabel: 'Supplementary Fields',
|
||
supplementPlaceholder: 'Please enter {label}',
|
||
ticketKicker: 'WORK ORDER',
|
||
uploadImage: 'Upload Image',
|
||
uploadImageCountExceeded: 'Up to 6 images',
|
||
uploadImageFailed: 'Failed to upload image',
|
||
uploadImageOversize: 'Image must be less than 5MB',
|
||
uploadTip: 'Upload JPG or PNG images, up to 6 images and 5MB each.',
|
||
uploadTipRepair: 'Upload JPG or PNG images, up to 6 images and 5MB each. Repair requires at least 1 image.',
|
||
checkoutTimeLabel: 'Checkout Time',
|
||
renewalTimeLabel: 'Renew Until',
|
||
submittedAtLabel: 'Submitted At',
|
||
submitSuccess: 'Ticket submitted',
|
||
submitFailed: 'Failed to submit ticket',
|
||
confirmSubmitTitle: 'Confirm',
|
||
confirmSubmitContent: 'Confirm submitting this ticket?',
|
||
missingStayContext: 'Missing current stay order or room information',
|
||
repairAttachmentRequired: 'Please upload repair images',
|
||
changeAreaRequired: 'Please select a room type',
|
||
checkoutTimeRequired: 'Please select checkout time',
|
||
checkoutReasonRequired: 'Please fill in the checkout reason',
|
||
loadHistoryFailed: 'Failed to load ticket history',
|
||
loadFlowsFailed: 'Failed to load ticket flows',
|
||
loadActionsFailed: 'Failed to load ticket actions',
|
||
continueProcess: 'Continue',
|
||
loadAssigneeFailed: 'Failed to load assignees',
|
||
submitSectionActionDesc: 'The ticket is in progress and will continue after submission.',
|
||
submitSectionSupplementDesc: 'The ticket is on hold and will continue after supplementing the information.',
|
||
currentStatusPending: 'Pending',
|
||
currentStatusProcessing: 'Processing',
|
||
currentStatusResolved: 'Resolved',
|
||
currentStatusClosed: 'Closed',
|
||
allStatus: 'All',
|
||
pendingStatus: 'Pending',
|
||
processingStatus: 'Processing',
|
||
resolvedStatus: 'Resolved',
|
||
closedStatus: 'Closed'
|
||
}
|
||
},
|
||
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() {
|
||
const isDarkBg = this.scrollTop < 40
|
||
return {
|
||
transition: 'all 0.2s ease',
|
||
background: isDarkBg ? 'rgba(0, 0, 0, 0.2)' : 'transparent',
|
||
borderRadius: '50%',
|
||
width: '64rpx',
|
||
height: '64rpx',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginLeft: '12rpx',
|
||
color: isDarkBg ? '#FFFFFF' : '#000000'
|
||
}
|
||
},
|
||
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 pickReadableValue(this.currentOrderDetail.areaName, this.currentOrderDetail.campusName, this.currentOrderDetail.areaCode)
|
||
},
|
||
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.t('currentStayOrder')
|
||
if ([0, 1, 2].includes(status)) return this.t('currentStatusPending')
|
||
if ([3, 4, 7].includes(status)) return this.t('currentStatusProcessing')
|
||
if ([5, 6].includes(status)) return this.t('currentStatusResolved')
|
||
return this.t('currentStatusClosed')
|
||
},
|
||
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.t('uploadTipRepair') : this.t('uploadTip')
|
||
},
|
||
submitButtonText() {
|
||
if (this.fixOnholdMode) {
|
||
return this.language === 'en' ? 'Submit Supplement' : '提交补充信息'
|
||
}
|
||
if (this.transitActionMode) {
|
||
return this.transitActionOption && this.transitActionOption.nextStatusDesc
|
||
? this.transitActionOption.nextStatusDesc
|
||
: this.t('continueProcess')
|
||
}
|
||
return this.t('submitTicket')
|
||
},
|
||
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.t('submitSectionSupplementDesc')
|
||
if (this.transitActionMode) return this.t('submitSectionActionDesc')
|
||
return this.t('submitSectionDefaultDesc')
|
||
},
|
||
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.t('submitSectionSupplementDesc')
|
||
}
|
||
if (this.transitActionMode) {
|
||
return this.transitActionOption && this.transitActionOption.nextStatusDesc
|
||
? `${this.language === 'en' ? 'Current action:' : '当前操作:'}${this.transitActionOption.nextStatusDesc}`
|
||
: this.t('submitSectionActionDesc')
|
||
}
|
||
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.t('allStatus') },
|
||
{ value: '0', label: this.t('pendingStatus') },
|
||
{ value: '3', label: this.t('processingStatus') },
|
||
{ value: '5', label: this.t('resolvedStatus') },
|
||
{ value: '9', label: this.t('closedStatus') }
|
||
]
|
||
},
|
||
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: {
|
||
t(key, params) {
|
||
return this.$translate(this.i18n, key, params)
|
||
},
|
||
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.t('loadOrderFailed'), 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.t('submitFailed')
|
||
}
|
||
if (this.transitActionMode && !this.transitActionFlowNo) {
|
||
return this.t('submitFailed')
|
||
}
|
||
if (!this.fixOnholdMode && !this.transitActionMode && !this.getSubmitOrderNo() && !this.getSubmitRoomCode()) {
|
||
return this.t('missingStayContext')
|
||
}
|
||
if (this.isRepairService && !(Array.isArray(this.form.attachments) && this.form.attachments.length)) {
|
||
return this.t('repairAttachmentRequired')
|
||
}
|
||
if (this.isChangeService && !this.switchForm.roomTypeCode) {
|
||
return this.t('changeAreaRequired')
|
||
}
|
||
if (this.isCheckOutService && !this.resolveCheckOutTime()) {
|
||
return this.t('checkoutTimeRequired')
|
||
}
|
||
if (this.isCheckOutService && !String(this.form.content || '').trim()) {
|
||
return this.t('checkoutReasonRequired')
|
||
}
|
||
if (this.showTransitAssigneeSelector && !this.transitSelectedAssigneeValue) {
|
||
return this.t('selectAssignee')
|
||
}
|
||
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.t('confirmSubmitTitle'),
|
||
content: this.t('confirmSubmitContent'),
|
||
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.t('submitSuccess'), 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.t('submitFailed'),
|
||
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.t('loadHistoryFailed'), 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.t('pendingStatus')
|
||
if ([3, 4, 7].includes(status)) return this.t('processingStatus')
|
||
if ([5, 6].includes(status)) return this.t('resolvedStatus')
|
||
return this.t('closedStatus')
|
||
},
|
||
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.t('loadFlowsFailed'), 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.t('loadActionsFailed'), 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.t('loadAssigneeFailed'), 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.t('uploadImageFailed'), icon: 'none' })
|
||
},
|
||
onUploadOversize() {
|
||
uni.showToast({ title: this.t('uploadImageOversize'), icon: 'none' })
|
||
},
|
||
onUploadExceed() {
|
||
uni.showToast({ title: this.t('uploadImageCountExceeded'), 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>
|