@keyfunc/keybricks-ds 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/DEPLOY.md +4 -4
  2. package/INTEGRATION.md +20 -1
  3. package/_adherence.oxlintrc.json +10 -4
  4. package/_ds_bundle.js +20559 -0
  5. package/_src/icon-index.js +1 -0
  6. package/_src/unicons-index.js +1 -0
  7. package/_src/usage-code.js +737 -0
  8. package/changelog.js +12 -0
  9. package/components/buttons/BUTTON_MIGRATION.md +111 -0
  10. package/components/buttons/Button.vue +59 -12
  11. package/components/buttons/Button.vue.d.ts +44 -0
  12. package/components/charts/BarList.jsx +1 -1
  13. package/components/charts/BarList.vue +8 -3
  14. package/components/charts/Ribbon3D.d.ts +2 -0
  15. package/components/charts/Scatter3D.d.ts +4 -0
  16. package/components/charts/SunburstChart.jsx +6 -1
  17. package/components/charts/SunburstChart.vue +7 -1
  18. package/components/charts/Surface3D.d.ts +2 -0
  19. package/components/charts/WorldMap.jsx +6 -1
  20. package/components/charts/WorldMap.vue +7 -1
  21. package/components/data/Table.jsx +1 -1
  22. package/components/data/Table.vue +1 -1
  23. package/components/display/ProgressBar.jsx +1 -1
  24. package/components/display/ProgressBar.vue +1 -1
  25. package/components/feedback/Accordion.jsx +1 -1
  26. package/components/feedback/Accordion.vue +1 -1
  27. package/components/feedback/ContextMenu.jsx +3 -3
  28. package/components/feedback/ContextMenu.vue +4 -4
  29. package/components/feedback/Drawer.jsx +3 -3
  30. package/components/feedback/Drawer.vue +3 -3
  31. package/components/feedback/MESSAGE_SERVICES.md +71 -0
  32. package/components/feedback/Toast.vue +5 -5
  33. package/components/feedback/_internal/MessageBoxView.vue +237 -0
  34. package/components/feedback/_internal/MessageView.vue +73 -0
  35. package/components/feedback/_internal/service-utils.js +46 -0
  36. package/components/feedback/message-box.d.ts +69 -0
  37. package/components/feedback/message-box.js +92 -0
  38. package/components/feedback/message.d.ts +37 -0
  39. package/components/feedback/message.js +68 -0
  40. package/components/forms/Checkbox.d.ts +2 -0
  41. package/components/forms/Checkbox.jsx +9 -4
  42. package/components/forms/Checkbox.vue +6 -2
  43. package/components/forms/DatePicker.jsx +3 -1
  44. package/components/forms/DatePicker.vue +2 -1
  45. package/components/forms/Dropzone.jsx +1 -1
  46. package/components/forms/Dropzone.vue +1 -1
  47. package/components/forms/MultiSelect.jsx +5 -3
  48. package/components/forms/MultiSelect.vue +4 -3
  49. package/components/forms/Slider.jsx +1 -1
  50. package/components/forms/Slider.vue +1 -1
  51. package/components/forms/Switch.jsx +4 -4
  52. package/components/forms/Switch.vue +4 -4
  53. package/components/navigation/Sidebar.jsx +2 -2
  54. package/components/navigation/Sidebar.vue +2 -2
  55. package/components/navigation/TopBar.jsx +1 -1
  56. package/components/navigation/TopBar.vue +1 -1
  57. package/components/navigation/Tree.jsx +2 -2
  58. package/components/navigation/Tree.vue +8 -5
  59. package/components/pipeline/DataPreview.jsx +1 -1
  60. package/components/pipeline/DataPreview.vue +1 -1
  61. package/components/pipeline/NodeCard.jsx +1 -1
  62. package/components/pipeline/NodeCard.vue +1 -1
  63. package/components/pipeline/PaletteItem.vue +1 -1
  64. package/components/pipeline/PalettePanel.jsx +3 -3
  65. package/components/pipeline/PalettePanel.vue +2 -2
  66. package/components/tabs/SegmentedControl.jsx +2 -2
  67. package/components/tabs/SegmentedControl.vue +2 -2
  68. package/index.html +1 -1
  69. package/package.json +12 -2
  70. package/tokens/dark.css +1 -1
  71. package/tokens/interaction.css +4 -0
  72. package/tokens/layout.css +3 -0
@@ -14,10 +14,10 @@ const contents = { display: 'contents' }
14
14
  :style="inline ? contents : { position: 'fixed', inset: 0, zIndex: 'var(--z-modal)', background: 'var(--bg-scrim)', display: 'flex', justifyContent: 'flex-end', animation: 'kb-fade-in var(--dur-fast) var(--ease-out)' }">
15
15
  <div @click.stop :style="inline ? contents : { height: '100%', animation: 'kb-slide-in var(--dur) var(--ease-out)' }">
16
16
  <div role="dialog" :aria-label="title" :data-size="size"
