unified-task-actions.vue 8.5 KB
<template>
  <up-popup
      :show="visible"
      mode="bottom"
      round
      closeable
      :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)"
            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="form-label">处理人</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="form-label">{{ reasonRequired ? '原因/备注(必填)' : '原因/备注' }}</view>
          <up-textarea
              v-model.trim="form.reason"
              :placeholder="reasonRequired ? '请输入原因/备注(必填)' : '请输入原因/备注(选填)'"
              :count="200"
              maxlength="200"
              height="120"
          />
        </view>

        <view v-if="current.requireAttachment" class="form-item">
          <view class="form-label">附件(必填)</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 needsInput = (action) => {
  if (!action) return false
  return 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
}

/** 动作按钮颜色 */
const getButtonType = (code) => {
  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>
.unified-action-popup {
  padding: 40rpx 40rpx calc(40rpx + env(safe-area-inset-bottom));
  max-height: 80vh;
  overflow-y: auto;
}

.unified-action-title {
  font-size: 34rpx;
  font-weight: bold;
  text-align: center;
  margin-bottom: 40rpx;
}

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

.unified-action-tip {
  color: #909399;
  font-size: 26rpx;
  padding: 20rpx 0;
}

.unified-action-form .form-item {
  margin-bottom: 30rpx;
}

.form-label {
  font-size: 28rpx;
  color: #303133;
  margin-bottom: 16rpx;
}

.worker-list {
  display: flex;
  flex-wrap: wrap;
  gap: 16rpx;
}

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

  ::v-deep .u-button {
    flex: 1;
  }
}
</style>