← [ BACK_TO_NOTES ]

优雅的弹窗(ConfirmDialog)实现

Vue/Vuetify 的共享确认框与 Snackbar 片段,明确布尔取消、并发拒绝、选项绑定与 Provider 卸载清理。

#frame #snippet #evergreen

优雅地确认框实现

基础概念

前端的确认弹窗主要为用于 删除 或者 重要操作的确认,主要为 取消 和 确认 操作选项。 所以可以直接提取出主要内容:

  • 提醒内容
  • 确认按钮、取消按钮

然后封装一个 ConfirmDialog 组件。此例使用 Vue 3 与 Vuetify,保留早期 DailyUse 的组件结构;不代表当前项目包仍以 @dailyuse/ui 发布。

确认实现采用“忙时拒绝新请求”,不排队、不覆盖旧 resolver。false 表示取消;忙时 Promise 拒绝。Provider 卸载时取消待处理请求;此最小服务不管理可用状态,应用应在卸载前停止接受新请求。模块单例仅用于同一个浏览器应用,SSR 应改为每应用创建并注入。

实战经验

<template>
  <div>
    <!-- Snackbar 消息提示 -->
    <v-snackbar
      v-model="snackbar.visible"
      :color="snackbar.color"
      :timeout="snackbar.timeout"
      location="top"
      multi-line
    >
      {{ snackbar.message }}
      <template #actions>
        <v-btn variant="text" @click="closeSnackbar"> 关闭 </v-btn>
      </template>
    </v-snackbar>

    <!-- 确认对话框 -->
    <v-dialog
      v-model="dialog.visible"
      :max-width="dialog.width"
      :persistent="dialog.persistent"
      :aria-label="dialog.title"
      @update:model-value="value => { if (!value) handleDialogCancel(); }"
    >
      <v-card>
        <v-card-title class="d-flex align-center">
          <v-icon
            :icon="getDialogIcon(dialog.type)"
            :color="getDialogColor(dialog.type)"
            class="mr-2"
          />
          {{ dialog.title }}
        </v-card-title>

        <v-card-text>
          {{ dialog.message }}
        </v-card-text>

        <v-card-actions>
          <v-spacer />
          <v-btn variant="text" @click="handleDialogCancel"> {{ dialog.cancelText }} </v-btn>
          <v-btn :color="getDialogColor(dialog.type)" variant="flat" @click="handleDialogConfirm">
            {{ dialog.confirmText }}
          </v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>
  </div>
</template>

<script setup lang="ts">
import { onUnmounted } from 'vue';
import { getGlobalMessage, type MessageType } from '../../composables/useMessage';

// 🔥 使用全局单例,确保所有地方调用的是同一个实例
const { snackbar, dialog, closeSnackbar, handleDialogConfirm, handleDialogCancel } = getGlobalMessage();
onUnmounted(handleDialogCancel);

/**
 * 获取对话框图标
 */
const getDialogIcon = (type: MessageType): string => {
  const icons = {
    success: 'mdi-check-circle',
    error: 'mdi-alert-circle',
    warning: 'mdi-alert',
    info: 'mdi-information',
  };
  return icons[type];
};

/**
 * 获取对话框颜色
 */
const getDialogColor = (type: MessageType): string => {
  const colors = {
    success: 'success',
    error: 'error',
    warning: 'warning',
    info: 'info',
  };
  return colors[type];
};
</script>

<style scoped>
.v-card-title {
  font-size: 1.25rem;
  font-weight: 500;
}
</style>
/**
 * Vuetify 消息提示封装
 * @module useMessage
 * @description 提供统一的消息提示、确认框、对话框等 UI 交互功能
 */

import { ref } from 'vue';

/**
 * 消息类型
 */
export type MessageType = 'success' | 'info' | 'warning' | 'error';

/**
 * 消息选项
 */
export interface MessageOptions {
  title?: string;
  message: string;
  type?: MessageType;
  duration?: number;
}

/**
 * 确认框选项
 */
export interface ConfirmOptions {
  title?: string;
  message: string;
  type?: MessageType;
  confirmText?: string;
  cancelText?: string;
  persistent?: boolean;
  width?: string | number;
}

