unified-task-actions.vue 11.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423
<template>
  <up-popup
      :show="visible"
      mode="bottom"
      round
      closeable
      bg-color="#F4F6FA"
      :safe-area-inset-bottom="true"
      @close="close"
  >
    <view class="unified-action-popup">
      <view class="unified-action-title">{{ current ? current.name : '请选择操作' }}</view>

      <!-- 第一步:动作列表 -->
      <view v-if="!current" class="unified-action-list">
        <up-button
            v-for="action in actions"
            :key="action.code"
            :type="getButtonType(action.code, action.name)"
            size="large"
            :customStyle="{ marginBottom: '20rpx' }"
            @click="selectAction(action)"
        >
          {{ action.name }}
        </up-button>
        <view v-if="!actions.length" class="unified-action-tip">当前节点暂未配置可执行动作</view>
      </view>

      <!-- 第二步:动作表单 -->
      <view v-else class="unified-action-form">
        <view v-if="current.requireAssignee" class="form-item">
          <view class="sec-head">
            <view class="sec-bar"></view>
            <view class="form-label">处理人</view>
          </view>
          <up-radio-group v-if="workers.length" v-model="form.nextAssignee" class="worker-list">
            <up-radio
                v-for="worker in workers"
                :key="worker.id"
                :name="String(worker.id)"
                :label="worker.nickname"
            />
          </up-radio-group>
          <view v-else class="unified-action-tip">
            {{ loadingWorkers ? '加载处理人中...' : '暂无可选处理人' }}
          </view>
        </view>

        <view class="form-item">
          <view class="sec-head">
            <view class="sec-bar"></view>
            <view class="form-label">{{ reasonLabel }}</view>
          </view>
          <up-textarea
              v-model.trim="form.reason"
              :placeholder="reasonPlaceholder"
              :count="200"
              maxlength="200"
              height="120"
          />
        </view>

        <view v-if="current.requireAttachment" class="form-item">
          <view class="sec-head">
            <view class="sec-bar"></view>
            <view class="form-label">附件(必填)</view>
          </view>
          <up-upload
              :file-list="attachImgs.rawImgList.value || []"
              @after-read="attachImgs.uploadImgs"
              @delete="attachImgs.deleteImg"
              multiple
              width="70"
              height="70"
              :max-count="attachImgs.uploadConfig.maxCount"
              :upload-text="attachImgs.uploadConfig.uploadText"
              :size-type="attachImgs.uploadConfig.sizeType"
          />
        </view>

        <view class="unified-action-btns">
          <!-- 只有一个动作时没有选择列表可返回,直接展示确定 -->
          <up-button v-if="actions.length > 1" type="info" size="large" @click="backToList">返回</up-button>
          <up-button type="primary" size="large" :loading="submitting" @click="submit">确定</up-button>
        </view>
      </view>
    </view>
  </up-popup>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'
import {
  executeTaskAction,
  getYlWorkersPage
} from '@/api/work-order-manage/work-order-manage'
import { useUploadImgs } from '@/common/utils/useUploadImgs'

const emit = defineEmits(['success'])

const visible = ref(false)
const actions = ref([])
const current = ref(null)
const currentItem = ref(null)
const submitting = ref(false)
const workers = ref([])
const loadingWorkers = ref(false)

const form = reactive({
  nextAssignee: '',
  reason: ''
})

const attachImgs = useUploadImgs({
  maxCount: 5,
  uploadText: '选择附件'
})

/** 该动作是否必须填写原因/备注 */
const isReasonRequired = (action) => {
  if (!action) return false
  return (
      action.requireReason === true ||
      ['REJECT', 'RETURN', 'RETURN_PREV', 'TRANSFER', 'DELEGATE'].includes(action.code)
  )
}

const reasonRequired = computed(() => isReasonRequired(current.value))

/** 处理类动作填的是实施/处理结果,其余动作填原因或备注 */
const reasonLabel = computed(() => {
  const base = current.value?.code === 'HANDLE' ? '处理结果' : '原因/备注'
  return reasonRequired.value ? base + '(必填)' : base
})

