跳转到内容

命令式服务

部分反馈不适合写成模板:删除前确认、保存后提示,这类反馈没有挂载位置的问题,适合一次调用弹出。库提供四个服务工厂:对话框、轻提示、通知与顶部进度条。

四者都自建宿主容器、自行管理挂载与卸载,用完后需调用 dispose()

工厂返回即可接收命令,与宿主何时渲染无关:在组件的挂载回调(Vue 的 onMounted、React 的 useEffect、自定义元素的 connectedCallback)里懒建服务并紧接着发第一条命令,与在模块作用域调用一样成立,不需要等一帧。

对话框服务

ts
import { createDialogService } from "@xihan-ui/vue";

const dialog = createDialogService({ okText: "确定", cancelText: "取消" });

const ok = await dialog.confirm({
  title: "删除这条记录?",
  content: "删除后不可恢复。",
  tone: "danger",
  okText: "删除",
});
if (ok) {
  // 用户已确认,且 onOk(如提供)已执行完成
}

dialog.dispose();
方法返回说明
confirm(options)Promise<boolean>确认走完 onOk 后 resolve true;取消或 Escape resolve false
info / success / warning / errorPromise<void>单按钮告知框,没有取消按钮,徽记由预设档决定
prompt(options)Promise<T | null>取值型弹窗:确认后返回一份值,取消 resolve null
setConfig(next)更换全局配置源
dispose()卸载宿主应用并移除容器

ConfirmOptions 的字段:title(必填)、contenttone(确认按钮语气,危险操作传 danger)、badge(标题旁的类型徽记)、okText / cancelTextonOk

异步确认

onOk 返回 Promise 时,确认按钮自动进入 pending 并阻止关闭;失败时保持打开,用户可以重试或取消。这是它相对于自行编写 <XhDialog> 的主要便利。

ts
await dialog.confirm({
  title: "发布这个版本?",
  onOk: () => api.publish(id), // 拒绝时对话框不关闭,用户可以再次确认
});

正文内容

content 接受字符串与渲染函数。字符串经 XhDialogDescription 渲染(读屏的 aria-describedby 指向它),函数则整块渲染在正文位:

ts
await dialog.confirm({
  title: "导入这份数据?",
  content: () => h(XhAlertRoot, { tone: "warning" }, () => "已存在的记录会被覆盖"),
});

不接受裸 VNode:服务宿主常驻,忙态切换时整棵重渲染,同一个 VNode 实例被复用时的行为未定义。

取值型弹窗

prompt 负责在弹窗中填写内容并把值带回。每次打开创建一份初值,bodyonOk 拿到的是同一份可写代理:

ts
const next = await dialog.prompt({
  title: "改邮箱",
  initialValue: { email: "", password: "" },
  body: value => [
    h(XhTextFieldRoot, { "value": value.email, "onUpdate:value": (v: string) => (value.email = v) }, () => h(XhTextFieldInput)),
    h(XhTextFieldRoot, { "type": "password", "value": value.password, "onUpdate:value": (v: string) => (value.password = v) }, () => h(XhTextFieldInput)),
  ],
  initialFocus: "[data-scope=text-field][data-part=input]",
  onOk: value => value.email.includes("@"), // 返回 false 表示校验未通过,弹窗不关闭
});
// next 是 { email, password } 的普通对象快照;取消 / Escape 得到 null

promptonOk 返回 false 表示不放行。confirmonOk 不适用这条:它的返回值不参与判定,只有拒绝才阻止关闭。

同一时刻只有一个

后续调用排队顺次弹出,避免多层模态叠加。当前项的内容与遮罩全部完成有限退场动画后,队列才放出下一项;无动画或减弱动效时不额外等待固定时间。

轻提示服务

ts
import { createToastService } from "@xihan-ui/vue";

const toast = createToastService({ placement: "top", max: 5 });

