| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188 |
- <script setup lang="ts">
- import { computed, ref } from 'vue'
- import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
- import AppHeader from '@/components/AppHeader.vue'
- import SegmentTabs from '@/components/SegmentTabs.vue'
- import ActionFormDrawer from '@/components/ActionFormDrawer.vue'
- import PhotoPreview from '@/components/PhotoPreview.vue'
- import { useUserStore } from '@/stores/user'
- import {
- acceptWorkOrder, applyDelayWorkOrder, auditDelayWorkOrder, cancelWorkOrder, dispatchWorkOrder,
- getAssignableUsers, getMobileWorkOrderDetail, processWorkOrder, resolveAttachmentUrl, splitAttachments,
- submitVerifyWorkOrder, uploadWorkOrderFiles, verifyWorkOrder, workOrderLevelText, workOrderStatusText,
- type AssignableUser, type WorkOrder, type WorkOrderDetail, type WorkOrderLog,
- } from '@/services/workorders'
- interface ActionPayload {
- content?: string; files: string[]; handlerId?: number; verifyUser?: number; remarks?: string
- planFinishTime?: string; delayedDeadline?: string; delayedReason?: string; passed?: boolean; approved?: boolean
- }
- const steps = ['创建', '派单', '接单', '处置', '验收', '完成']
- const userStore = useUserStore()
- const orderId = ref<number>()
- const detail = ref<WorkOrderDetail>()
- const loading = ref(true)
- const activeTab = ref(0)
- const drawerOpen = ref(false)
- const activeAction = ref('')
- const submitting = ref(false)
- const handlers = ref<AssignableUser[]>([])
- const reviewers = ref<AssignableUser[]>([])
- const photoPreview = ref<InstanceType<typeof PhotoPreview>>()
- const order = computed<WorkOrder | undefined>(() => detail.value?.order)
- const currentUserId = computed(() => Number(userStore.profile?.userId || 0))
- const permissions = computed(() => userStore.permissions)
- const hasPerm = (permission: string) => permissions.value.includes('*:*:*') || permissions.value.includes(permission)
- const statusLabel = computed(() => order.value?.orderStatus === 3 && order.value?.delayApplyStatus === 1 ? '延期待审核' : workOrderStatusText(order.value?.orderStatus))
- const statusTone = computed(() => order.value?.orderStatus === 0 || order.value?.delayApplyStatus === 1 ? 'orange' : [3, 6].includes(Number(order.value?.orderStatus)) ? 'purple' : [4, 5].includes(Number(order.value?.orderStatus)) ? 'green' : order.value?.orderStatus === 7 ? 'gray' : '')
- const currentStep = computed(() => ({ 0: 2, 1: 3, 2: 4, 3: 4, 4: 5, 5: 6, 6: 4, 7: 4 }[Number(order.value?.orderStatus)] || 1))
- const isTerminated = computed(() => order.value?.orderStatus === 7)
- const beforeImages = computed(() => splitAttachments(order.value?.maintenanceBeforeImg).map(path => resolveAttachmentUrl(path)))
- const afterImages = computed(() => splitAttachments(order.value?.maintenanceAfterImg).map(path => resolveAttachmentUrl(path)))
- const actions = computed(() => {
- const result: string[] = []
- const item = order.value
- if (!item) return result
- const mine = item.receiveUser === currentUserId.value
- const myAudit = item.verifyUser === currentUserId.value
- const status = Number(item.orderStatus)
- if (hasPerm('ops:workOrder:cancel') && [0, 1].includes(status)) result.push('取消工单')
- if (hasPerm('ops:workOrder:dispatch') && status === 0) result.push('派单')
- if (hasPerm('ops:workOrder:process') && mine && status === 1) result.push('核查接单')
- if (hasPerm('ops:workOrder:process') && mine && [2, 6].includes(status)) result.push('工单处置')
- if (hasPerm('ops:workOrder:process') && mine && status === 3 && Number(item.delayApplyStatus || 0) === 0) result.push('申请延期')
- if (hasPerm('ops:workOrder:process') && mine && [3, 6].includes(status)) result.push('提交验收')
- if (hasPerm('ops:workOrder:audit') && myAudit && status === 3 && item.delayApplyStatus === 1) result.push('延期审核')
- if (hasPerm('ops:workOrder:audit') && myAudit && status === 4) result.push('验收审核')
- return result
- })
- const logGroups = computed(() => {
- const groups: Record<string, WorkOrderLog[]> = {}
- for (const log of detail.value?.logs || []) {
- const day = String(log.operTime || '').slice(0, 10) || '未知日期'
- ;(groups[day] ||= []).push(log)
- }
- return Object.keys(groups).sort((a, b) => b.localeCompare(a)).map(day => ({ day, items: groups[day] }))
- })
- onLoad(async options => {
- orderId.value = Number(options?.id)
- if (!orderId.value) { loading.value = false; uni.showToast({ title: '工单参数无效', icon: 'none' }); return }
- try {
- if (!userStore.profile) await userStore.fetchProfile()
- await loadDetail()
- } catch (error) {
- uni.showToast({ title: error instanceof Error ? error.message : '工单详情获取失败', icon: 'none' })
- } finally { loading.value = false }
- })
- onPullDownRefresh(async () => {
- try {
- await loadDetail()
- uni.showToast({ title: '刷新成功', icon: 'success' })
- } catch (error) {
- uni.showToast({ title: error instanceof Error ? error.message : '刷新失败', icon: 'none' })
- } finally {
- uni.stopPullDownRefresh()
- }
- })
- async function loadDetail() {
- if (orderId.value) detail.value = await getMobileWorkOrderDetail(orderId.value)
- }
- async function openAction(action: string) {
- activeAction.value = action
- if (action === '派单') {
- if (!orderId.value) return
- try { [handlers.value, reviewers.value] = await Promise.all([getAssignableUsers(orderId.value, 'handler'), getAssignableUsers(orderId.value, 'reviewer')]) }
- catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '候选人获取失败', icon: 'none' }); return }
- }
- drawerOpen.value = true
- }
- async function submitted(action: string, payload: ActionPayload) {
- if (!orderId.value || submitting.value) return
- submitting.value = true
- uni.showLoading({ title: payload.files.length ? '上传并提交...' : '正在提交...' })
- try {
- const paths = await uploadWorkOrderFiles(payload.files)
- const common = { content: payload.content || '', attachUrl: paths.join(',') }
- if (action === '派单') await dispatchWorkOrder(orderId.value, { handlerId: payload.handlerId, verifyUser: payload.verifyUser, remarks: payload.remarks, planFinishTime: payload.planFinishTime })
- else if (action === '核查接单') await acceptWorkOrder(orderId.value, common)
- else if (action === '工单处置') await processWorkOrder(orderId.value, common)
- else if (action === '提交验收') await submitVerifyWorkOrder(orderId.value, common)
- else if (action === '取消工单') await cancelWorkOrder(orderId.value, common)
- else if (action === '申请延期') await applyDelayWorkOrder(orderId.value, { delayedDeadline: payload.delayedDeadline, delayedReason: payload.delayedReason, attachUrl: paths.join(',') })
- else if (action === '延期审核') await auditDelayWorkOrder(orderId.value, { approved: payload.approved, auditOpinion: payload.content })
- else if (action === '验收审核') await verifyWorkOrder(orderId.value, { passed: payload.passed, content: payload.content, attachUrl: paths.join(',') })
- drawerOpen.value = false
- await loadDetail()
- uni.showToast({ title: '操作成功', icon: 'success' })
- } catch (error) {
- uni.showToast({ title: error instanceof Error ? error.message : '操作失败', icon: 'none' })
- } finally { uni.hideLoading(); submitting.value = false }
- }
- function preview(url: string, urls: string[]) { photoPreview.value?.open(url, urls) }
- function dayLabel(day: string) {
- const today = new Date().toISOString().slice(0, 10)
- return day === today ? '今天' : `${Number(day.slice(5, 7))}月${Number(day.slice(8, 10))}日`
- }
- function timeLabel(time?: string) { return time ? time.slice(11, 16) : '--:--' }
- </script>
- <template>
- <view class="page wod-page" :class="{ 'has-actions': actions.length }">
- <AppHeader title="工单详情" />
- <view v-if="loading" class="state-tip">正在加载...</view>
- <template v-else-if="order">
- <view class="overview card">
- <view class="topbar"><text class="order-no">{{ order.orderNo }}</text><text class="status-pill" :class="statusTone">{{ statusLabel }}</text></view>
- <view class="steps"><view v-for="(name,index) in steps" :key="name" class="step" :class="{ done:index + 1 < currentStep, active:index + 1 === currentStep, terminated:isTerminated && index + 1 === currentStep }"><view class="step-dot">{{ isTerminated && index + 1 === currentStep ? '×' : index + 1 < currentStep ? '✓' : index + 1 }}</view><text>{{ isTerminated && index + 1 === currentStep ? '已终止' : name }}</text></view></view>
- </view>
- <view class="detail-card card">
- <SegmentTabs v-model="activeTab" :items="['工单信息','处置流程']" />
- <view v-if="activeTab === 0" class="info-panel">
- <view class="info-section">
- <view class="section-heading">故障信息</view>
- <view class="grid">
- <view class="full description"><text>故障描述</text><text>{{ order.orderDesc || '—' }}</text></view>
- <view><text>优先级</text><text><i class="priority"></i>{{ workOrderLevelText(order.orderLevel) }}</text></view>
- <view><text>故障发生时间</text><text>{{ order.faultTime || '—' }}</text></view>
- <view><text>计划完成时间</text><text>{{ order.planFinishTime || '—' }}</text></view>
- </view>
- </view>
- <view class="info-section"><view class="section-heading">设备信息</view><view class="grid"><view><text>设备名称</text><text>{{ order.deviceName || (order.deviceId ? '—' : '未关联设备') }}</text></view><view><text>设备编码</text><text>{{ order.deviceCode || '—' }}</text></view><view><text>设备类型</text><text>{{ order.deviceTypeName || '—' }}</text></view><view class="full"><text>安装位置</text><text>{{ order.deviceLocation || '—' }}</text></view></view></view>
- <view class="info-section">
- <view class="section-heading">处置相关人员</view>
- <view class="grid">
- <view><text>处置人</text><text>{{ order.receiveUserName || '—' }}</text></view>
- <view><text>核验人</text><text>{{ order.verifyUserName || '—' }}</text></view>
- </view>
- </view>
- <view class="info-section"><view class="section-heading">附件</view><view class="attachments"><view><view class="attach-head"><text>维修前</text><text>{{ beforeImages.length }} 项</text></view><view class="photos"><image v-for="url in beforeImages" :key="url" :src="url" mode="aspectFill" @tap="preview(url,beforeImages)" /><text v-if="!beforeImages.length">暂无</text></view></view><view><view class="attach-head"><text>维修后</text><text>{{ afterImages.length }} 项</text></view><view class="photos"><image v-for="url in afterImages" :key="url" :src="url" mode="aspectFill" @tap="preview(url,afterImages)" /><text v-if="!afterImages.length">暂无</text></view></view></view></view>
- <view class="info-section"><view class="section-heading">延期申请</view><view v-if="order.delayApplyStatus" class="delay"><text class="status-pill" :class="order.delayApplyStatus === 2 ? 'green' : 'orange'">{{ ['','待延期审核','延期审核通过','延期审核驳回'][order.delayApplyStatus] }}</text><view><text>{{ order.delayedReason || '—' }}</text><text>延期截止:{{ order.delayedDeadline || '—' }}</text></view></view><view v-else class="empty">没有延期信息</view></view>
- </view>
- <view v-else class="log-panel"><view class="log-tip">按时间倒序分组 · 滑动即可回溯</view><view v-if="!logGroups.length" class="empty">暂无处置记录</view><template v-for="group in logGroups" :key="group.day"><view class="log-day">{{ dayLabel(group.day) }}</view><view v-for="(log, index) in group.items" :key="log.logId" class="log-item" :class="{ 'last-log': index === group.items.length - 1 }"><text class="time">{{ timeLabel(log.operTime) }}</text><view><text class="log-title">{{ log.operUserName || '系统' }} · {{ log.operTypeDesc || '操作' }}</text><text>{{ log.operDesc || '—' }}</text><view v-if="log.attachList?.length" class="log-images"><image v-for="path in log.attachList" :key="path" :src="resolveAttachmentUrl(path)" mode="aspectFill" @tap="preview(resolveAttachmentUrl(path),log.attachList!.map(item=>resolveAttachmentUrl(item)))" /></view></view></view></template></view>
- </view>
- <view v-if="actions.length" class="bottom-actions"><button v-for="action in actions" :key="action" :class="action === '取消工单' ? 'danger' : action === actions[actions.length - 1] ? 'primary' : 'default'" @tap="openAction(action)">{{ action }}</button></view>
- <ActionFormDrawer v-model="drawerOpen" :action="activeAction" domain="workorder" :handler-options="handlers" :reviewer-options="reviewers" :default-reviewer-id="currentUserId" :submitting="submitting" @submitted="submitted" />
- </template>
- <view v-else class="state-tip">工单不存在或无权查看</view>
- <PhotoPreview ref="photoPreview" />
- </view>
- </template>
- <style scoped lang="scss">
- .grid .description > text:last-child{font-weight:400;white-space:pre-wrap;overflow-wrap:anywhere}
- .wod-page{padding-bottom:24rpx}.wod-page.has-actions{padding-bottom:145rpx}.state-tip{padding:180rpx 30rpx;color:#8c99a8;text-align:center}.overview{margin:20rpx 24rpx;padding:25rpx}.topbar{display:flex;align-items:center;justify-content:space-between}.order-no{color:#253a52;font-size:29rpx;font-weight:700}.steps{display:flex;margin-top:30rpx}.step{position:relative;display:flex;flex:1;flex-direction:column;align-items:center;color:#a0aab6;font-size:20rpx}.step:not(:last-child)::after{content:'';position:absolute;top:18rpx;left:62%;z-index:0;width:76%;height:3rpx;background:#dfe5ec}.step-dot{position:relative;z-index:1;display:flex;align-items:center;justify-content:center;width:38rpx;height:38rpx;margin-bottom:10rpx;border:2rpx solid #d5dce5;border-radius:50%;background:#fff;font-size:20rpx}.step.done,.step.active{color:#08264c}.step.done::after{background:#7792ae}.step.done .step-dot,.step.active .step-dot{color:#fff;border-color:#08264c;background:#08264c}.step.terminated{color:#e25158}.step.terminated .step-dot{color:#fff;border-color:#e25158;background:#e25158}.detail-card{margin:0 24rpx;overflow:hidden}.info-section{padding:26rpx;border-bottom:1rpx solid #edf1f5}.section-heading{margin-bottom:20rpx;color:#263b52;font-size:28rpx;font-weight:700}.grid{display:grid;grid-template-columns:1fr 1fr;gap:23rpx 20rpx}.grid view{display:flex;flex-direction:column;min-width:0}.grid .full{grid-column:1/-1}.grid view>text:first-child{margin-bottom:9rpx;color:#919dac;font-size:22rpx}.grid view>text:last-child{color:#405269;font-size:25rpx;line-height:1.55;word-break:break-all}.priority{display:inline-block;width:13rpx;height:13rpx;margin-right:8rpx;border-radius:50%;background:#4dad7c}.attachments{display:grid;grid-template-columns:1fr 1fr;gap:18rpx}.attachments>view{overflow:hidden;border:1rpx solid #e1e7ee;border-radius:12rpx}.attach-head{display:flex;justify-content:space-between;padding:15rpx;color:#52647a;background:#f7f9fb;font-size:22rpx}.photos{display:flex;align-items:center;gap:8rpx;min-height:112rpx;padding:10rpx;color:#a4afbc;flex-wrap:wrap}.photos image{width:92rpx;height:92rpx;border-radius:8rpx}.delay{display:flex;align-items:center;gap:17rpx}.delay>view{display:flex;flex-direction:column;color:#46586d}.delay>view text:last-child{margin-top:7rpx;color:#929ead;font-size:22rpx}.empty{padding:18rpx;color:#99a4b2;text-align:center;background:#f7f9fb;border-radius:10rpx}.log-panel{padding:22rpx 25rpx}.log-tip{padding:13rpx 18rpx;color:#8290a1;border-radius:10rpx;background:#f4f7fa;font-size:22rpx}.log-day{margin:25rpx 0 10rpx;color:#8290a1;font-size:23rpx;font-weight:600}.log-item{display:grid;grid-template-columns:112rpx 1fr;gap:12rpx;padding:18rpx 0;border-bottom:1rpx solid #edf1f5;color:#65758a;font-size:23rpx;line-height:1.55}.time{position:relative;align-self:stretch;padding-right:30rpx;color:#8b98a8}.time::before{content:'';position:absolute;top:8rpx;right:14rpx;z-index:1;width:13rpx;height:13rpx;border:4rpx solid #fff;border-radius:50%;background:#3b5bdb;box-shadow:0 0 0 2rpx #9bb3d9;transform:translateX(50%)}.time::after{content:'';position:absolute;top:29rpx;right:14rpx;bottom:-19rpx;width:2rpx;background:#d8e1ec;transform:translateX(50%)}.last-log .time::after{display:none}.log-item>view{display:flex;flex-direction:column}.log-title{margin-bottom:6rpx;color:#33475e;font-size:25rpx;font-weight:600}.log-images{display:flex;gap:10rpx;margin-top:12rpx;flex-wrap:wrap}.log-images image{width:100rpx;height:100rpx;border-radius:8rpx}.bottom-actions{position:fixed;right:0;bottom:0;left:0;z-index:30;padding:16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));background:#fff;box-shadow:0 -5rpx 20rpx rgba(38,59,83,.08)}.bottom-actions{display:flex;gap:10rpx;overflow-x:auto}.bottom-actions button{flex:1;min-width:120rpx;height:78rpx;margin:0;padding:0 16rpx;border-radius:999rpx;font-size:23rpx;line-height:78rpx}.bottom-actions .primary{color:#fff;background:#08264c}.bottom-actions .default{color:#43576e;border:1rpx solid #d7e0ea;background:#fff}.bottom-actions .danger{color:#e05258;border:1rpx solid #efc8ca;background:#fff}
- .step.done::after{background:#3b5bdb}
- .step.done .step-dot,.step.active .step-dot{border-color:#3b5bdb;background:#3b5bdb}
- .log-item{border-bottom:0}
- .time::after{top:18rpx;bottom:-55rpx;z-index:0;background:repeating-linear-gradient(to bottom,#3b5bdb 0 6rpx,transparent 6rpx 12rpx)}
- </style>
|