/**
 * Snackbar 状态
 */
interface SnackbarState {
  visible: boolean;
  message: string;
  color: string;
  timeout: number;
}

/**
 * Dialog 状态
 */
interface DialogState {
  visible: boolean;
  title: string;
  message: string;
  type: MessageType;
  confirmText: string;
  cancelText: string;
  persistent: boolean;
  width: string | number;
  resolve: ((value: boolean) => void) | null;
}

/**
 * Vuetify 消息提示 Composable
 * @description 提供优雅的消息提示功能
 *
 * @example
 * ```typescript
 * const message = useMessage()
 *
 * // 基础用法
 * message.success('操作成功')
 * message.error('操作失败')
 * message.warning('请注意')
 * message.info('提示信息')
 *
 * // 确认框
 * const confirmed = await message.confirm({
 *   title: '确认删除',
 *   message: '确定要删除这条记录吗?',
 *   type: 'warning'
 * })
 *
 * if (confirmed) {
 *   await deleteRecord()
 * }
 *
 * // 删除确认(快捷方式)
 * await message.delConfirm('确定要删除吗?')
 * ```
 */
export function useMessage() {
  // Snackbar 状态
  const snackbar = ref<SnackbarState>({
    visible: false,
    message: '',
    color: 'success',
    timeout: 3000,
  });

  // Dialog 状态
  const dialog = ref<DialogState>({
    visible: false,
    title: '',
    message: '',
    type: 'info',
    confirmText: '确定',
    cancelText: '取消',
    persistent: true,
    width: 400,
    resolve: null,
  });

  /**
   * 显示 Snackbar
   */
  const showSnackbar = (message: string, color: string, timeout = 3000) => {
    snackbar.value = {
      visible: true,
      message,
      color,
      timeout,
    };
  };

  /**
   * 成功提示
   * @param message - 提示内容
   * @param duration - 显示时长(毫秒)
   */
  const success = (message: string, duration = 3000) => {
    showSnackbar(message, 'success', duration);
  };

  /**
   * 错误提示
   * @param message - 提示内容
   * @param duration - 显示时长(毫秒)
   */
  const error = (message: string, duration = 4000) => {
    showSnackbar(message, 'error', duration);
  };

  /**
   * 警告提示
   * @param message - 提示内容
   * @param duration - 显示时长(毫秒)
   */
  const warning = (message: string, duration = 3500) => {
    showSnackbar(message, 'warning', duration);
  };

  /**
   * 信息提示
   * @param message - 提示内容
   * @param duration - 显示时长(毫秒)
   */
  const info = (message: string, duration = 3000) => {
    showSnackbar(message, 'info', duration);
  };

  /**
   * 通用确认框
   * @param options - 确认框选项
   * @returns Promise<boolean> - 用户选择结果
   *
   * @example
   * ```typescript
   * const confirmed = await message.confirm({
   *   title: '确认操作',
   *   message: '确定要执行此操作吗?',
   *   type: 'warning',
   *   confirmText: '确定',
   *   cancelText: '取消'
   * })
   * ```
   */
  const confirm = (options: ConfirmOptions): Promise<boolean> => {
    if (dialog.value.resolve) {
      return Promise.reject(new Error("Confirmation already pending"));
    }
    return new Promise((resolve) => {
      dialog.value = {
        visible: true,
        title: options.title || '提示',
        message: options.message,
        type: options.type || 'info',
        confirmText: options.confirmText ?? '确定',
        cancelText: options.cancelText ?? '取消',
        persistent: options.persistent ?? true,
        width: options.width ?? 400,
        resolve,
      };
    });
  };

  /**
   * 删除确认框(快捷方式)
   * @param message - 确认内容
   * @param title - 标题
   * @returns Promise<boolean>
   *
   * @example
   * ```typescript
   * try {
   *   const confirmed = await message.delConfirm('确定要删除这条记录吗?')
   *   if (!confirmed) return // false 是取消,不会进入 catch
   *   await deleteApi(id)
   *   message.success('删除成功')
   * } catch {
   *   // 服务忙或删除请求异常
   *   console.error('确认服务或删除操作失败')
   * }
   * ```
   */
  const delConfirm = (message?: string, title?: string): Promise<boolean> => {
    return confirm({
      title: title || '确认删除',
      message: message || '确定要删除这条记录吗?删除后无法恢复。',
      type: 'warning',
      confirmText: '确定删除',
      cancelText: '取消',
    });
  };

  /**
   * 保存确认框
   * @param message - 确认内容
   * @param title - 标题
   * @returns Promise<boolean>
   */
  const saveConfirm = (message?: string, title?: string): Promise<boolean> => {
    return confirm({
      title: title || '确认保存',
      message: message || '确定要保存当前修改吗?',
      type: 'info',
      confirmText: '保存',
      cancelText: '取消',
    });
  };

  /**
   * 离开确认框(用于未保存提示)
   * @param message - 确认内容
   * @returns Promise<boolean>
   */
  const leaveConfirm = (message?: string): Promise<boolean> => {
    return confirm({
      title: '离开页面',
      message: message || '你有未保存的修改,确定要离开吗?',
      type: 'warning',
      confirmText: '离开',
      cancelText: '继续编辑',
    });
  };

  /**
   * 关闭 Snackbar
   */
  const closeSnackbar = () => {
    snackbar.value.visible = false;
  };

  /**
   * 处理 Dialog 确认
   */
  const handleDialogConfirm = () => {
    if (dialog.value.resolve) {
      dialog.value.resolve(true);
    }
    dialog.value.visible = false;
    dialog.value.resolve = null;
  };

  /**
   * 处理 Dialog 取消
   */
  const handleDialogCancel = () => {
    if (dialog.value.resolve) {
      dialog.value.resolve(false);
    }
    dialog.value.visible = false;
    dialog.value.resolve = null;
  };

  return {
    // 状态
    snackbar,
    dialog,

    // 提示方法
    success,
    error,
    warning,
    info,

    // 确认框方法
    confirm,
    delConfirm,
    saveConfirm,
    leaveConfirm,

    // 控制方法
    closeSnackbar,
    handleDialogConfirm,
    handleDialogCancel,
  };
}