toast.success("已保存");
toast.danger("保存失败,请重试", { duration: 8000 });
方法返回说明
create(options)string(id)入队;同 id 已存在则就地改写
update(id, options)改写正在显示的条目
dismiss(id) / dismissAll()手动关闭
info / success / warning / dangerstring(id)语气快捷方法,第一个参数是正文
loading(message, options)string(id)loading 态弹出一条并返回 id,之后用 update 收尾
promise(input, options)Promise<T>先弹出 loading,落定后就地改写为成功 / 失败
pauseAll() / resumeAll()整组暂停计时、再恢复
setConfig(next)更换全局配置源(切换语言用)
dispose()卸载宿主应用并移除容器

在途 → 完成

loadingupdate 是一条完整的链,不连发两条:

ts
const id = toast.loading("正在上传…");
try {
  await upload(file);
  toast.update(id, { loading: false, tone: "success", title: "上传完成" });
}
catch {
  toast.update(id, { loading: false, tone: "danger", title: "上传失败" });
}

同一条链有封装写法,结果与拒绝都原样返回:

ts
const url = await toast.promise(upload(file), {
  loading: "正在上传…",
  success: result => `上传完成:${result.name}`,
  error: reason => `上传失败:${(reason as Error).message}`,
});

行内动作

提供 actionLabel 才渲染动作按钮,按下的行为写在 onAction 中:

ts
toast.info("已删除 3 条记录", { duration: 8000, actionLabel: "撤销", onAction: () => restore() });

文案进入队列记录,回调保存在服务中:记录只存放可整份替换、序列化、比对的纯数据。

重复与优先级

同一句错误连续发出多次时,dedupe: 'content' 把它们合并为一条并在标题后追加计数:

ts
const toast = createToastService({ dedupe: "content" });
toast.danger("同步失败");
toast.danger("同步失败"); // 界面上是「同步失败 ×2」

超出 max 时先移除低优先级的条目,同级中移除最旧的。未指定优先级时按语气派生(danger 最高、 warning 次之、其余持平),也可以逐条写 priority:一条报错不应被随后的多条提示挤出。

服务档的默认落位是 top,最多同时留 5 条,超出时移除最旧的。落位是整个服务的口径: 一次操作的反馈不应逐条分散到不同位置,在 createToastService({ placement }) 中一次确定。

默认不显示关闭按钮。一条轻提示是一枚状态字形加一句话的小条,到时自行消失; 多一个关闭按钮就多一次是否点击的判断。确需保留出口(如 duration: 0 的常驻提示)时显式开启:

ts
toast.danger("导出失败,请重试", { duration: 0, closable: true });

通知服务

ts
import { createNotificationService } from "@xihan-ui/vue";

const notify = createNotificationService({ placement: "bottom-end", max: 5 });

notify.info("有新的审批", { description: "张三提交了一份请假单" });
notify.danger("同步失败", { description: "网络中断,稍后自动重试", duration: 0 });
方法返回说明
create(options)string(id)入队;同 id 已存在则就地改写
update(id, options)改写正在显示的条目
dismiss(id) / dismissAll()手动关闭
info / success / warning / dangerstring(id)语气快捷方法,第一个参数是标题,正文写在 options.description
pauseAll() / resumeAll()当前卡片整组暂停计时、再恢复
setConfig(next)更换全局配置源(切换语言用)
dispose()卸载宿主应用并移除容器

行内动作、dedupepriority 与轻提示同形:两者运行同一台队列状态机,上限、移除与 合并计数只有一份实现。

与轻提示的两处不同:条目有标题与正文两层,且单条可以用 options.placement 覆盖落位: 消息各有轻重,逐条决定位置是合理的。duration: 0 即常驻不消失,由用户手动关闭。

队列需要位于页面结构中(通知中心一栏自行排版)时改用组件形态的 通知,两者不共享队列。

顶部进度条服务

路由守卫与请求拦截器都在组件树之外,需要命令式入口:

ts
import { createLoadingBarService } from "@xihan-ui/vue";

const bar = createLoadingBarService();

router.beforeEach(() => { bar.start(); });
router.afterEach(() => { bar.finish(); });

