vome-core 0.0.28 → 0.0.32

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 (57) hide show
  1. package/dist/admin/components/vm-aside-list-item.vue +18 -6
  2. package/dist/admin/components/vm-card.vue +13 -7
  3. package/dist/admin/components/vm-page-tabs.vue +3 -2
  4. package/dist/admin/components/vm-split-layout.vue +4 -0
  5. package/dist/admin/config/plugin-dev.js +1 -1
  6. package/dist/admin/crud/components/vm-form-hint.vue +40 -22
  7. package/dist/admin/crud/config.js +1 -1
  8. package/dist/admin/crud/confirm.js +1 -1
  9. package/dist/admin/crud/dict.js +1 -1
  10. package/dist/admin/crud/index.d.ts +1 -1
  11. package/dist/admin/crud/index.js +1 -1
  12. package/dist/admin/crud/key.js +1 -1
  13. package/dist/admin/crud/mitt.js +1 -1
  14. package/dist/admin/crud/plugins.js +1 -1
  15. package/dist/admin/crud/span.js +1 -1
  16. package/dist/admin/crud/style.js +1 -1
  17. package/dist/admin/crud/validate.js +1 -1
  18. package/dist/admin/crud/vm-import-btn.vue +686 -0
  19. package/dist/admin/crud/vm-toolbar.vue +16 -0
  20. package/dist/admin/directives/perm.js +1 -1
  21. package/dist/admin/hooks/useUpload.js +1 -1
  22. package/dist/admin/lib/browser.js +1 -1
  23. package/dist/admin/lib/cn.js +1 -1
  24. package/dist/admin/lib/dialog-float.js +1 -1
  25. package/dist/admin/lib/export-excel.js +1 -1
  26. package/dist/admin/lib/file-preview.js +1 -1
  27. package/dist/admin/lib/import-excel.d.ts +18 -0
  28. package/dist/admin/lib/import-excel.js +1 -0
  29. package/dist/admin/lib/json.js +1 -1
  30. package/dist/admin/lib/menu.js +1 -1
  31. package/dist/admin/lib/tree.js +1 -1
  32. package/dist/admin/lib/upload.js +1 -1
  33. package/dist/admin/lib/video-frame.js +1 -1
  34. package/dist/admin/router/index.js +13 -3
  35. package/dist/client/vite-plugin-eps.d.ts +3 -3
  36. package/dist/client/vite-plugin-eps.js +6 -2
  37. package/dist/index.js +1 -1
  38. package/dist/server/index.js +1 -1
  39. package/dist/shared/excel.d.ts +10 -0
  40. package/dist/shared/excel.js +1 -1
  41. package/dist/shared/index.js +1 -1
  42. package/dist/shared/tree.js +1 -1
  43. package/dist/src/core/index.d.ts +2 -2
  44. package/dist/src/ext/index.d.ts +1 -1
  45. package/dist/src/ext/load-plugin.d.ts +16 -0
  46. package/dist/src/orm/import-excel.d.ts +31 -0
  47. package/dist/src/routing/index.d.ts +1 -0
  48. package/dist/src/routing/public-routes.d.ts +6 -0
  49. package/dist/src/server/index.d.ts +2 -2
  50. package/dist/src/shared/excel.d.ts +10 -0
  51. package/dist/src/shared/index.d.ts +1 -1
  52. package/dist/typings/routing/crud.d.ts +5 -0
  53. package/package.json +8 -3
  54. package/src/shared/excel.d.ts +10 -0
  55. package/src/shared/excel.ts +77 -0
  56. package/src/shared/index.ts +4 -0
  57. package/dist/admin/crud/components/vm-json-viewer.vue +0 -147
