ActionFormDrawer.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. <script setup lang="ts">
  2. import { computed, ref, watch } from 'vue'
  3. import FormField from './FormField.vue'
  4. import UploadPanel from './UploadPanel.vue'
  5. import type { AssignableUser } from '@/services/workorders'
  6. export interface WorkOrderActionPayload {
  7. content?: string
  8. files: string[]
  9. handlerId?: number
  10. verifyUser?: number
  11. remarks?: string
  12. planFinishTime?: string
  13. delayedDeadline?: string
  14. delayedReason?: string
  15. passed?: boolean
  16. approved?: boolean
  17. }
  18. const props = withDefaults(defineProps<{ modelValue: boolean; action: string; domain: 'workorder' | 'warning'; handlerOptions?: AssignableUser[]; reviewerOptions?: AssignableUser[]; defaultReviewerId?: number; submitting?: boolean }>(), { handlerOptions: () => [], reviewerOptions: () => [], submitting: false })
  19. const emit = defineEmits<{ 'update:modelValue': [value: boolean]; submitted: [action: string, payload: WorkOrderActionPayload] }>()
  20. const text = ref('')
  21. const remark = ref('')
  22. const dateTime = ref('')
  23. const person = ref('')
  24. const reviewer = ref('')
  25. const handlerId = ref<number>()
  26. const reviewerId = ref<number>()
  27. const files = ref<string[]>([])
  28. const result = ref('通过')
  29. const disposer = ref('张伟')
  30. const checker = ref('赵敏')
  31. watch(() => props.action, reset)
  32. watch(() => props.modelValue, value => { if (value) reset() })
  33. function reset() {
  34. text.value = ''
  35. remark.value = ''
  36. dateTime.value = ''
  37. person.value = ''
  38. reviewer.value = ''
  39. handlerId.value = undefined
  40. reviewerId.value = undefined
  41. files.value = []
  42. result.value = '通过'
  43. if (props.action === '派单' && props.defaultReviewerId) {
  44. const currentUser = props.reviewerOptions.find(user => user.userId === props.defaultReviewerId)
  45. if (currentUser) {
  46. reviewerId.value = currentUser.userId
  47. reviewer.value = userLabel(currentUser)
  48. }
  49. }
  50. }
  51. const title = computed(() => {
  52. const map: Record<string, string> = { '取消工单':'取消工单','派单':'工单派单','核查接单':'核查接单','开始处理':props.domain === 'warning' ? '开始处置' : '工单处置','工单处置':'工单处置','申请延期':'申请延期','提交验收':'提交验收','验收审核':'工单验收','延期审核':'延期审核','发布':'发布预警','接收':'接收预警','提交处置':'提交处置','核查':'核查' }
  53. return map[props.action] || props.action
  54. })
  55. const needsUpload = computed(() => ['取消工单','核查接单','开始处理','工单处置','申请延期','提交验收','验收审核','提交处置','核查'].includes(props.action))
  56. function close() { emit('update:modelValue', false) }
  57. function userLabel(user: AssignableUser) { return user.deptName ? `${user.nickName}(${user.deptName})` : user.nickName }
  58. function pickUser(kind: 'handler' | 'reviewer') {
  59. const options = kind === 'handler' ? props.handlerOptions : props.reviewerOptions
  60. if (!options.length) { uni.showToast({ title: '暂无可选人员', icon: 'none' }); return }
  61. uni.showActionSheet({ itemList: options.map(userLabel), success:r => {
  62. const selected = options[r.tapIndex]
  63. if (kind === 'handler') { handlerId.value = selected.userId; person.value = userLabel(selected) }
  64. else { reviewerId.value = selected.userId; reviewer.value = userLabel(selected) }
  65. } })
  66. }
  67. function invalid(message: string) { uni.showToast({ title: message, icon: 'none' }); return false }
  68. function isFutureDateTime(value: string) {
  69. const timestamp = Date.parse(value.replace(' ', 'T'))
  70. return Number.isFinite(timestamp) && timestamp > Date.now()
  71. }
  72. function submit() {
  73. const content = text.value.trim()
  74. if (props.domain === 'workorder') {
  75. if (props.action === '派单' && (!handlerId.value || !reviewerId.value)) return invalid('请选择处置人和核验人')
  76. if (props.action === '派单' && dateTime.value && !isFutureDateTime(dateTime.value)) return invalid('计划完成时间必须晚于当前时间')
  77. if (['核查接单','工单处置','开始处理','取消工单','提交验收'].includes(props.action) && !content) return invalid('请填写操作内容')
  78. if (['核查接单','提交验收'].includes(props.action) && !files.value.length) return invalid('请上传现场佐证材料')
  79. if (props.action === '申请延期' && (!dateTime.value.trim() || !content)) return invalid('请填写延期时间和原因')
  80. if (props.action === '申请延期' && !isFutureDateTime(dateTime.value)) return invalid('延期截止时间必须晚于当前时间')
  81. }
  82. const payload: WorkOrderActionPayload = {
  83. content,
  84. files: files.value,
  85. handlerId: handlerId.value,
  86. verifyUser: reviewerId.value,
  87. remarks: remark.value.trim(),
  88. planFinishTime: dateTime.value.trim() || undefined,
  89. delayedDeadline: dateTime.value.trim() || undefined,
  90. delayedReason: content,
  91. passed: result.value === '通过',
  92. approved: result.value === '通过',
  93. }
  94. emit('submitted', props.action, payload)
  95. if (props.domain === 'warning') close()
  96. }
  97. </script>
  98. <template>
  99. <view v-if="modelValue" class="drawer-root">
  100. <view class="mask" @tap="close"></view>
  101. <view class="drawer">
  102. <view class="drawer-head"><text>{{ title }}</text><text class="close" @tap="close">×</text></view>
  103. <scroll-view class="drawer-body" scroll-y>
  104. <template v-if="action === '派单'">
  105. <FormField v-model="person" label="处置人" type="select" required @select="pickUser('handler')" />
  106. <FormField v-model="reviewer" label="核验人" type="select" required @select="pickUser('reviewer')" />
  107. <FormField v-model="remark" label="派单备注" type="textarea" placeholder="可填写故障要点、现场注意事项等派单说明" />
  108. <FormField v-model="dateTime" label="计划完成时间" type="datetime" future-only placeholder="请选择" />
  109. </template>
  110. <template v-else-if="action === '核查接单'">
  111. <FormField v-model="text" label="核查结论" type="textarea" required placeholder="请如实填写现场核查结论,注明故障是否属实、现场现状等内容" />
  112. </template>
  113. <template v-else-if="action === '取消工单'">
  114. <FormField v-model="text" label="取消原因" type="textarea" required placeholder="请详细说明工单取消事由" />
  115. </template>
  116. <template v-else-if="action === '申请延期'">
  117. <FormField v-model="dateTime" label="延期截止时间" type="datetime" future-only required placeholder="请选择延期截止时间" />
  118. <FormField v-model="text" label="延期原因" type="textarea" required placeholder="请填写延期原因" />
  119. </template>
  120. <template v-else-if="action === '提交验收'">
  121. <FormField v-model="text" label="验收说明" type="textarea" required placeholder="请填报维修处置完成情况" />
  122. </template>
  123. <template v-else-if="action === '验收审核' || action === '延期审核'">
  124. <view class="choice-field"><text class="field-label"><text class="required-mark">*</text>{{ action === '验收审核' ? '验收结果' : '审核结果' }}</text><view class="choice-row" :class="{ 'audit-compact': action === '延期审核' }"><button :class="{active:result==='通过'}" @tap="result='通过'">{{ action === '验收审核' ? '验收通过(结案)' : '审核通过' }}</button><button :class="{active:result==='驳回'}" @tap="result='驳回'">{{ action === '验收审核' ? '验收驳回' : '审核驳回' }}</button></view></view>
  125. <FormField v-model="text" :label="action === '验收审核' ? '验收意见' : '审核意见'" type="textarea" placeholder="请输入意见" />
  126. </template>
  127. <template v-else-if="action === '发布'">
  128. <view class="choice-field"><text class="field-label"><b>*</b>处置人</text><view class="choice-row chips"><button v-for="name in ['张伟','李强','王芳']" :key="name" :class="{active:disposer===name}" @tap="disposer=name">{{ name }}</button></view></view>
  129. <view class="choice-field"><text class="field-label"><b>*</b>核查人</text><view class="choice-row chips"><button v-for="name in ['赵敏','刘洋','陈静']" :key="name" :class="{active:checker===name}" @tap="checker=name">{{ name }}</button></view></view>
  130. </template>
  131. <template v-else-if="action === '接收'">
  132. <view class="confirm-box"><text>确认接收该预警?</text><text>接收后预警将进入处置流程,并记录当前操作人及接收时间。</text></view>
  133. </template>
  134. <template v-else-if="action === '核查'">
  135. <view class="choice-field"><text class="field-label"><b>*</b>核查结果</text><view class="choice-row"><button :class="{active:result==='通过'}" @tap="result='通过'">已解除</button><button :class="{active:result==='驳回'}" @tap="result='驳回'">驳回继续处置</button></view></view>
  136. <FormField v-model="text" label="备注" type="textarea" placeholder="请输入核查意见" />
  137. </template>
  138. <template v-else>
  139. <FormField v-model="text" label="处置内容" type="textarea" :required="domain === 'workorder'" :placeholder="action === '提交处置' ? '请输入处置结论' : '请记录故障原因、处置方案、实施措施及处理结果'" />
  140. </template>
  141. <view v-if="needsUpload" class="upload-field"><text class="field-label"><text v-if="['核查接单','提交验收'].includes(action)" class="required-mark">*</text>{{ ['核查接单','提交验收'].includes(action) ? '现场佐证材料' : '上传附件(可选)' }}</text><UploadPanel v-model="files" :max-size-mb="10" /><text class="upload-tip">支持 png / jpg / jpeg,单个文件不超过 10MB</text></view>
  142. </scroll-view>
  143. <view class="drawer-actions"><button class="cancel" :disabled="submitting" @tap="close">取消</button><button class="submit" :disabled="submitting" @tap="submit">{{ submitting ? '正在提交...' : action === '派单' ? '确认派单' : action === '发布' ? '确认发布' : action === '接收' ? '确认接收' : '确认提交' }}</button></view>
  144. </view>
  145. </view>
  146. </template>
  147. <style scoped lang="scss">
  148. .drawer-root{position:fixed;inset:0;z-index:100}.mask{position:absolute;inset:0;background:rgba(5,18,35,.55);animation:drawer-mask-in .24s ease-out both}.drawer{position:absolute;right:0;bottom:0;left:0;overflow:hidden;border-radius:32rpx 32rpx 0 0;background:#fff;box-shadow:0 -12rpx 40rpx rgba(4,20,38,.2);animation:drawer-slide-in .28s cubic-bezier(.22,.78,.28,1) both}.drawer-head{display:flex;align-items:center;justify-content:space-between;height:96rpx;padding:0 30rpx;color:#122b48;border-bottom:1rpx solid #edf1f5;font-size:31rpx;font-weight:700}.close{display:flex;align-items:center;justify-content:center;width:58rpx;height:58rpx;color:#7f8c9b;border-radius:50%;background:#f2f5f8;font-size:42rpx;font-weight:300}.drawer-body{max-height:62vh;padding:5rpx 30rpx 25rpx}.choice-field,.upload-field{padding:23rpx 0;border-bottom:1rpx solid #edf1f5}.field-label{display:block;margin-bottom:16rpx;color:#34485f;font-size:27rpx}.required-mark{margin-right:6rpx;color:#e34d59}.choice-row{display:grid;grid-template-columns:1fr 1fr;gap:14rpx}.choice-row.chips{grid-template-columns:repeat(3,1fr)}.choice-row button{min-height:70rpx;margin:0;padding:8rpx 12rpx;color:#52647a;border:1rpx solid #d9e1ea;border-radius:999rpx;background:#fff;font-size:23rpx;line-height:1.35}.choice-row button.active{color:#08264c;border-color:#08264c;background:#eef3f8;font-weight:600}.upload-tip{display:block;margin-top:14rpx;color:#97a3b1;font-size:21rpx}.confirm-box{display:flex;flex-direction:column;margin:25rpx 0;padding:26rpx;color:#2e435b;border-radius:16rpx;background:#f3f7fb;font-size:29rpx;font-weight:650}.confirm-box text:last-child{margin-top:12rpx;color:#7d8c9e;font-size:23rpx;font-weight:400;line-height:1.6}.drawer-actions{display:grid;grid-template-columns:1fr 1fr;gap:18rpx;padding:18rpx 26rpx calc(18rpx + env(safe-area-inset-bottom));border-top:1rpx solid #edf1f5;background:#fff}.drawer-actions button{height:80rpx;margin:0;border-radius:999rpx;font-size:27rpx;line-height:80rpx}.cancel{color:#43566d;border:1rpx solid #d7e0e9;background:#fff}.submit{color:#fff;background:#08264c}@keyframes drawer-slide-in{from{transform:translateY(105%)}to{transform:translateY(0)}}@keyframes drawer-mask-in{from{opacity:0}to{opacity:1}}
  149. .audit-compact{display:flex;align-items:center;gap:14rpx}.audit-compact button{display:flex;flex:0 0 180rpx;align-items:center;justify-content:center;width:180rpx;height:62rpx;min-height:62rpx;padding:0 16rpx;border-radius:12rpx;line-height:1}.field-label b{margin-right:6rpx;color:#e34d59}
  150. </style>