http.interceptors.request.use((cfg) => { bar.start(); return cfg; });
http.interceptors.response.use(
  (res) => { bar.finish(); return res; },
  (err) => { bar.error(); return Promise.reject(err); },
);
方法说明
start()在途计数 +1;从 0 起跳即开始爬升
finish()在途计数 −1(下限为 0,多次调用不会变负);归零才收起
error()强制归零并以 errorTone(默认 danger)收起
finishAll()无论剩余多少在途请求一律收起
set(value)切换为确定进度;再次 start() 回到不确定
setConfig(next)更换全局配置源
dispose()卸载宿主应用并移除容器

在途计数是这层封装的要点。写成布尔开关时,三个并发请求中第一个返回就会收起进度条,其余两个仍在进行:进度条比请求先结束。

切换语言(Vue 侧)

Vue 的四个服务都自建宿主应用,无法接入组件树中的 provideXhConfig,因此配置从 config 选项提供。传入 ref 或 getter,不传一次性的对象:传对象时文案只在创建服务时求值一次,之后应用切换语言,服务子树中的按钮与读屏名不随之更新;队列中排队的对话框也会跨过这次切换。

ts
const dialog = createDialogService({
  config: () => ({ locale: app.locale.value, translations: myOverrides[app.locale.value] }),
  okText: () => t("common.ok"),
  cancelText: () => t("common.cancel"),
});

// 没有响应式源时也可以命令式推送
dialog.setConfig({ locale: "en-US" });

取值优先级:调用点 > 服务选项 > config.translations.<组件> > 组件内建默认。

Web Components 侧

同样四个工厂,从 @xihan-ui/web-components/services 取,句柄的方法与 Vue 侧同名同形:

ts
import { createToastService } from "@xihan-ui/web-components/services";

const toast = createToastService({ placement: "top", max: 5 });
toast.success("已保存");

服务自行生成真实的自定义元素与角色节点(<xh-toast><xh-notification><xh-dialog><xh-loading-bar>),得到的仍是一棵可查询、可选中的 DOM;用到的元素在服务创建时按需注册, 不必先 import '@xihan-ui/web-components/define'

与 Vue 侧的两处不同都来自这一侧的身份:

  • 没有 config 入参,也没有 setConfig。全局配置沿 DOM 祖先链解析,服务的宿主容器挂在 文档中,语言、尺寸、浮层落点由 setXhConfig 与外层 <xh-config> 决定。
  • 对话框的正文渲染函数接收节点:content: (body) => { … } 拿到正文角色节点后自行写入, 没有 prompt:取值型弹窗需要放表单时直接写 <xh-dialog>

不适合使用服务的场景

  • 确认可撤销的操作:直接执行,然后发一条带撤销按钮的轻提示。事前确认对用户是额外的一道关卡,撤销才是有效的兜底。
  • 提示内容较长或需要用户处理:轻提示会自行消失,用警告提示常驻,或用通知分标题与正文两层。
  • 对话框中需要放表单:用组件形态的对话框,服务档只提供标题、正文与按钮行。

一个应用创建几个

各创建一个,挂在应用启动处,全局共用。每个页面各创建一个会产生多个宿主容器,多组提示互相遮盖。

服务不经 provide/inject,因此在组件外(路由守卫、拦截器、store)也能调用,这正是命令式的意义。但也因此它无法获取 全局配置 注入的文案:服务的文案在 createDialogService / createToastService / createNotificationService 的入参中单独提供。

与其他库的对应关系

其他库本库
Element Plus ElMessageBox.confirmdialog.confirm
Element Plus ElMessage / ElNotificationtoast.* / 通知
Ant Design Modal.confirm / message / notificationdialog.confirm / toast.* / 通知
Naive UI useDialog / useMessagecreateDialogService / createToastService
Semi Design Modal.confirm / Toastdialog.confirm / toast.*

轻提示与通知按发起方分工。轻提示是用户刚才操作的结果,一句话、自行消失;通知是系统或他人主动推送的消息,有标题与正文两层、可以常驻。两者都有服务档(createToastService / createNotificationService),队列各自独立;通知另有组件形态 XhNotificationRoot,队列需要位于页面结构中(通知中心一栏自行排版)时使用。轻提示没有容器组件:反馈落位是整个服务的口径。

Released under The MIT License