const reasonPlaceholder = computed(() => {
  const base = current.value?.code === 'HANDLE' ? '请输入处理结果' : '请输入原因/备注'
  return reasonRequired.value ? base + '(必填)' : base + '(选填)'
})

/** 动作是否需要用户补充信息(处理人/必填原因/附件),都不需要时直接提交 */
const needsInput = (action) => {
  if (!action) return false
  // 处理类动作(HANDLE)必须让用户确认:统一动作没有业务表单,
  // 静默提交会让实施结果、实施照片、实施人全部缺失
  return action.code === 'HANDLE'
      || action.requireAssignee === true
      || action.requireAttachment === true
      || isReasonRequired(action)
}

/**
 * 打开动作弹窗
 *
 * 列表页的按钮文案就是动作名称,按钮自身已经限定了动作,
 * 所以只有一个动作时不再展示「请选择操作」列表:
 *   - 动作无必填内容:直接提交(例如「分配」由后端按道路自动指派);
 *   - 动作需要处理人/原因/附件:直接进入动作表单。
 * 只有传入多个动作(需要用户区分)时才展示动作列表。
 */
const open = async (item, actionList) => {
  currentItem.value = item
  actions.value = actionList || []
  current.value = null
  form.nextAssignee = ''
  form.reason = ''
  attachImgs.clearImgs()

  if (actions.value.length === 1) {
    const action = actions.value[0]
    if (needsInput(action)) {
      visible.value = true
      await selectAction(action)
    } else {
      current.value = action
      await submit()
    }
    return
  }

  visible.value = true
}

const close = () => {
  visible.value = false
  current.value = null
  submitting.value = false
}

const backToList = () => {
  current.value = null
}

/** 动作按钮颜色:名称优先(部分流程「退回」用的是 COMPLETE 编码) */
const getButtonType = (code, name) => {
  const text = name || ''
  if (text.includes('驳回') || text.includes('拒绝')) return 'error'
  if (text.includes('退回') || text.includes('回退') || text.includes('不通过')) return 'warning'
  if (text.includes('转办') || text.includes('转派') || text.includes('转交')) return 'info'
  switch (code) {
    case 'APPROVE':
    case 'HANDLE':
    case 'COMPLETE':
      return 'success'
    case 'REJECT':
      return 'error'
    case 'RETURN':
    case 'RETURN_PREV':
      return 'warning'
    case 'WITHDRAW':
      return 'info'
    default:
      return 'primary'
  }
}

/** 选择动作,按需加载处理人 */
const selectAction = async (action) => {
  // 退回动作必须在配置中指定目标节点(小程序端不提供节点选择)
  if (action.code === 'RETURN' && !action.targetNodeKey) {
    uni.showToast({ title: '该退回动作未配置目标节点,请在PC端配置', icon: 'none' })
    return
  }
  current.value = action
  form.nextAssignee = ''
  form.reason = ''
  attachImgs.clearImgs()
  if (action.requireAssignee) {
    loadWorkers()
  }
}

/** 加载处理人 */
const loadWorkers = async () => {
  if (workers.value.length > 0) return
  loadingWorkers.value = true
  try {
    const res = await getYlWorkersPage({
      busiLine: currentItem.value?.busiLine,
      pageNo: 1,
      pageSize: 100
    })
    workers.value = res?.list || []
  } catch (e) {
    workers.value = []
  } finally {
    loadingWorkers.value = false
  }
}