17
- :style="{ width: px(width), maxWidth: '100%', height: inline ? px(height) : '100%', background: 'var(--bg-raised)', borderLeft: '1px solid var(--border-card)', boxShadow: inline ? 'var(--shadow-menu)' : '-8px 0 24px rgba(0,0,0,.12)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', ...(inline ? { border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)' } : null), ...style }">
17
+ :style="{ width: px(width), maxWidth: '100%', height: inline ? px(height) : '100%', background: 'var(--bg-raised)', borderLeft: '1px solid var(--border-card)', boxShadow: inline ? 'var(--shadow-menu)' : 'var(--shadow-drawer)', display: 'flex', flexDirection: 'column', boxSizing: 'border-box', ...(inline ? { border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)' } : null), ...style }">
18
18
  <div style="display:flex;align-items:flex-start;justify-content:space-between;padding:16px 16px 12px;border-bottom:1px solid var(--border-card);flex-shrink:0">
19
- <div style="display:flex;flex-direction:column;gap:3px">
20
- <span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading)">{{ title }}</span>
19
+ <div style="display:flex;flex-direction:column;gap:3px;min-width:0">
20
+ <span style="font-family:var(--font-sans);font-weight:var(--weight-semibold);font-size:var(--text-md);line-height:var(--leading-md);letter-spacing:var(--tracking-md);color:var(--text-heading);min-width:0;overflow-wrap:anywhere">{{ title }}</span>
21
21
  <span v-if="subtitle" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)">{{ subtitle }}</span>
22
22
  </div>
23
23
  <IconButton icon="x" label="Close" :style="{ margin: 'calc((var(--leading-md) - var(--control-h)) / 2) -6px 0 0' }" @click="emit('close')" />
@@ -0,0 +1,71 @@
1
+ # Vue 消息服务与 Element Plus 迁移
2
+
3
+ 2.6.0 新增源码入口,适用于 Vue 3,复用 Keybricks Toast、Button、IconButton 和 Icon。
4
+ 本次仅补齐源码,未执行测试、类型检查、构建或浏览器验证,也尚未发布。
5
+
6
+ ```js
7
+ import Message from '@keyfunc/keybricks-ds/vue/message'
8
+ import MessageBox from '@keyfunc/keybricks-ds/vue/message-box'
9
+ import '@keyfunc/keybricks-ds/styles.css'
10
+
11
+ const notice = Message({ message: '保存成功', type: 'success', showClose: true })
12
+ notice.close()
13
+ Message.warning({ message: '请检查输入', duration: 6000 })
14
+ Message.closeAll()
15
+
16
+ await MessageBox.confirm('确认删除?', '删除', {
17
+ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning',
18
+ })
19
+ const { value } = await MessageBox.prompt('请输入名称', '新建', {
20
+ inputValidator: value => value.trim() ? true : '名称不能为空',
21
+ })
22
+ ```
23
+
24
+ ## 已提供的能力
25
+
26
+ | 原接口 | 新服务 |
27
+ | --- | --- |
28
+ | `ElMessage(options / string / VNode)` | `Message(...)`,返回 `{ close() }` |
29
+ | `success / info / warning / error` | 同名快捷方法 |
30
+ | `duration / showClose / offset / zIndex / customClass / center` | 支持;默认 3000 毫秒,0 不自动关闭 |
31
+ | 消息堆叠与 `grouping` | 同一挂载目标按真实高度向下堆叠;分组显示重复次数并重置计时 |
32
+ | 悬停与焦点暂停计时 | 支持,离开后重新计时 |
33
+ | `onClose / closeAll(type?)` | 支持,手动及自动关闭均清理实例 |
34
+ | `MessageBox(options)` | 返回 Promise,弹窗按调用顺序排队 |
35
+ | `alert / confirm / prompt` | 支持 `(message, title?, options?, appContext?)` 和 `(message, options?, appContext?)` |
36
+ | 确认、取消、关闭 | 确认 resolve;取消 reject `'cancel'`;关闭默认 reject `'cancel'`,开启 `distinguishCancelAndClose` 后 reject `'close'` |
37
+ | 输入结果 | `showInput` 开启时确认返回 `{ value, action: 'confirm' }`,包括 confirm 配合 `showInput` 的场景 |
38
+ | `inputValue / inputType / inputPlaceholder` | 支持,`textarea` 支持换行 |
39
+ | `inputPattern / inputValidator / inputErrorMessage` | 支持;额外支持异步校验,输入变更或关闭后忽略过期结果 |
40
+ | `beforeClose(action, instance, done)` | 调用 done 才关闭;instance 提供可写的输入值、按钮文案、loading、disabled 等状态 |
41
+ | `callback` | 有 callback 时按原服务方式回调,不结算返回的 Promise;开启输入时回调第一个参数为 `{ value, action }` |
42
+ | `MessageBox.close()` | 清空队列,不调用操作回调,不结算已有 Promise |
43
+ | 按钮显示、文案、class、loading、disabled | 支持;`el-button--danger` 显式映射为 DS danger 按钮 |
44
+ | `closeOnClickModal / closeOnPressEscape / showClose` | 分别控制遮罩、Esc、关闭按钮;alert 默认禁用遮罩和 Esc 关闭 |
45
+ | 键盘及滚动 | 焦点圈定、初始聚焦、关闭还原焦点、引用计数锁定 body 滚动 |
46
+ | 内容及图标 | 消息支持字符串、VNode、返回 VNode 的函数;icon 支持 DS 图标名、Vue 组件或 VNode |
47
+ | `appendTo / appContext` | 支持挂载目标和应用上下文,后者作为第二参数或快捷方法最后一个参数传入 |
48
+
49
+ ## 兼容边界
50
+
51
+ 这不是 Element Plus 的完整同构实现,不能宣称任意项目可完美替换。
52
+
53
+ - 默认采用 DS 外观和 class;原来的 `.el-message`、`.el-message-box` 等选择器及依赖其 DOM 的逻辑需要调整。`customClass` 会保留,应用需检查类名对应的具体规则。
54
+ - 不读取 Element Plus 的 ConfigProvider、locale、全局 zIndex 或应用插件配置。可通过 `Message.defaults` 和 `MessageBox.defaults` 设置文案、层级、挂载目标;切换语言时更新 defaults。默认按钮文案为英文。
55
+ - 默认层级采用 `--z-toast / --z-modal`。与 Element Plus 弹层混用时,应用应根据现有弹层层级设置 defaults.zIndex。
56
+ - 不提供 `draggable / overflow / buttonSize / closeIcon / loadingIcon / showHeader`、消息 `placement` 等完整扩展接口;`plain` 当前仅移除消息阴影。实例不是 Element Plus 的组件内部实例,仅提供文档约定的状态。
57
+ - `appendTo` 只改变挂载节点,定位仍为 fixed;自定义目标的 transform、主题、语言继承以及与其他弹层的焦点管理需要应用确认。
58
+ - 默认字符串按文本渲染。`dangerouslyUseHTMLString` 显式开启后按 HTML 渲染;DS 不做 HTML 净化,应用必须保留自己的安全处理。
59
+ - 本服务仅支持 Vue;React 原有 Toast、Modal 接口不受影响。服务使用独立子路径导出,不依赖生成的 Vue barrel。
60
+
61
+ ## emp2 的接入边界
62
+
63
+ `vue3/ts/message.ts` 中的业务行为应保留:HTML 净化、错误脱敏、SAP 字符处理、国际化、错误详情开关、默认持续时间、基于 id 的去重和 `closeMessage`。
64
+ DS 发布并更新应用安装版本后,再把文件里的 `ElMessage / ElMessageBox` 导入改为上述两个服务;业务封装继续调用相同方法。
65
+ 接入时还需配置当前语言的默认按钮文案、与现有弹层兼容的 zIndex,并检查 `limited-height-message` 和其他依赖 Element DOM 的自定义样式。
66
+ 本轮未修改业务封装,也未修改应用依赖或 lock 文件。
67
+
68
+ ## 发布说明
69
+
70
+ 源码版本已更新为 2.6.0,changelog 标记为 unreleased。发布前需要由维护者执行仓库正常的生成及检查流程,使包含包版本的 token 元数据同步到新版本。
71
+ 按本次任务约束,未运行这些命令,未查看或修改生成的 barrel、token 产物、文档 bundle 或 dist,也未执行 Git 操作。
@@ -3,17 +3,17 @@ import { computed } from 'vue'
3
3
  import Icon from '../icons/Icon.vue'
