@mtn-ui-z/utils 0.0.1

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.
package/src/theme.ts ADDED
@@ -0,0 +1,502 @@
1
+ /**
2
+ * 运行时主题 / 密度 / 色板
3
+ *
4
+ * - CSS 变量由 @mtn-ui-z/theme 在 :root 提供默认值;业务未分层样式写在最后可覆盖 theme(见 theme README 的 @layer 说明)。
5
+ * - 亮/暗:`setThemeMode` 维护 `data-theme="dark"`(亮为移除属性),与 `[data-theme="dark"]` 令牌对齐。
6
+ * - 紧凑:`setDensityMode` 维护 `data-mtn-density="compact"`(舒适为移除属性)。
7
+ * - Ant:根组件使用 ConfigProvider,`:theme` 绑定含 `antThemeConfigRef` 与 `algorithm`(随 themeModeRef),见官方定制主题文档。
8
+ */
9
+
10
+ import { nextTick, ref } from 'vue'
11
+
12
+ /** 可覆盖的主题色(与 theme tokens 中变量对应) */
13
+ export interface ThemeColorOverrides {
14
+ primary?: string
15
+ primaryHover?: string
16
+ primaryActive?: string
17
+ success?: string
18
+ successHover?: string
19
+ warning?: string
20
+ warningHover?: string
21
+ danger?: string
22
+ dangerHover?: string
23
+ dangerActive?: string
24
+ info?: string
25
+ infoHover?: string
26
+ }
27
+
28
+ /** 亮色 / 暗色模式 */
29
+ export type ThemeMode = 'light' | 'dark'
30
+
31
+ /**
32
+ * 全局紧凑程度(与 html 上 data-mtn-density 一致)
33
+ * - default:舒适,不写属性
34
+ * - compact:紧凑,写入 data-mtn-density="compact"
35
+ */
36
+ export type DensityMode = 'default' | 'compact'
37
+
38
+ /** 色板名:`default` 表示不设置 data-mtn-preset;其余需 @mtn-ui-z/theme 或业务提供对应样式 */
39
+ export const MTN_THEME_PRESETS = ['default', 'ocean'] as const
40
+
41
+ /** documentElement 上 data-mtn-preset 的合法值 */
42
+ export type MtnThemePreset = (typeof MTN_THEME_PRESETS)[number]
43
+
44
+ const MTN_PRESET_ATTR = 'data-mtn-preset'
45
+
46
+ /**
47
+ * Ant Design Vue 4 ConfigProvider 的 theme.token 子集
48
+ * 颜色/圆角来自 CSS 变量;尺寸类来自 --mtn-control-height、--mtn-padding-* 等(随密度变化)
49
+ */
50
+ export interface MtnAntThemeToken {
51
+ colorPrimary?: string
52
+ colorPrimaryHover?: string
53
+ colorPrimaryActive?: string
54
+ /** Link 按钮/文本色(如 ant-btn-link、a 链接) */
55
+ colorLink?: string
56
+ colorLinkHover?: string
57
+ colorLinkActive?: string
58
+ borderRadius?: number
59
+ colorBgBase?: string
60
+ colorText?: string
61
+ colorTextSecondary?: string
62
+ colorBorder?: string
63
+ colorSuccess?: string
64
+ colorWarning?: string
65
+ colorError?: string
66
+ colorInfo?: string
67
+ /** 控件高度(px 数值),对应 --mtn-control-height */
68
+ controlHeight?: number
69
+ fontSize?: number
70
+ padding?: number
71
+ paddingXS?: number
72
+ paddingSM?: number
73
+ paddingLG?: number
74
+ margin?: number
75
+ marginXS?: number
76
+ marginSM?: number
77
+ marginLG?: number
78
+ }
79
+
80
+ /** 当前主题模式(亮/暗),供 ConfigProvider 与 algorithm 组合使用,并同步到 document.documentElement 的 data-theme */
81
+ export const themeModeRef = ref<ThemeMode>('light')
82
+
83
+ /** 当前密度(舒适 / 紧凑),与 data-mtn-density 同步 */
84
+ export const densityModeRef = ref<DensityMode>('default')
85
+
86
+ const THEME_VAR_MAP: Record<keyof ThemeColorOverrides, string> = {
87
+ primary: '--color-primary',
88
+ primaryHover: '--color-primary-hover',
89
+ primaryActive: '--color-primary-active',
90
+ success: '--color-success',
91
+ successHover: '--color-success-hover',
92
+ warning: '--color-warning',
93
+ warningHover: '--color-warning-hover',
94
+ danger: '--color-danger',
95
+ dangerHover: '--color-danger-hover',
96
+ dangerActive: '--color-danger-active',
97
+ info: '--color-info',
98
+ infoHover: '--color-info-hover'
99
+ }
100
+
101
+ function hexLighten(hex: string, percent: number): string {
102
+ const n = Math.round((percent / 100) * 255)
103
+ const r = Math.min(255, parseInt(hex.slice(1, 3), 16) + n)
104
+ const g = Math.min(255, parseInt(hex.slice(3, 5), 16) + n)
105
+ const b = Math.min(255, parseInt(hex.slice(5, 7), 16) + n)
106
+ return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')
107
+ }
108
+
109
+ function hexDarken(hex: string, percent: number): string {
110
+ const n = Math.round((percent / 100) * 255)
111
+ const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - n)
112
+ const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - n)
113
+ const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - n)
114
+ return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('')
115
+ }
116
+
117
+ const DEFAULT_PRIMARY = '#409eff'
118
+ const DEFAULT_HOVER_LIGHTEN = 15
119
+ const DEFAULT_ACTIVE_DARKEN = 10
120
+ const DEFAULT_BORDER_RADIUS = 4
121
+ const DEFAULT_CONTROL_HEIGHT = 32
122
+ const DEFAULT_FONT_SIZE = 14
123
+ const DEFAULT_PADDING_MD = 16
124
+ const DEFAULT_PADDING_SM = 12
125
+ const DEFAULT_PADDING_XS = 8
126
+ const DEFAULT_PADDING_LG = 24
127
+ const DEFAULT_MARGIN_MD = 16
128
+ const DEFAULT_MARGIN_SM = 12
129
+ const DEFAULT_MARGIN_XS = 8
130
+ const DEFAULT_MARGIN_LG = 24
131
+
132
+ function readCssVar(name: string): string {
133
+ if (typeof document === 'undefined') return ''
134
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
135
+ }
136
+
137
+ /** 解析 "14px" / "14" → 整数 px,供 Ant token 使用 */
138
+ function parsePxInt(value: string, fallback: number): number {
139
+ const v = value.trim()
140
+ if (!v) return fallback
141
+ const n = parseFloat(v)
142
+ return Number.isFinite(n) ? Math.round(n) : fallback
143
+ }
144
+
145
+ /** 供 Ant Design Vue ConfigProvider 使用的主题配置(响应式),绑定 :theme="antThemeConfigRef" 即可 */
146
+ export const antThemeConfigRef = ref<{ token: MtnAntThemeToken }>({
147
+ token: {
148
+ colorPrimary: DEFAULT_PRIMARY,
149
+ colorPrimaryHover: hexLighten(DEFAULT_PRIMARY, DEFAULT_HOVER_LIGHTEN),
150
+ colorPrimaryActive: hexDarken(DEFAULT_PRIMARY, DEFAULT_ACTIVE_DARKEN),
151
+ borderRadius: DEFAULT_BORDER_RADIUS,
152
+ controlHeight: DEFAULT_CONTROL_HEIGHT,
153
+ fontSize: DEFAULT_FONT_SIZE,
154
+ padding: DEFAULT_PADDING_MD,
155
+ paddingXS: DEFAULT_PADDING_XS,
156
+ paddingSM: DEFAULT_PADDING_SM,
157
+ paddingLG: DEFAULT_PADDING_LG,
158
+ margin: DEFAULT_MARGIN_MD,
159
+ marginXS: DEFAULT_MARGIN_XS,
160
+ marginSM: DEFAULT_MARGIN_SM,
161
+ marginLG: DEFAULT_MARGIN_LG
162
+ }
163
+ })
164
+
165
+ /**
166
+ * 从当前 document 计算样式读取 CSS 变量,写入 antThemeConfigRef(亮/暗、密度、预设、setThemePrimary 后由内部 nextTick 调用)
167
+ */
168
+ export function syncAntTokensFromCssVars(): void {
169
+ if (typeof document === 'undefined') return
170
+ const g = readCssVar
171
+ const rb = g('--radius-base')
172
+ const borderRadius = Math.round(parseFloat(rb)) || DEFAULT_BORDER_RADIUS
173
+ antThemeConfigRef.value = {
174
+ token: {
175
+ ...antThemeConfigRef.value.token,
176
+ colorPrimary: g('--color-primary') || antThemeConfigRef.value.token.colorPrimary,
177
+ colorPrimaryHover: g('--color-primary-hover') || antThemeConfigRef.value.token.colorPrimaryHover,
178
+ colorPrimaryActive: g('--color-primary-active') || antThemeConfigRef.value.token.colorPrimaryActive,
179
+ colorLink: g('--color-primary') || antThemeConfigRef.value.token.colorLink,
180
+ colorLinkHover: g('--color-primary-hover') || antThemeConfigRef.value.token.colorLinkHover,
181
+ colorLinkActive: g('--color-primary-active') || antThemeConfigRef.value.token.colorLinkActive,
182
+ borderRadius,
183
+ colorBgBase: g('--color-bg-base'),
184
+ colorText: g('--color-text-primary'),
185
+ colorTextSecondary: g('--color-text-secondary'),
186
+ colorBorder: g('--color-border-base'),
187
+ colorSuccess: g('--color-success'),
188
+ colorWarning: g('--color-warning'),
189
+ colorError: g('--color-danger'),
190
+ colorInfo: g('--color-info'),
191
+ controlHeight: parsePxInt(g('--mtn-control-height'), DEFAULT_CONTROL_HEIGHT),
192
+ fontSize: parsePxInt(g('--mtn-font-size-base'), DEFAULT_FONT_SIZE),
193
+ padding: parsePxInt(g('--mtn-padding-md'), DEFAULT_PADDING_MD),
194
+ paddingXS: parsePxInt(g('--mtn-padding-xs'), DEFAULT_PADDING_XS),
195
+ paddingSM: parsePxInt(g('--mtn-padding-sm'), DEFAULT_PADDING_SM),
196
+ paddingLG: parsePxInt(g('--mtn-padding-lg'), DEFAULT_PADDING_LG),
197
+ margin: parsePxInt(g('--mtn-margin-md'), DEFAULT_MARGIN_MD),
198
+ marginXS: parsePxInt(g('--mtn-margin-xs'), DEFAULT_MARGIN_XS),
199
+ marginSM: parsePxInt(g('--mtn-margin-sm'), DEFAULT_MARGIN_SM),
200
+ marginLG: parsePxInt(g('--mtn-margin-lg'), DEFAULT_MARGIN_LG)
201
+ }
202
+ }
203
+ }
204
+
205
+ export interface ApplyMtnThemeOptions {
206
+ mode?: ThemeMode
207
+ /** 舒适 default / 紧凑 compact */
208
+ density?: DensityMode
209
+ preset?: MtnThemePreset | string | null
210
+ colors?: ThemeColorOverrides
211
+ /** 合并到 token,在 sync 之后应用,可覆盖从 CSS 读到的值 */
212
+ antToken?: Partial<MtnAntThemeToken>
213
+ }
214
+
215
+ /** 一键应用:模式 / 密度 / 预设 / 颜色 / 可选 Ant token,并在下一 tick 与 CSS 变量对齐 */
216
+ export function applyMtnTheme(opts: ApplyMtnThemeOptions): void {
217
+ if (opts.mode != null) setThemeMode(opts.mode)
218
+ if (opts.density != null) setDensityMode(opts.density)
219
+ if (opts.preset !== undefined) setThemePreset(opts.preset)
220
+ if (opts.colors != null && Object.keys(opts.colors).length > 0) {
221
+ setThemeColors(opts.colors)
222
+ }
223
+ nextTick(() => {
224
+ syncAntTokensFromCssVars()
225
+ if (opts.antToken != null && Object.keys(opts.antToken).length > 0) {
226
+ antThemeConfigRef.value = {
227
+ token: { ...antThemeConfigRef.value.token, ...opts.antToken }
228
+ }
229
+ }
230
+ })
231
+ }
232
+
233
+ /**
234
+ * 设置主题色(写 CSS 变量 + 同步 Ant ConfigProvider token)
235
+ * 会写入 document.documentElement.style,并更新 antThemeConfigRef
236
+ *
237
+ * @param colors 要覆盖的颜色,键为 camelCase(如 primary、primaryHover)
238
+ */
239
+ export function setThemeColors(colors: ThemeColorOverrides): void {
240
+ if (colors.primary != null) {
241
+ const hover =
242
+ colors.primaryHover ?? hexLighten(colors.primary, DEFAULT_HOVER_LIGHTEN)
243
+ const active =
244
+ colors.primaryActive ?? hexDarken(colors.primary, DEFAULT_ACTIVE_DARKEN)
245
+
246
+ if (typeof document !== 'undefined') {
247
+ const root = document.documentElement
248
+ for (const [key, value] of Object.entries(colors)) {
249
+ if (value == null) continue
250
+ const cssVar = THEME_VAR_MAP[key as keyof ThemeColorOverrides]
251
+ if (cssVar) root.style.setProperty(cssVar, value)
252
+ }
253
+ root.style.setProperty('--ant-primary-color', colors.primary)
254
+ root.style.setProperty('--ant-primary-color-hover', hover)
255
+ root.style.setProperty('--ant-primary-color-active', active)
256
+ root.style.setProperty('--ant-color-link', colors.primary)
257
+ root.style.setProperty('--ant-color-link-hover', hover)
258
+ root.style.setProperty('--ant-color-link-active', active)
259
+ }
260
+
261
+ antThemeConfigRef.value = {
262
+ token: {
263
+ ...antThemeConfigRef.value.token,
264
+ colorPrimary: colors.primary,
265
+ colorPrimaryHover: hover,
266
+ colorPrimaryActive: active,
267
+ colorLink: colors.primary,
268
+ colorLinkHover: hover,
269
+ colorLinkActive: active
270
+ }
271
+ }
272
+ nextTick(() => syncAntTokensFromCssVars())
273
+ } else if (typeof document !== 'undefined') {
274
+ const root = document.documentElement
275
+ for (const [key, value] of Object.entries(colors)) {
276
+ if (value == null) continue
277
+ const cssVar = THEME_VAR_MAP[key as keyof ThemeColorOverrides]
278
+ if (cssVar) root.style.setProperty(cssVar, value)
279
+ }
280
+ nextTick(() => syncAntTokensFromCssVars())
281
+ }
282
+ }
283
+
284
+ /**
285
+ * 仅设置主色,并自动生成 hover/active(简单变亮/变深)
286
+ * 会更新 antThemeConfigRef,需在根节点用 <a-config-provider :theme="antThemeConfigRef"> 包裹才能生效
287
+ *
288
+ * @param primary 主色,如 '#1890ff'
289
+ * @param hoverLighten hover 时亮度增加比例,默认 15
290
+ * @param activeDarken active 时亮度降低比例,默认 10
291
+ */
292
+ export function setThemePrimary(
293
+ primary: string,
294
+ hoverLighten = DEFAULT_HOVER_LIGHTEN,
295
+ activeDarken = DEFAULT_ACTIVE_DARKEN
296
+ ): void {
297
+ const isHex = /^#[0-9A-Fa-f]{6}$/.test(primary)
298
+ if (!isHex) {
299
+ if (typeof process !== 'undefined' ? process.env.NODE_ENV !== 'production' : true) {
300
+ console.warn('[mtn-ui] setThemePrimary 建议使用 6 位十六进制色,如 #1890ff')
301
+ }
302
+ setThemeColors({ primary })
303
+ return
304
+ }
305
+ setThemeColors({
306
+ primary,
307
+ primaryHover: hexLighten(primary, hoverLighten),
308
+ primaryActive: hexDarken(primary, activeDarken)
309
+ })
310
+ }
311
+
312
+ /**
313
+ * 切换亮色/暗色模式
314
+ * - 更新 themeModeRef,便于与 Ant Design Vue 的 theme.defaultAlgorithm / theme.darkAlgorithm 组合
315
+ * - 暗色:`data-theme="dark"`;亮色:移除 `data-theme`(与 SCSS theme-mixins 中 :root 亮主题一致)
316
+ * - Tailwind `dark:`:请配置 `darkMode: ['selector', '[data-theme="dark"]']` 或使用 class 策略
317
+ */
318
+ export function setThemeMode(
319
+ mode: ThemeMode,
320
+ options?: { persist?: boolean; storageKey?: string }
321
+ ): void {
322
+ if (typeof document === 'undefined') return
323
+ themeModeRef.value = mode
324
+ const root = document.documentElement
325
+ if (mode === 'dark') {
326
+ root.setAttribute('data-theme', 'dark')
327
+ } else {
328
+ root.removeAttribute('data-theme')
329
+ }
330
+ if (options?.persist) {
331
+ persistThemeMode(mode, { storageKey: options.storageKey })
332
+ }
333
+ nextTick(() => syncAntTokensFromCssVars())
334
+ }
335
+
336
+ /**
337
+ * 在亮色与暗色之间切换,并返回切换后的模式
338
+ */
339
+ export function toggleThemeMode(options?: { persist?: boolean; storageKey?: string }): ThemeMode {
340
+ const next: ThemeMode = themeModeRef.value === 'dark' ? 'light' : 'dark'
341
+ setThemeMode(next, options)
342
+ return next
343
+ }
344
+
345
+ /** 当前亮/暗模式 */
346
+ export function getThemeMode(): ThemeMode {
347
+ return themeModeRef.value
348
+ }
349
+
350
+ const THEME_MODE_STORAGE_KEY = 'mtn-ui-theme-mode'
351
+ const DENSITY_STORAGE_KEY = 'mtn-ui-density-mode'
352
+
353
+ const MTN_DENSITY_ATTR = 'data-mtn-density'
354
+
355
+ /**
356
+ * 设置全局密度(紧凑 / 舒适),并可选持久化
357
+ */
358
+ export function setDensityMode(
359
+ mode: DensityMode,
360
+ options?: { persist?: boolean; storageKey?: string }
361
+ ): void {
362
+ if (typeof document === 'undefined') return
363
+ densityModeRef.value = mode
364
+ const root = document.documentElement
365
+ if (mode === 'compact') {
366
+ root.setAttribute(MTN_DENSITY_ATTR, 'compact')
367
+ } else {
368
+ root.removeAttribute(MTN_DENSITY_ATTR)
369
+ }
370
+ if (options?.persist) {
371
+ persistDensityMode(mode, { storageKey: options?.storageKey })
372
+ }
373
+ nextTick(() => syncAntTokensFromCssVars())
374
+ }
375
+
376
+ export function getDensityMode(): DensityMode {
377
+ return densityModeRef.value
378
+ }
379
+
380
+ /**
381
+ * 在紧凑与舒适之间切换
382
+ */
383
+ export function toggleDensityMode(options?: { persist?: boolean; storageKey?: string }): DensityMode {
384
+ const next: DensityMode = densityModeRef.value === 'compact' ? 'default' : 'compact'
385
+ setDensityMode(next, options)
386
+ return next
387
+ }
388
+
389
+ /**
390
+ * 从 localStorage 恢复密度(与 initTheme 搭配,尽早调用)
391
+ */
392
+ export function initDensityFromStorage(options?: { storageKey?: string }): DensityMode {
393
+ if (typeof window === 'undefined') return densityModeRef.value
394
+ const key = options?.storageKey ?? DENSITY_STORAGE_KEY
395
+ try {
396
+ const raw = window.localStorage.getItem(key)
397
+ if (raw === 'compact') {
398
+ setDensityMode('compact')
399
+ return 'compact'
400
+ }
401
+ if (raw === 'default') {
402
+ setDensityMode('default')
403
+ return 'default'
404
+ }
405
+ } catch {
406
+ /* ignore */
407
+ }
408
+ return densityModeRef.value
409
+ }
410
+
411
+ export function persistDensityMode(mode: DensityMode, options?: { storageKey?: string }): void {
412
+ if (typeof window === 'undefined') return
413
+ const key = options?.storageKey ?? DENSITY_STORAGE_KEY
414
+ try {
415
+ window.localStorage.setItem(key, mode)
416
+ } catch {
417
+ /* ignore */
418
+ }
419
+ }
420
+
421
+ /**
422
+ * 从 localStorage 恢复亮/暗(应用入口尽早调用以减少闪烁)
423
+ */
424
+ export function initThemeFromStorage(options?: { storageKey?: string }): ThemeMode {
425
+ if (typeof window === 'undefined') return themeModeRef.value
426
+ const key = options?.storageKey ?? THEME_MODE_STORAGE_KEY
427
+ try {
428
+ const raw = window.localStorage.getItem(key)
429
+ if (raw === 'dark' || raw === 'light') {
430
+ setThemeMode(raw)
431
+ return raw
432
+ }
433
+ } catch {
434
+ /* 私有模式等 */
435
+ }
436
+ return themeModeRef.value
437
+ }
438
+
439
+ export function persistThemeMode(mode: ThemeMode, options?: { storageKey?: string }): void {
440
+ if (typeof window === 'undefined') return
441
+ const key = options?.storageKey ?? THEME_MODE_STORAGE_KEY
442
+ try {
443
+ window.localStorage.setItem(key, mode)
444
+ } catch {
445
+ /* ignore */
446
+ }
447
+ }
448
+
449
+ /**
450
+ * 初始化:从 localStorage 恢复亮/暗与密度;无记录时可选跟随 prefers-color-scheme;最后同步 Ant token
451
+ */
452
+ export function initTheme(options?: {
453
+ storageKey?: string
454
+ densityStorageKey?: string
455
+ preferColorScheme?: boolean
456
+ }): ThemeMode {
457
+ if (typeof window === 'undefined') return themeModeRef.value
458
+ const key = options?.storageKey ?? THEME_MODE_STORAGE_KEY
459
+ const densityKey = options?.densityStorageKey ?? DENSITY_STORAGE_KEY
460
+ const modeAfterStorage = initThemeFromStorage({ storageKey: key })
461
+ initDensityFromStorage({ storageKey: densityKey })
462
+ let hadStored = false
463
+ try {
464
+ const raw = window.localStorage.getItem(key)
465
+ hadStored = raw === 'dark' || raw === 'light'
466
+ } catch {
467
+ /* ignore */
468
+ }
469
+ if (hadStored) {
470
+ nextTick(() => syncAntTokensFromCssVars())
471
+ return modeAfterStorage
472
+ }
473
+ if (options?.preferColorScheme && window.matchMedia('(prefers-color-scheme: dark)').matches) {
474
+ setThemeMode('dark')
475
+ nextTick(() => syncAntTokensFromCssVars())
476
+ return 'dark'
477
+ }
478
+ nextTick(() => syncAntTokensFromCssVars())
479
+ return themeModeRef.value
480
+ }
481
+
482
+ /**
483
+ * 切换色板预设(仅设置 document 上 data-mtn-preset;样式需业务或未来 @mtn-ui-z/theme 提供)
484
+ * - 与亮/暗正交:可同时 data-theme="dark" + data-mtn-preset="ocean"
485
+ * - `default` / `null` / `''` 会移除 data-mtn-preset
486
+ */
487
+ export function setThemePreset(preset: MtnThemePreset | string | null | undefined): void {
488
+ if (typeof document === 'undefined') return
489
+ const root = document.documentElement
490
+ if (preset == null || preset === '' || preset === 'default') {
491
+ root.removeAttribute(MTN_PRESET_ATTR)
492
+ } else {
493
+ root.setAttribute(MTN_PRESET_ATTR, preset)
494
+ }
495
+ nextTick(() => syncAntTokensFromCssVars())
496
+ }
497
+
498
+ /** 当前 data-mtn-preset,未设置时为 null */
499
+ export function getThemePreset(): string | null {
500
+ if (typeof document === 'undefined') return null
501
+ return document.documentElement.getAttribute(MTN_PRESET_ATTR)
502
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type {
2
+ MaybeRef,
3
+ MaybeRefOrGetter,
4
+ Pausable,
5
+ Stoppable
6
+ } from '@vueuse/core'
package/src/types.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 类型定义
3
+ * VueUse 相关的类型导出
4
+ */
5
+
6
+ export type {
7
+ MaybeRef,
8
+ MaybeRefOrGetter,
9
+ Pausable,
10
+ Stoppable
11
+ } from '@vueuse/core'
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Modal 命令式调用 hook
3
+ * 用法:
4
+ * const modal = useModal(MyModalComponent)
5
+ * modal.show({ title: '标题', row: { id: 1 } })
6
+ */
7
+ import { ref, type Component } from 'vue'
8
+ import { ModalRegistry } from './modalRegistry'
9
+
10
+ export interface UseModalOptions {
11
+ /**
12
+ * Modal 唯一 ID(不传则自动生成)
13
+ */
14
+ id?: string
15
+ /**
16
+ * Modal 关闭后是否销毁实例
17
+ */
18
+ destroyOnClose?: boolean
19
+ }
20
+
21
+ export interface UseModalShowOptions {
22
+ /**
23
+ * 业务数据
24
+ */
25
+ row?: unknown
26
+ /**
27
+ * 其他 a-modal props
28
+ */
29
+ [key: string]: unknown
30
+ }
31
+
32
+ export interface UseModalReturn {
33
+ show: (options?: UseModalShowOptions) => void
34
+ hide: () => void
35
+ destroy: () => void
36
+ visible: ReturnType<typeof ref<boolean>>
37
+ }
38
+
39
+ function getModalProvider() {
40
+ return (globalThis as any).__mtnModalProvider__ ?? null
41
+ }
42
+
43
+ function genId(prefix: string) {
44
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
45
+ }
46
+
47
+ /**
48
+ * 命令式 Modal Hook
49
+ */
50
+ export function useModal<T extends Component>(
51
+ modalComponent: T,
52
+ options: UseModalOptions = {}
53
+ ): UseModalReturn {
54
+ // 用组件对象本身作为 id 前缀,保证唯一性
55
+ const id = options.id ?? genId((modalComponent as any).name ?? 'modal')
56
+
57
+ // 先注册组件(provider 会在渲染时从 registry 取)
58
+ ModalRegistry.register(id, modalComponent)
59
+
60
+ const visible = ref(false)
61
+
62
+ function show(opts: UseModalShowOptions = {}) {
63
+ const provider = getModalProvider()
64
+ if (!provider) {
65
+ console.warn('[useModal] ModalProvider 未挂载,请确保 App.vue 中使用了 <MtnModalProvider>')
66
+ return
67
+ }
68
+
69
+ // 合并 props:open + 用户传入的 options + 关闭回调
70
+ provider.register(id, modalComponent, {
71
+ ...opts,
72
+ open: true,
73
+ onCancel: (...args: unknown[]) => {
74
+ ;(opts.onCancel as Function)?.(...args)
75
+ hide()
76
+ },
77
+ onOk: (...args: unknown[]) => {
78
+ ;(opts.onOk as Function)?.(...args)
79
+ if (options.destroyOnClose) {
80
+ destroy()
81
+ } else {
82
+ hide()
83
+ }
84
+ }
85
+ })
86
+
87
+ visible.value = true
88
+ }
89
+
90
+ function hide() {
91
+ const provider = getModalProvider()
92
+ if (provider) {
93
+ // 通知 antd modal 开始关闭动画(open: false 触发 @update:open 事件)
94
+ provider.update(id, { open: false })
95
+ }
96
+ visible.value = false
97
+ }
98
+
99
+ function destroy() {
100
+ const provider = getModalProvider()
101
+ if (provider) {
102
+ // 触发关闭动画,等 @update:open(false) + 300ms 动画结束后 confirmClose 会自动删
103
+ provider.update(id, { open: false })
104
+ }
105
+ visible.value = false
106
+ ModalRegistry.unregister(id)
107
+ }
108
+
109
+ return { show, hide, destroy, visible }
110
+ }