/** 提交动作 */
const submit = async () => {
  if (!current.value || !currentItem.value) return
  const action = current.value
  const reason = form.reason
  if (reasonRequired.value && !reason) {
    uni.showToast({ title: '请填写原因/备注', icon: 'none' })
    return
  }
  if (action.requireAssignee && !form.nextAssignee) {
    uni.showToast({ title: '请选择处理人', icon: 'none' })
    return
  }
  const attachments = attachImgs.getSuccessImgUrls()
  if (action.requireAttachment && attachments.length === 0) {
    uni.showToast({ title: '请上传附件', icon: 'none' })
    return
  }

  submitting.value = true
  uni.showLoading({ title: '提交中...', mask: true })
  try {
    const data = {
      taskId: currentItem.value.taskId,
      actionCode: action.code,
      reason: reason || undefined,
      targetNodeKey: action.targetNodeKey || undefined
    }
    if (action.requireAssignee) {
      data.nextAssignee = Number(form.nextAssignee)
    }
    if (attachments.length > 0) {
      data.attachments = attachments
    }
    await executeTaskAction(data)
    uni.hideLoading()
    uni.showToast({ title: '操作成功', icon: 'success', duration: 1000 })
    visible.value = false
    emit('success', currentItem.value)
  } catch (e) {
    uni.hideLoading()
    // 请求层已统一提示错误信息
  } finally {
    submitting.value = false
  }
}

defineExpose({ open })
</script>

<style lang="scss" scoped>
// 弹窗内容:浅灰底(由 up-popup 的 bg-color 控制),左右 24rpx
.unified-action-popup {
  padding: 32rpx 24rpx calc(40rpx + env(safe-area-inset-bottom));
  max-height: 80vh;
  overflow-y: auto;
  box-sizing: border-box;
}

.unified-action-title {
  font-size: 32rpx;
  font-weight: 500;
  color: #1F2937;
  text-align: center;
  margin-bottom: 24rpx;
}

.unified-action-list {
  display: flex;
  flex-direction: column;
}

.unified-action-tip {
  color: #9CA3AF;
  font-size: 28rpx;
  padding: 20rpx 0;
}

// 表单分区:白色卡片(设计稿 .card)
.unified-action-form .form-item {
  margin-bottom: 24rpx;
  padding: 8rpx 24rpx 24rpx;
  background-color: #ffffff;
  border-radius: 16rpx;
  box-shadow: 0 4rpx 16rpx rgba(17, 24, 39, 0.06);
}

// 区块标题:蓝色竖条 + 标题(设计稿 .card-title)
.sec-head {
  display: flex;
  align-items: center;
  gap: 12rpx;
  padding: 20rpx 0 8rpx;
}

.sec-bar {
  width: 6rpx;
  height: 28rpx;
  background-color: #2563EB;
  border-radius: 3rpx;
}

.form-label {
  font-size: 30rpx;
  font-weight: 500;
  color: #1F2937;
  margin-bottom: 0;
}

// 处理人:整行选项,多个人之间用分隔线(设计稿 .row)
.worker-list {
  display: flex;
  flex-direction: column;
  gap: 0;

  :deep(.u-radio) {
    display: flex;
    align-items: center;
    padding: 20rpx 0;
    margin-right: 0;
  }

  :deep(.u-radio + .u-radio) {
    border-top: 1rpx solid #E8EBF0;
  }

  :deep(.u-radio__text) {
    font-size: 28rpx;
    color: #1F2937;
  }
}

// 文本域:浅灰底 + 圆角、去掉默认描边(设计稿 .textarea)
.unified-action-form :deep(.u-textarea) {
  border: none;
  background-color: #F4F6FA;
  border-radius: 8rpx;
}

.unified-action-form :deep(.u-textarea__field) {
  font-size: 28rpx;
  color: #1F2937;
}

// 字数统计贴灰底显示(组件内部写死了白底,需 !important 覆盖)
.unified-action-form :deep(.u-textarea__count) {
  background-color: transparent !important;
  color: #9CA3AF;
}

.unified-action-btns {
  display: flex;
  gap: 24rpx;
  margin-top: 32rpx;

  ::v-deep .u-button {
    flex: 1;
    height: 88rpx !important;
    border-radius: 16rpx !important;
    border: none !important;
  }

  ::v-deep .u-button__text {
    font-size: 32rpx !important;
    font-weight: 500 !important;
  }

  // 主按钮(确定):蓝色渐变
  ::v-deep .u-button--primary {
    background-image: linear-gradient(135deg, #1D4ED8 0%, #2563EB 60%, #3B82F6 100%) !important;
  }
}
</style>