4
4
  import IconButton from '../buttons/IconButton.vue'
5
5
  const toastTones = { success: { icon: 'circle-check', color: 'var(--success-strong)' }, danger: { icon: 'triangle-alert', color: 'var(--danger)' }, warning: { icon: 'triangle-alert', color: 'var(--warning)' }, info: { icon: 'info', color: 'var(--action-primary)' } }
6
- const props = defineProps({ tone: { type: String, default: 'success' }, title: String, meta: String, dismissible: Boolean, style: Object })
6
+ const props = defineProps({ tone: { type: String, default: 'success' }, title: String, meta: String, dismissible: Boolean, closeLabel: { type: String, default: 'Dismiss' }, style: Object })
7
7
  const emit = defineEmits(['close'])
8
- const t = computed(() => toastTones[props.tone])
8
+ const t = computed(() => toastTones[props.tone] || toastTones.info)
9
9
  </script>
10
10
  <template>
11
11
  <div :style="{ display: 'flex', alignItems: 'flex-start', gap: '10px', minWidth: '280px', maxWidth: '380px', background: 'var(--bg-raised)', border: '1px solid var(--border-card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-menu)', padding: '12px 12px 12px 14px', animation: 'kb-toast-in var(--dur) var(--ease-out)', ...style }">
12
- <span :style="{ color: t.color, display: 'inline-flex', alignItems: 'center', flexShrink: 0, height: 'var(--leading-base)' }"><Icon :name="t.icon" :size="16" /></span>
12
+ <span :style="{ color: t.color, display: 'inline-flex', alignItems: 'center', flexShrink: 0, height: 'var(--leading-base)' }"><slot name="icon"><Icon :name="t.icon" :size="16" /></slot></span>
13
13
  <div style="display:flex;flex-direction:column;flex:1;min-width:0">
