Files
QSY-lvjuxcx/pkgService/ticket_workbench/ticket_workbench.vue
liangjian 445bd850f4 feat: 新增工单中心、房间巡查、失物招领等业务模块
- 新增工单中心(ticket_center)与工单工作台(ticket_workbench)页面
- 新增房间巡查(room_inspection)与失物招领发布(lost_found_publish)页面
- 预约看样流程新增房型/楼栋/楼层选择器组件
- 新增工具模块: booking-entry、privacy、reservation-flow
- api/index.js 补充楼层列表、看样提交等接口
- locales/messages.js 补充多语言文案
- manifest.json 开启微信小程序隐私检查
- 完善登录、注册、申请详情、账单等相关页面交互
2026-07-24 09:35:46 +08:00

2253 lines
77 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>{{ 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>