@@ -0,0 +1,686 @@
1
+ <template>
2
+ <div v-if="visible" class="vm-import-btn">
3
+ <button
4
+ type="button"
5
+ class="vm-crud-toolbar__btn is-ghost"
6
+ :disabled="disabled"
7
+ @click="openDialog"
8
+ >
9
+ <i class="ri-upload-2-line" />
10
+ {{ buttonText }}
11
+ </button>
12
+
13
+ <Dialog :open="open" @update:open="onOpenChange">
14
+ <DialogContent
15
+ shell="crud"
16
+ class="vm-import-btn__dialog is-height"
17
+ :style="{ '--vm-dialog-height': '600px' }"
18
+ @pointer-down-outside="preventDialogOutsideClose"
19
+ @focus-outside="preventDialogOutsideClose"
20
+ @interact-outside="preventDialogOutsideClose"
21
+ >
22
+ <DialogHeader class="vm-crud-shell__head">
23
+ <DialogTitle class="vm-crud-shell__title">导入</DialogTitle>
24
+ </DialogHeader>
25
+
26
+ <div class="vm-crud-shell__body vm-import-btn__body">
27
+ <div class="vm-import-btn__bar">
28
+ <p v-if="tip" class="vm-import-btn__tip">{{ tip }}</p>
29
+ <span v-else-if="file" class="vm-import-btn__file-name">
30
+ {{ file.name }}
31
+ </span>
32
+ <span v-else />
33
+ <div class="vm-import-btn__bar-actions">
34
+ <button
35
+ v-if="previewRows.length"
36
+ type="button"
37
+ class="vm-import-btn__repick"
38
+ :disabled="submitting || parsing"
39
+ @click="onPick"
40
+ >
41
+ 重新选择
42
+ </button>
43
+ <button
44
+ type="button"
45
+ class="vm-import-btn__tpl"
46
+ :disabled="submitting || downloading || parsing"
47
+ @click="onTemplate"
48
+ >
49
+ {{ downloading ? '下载中…' : '下载模版' }}
50
+ </button>
51
+ </div>
52
+ </div>
53
+
54
+ <div
55
+ v-if="!previewRows.length"
56
+ class="vm-import-btn__drop"
57
+ :class="{
58
+ 'is-drag': dragging,
59
+ 'is-disabled': submitting || parsing,
60
+ }"
61
+ @click="onPick"
62
+ @dragenter.prevent="onDragEnter"
63
+ @dragover.prevent
64
+ @dragleave.prevent="onDragLeave"
65
+ @drop.prevent="onDrop"
66
+ >
67
+ <i class="ri-upload-cloud-2-line" />
68
+ <span v-if="parsing">正在解析…</span>
69
+ <span v-else>
70
+ 点击上传或将文件拖拽到此处,文件大小限制{{ maxSizeMb }}M
71
+ </span>
72
+ <span class="vm-import-btn__ext">仅支持 .xlsx 请下载导入模版</span>
73
+ </div>
74
+
75
+ <div v-if="previewRows.length" class="vm-import-btn__preview">
76
+ <div class="vm-import-btn__table-wrap">
77
+ <table class="vm-import-btn__table">
78
+ <thead>
79
+ <tr>
80
+ <th
81
+ v-for="col in previewHeaders"
82
+ :key="col"
83
+ class="vm-import-btn__th"
84
+ >
85
+ {{ col }}
86
+ </th>
87
+ </tr>
88
+ </thead>
89
+ <tbody>
90
+ <tr v-for="(row, idx) in pageRows" :key="pageStart + idx">
91
+ <td
92
+ v-for="col in previewHeaders"
93
+ :key="col"
94
+ class="vm-import-btn__td"
95
+ >
96
+ {{ formatCell(row[col]) }}
97
+ </td>
98
+ </tr>
99
+ </tbody>
100
+ </table>
101
+ </div>
102
+ <div class="vm-import-btn__pager">
103
+ <span class="vm-import-btn__total">共 {{ previewRows.length }} 条</span>
104
+ <div class="vm-import-btn__pager-actions">
105
+ <button
106
+ type="button"
107
+ class="vm-import-btn__page-btn"
108
+ :disabled="page <= 1 || submitting"
109
+ @click="goPrevPage"
110
+ >
111
+ 上一页
112
+ </button>
113
+ <span class="vm-import-btn__page-num">
114
+ {{ page }} / {{ totalPages }}
115
+ </span>
116
+ <button
117
+ type="button"
118
+ class="vm-import-btn__page-btn"
119
+ :disabled="page >= totalPages || submitting"
120
+ @click="goNextPage"
121
+ >
122
+ 下一页
123
+ </button>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <input
129
+ ref="inputRef"
130
+ type="file"
131
+ class="vm-import-btn__input"
132
+ accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
133
+ @change="onFileInput"
134
+ />
135
+ </div>
136
+
137
+ <DialogFooter class="vm-crud-shell__foot">
138
+ <button
139
+ type="button"
140
+ class="vm-crud-shell__btn is-ghost"
141
+ :disabled="submitting"
142
+ @click="closeDialog"
143
+ >
144
+ 取消
145
+ </button>
146
+ <button
147
+ type="button"
148
+ class="vm-crud-shell__btn is-primary"
149
+ :disabled="submitting || parsing || !canSubmit"
150
+ @click="onSubmit"
151
+ >
152
+ {{ submitting ? '提交中…' : '提交' }}
153
+ </button>
154
+ </DialogFooter>
155
+ </DialogContent>
156
+ </Dialog>
157
+ </div>
158
+ </template>
159
+
160
+ <script setup lang="ts">
161
+ import { computed, ref } from 'vue'
162
+ import { toast } from 'vue-sonner'
163
+ import { preventDialogOutsideClose } from '../lib/dialog-float'
164
+ import {
165
+ downloadImportTemplate,
166
+ parseImportExcelPreview,
167
+ uploadImportExcel,
168
+ } from '../lib/import-excel'
169
+ import { useCrud } from './useCrud'
170
+
171
+ defineOptions({ name: 'vm-import-btn' })
172
+
173
+ /** 与服务端 importTemplate(excelFilename → .xlsx)保持一致 */
174
+ const ACCEPT_EXT = '.xlsx'
175
+ const MAX_SIZE_MB_DEFAULT = 10
176
+ const PAGE_SIZE = 20
177
+
178
+ const props = withDefaults(
179
+ defineProps<{
180
+ disabled?: boolean
181
+ text?: string
182
+ /** 弹窗左上提示文案 */
183
+ tip?: string
184
+ templateFilename?: string | (() => string)
185
+ /** 文件大小上限(MB) */
186
+ maxSizeMb?: number
187
+ /** 权限键,默认 add */
188
+ permission?: keyof CrudPermission
189
+ }>(),
190
+ {
191
+ disabled: false,
192
+ tip: '',
193
+ maxSizeMb: MAX_SIZE_MB_DEFAULT,
194
+ permission: 'add',
195
+ },
196
+ )
197
+
198
+ const emit = defineEmits<{
199
+ success: [result: { inserted: number; updated: number; failed: number }]
200
+ }>()
201
+
202
+ const crud = useCrud()
203
+ const open = ref(false)
204
+ const submitting = ref(false)
205
+ const downloading = ref(false)
206
+ const parsing = ref(false)
207
+ const dragging = ref(false)
208
+ const file = ref<File | null>(null)
209
+ const previewHeaders = ref<string[]>([])
210
+ const previewRows = ref<Record<string, unknown>[]>([])
211
+ const page = ref(1)
212
+ const inputRef = ref<HTMLInputElement | null>(null)
213
+ const dragDepth = ref(0)
214
+
215
+ const visible = computed(() => Boolean(crud.getPermission(props.permission)))
216
+
217
+ const buttonText = computed(
218
+ () => props.text || crud.dict.label.import || '导入',
219
+ )
220
+
221
+ const totalPages = computed(() =>
222
+ Math.max(1, Math.ceil(previewRows.value.length / PAGE_SIZE)),
223
+ )
224
+
225
+ const pageStart = computed(() => (page.value - 1) * PAGE_SIZE)
226
+
227
+ const pageRows = computed(() =>
228
+ previewRows.value.slice(pageStart.value, pageStart.value + PAGE_SIZE),
229
+ )
230
+
231
+ const canSubmit = computed(
232
+ () => Boolean(file.value) && previewRows.value.length > 0,
233
+ )
234
+
235
+ function goPrevPage() {
236
+ if (page.value <= 1) return
237
+ page.value -= 1
238
+ }
239
+
240
+ function goNextPage() {
241
+ if (page.value >= totalPages.value) return
242
+ page.value += 1
243
+ }
244
+
245
+ function formatCell(v: unknown) {
246
+ if (v == null) return ''
247
+ return String(v)
248
+ }
249
+
250
+ function resolveTemplateName() {
251
+ const raw = props.templateFilename
252
+ const name = typeof raw === 'function' ? raw() : raw
253
+ const base = name || '导入模板'
254
+ return base.endsWith(ACCEPT_EXT) ? base : `${base}${ACCEPT_EXT}`
255
+ }
256
+
257
+ function resetPreview() {
258
+ file.value = null
259
+ previewHeaders.value = []
260
+ previewRows.value = []
261
+ page.value = 1
262
+ parsing.value = false
263
+ }
264
+
265
+ function openDialog() {
266
+ if (props.disabled) return
267
+ resetPreview()
268
+ open.value = true
269
+ }
270
+
271
+ function closeDialog() {
272
+ if (submitting.value) return
273
+ open.value = false
274
+ resetPreview()
275
+ dragging.value = false
276
+ dragDepth.value = 0
277
+ }
278
+
279
+ function onOpenChange(v: boolean) {
280
+ if (!v) {
281
+ closeDialog()
282
+ return
283
+ }
284
+ open.value = true
285
+ }
286
+
287
+ function onPick() {
288
+ if (submitting.value || parsing.value || props.disabled) return
289
+ inputRef.value?.click()
290
+ }
291
+
292
+ function isXlsx(f: File) {
293
+ return f.name.toLowerCase().endsWith(ACCEPT_EXT)
294
+ }
295
+
296
+ async function acceptFile(f: File | undefined | null) {
297
+ if (!f || parsing.value || submitting.value) return
298
+ if (!isXlsx(f)) {
299
+ toast.error(`仅支持 ${ACCEPT_EXT} 文件(与下载模版后缀一致)`)
300
+ return
301
+ }
302
+ const limit = props.maxSizeMb * 1024 * 1024
303
+ if (f.size > limit) {
304
+ toast.error(`文件大小不能超过 ${props.maxSizeMb}M`)
305
+ return
306
+ }
307
+
308
+ parsing.value = true
309
+ try {
310
+ const preview = await parseImportExcelPreview(f)
311
+ if (!preview.rows.length) {
312
+ resetPreview()
313
+ toast.error('文件中没有可导入的数据行')
314
+ return
315
+ }
316
+ file.value = f
317
+ previewHeaders.value = preview.headers
318
+ previewRows.value = preview.rows
319
+ page.value = 1
320
+ } catch (e) {
321
+ resetPreview()
322
+ toast.error(e instanceof Error ? e.message : '解析 Excel 失败')
323
+ } finally {
324
+ parsing.value = false
325
+ }
326
+ }
327
+
328
+ function onFileInput(ev: Event) {
329
+ const input = ev.target as HTMLInputElement
330
+ const f = input.files?.[0]
331
+ input.value = ''
332
+ void acceptFile(f)
333
+ }
334
+
335
+ function onDragEnter() {
336
+ if (submitting.value || parsing.value) return
337
+ dragDepth.value += 1
338
+ dragging.value = true
339
+ }
340
+
341
+ function onDragLeave() {
342
+ dragDepth.value = Math.max(0, dragDepth.value - 1)
343
+ if (dragDepth.value === 0) dragging.value = false
344
+ }
345
+
346
+ function onDrop(ev: DragEvent) {
347
+ dragDepth.value = 0
348
+ dragging.value = false
349
+ if (submitting.value || parsing.value) return
350
+ void acceptFile(ev.dataTransfer?.files?.[0])
351
+ }
352
+
353
+ async function onTemplate() {
354
+ if (downloading.value || submitting.value || props.disabled) return
355
+ const ns = crud.service?.namespace
356
+ if (!ns) {
357
+ toast.error('当前页未绑定 service,无法下载模版')
358
+ return
359
+ }
360
+ downloading.value = true
361
+ try {
362
+ await downloadImportTemplate(ns, resolveTemplateName())
363
+ toast.success('模版已下载')
364
+ } catch (e) {
365
+ toast.error(e instanceof Error ? e.message : '下载模版失败')
366
+ } finally {
367
+ downloading.value = false
368
+ }
369
+ }
370
+
371
+ async function onSubmit() {
372
+ if (submitting.value || parsing.value || props.disabled || !canSubmit.value) {
373
+ return
374
+ }
375
+ if (!file.value || !isXlsx(file.value)) {
376
+ toast.error(`仅支持 ${ACCEPT_EXT} 文件(与下载模版后缀一致)`)
377
+ return
378
+ }
379
+ if (!previewRows.value.length) {
380
+ toast.error('请先选择包含数据的 Excel 文件')
381
+ return
382
+ }
383
+
384
+ const ns = crud.service?.namespace
385
+ if (!ns) {
386
+ toast.error('当前页未绑定 service,无法导入')
387
+ return
388
+ }
389
+
390
+ submitting.value = true
391
+ try {
392
+ const result = await uploadImportExcel(ns, file.value)
393
+ const failN = result.failed?.length || 0
394
+ if (failN) {
395
+ const tipText = result.failed
396
+ .slice(0, 3)
397
+ .map((f) => `第${f.row}行: ${f.message}`)
398
+ .join(';')
399
+ toast.warning(
400
+ `导入完成:新增 ${result.inserted},更新 ${result.updated},失败 ${failN}${tipText ? `(${tipText})` : ''}`,
401
+ )
402
+ } else {
403
+ toast.success(
404
+ `导入成功:新增 ${result.inserted},更新 ${result.updated}`,
405
+ )
406
+ }
407
+ emit('success', {
408
+ inserted: result.inserted,
409
+ updated: result.updated,
410
+ failed: failN,
411
+ })
412
+ await crud.refresh()
413
+ open.value = false
414
+ resetPreview()
415
+ } catch (e) {
416
+ toast.error(e instanceof Error ? e.message : '导入失败')
417
+ } finally {
418
+ submitting.value = false
419
+ }
420
+ }
421
+ </script>
422
+
423
+ <style lang="scss" scoped>
424
+ .vm-import-btn {
425
+ display: inline-flex;
426
+ align-items: center;
427
+ }
428
+
429
+ .vm-import-btn__input {
430
+ display: none;
431
+ }
432
+
433
+ /* 导入弹窗:高度 600,宽度沿用默认 CRUD(50vw) */
434
+ :deep(.vm-import-btn__dialog[data-slot='dialog-content']),
435
+ :deep(.vm-import-btn__dialog.vm-dialog-shell-crud) {
436
+ height: 600px !important;
437
+ max-height: 600px !important;
438
+ }
439
+
440
+ .vm-import-btn__body {
441
+ display: flex;
442
+ min-height: 0;
443
+ flex: 1;
444
+ flex-direction: column;
445
+ gap: 12px;
446
+ overflow: hidden;
447
+ }
448
+
449
+ .vm-import-btn__bar {
450
+ display: flex;
451
+ flex-shrink: 0;
452
+ align-items: center;
453
+ justify-content: space-between;
454
+ gap: 12px;
455
+ }
456
+
457
+ .vm-import-btn__bar-actions {
458
+ display: inline-flex;
459
+ flex-shrink: 0;
460
+ align-items: center;
461
+ gap: 8px;
462
+ }
463
+
464
+ .vm-import-btn__file-name {
465
+ overflow: hidden;
466
+ color: var(--muted-foreground);
467
+ font-size: 13px;
468
+ text-overflow: ellipsis;
469
+ white-space: nowrap;
470
+ }
471
+
472
+ .vm-import-btn__tip {
473
+ margin: 0;
474
+ color: var(--warning);
475
+ font-size: 13px;
476
+ font-weight: 500;
477
+ }
478
+
479
+ .vm-import-btn__repick {
480
+ flex-shrink: 0;
481
+ height: 32px;
482
+ padding: 0 12px;
483
+ border: none;
484
+ border-radius: 10px;
485
+ background: var(--muted);
486
+ color: var(--foreground);
487
+ font-size: 13px;
488
+ font-weight: 600;
489
+ cursor: pointer;
490
+
491
+ &:hover:not(:disabled) {
492
+ background: color-mix(in srgb, var(--muted) 70%, var(--foreground) 8%);
493
+ }
494
+
495
+ &:disabled {
496
+ cursor: not-allowed;
497
+ opacity: 0.45;
498
+ }
499
+ }
500
+
501
+ .vm-import-btn__tpl {
502
+ flex-shrink: 0;
503
+ height: 32px;
504
+ padding: 0 12px;
505
+ border: none;
506
+ border-radius: 10px;
507
+ background: var(--brand-soft);
508
+ color: var(--brand);
509
+ font-size: 13px;
510
+ font-weight: 600;
511
+ cursor: pointer;
512
+ transition: background 0.15s ease, opacity 0.15s ease;
513
+
514
+ &:hover:not(:disabled) {
515
+ background: color-mix(in srgb, var(--brand-soft) 70%, var(--brand) 12%);
516
+ }
517
+
518
+ &:disabled {
519
+ cursor: not-allowed;
520
+ opacity: 0.45;
521
+ }
522
+ }
523
+
524
+ .vm-import-btn__drop {
525
+ display: grid;
526
+ flex: 1;
527
+ min-height: 0;
528
+ place-items: center;
529
+ place-content: center;
530
+ gap: 8px;
531
+ padding: 20px 16px;
532
+ border: 1px dashed var(--border);
533
+ border-radius: 12px;
534
+ background: var(--muted);
535
+ color: var(--muted-foreground);
536
+ font-size: 13px;
537
+ text-align: center;
538
+ cursor: pointer;
539
+ transition:
540
+ border-color 0.15s ease,
541
+ color 0.15s ease,
542
+ background 0.15s ease;
543
+
544
+ i {
545
+ font-size: 32px;
546
+ line-height: 1;
547
+ }
548
+
549
+ &:hover:not(.is-disabled),
550
+ &.is-drag {
551
+ border-color: var(--brand);
552
+ background: var(--brand-soft);
553
+ color: var(--brand);
554
+ }
555
+
556
+ &.is-disabled {
557
+ cursor: not-allowed;
558
+ opacity: 0.55;
559
+ }
560
+ }
561
+
562
+ .vm-import-btn__ext {
563
+ font-size: 12px;
564
+ opacity: 0.8;
565
+ }
566
+
567
+ .vm-import-btn__preview {
568
+ display: flex;
569
+ min-height: 0;
570
+ flex: 1;
571
+ flex-direction: column;
572
+ gap: 10px;
573
+ overflow: hidden;
574
+ }
575
+
576
+ .vm-import-btn__table-wrap {
577
+ min-height: 0;
578
+ flex: 1;
579
+ overflow: auto;
580
+ border: 1px solid var(--border);
581
+ border-radius: 12px;
582
+ background: var(--card);
583
+ }
584
+
585
+ .vm-import-btn__table {
586
+ width: max-content;
587
+ min-width: 100%;
588
+ border-collapse: collapse;
589
+ font-size: 13px;
590
+ }
591
+
592
+ .vm-import-btn__th,
593
+ .vm-import-btn__td {
594
+ max-width: 220px;
595
+ padding: 10px 12px;
596
+ border-bottom: 1px solid var(--border);
597
+ overflow: hidden;
598
+ text-overflow: ellipsis;
599
+ white-space: nowrap;
600
+ text-align: left;
601
+ }
602
+
603
+ .vm-import-btn__th {
604
+ position: sticky;
605
+ top: 0;
606
+ z-index: 1;
607
+ background: var(--muted);
608
+ color: var(--muted-foreground);
609
+ font-weight: 600;
610
+ }
611
+
612
+ .vm-import-btn__pager {
613
+ display: flex;
614
+ flex-shrink: 0;
615
+ align-items: center;
616
+ justify-content: space-between;
617
+ gap: 12px;
618
+ }
619
+
620
+ .vm-import-btn__total {
621
+ color: var(--muted-foreground);
622
+ font-size: 13px;
623
+ }
624
+
625
+ .vm-import-btn__pager-actions {
626
+ display: inline-flex;
627
+ align-items: center;
628
+ gap: 8px;
629
+ }
630
+
631
+ .vm-import-btn__page-num {
632
+ min-width: 52px;
633
+ color: var(--muted-foreground);
634
+ font-size: 12px;
635
+ text-align: center;
636
+ }
637
+
638
+ .vm-import-btn__page-btn {
639
+ height: 30px;
640
+ padding: 0 12px;
641
+ border: none;
642
+ border-radius: 10px;
643
+ background: var(--muted);
644
+ color: var(--foreground);
645
+ font-size: 12px;
646
+ font-weight: 600;
647
+ cursor: pointer;
648
+
649
+ &:hover:not(:disabled) {
650
+ background: color-mix(in srgb, var(--muted) 70%, var(--foreground) 8%);
651
+ }
652
+
653
+ &:disabled {
654
+ cursor: not-allowed;
655
+ opacity: 0.45;
656
+ }
657
+ }
658
+
659
+ .vm-crud-toolbar__btn {
660
+ display: inline-flex;
661
+ height: 36px;
662
+ align-items: center;
663
+ gap: 4px;
664
+ padding: 0 14px;
665
+ border: none;
666
+ border-radius: 12px;
667
+ font-size: 13px;
668
+ font-weight: 600;
669
+ cursor: pointer;
670
+ transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
671
+
672
+ &:disabled {
673
+ cursor: not-allowed;
674
+ opacity: 0.45;
675
+ }
676
+
677
+ &.is-ghost {
678
+ background: var(--muted);
679
+ color: var(--foreground);
680
+
681
+ &:hover:not(:disabled) {
682
+ background: color-mix(in srgb, var(--muted) 70%, var(--foreground) 8%);
683
+ }
684
+ }
685
+ }
686
+ </style>