14
- <span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-heading)">{{ title }}</span>
14
+ <span style="font-family:var(--font-sans);font-weight:var(--weight-medium);font-size:var(--text-base);line-height:var(--leading-base);letter-spacing:var(--tracking-base);color:var(--text-heading);overflow-wrap:anywhere"><slot>{{ title }}</slot></span>
15
15
  <span v-if="meta" style="font-family:var(--font-sans);font-size:var(--text-xs);line-height:var(--leading-xs);color:var(--text-secondary)">{{ meta }}</span>
16
16
  </div>
17
- <IconButton v-if="dismissible" icon="x" size="sm" muted label="Dismiss" :style="{ margin: '-2px 0' }" @click="emit('close')" />
17
+ <IconButton v-if="dismissible" icon="x" size="sm" muted :label="closeLabel" :style="{ margin: '-2px 0' }" @click="emit('close')" />
18
18
  </div>
19
19
  </template>
@@ -0,0 +1,237 @@
1
+ <script setup>
2
+ import { computed, h, isVNode, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
3
+ import Button from '../../buttons/Button.vue'
4
+ import IconButton from '../../buttons/IconButton.vue'
5
+ import Icon from '../../icons/Icon.vue'
6
+ import { contentNode, lockBody } from './service-utils.js'
7
+
8
+ const props = defineProps({ options: { type: Object, required: true } })
9
+ const emit = defineEmits(['action'])
10
+ const panel = ref(null)
11
+ const input = ref(null)
12
+ const error = ref('')
13
+ const validating = ref(false)
14
+ const Content = () => contentNode(props.options.message, props.options.dangerouslyUseHTMLString)
15
+ const Title = () => contentNode(props.options.title)
16
+ const icons = { success: 'circle-check', warning: 'triangle-alert', error: 'triangle-alert', info: 'info' }
17
+ const colors = { success: 'var(--success-strong)', warning: 'var(--warning)', error: 'var(--danger)', info: 'var(--action-primary)' }
18
+ const icon = computed(() => props.options.icon || icons[props.options.type])
19
+ const CustomIcon = () => isVNode(icon.value) ? icon.value : h(icon.value)
20
+ const confirmVariant = computed(() => props.options.confirmButtonClass?.includes('el-button--danger') ? 'danger' : 'primary')
21
+ let unlock
22
+ let previousFocus
23
+ let disposed = false
24
+ let validationRevision = 0
25
+
26
+ async function confirm() {
27
+ const options = props.options
28
+ if (validating.value || options.confirmButtonDisabled || options.confirmButtonLoading) return
29
+ if (options.showInput) {
30
+ const revision = ++validationRevision
31
+ const value = options.inputValue ?? ''
32
+ validating.value = true
33
+ let message = ''
34
+ try {
35
+ if (options.inputPattern) {
36
+ // 避免全局正则的 lastIndex 导致同一输入校验结果交替。
37
+ const pattern = new RegExp(options.inputPattern.source, options.inputPattern.flags)
38
+ if (!pattern.test(value)) message = options.inputErrorMessage || 'Invalid input'
39
+ }
40
+ if (!message && options.inputValidator) {
41
+ const result = await options.inputValidator(value)
42
+ if (result === false || typeof result === 'string') message = result || options.inputErrorMessage || 'Invalid input'
43
+ }
44
+ } catch (reason) {
45
+ message = options.inputErrorMessage || reason?.message || 'Invalid input'
46
+ } finally {
47
+ validating.value = false
48
+ }
49
+ if (disposed || revision !== validationRevision) return
50
+ error.value = message
51
+ if (message) { input.value?.focus(); return }
52
+ }
53
+ emit('action', 'confirm')
54
+ }
55
+
56
+ function close() { emit('action', 'close') }
57
+ function onKeydown(event) {
58
+ if (event.key === 'Escape' && props.options.closeOnPressEscape) {
59
+ event.preventDefault()
60
+ event.stopImmediatePropagation()
61
+ close()
62
+ }
63
+ if (event.key !== 'Tab') return
64
+ const focusable = [...panel.value.querySelectorAll('button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])')]
65
+ .filter(node => node.getClientRects().length)
66
+ const first = focusable[0], last = focusable[focusable.length - 1]
67
+ if (!first) { event.preventDefault(); panel.value.focus(); return }
68
+ if (event.shiftKey && (document.activeElement === first || !panel.value.contains(document.activeElement))) {
69
+ event.preventDefault(); last.focus()
70
+ } else if (!event.shiftKey && (document.activeElement === last || !panel.value.contains(document.activeElement))) {
71
+ event.preventDefault(); first.focus()
72
+ }
73
+ }
74
+ function keepFocus(event) {
75
+ if (!panel.value.contains(event.target)) panel.value.focus()
76
+ }
77
+ function changeInput(event) {
78
+ validationRevision++
79
+ props.options.inputValue = event.target.value
80
+ error.value = ''
81
+ }
82
+ onMounted(async () => {
83
+ previousFocus = document.activeElement
84
+ if (props.options.lockScroll) unlock = lockBody()
85
+ document.addEventListener('keydown', onKeydown, true)
86
+ document.addEventListener('focusin', keepFocus)
87
+ await nextTick()
88
+ if (disposed || !props.options.autofocus) return
89
+ const target = input.value || panel.value.querySelector('[data-confirm] button') || panel.value
90
+ target.focus()
91
+ if (input.value) input.value.select()
92
+ })
93
+ onBeforeUnmount(() => {
94
+ disposed = true
95
+ unlock?.()
96
+ document.removeEventListener('keydown', onKeydown, true)
97
+ document.removeEventListener('focusin', keepFocus)
98
+ if (previousFocus?.isConnected) previousFocus.focus()
99
+ })
100
+ </script>
101
+
102
+ <template>
103
+ <div
104
+ :class="['kb-message-box-overlay', options.modalClass]"
105
+ :style="{ zIndex: options.zIndex ?? 'var(--z-modal)', background: options.modal === false ? 'transparent' : undefined }"
106
+ @click.self="options.closeOnClickModal && close()"
107
+ >
108
+ <section
109
+ ref="panel"
110
+ :class="['kb-message-box', options.customClass, { 'kb-message-box--center': options.center, 'kb-message-box--round': options.roundButton }]"
111
+ :style="{ width: typeof options.width === 'number' ? `${options.width}px` : options.width }"
112
+ :data-size="options.size || 'lg'"
113
+ :aria-labelledby="`${options.id}-title`"
114
+ :aria-describedby="`${options.id}-content`"
115
+ role="alertdialog"
116
+ aria-modal="true"
117
+ tabindex="-1"
118
+ >
119
+ <header class="kb-message-box__header">
120
+ <span :id="`${options.id}-title`"><Title /></span>
121
+ <IconButton v-if="options.showClose" icon="x" :label="options.closeLabel || 'Close'" @click="close" />
122
+ </header>
123
+ <div :id="`${options.id}-content`" class="kb-message-box__body">
124
+ <div class="kb-message-box__message">
125
+ <span v-if="icon" :style="{ color: colors[options.type], display: 'inline-flex' }">
126
+ <Icon v-if="typeof icon === 'string'" :name="icon" :size="20" />
127
+ <CustomIcon v-else />
128
+ </span>
129
+ <div><Content /></div>
130
+ </div>
131
+ <div v-if="options.showInput" class="kb-message-box__input-wrap">
132
+ <component
133
+ :is="options.inputType === 'textarea' ? 'textarea' : 'input'"
134
+ ref="input"
135
+ class="kb-message-box__input"
136
+ :type="options.inputType === 'textarea' ? undefined : options.inputType"
137
+ :value="options.inputValue"
138
+ :placeholder="options.inputPlaceholder"
139
+ :aria-label="options.inputPlaceholder || options.title || 'Input'"
140
+ :aria-invalid="!!error"
141
+ :aria-describedby="error ? `${options.id}-error` : undefined"
142
+ @input="changeInput"
143
+ @keydown.enter="!$event.isComposing && options.inputType !== 'textarea' && confirm()"
144
+ />
145
+ <div v-if="error" :id="`${options.id}-error`" class="kb-message-box__error" role="alert">{{ error }}</div>
146
+ </div>
147
+ </div>
148
+ <footer class="kb-message-box__footer">
149
+ <Button
150
+ v-if="options.showCancelButton"
151
+ variant="outline"
152
+ :class="options.cancelButtonClass"
153
+ :disabled="options.cancelButtonDisabled"
154
+ :loading="options.cancelButtonLoading"
155
+ @click="emit('action', 'cancel')"
156
+ >
157
+ {{ options.cancelButtonText ?? 'Cancel' }}
158
+ </Button>
159
+ <span v-if="options.showConfirmButton" data-confirm>
160
+ <Button
161
+ :variant="confirmVariant"
162
+ :class="options.confirmButtonClass"
163
+ :disabled="options.confirmButtonDisabled"
164
+ :loading="options.confirmButtonLoading || validating"
165
+ @click="confirm"
166
+ >
167
+ {{ options.confirmButtonText ?? 'OK' }}
168
+ </Button>
169
+ </span>
170
+ </footer>
171
+ </section>
172
+ </div>
173
+ </template>
174
+
175
+ <style scoped>
176
+ .kb-message-box-overlay {
177
+ position: fixed;
178
+ inset: 0;
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ background: var(--bg-scrim);
183
+ animation: kb-fade-in var(--dur-fast) var(--ease-out);
184
+ }
185
+ .kb-message-box {
186
+ width: 440px;
187
+ max-width: 92vw;
188
+ max-height: 92vh;
189
+ overflow: auto;
190
+ box-sizing: border-box;
191
+ background: var(--bg-raised);
192
+ border: 1px solid var(--border-card);
193
+ border-radius: var(--radius-lg);
194
+ box-shadow: var(--shadow-modal);
195
+ font-family: var(--font-sans);
196
+ font-size: var(--text-base);
197
+ line-height: var(--leading-base);
198
+ letter-spacing: var(--tracking-base);
199
+ color: var(--text-body);
200
+ animation: kb-rise var(--dur) var(--ease-out);
201
+ }
202
+ .kb-message-box__header {
203
+ display: flex;
204
+ align-items: center;
205
+ justify-content: space-between;
206
+ gap: var(--group-gap);
207
+ padding: var(--section-gap) var(--section-gap) 0;
208
+ font-weight: var(--weight-semibold);
209
+ font-size: var(--text-md);
210
+ line-height: var(--leading-md);
211
+ letter-spacing: var(--tracking-md);
212
+ color: var(--text-heading);
213
+ }
214
+ .kb-message-box__header > span { min-width: 0; overflow-wrap: anywhere; }
215
+ .kb-message-box__body { padding: 20px var(--section-gap) 0; }
216
+ .kb-message-box__message { display: flex; align-items: flex-start; gap: var(--control-px); }
217
+ .kb-message-box__message > div { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
218
+ .kb-message-box__input-wrap { margin-top: var(--group-gap); }
219
+ .kb-message-box__input {
220
+ box-sizing: border-box;
221
+ width: 100%;
222
+ min-height: var(--control-h);
223
+ padding: 0 var(--control-px);
224
+ font: inherit;
225
+ color: var(--text-heading);
226
+ background: var(--bg-surface);
227
+ border: 1px solid var(--border-control);
228
+ border-radius: var(--radius-md);
229
+ }
230
+ .kb-message-box__input:focus { outline: none; border-color: var(--focus-border); box-shadow: var(--ring-focus); }
231
+ .kb-message-box__input[aria-invalid="true"] { border-color: var(--danger); }
232
+ .kb-message-box__error { margin-top: var(--control-gap); color: var(--danger-text); }
233
+ .kb-message-box__footer { display: flex; justify-content: flex-end; gap: var(--control-gap); padding: var(--section-gap); }
234
+ .kb-message-box--center .kb-message-box__message { justify-content: center; text-align: center; }
235
+ .kb-message-box--center .kb-message-box__footer { justify-content: center; }
236
+ .kb-message-box--round :deep(.kb-message-box__footer button) { border-radius: var(--radius-full) !important; }
237
+ </style>
@@ -0,0 +1,73 @@
1
+ <script setup>
2
+ import { computed, h, isVNode, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
+ import Toast from '../Toast.vue'
4
+ import Icon from '../../icons/Icon.vue'
5
+ import { contentNode } from './service-utils.js'
6
+
7
+ const props = defineProps({ options: { type: Object, required: true } })
8
+ const emit = defineEmits(['close', 'height'])
9
+ const root = ref(null)
10
+ const tone = computed(() => props.options.type === 'error' ? 'danger' : props.options.type)
11
+ const Content = () => contentNode(props.options.message, props.options.dangerouslyUseHTMLString)
12
+ const CustomIcon = () => typeof props.options.icon === 'string'
13
+ ? h(Icon, { name: props.options.icon, size: 16 })
14
+ : isVNode(props.options.icon) ? props.options.icon : h(props.options.icon)
15
+ let timer
16
+ let observer
17
+ let paused = false
18
+ function stop() { clearTimeout(timer) }
19
+ function start() {
20
+ stop()
21
+ if (!paused && props.options.duration > 0) timer = setTimeout(() => emit('close'), props.options.duration)
22
+ }
23
+ function onEscape(event) {
24
+ if (event.key === 'Escape') emit('close')
25
+ }
26
+ watch(() => [props.options.restart, props.options.duration], start)
27
+ onMounted(() => {
28
+ start()
29
+ const measure = () => emit('height', root.value.getBoundingClientRect().height)
30
+ measure()
31
+ if (typeof ResizeObserver !== 'undefined') {
32
+ observer = new ResizeObserver(measure)
33
+ observer.observe(root.value)
34
+ }
35
+ document.addEventListener('keydown', onEscape)
36
+ })
37
+ onBeforeUnmount(() => {
38
+ stop()
39
+ observer?.disconnect()
40
+ document.removeEventListener('keydown', onEscape)
41
+ })
42
+ </script>
43
+
44
+ <template>
45
+ <div
46
+ :id="options.id"
47
+ ref="root"
48
+ :class="['kb-message', options.customClass, { 'kb-message--center': options.center, 'kb-message--plain': options.plain }]"
49
+ :role="options.type === 'error' || options.type === 'warning' ? 'alert' : 'status'"
50
+ :style="{ position: 'fixed', top: `${options.top}px`, left: '50%', transform: 'translateX(-50%)', zIndex: options.zIndex ?? 'var(--z-toast)', maxWidth: '92vw' }"
51
+ @mouseenter="paused = true; stop()"
52
+ @mouseleave="paused = false; start()"
53
+ @focusin="paused = true; stop()"
54
+ @focusout="paused = false; start()"
55
+ >
56
+ <Toast
57
+ :tone="tone"
58
+ :dismissible="options.showClose"
59
+ :close-label="options.closeLabel"
60
+ :style="{ minWidth: 'min(280px, 92vw)', maxWidth: '100%', boxSizing: 'border-box', ...(options.plain ? { boxShadow: 'none' } : {}) }"
61
+ @close="emit('close')"
62
+ >
63
+ <template v-if="options.icon" #icon><CustomIcon /></template>
64
+ <Content />
65
+ <span v-if="options.repeatNum > 1" class="kb-message__count"> × {{ options.repeatNum }}</span>
66
+ </Toast>
67
+ </div>
68
+ </template>
69
+
70
+ <style scoped>
71
+ .kb-message--center { text-align: center; }
72
+ .kb-message__count { color: var(--text-secondary); font-variant-numeric: tabular-nums; }
73
+ </style>
@@ -0,0 +1,46 @@
1
+ import { h, isVNode, render } from 'vue'
2
+
3
+ // 默认按文本渲染,只有调用方显式开启时才渲染可信 HTML。
4
+ export function contentNode(message, html = false) {
5
+ const value = typeof message === 'function' ? message() : message
6
+ if (isVNode(value) || Array.isArray(value)) return value
7
+ return html ? h('span', { innerHTML: String(value ?? '') }) : String(value ?? '')
8
+ }
9
+
10
+ export function resolveTarget(options = {}) {
11
+ const target = typeof options.appendTo === 'string'
12
+ ? document.querySelector(options.appendTo) : options.appendTo || document.body
13
+ if (!target) throw new Error('Keybricks: appendTo 未找到挂载目标')
14
+ return target
15
+ }
16
+
17
+ export function mountService(component, props, options = {}, appContext) {
18
+ const target = resolveTarget(options)
19
+ const container = document.createElement('div')
20
+ const vnode = h(component, props)
21
+ vnode.appContext = appContext || null
22
+ target.appendChild(container)
23
+ try {
24
+ render(vnode, container)
25
+ } catch (error) {
26
+ render(null, container)
27
+ container.remove()
28
+ throw error
29
+ }
30
+ return () => {
31
+ render(null, container)
32
+ container.remove()
33
+ }
34
+ }
35
+
36
+ let locks = 0
37
+ let previousOverflow
38
+ export function lockBody() {
39
+ if (locks++ === 0) {
40
+ previousOverflow = document.body.style.overflow
41
+ document.body.style.overflow = 'hidden'
42
+ }
43
+ return () => {
44
+ if (--locks === 0) document.body.style.overflow = previousOverflow
45
+ }
46
+ }
@@ -0,0 +1,69 @@
1
+ import type { AppContext, Component, VNode } from 'vue';
2
+ import type { MessageContent, MessageType } from './message.js';
3
+
4
+ export type MessageBoxAction = 'confirm' | 'cancel' | 'close';
5
+ export interface MessageBoxInputResult { value: string; action: MessageBoxAction; }
6
+ export type MessageBoxResult = MessageBoxAction | MessageBoxInputResult;
7
+ export interface MessageBoxOptions {
8
+ title?: MessageContent;
9
+ message?: MessageContent;
10
+ type?: MessageType | '';
11
+ icon?: string | Component | VNode;
12
+ width?: string | number;
13
+ size?: 'sm' | 'md' | 'lg';
14
+ customClass?: string;
15
+ modalClass?: string;
16
+ zIndex?: number;
17
+ appendTo?: string | HTMLElement;
18
+ modal?: boolean;
19
+ lockScroll?: boolean;
20
+ autofocus?: boolean;
21
+ center?: boolean;
22
+ roundButton?: boolean;
23
+ showClose?: boolean;
24
+ closeLabel?: string;
25
+ closeOnClickModal?: boolean;
26
+ closeOnPressEscape?: boolean;
27
+ distinguishCancelAndClose?: boolean;
28
+ dangerouslyUseHTMLString?: boolean;
29
+ showConfirmButton?: boolean;
30
+ showCancelButton?: boolean;
31
+ confirmButtonText?: string;
32
+ cancelButtonText?: string;
33
+ confirmButtonClass?: string;
34
+ cancelButtonClass?: string;
35
+ confirmButtonLoading?: boolean;
36
+ cancelButtonLoading?: boolean;
37
+ confirmButtonDisabled?: boolean;
38
+ cancelButtonDisabled?: boolean;
39
+ showInput?: boolean;
40
+ inputValue?: string;
41
+ inputType?: string;
42
+ inputPlaceholder?: string;
43
+ inputPattern?: RegExp;
44
+ inputErrorMessage?: string;
45
+ inputValidator?: (value: string) => boolean | string | Promise<boolean | string>;
46
+ beforeClose?: (action: MessageBoxAction, instance: MessageBoxState, done: () => void) => void;
47
+ callback?: (result: MessageBoxResult, instance: MessageBoxState) => void;
48
+ }
49
+ export interface MessageBoxState extends MessageBoxOptions {
50
+ inputValue: string;
51
+ confirmButtonLoading: boolean;
52
+ cancelButtonLoading: boolean;
53
+ action?: MessageBoxAction;
54
+ }
55
+ export interface MessageBoxShortcut<Result = MessageBoxResult> {
56
+ (message: MessageContent, title?: MessageContent, options?: MessageBoxOptions, appContext?: AppContext | null): Promise<Result>;
57
+ (message: MessageContent, options?: MessageBoxOptions, appContext?: AppContext | null): Promise<Result>;
58
+ }
59
+ export interface MessageBoxService {
60
+ (options?: MessageBoxOptions | MessageContent, appContext?: AppContext | null): Promise<MessageBoxResult>;
61
+ alert: MessageBoxShortcut;
62
+ confirm: MessageBoxShortcut;
63
+ prompt: MessageBoxShortcut<MessageBoxInputResult>;
64
+ /** 清空队列,不结算已有 Promise,与 ElMessageBox.close 相同。 */
65
+ close(): void;
66
+ defaults: Partial<MessageBoxOptions>;
67
+ }
68
+ export declare const MessageBox: MessageBoxService;
69
+ export default MessageBox;
@@ -0,0 +1,92 @@
1
+ import { getCurrentInstance, isVNode, reactive } from 'vue'
2
+ import MessageBoxView from './_internal/MessageBoxView.vue'
3
+ import { mountService } from './_internal/service-utils.js'
4
+
5
+ const queue = []
6
+ let active
7
+ let seed = 0
8
+
9
+ function next() {
10
+ if (active || !queue.length) return
11
+ const entry = active = queue.shift()
12
+ try {
13
+ entry.dispose = mountService(MessageBoxView, {
14
+ options: entry.state,
15
+ onAction: action => requestAction(entry, action),
16
+ }, entry.state, entry.context)
17
+ } catch (error) {
18
+ active = undefined
19
+ entry.reject(error)
20
+ next()
21
+ }
22
+ }
23
+
24
+ function finish(entry, action) {
25
+ if (active !== entry || entry.closed) return
26
+ entry.closed = true
27
+ entry.dispose?.()
28
+ active = undefined
29
+ const result = entry.state.showInput ? { value: entry.state.inputValue, action } : action
30
+ try {
31
+ if (entry.state.callback) entry.state.callback(result, entry.state)
32
+ else if (action === 'confirm') entry.resolve(result)
33
+ else entry.reject(action === 'close' && !entry.state.distinguishCancelAndClose ? 'cancel' : action)
34
+ } finally {
35
+ next()
36
+ }
37
+ }
38
+
39
+ function requestAction(entry, action) {
40
+ if (entry.closed || active !== entry) return
41
+ entry.state.action = action
42
+ const done = () => finish(entry, action)
43
+ if (entry.state.beforeClose) entry.state.beforeClose(action, entry.state, done)
44
+ else done()
45
+ }
46
+
47
+ /** Vue 弹窗服务;队列内同一时刻只显示一个确认框。 */
48
+ export function MessageBox(value = {}, appContext) {
49
+ if (typeof document === 'undefined') return Promise.reject(new Error('Keybricks MessageBox 需要浏览器环境'))
50
+ const options = typeof value === 'string' || typeof value === 'function' || isVNode(value) ? { message: value } : value
51
+ const state = reactive({
52
+ id: `kb-message-box-${++seed}`, title: '', message: '', type: '',
53
+ showClose: true, showConfirmButton: true, showCancelButton: false,
54
+ closeOnClickModal: true, closeOnPressEscape: true, lockScroll: true,
55
+ autofocus: true, inputValue: '', inputType: 'text', inputErrorMessage: '',
56
+ confirmButtonLoading: false, cancelButtonLoading: false,
57
+ confirmButtonDisabled: false, cancelButtonDisabled: false,
58
+ ...MessageBox.defaults, ...options,
59
+ })
60
+ return new Promise((resolve, reject) => {
61
+ queue.push({ state, resolve, reject, context: appContext || getCurrentInstance()?.appContext })
62
+ next()
63
+ })
64
+ }
65
+
66
+ for (const kind of ['alert', 'confirm', 'prompt']) {
67
+ MessageBox[kind] = (message, title, options, appContext) => {
68
+ if (title && typeof title === 'object' && !isVNode(title)) {
69
+ appContext = options
70
+ options = title
71
+ title = undefined
72
+ }
73
+ return MessageBox({
74
+ boxType: kind, showCancelButton: kind !== 'alert', showInput: kind === 'prompt',
75
+ closeOnClickModal: kind !== 'alert', closeOnPressEscape: kind !== 'alert',
76
+ ...options, message, ...(title === undefined ? {} : { title }),
77
+ }, appContext)
78
+ }
79
+ }
80
+
81
+ // 与 ElMessageBox.close 一致:清空所有弹窗,不触发用户操作回调或结算 Promise。
82
+ MessageBox.close = () => {
83
+ queue.length = 0
84
+ if (active) {
85
+ const entry = active
86
+ active = undefined
87
+ entry.closed = true
88
+ entry.dispose?.()
89
+ }
90
+ }
91
+ export default MessageBox
92
+ MessageBox.defaults = {}