use-toast.js 3.37 KB
/**
 * 命令式 Toast —— 组件内受控的轻量全局队列(不读 env、不发请求、无 DOM 依赖)。
 *
 * 队列是一个模块级 Vue `ref`,由 `useToast()` 推入、`ToastHost.vue` 渲染消费。逻辑(入队 / 自动
 * 消失定时器 / 手动 dismiss / duration 覆盖)全在此文件,可脱离 DOM 单测(§8)。这是任务文档允许的
 * 唯一「全局状态」例外,且严格受控于本包。
 *
 * @typedef {'success'|'error'|'info'} ToastType
 *
 * @typedef {Object} ToastItem
 * @property {string} id        队列内唯一 id
 * @property {ToastType} type    语义类型,决定 ToastHost 的视觉
 * @property {string} message    展示文案(由调用方注入,本包不存文案)
 * @property {number} duration   自动消失毫秒数;<=0 表示不自动消失
 *
 * @typedef {Object} ToastOptions
 * @property {number} [duration] 覆盖默认 4000ms;<=0 则不自动消失
 *
 * @typedef {Object} ToastApi
 * @property {(message: string, opts?: ToastOptions) => string} success
 * @property {(message: string, opts?: ToastOptions) => string} error
 * @property {(message: string, opts?: ToastOptions) => string} info
 * @property {(id: string) => void} dismiss
 */

import { ref, readonly } from 'vue'

/** 默认自动消失时长(ms)。 */
const DEFAULT_DURATION = 4000

/** @type {import('vue').Ref<ToastItem[]>} 受控全局队列。 */
const queue = ref(/** @type {ToastItem[]} */ ([]))

/** @type {Map<string, ReturnType<typeof setTimeout>>} id → 自动消失定时器句柄。 */
const timers = new Map()

let seq = 0

/**
 * 生成队列内唯一 id。
 * @returns {string}
 */
function nextId() {
  seq += 1
  return `toast-${seq}`
}

/**
 * 从队列移除指定 toast 并清掉其定时器(手动关闭 / 自动消失 / 编程 dismiss 共用)。
 * @param {string} id
 * @returns {void}
 */
function dismiss(id) {
  const timer = timers.get(id)
  if (timer !== undefined) {
    clearTimeout(timer)
    timers.delete(id)
  }
  queue.value = queue.value.filter((t) => t.id !== id)
}

/**
 * 入队一条 toast,按 duration 安排自动消失(排队由队列顺序天然保证)。
 * @param {ToastType} type
 * @param {string} message
 * @param {ToastOptions} [opts]
 * @returns {string} 新 toast 的 id(供调用方主动 dismiss)
 */
function push(type, message, opts = {}) {
  const id = nextId()
  const duration =
    typeof opts.duration === 'number' ? opts.duration : DEFAULT_DURATION
  /** @type {ToastItem} */
  const item = { id, type, message, duration }
  queue.value = [...queue.value, item]
  if (duration > 0) {
    timers.set(
      id,
      setTimeout(() => dismiss(id), duration),
    )
  }
  return id
}

/** @type {ToastApi} */
const api = Object.freeze({
  success: (message, opts) => push('success', message, opts),
  error: (message, opts) => push('error', message, opts),
  info: (message, opts) => push('info', message, opts),
  dismiss,
})

/**
 * 命令式 toast 入口。返回稳定的 api 对象(`success`/`error`/`info` 入队,`dismiss` 关闭)。
 * @returns {ToastApi}
 */
export function useToast() {
  return api
}

/**
 * 供 ToastHost.vue 订阅的只读队列。组件只读不写,写入一律走 `useToast()`,保证队列受控。
 * @returns {import('vue').Ref<readonly ToastItem[]>}
 */
export function useToastQueue() {
  return /** @type {import('vue').Ref<readonly ToastItem[]>} */ (readonly(queue))
}