/**
 * 全局消息实例(单例模式)
 */
let globalMessageInstance: ReturnType<typeof useMessage> | null = null;

/**
 * 获取全局消息实例
 * @description 提供全局单例,可在任何地方使用
 *
 * @example
 * ```typescript
 * import { getGlobalMessage } from '@dailyuse/ui'
 *
 * const message = getGlobalMessage()
 * message.success('操作成功')
 * ```
 */
export function getGlobalMessage(): ReturnType<typeof useMessage> {
  if (!globalMessageInstance) {
    globalMessageInstance = useMessage();
  }
  return globalMessageInstance;
}

/**
 * 消息提示类型定义(用于组件)
 */
export type MessageInstance = ReturnType<typeof useMessage>;

使用指南

把 组件 在 app.vue 中导入,然后就可以在需要用的地方导入 composables 文件就可以直接使用了

// 与 Provider 导入同一个模块实例;路径按应用目录调整
import { getGlobalMessage } from '../../composables/useMessage';

const message = getGlobalMessage();

// ✅ 删除 KeyResult - 使用优雅的确认对话框
const handleDeleteKeyResult = async () => {
  try {
    // 使用 useMessage 的 delConfirm 获取用户确认
    const confirmed = await message.delConfirm(
      '此操作将同时删除所有关联的进度记录,无法撤销。',
      '删除关键结果'
    );

    if (!confirmed) {
      return;
    }

    // 用户确认删除
    await deleteKeyResultForGoal(props.keyResult.goalUuid, props.keyResult.uuid);
  } catch (error) {
    console.error('删除关键结果失败:', error);
    message.error('删除关键结果失败');
  }
};

经验总结

blogs/dailyuse-vue-shared-ui-state-issues

验证与 UI 边界

检查取消返回 false、忙时拒绝且原请求仍可完成、两种按钮文本/宽度/关闭策略生效,以及 Provider 卸载后的 Promise 结束。Vuetify 版本与组件配置仍需实际验证对话框名称、焦点管理和键盘关闭行为;共享状态正确不等于可访问性自动合格。

创建于 2025/1/1 更新于 2026/10/16
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本