ConfirmAction.vue 4.23 KB
<script setup>
/**
 * 二次确认处置按钮 —— 把「点击 → 确认 → 调一个 server 端点 → 按返回更新视图」的处置时序
 * 抽成可测组件。
 *
 * 本组件**不直接发请求**:它只在确认后调用注入的 `action`(page 传入、内部委托 api 层的单个
 * 端点方法)。`action` 必须是「发一个指令」的 thunk,副作用归 server。
 *
 * 触发器用 `Button`、确认框用 `Modal`,不自己画。原先这两样都是手写的:触发器是一个裸
 * `<button>` 加一套自己的样式,和旁边的 `Button` 并排就是两个东西;确认框是 `position: fixed`
 * 的浮层,坐标在点击那一刻算一次,于是
 *
 *   - 点别处不关、Esc 不关,表格里一行点一个就**叠一堆同时挂着**,没法用
 *   - 页面一滚,浮层留在原地
 *   - 没有焦点管理
 *
 * `Modal` 这三样都有(居中、backdrop 关闭、Esc、焦点陷阱),所以这里只负责时序。
 * 对外的 props / 事件 / data-testid 一个没改,8 处调用方不用动。
 */
import { ref } from 'vue'

import Button from './Button.vue'
import Modal from './Modal.vue'

const props = defineProps({
  label: { type: String, required: true },
  /** 确认后执行的处置(返回 Promise)。必须只发一个 server 指令。 */
  action: { type: Function, required: true },
  /** 确认弹框里的问句。 */
  confirmText: { type: String, default: 'Are you sure?' },
  /** 取消按钮文案。 */
  cancelLabel: { type: String, default: 'Cancel' },
  /** 确认按钮文案。 */
  okLabel: { type: String, default: 'Confirm' },
  /** 处置进行中的确认按钮文案。 */
  pendingLabel: { type: String, default: 'Working…' },
  /** 危险处置:触发器与确认按钮转 danger 色。 */
  danger: { type: Boolean, default: false },
})

const emit = defineEmits(['done', 'error'])

const open = ref(false)
const pending = ref(false)
const error = ref('')

function ask() {
  error.value = ''
  open.value = true
}

/** 处置进行中不许关:关掉之后请求还在飞,结果落在一个已经消失的框上。 */
function setOpen(next) {
  if (pending.value) return
  open.value = next
}

async function confirm() {
  pending.value = true
  error.value = ''
  try {
    const result = await props.action()
    pending.value = false
    open.value = false
    emit('done', result)
  } catch (e) {
    // 失败不关框:错误就显示在原地,人能直接再点一次确认。
    pending.value = false
    error.value = e?.message || String(e)
    emit('error', e)
  }
}
</script>

<template>
  <!-- 单根:调用方会给它传 class(apps/admin 的 UserDetail 就传了),
       多根组件的 class 不会自动落到任何元素上,会被静默丢掉。
       Modal 自己 Teleport 到 body,放在这层里不影响布局。 -->
  <span class="confirm-action">
    <Button
      :variant="danger ? 'danger' : 'secondary'"
      size="sm"
      data-testid="confirm-trigger"
      @click="ask"
    >
      {{ label }}
    </Button>

    <Modal
    :open="open"
    width="420px"
      :close-on-backdrop="!pending"
      @update:open="setOpen"
    >
      <div data-testid="confirm-dialog">
        <p class="confirm-action__text">{{ confirmText }}</p>
        <p
          v-if="error"
          class="confirm-action__error"
          data-testid="confirm-error"
        >
          {{ error }}
        </p>
      </div>
      <template #footer>
        <Button
          variant="ghost"
          :disabled="pending"
          data-testid="confirm-cancel"
          @click="setOpen(false)"
        >
          {{ cancelLabel }}
        </Button>
        <Button
          :variant="danger ? 'danger' : 'primary'"
          :loading="pending"
          data-testid="confirm-ok"
          @click="confirm"
        >
          {{ pending ? pendingLabel : okLabel }}
        </Button>
      </template>
    </Modal>
  </span>
</template>

<style scoped>
.confirm-action {
  display: inline-block;
}

.confirm-action__text {
  margin: 0;
  font-size: var(--drama-font-size-md);
  line-height: var(--drama-line-height-normal, 1.6);
}

.confirm-action__error {
  margin: var(--drama-space-md) 0 0;
  color: var(--drama-color-danger);
  font-size: var(--drama-font-size-sm);
}
</style>