vome-core 0.3.69 → 0.3.71

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 (280) hide show
  1. package/dist/admin/components/ui/avatar/Avatar.vue +18 -18
  2. package/dist/admin/components/ui/avatar/AvatarFallback.vue +21 -21
  3. package/dist/admin/components/ui/avatar/AvatarImage.vue +16 -16
  4. package/dist/admin/components/ui/avatar/index.js +1 -1
  5. package/dist/admin/components/ui/badge/Badge.vue +26 -26
  6. package/dist/admin/components/ui/button/Button.vue +31 -31
  7. package/dist/admin/components/ui/button/index.d.ts +1 -1
  8. package/dist/admin/components/ui/checkbox/Checkbox.vue +35 -35
  9. package/dist/admin/components/ui/checkbox/index.js +1 -1
  10. package/dist/admin/components/ui/dialog/Dialog.vue +19 -19
  11. package/dist/admin/components/ui/dialog/DialogClose.vue +15 -15
  12. package/dist/admin/components/ui/dialog/DialogContent.vue +267 -267
  13. package/dist/admin/components/ui/dialog/DialogDescription.vue +23 -23
  14. package/dist/admin/components/ui/dialog/DialogFooter.vue +27 -27
  15. package/dist/admin/components/ui/dialog/DialogHeader.vue +17 -17
  16. package/dist/admin/components/ui/dialog/DialogOverlay.vue +21 -21
  17. package/dist/admin/components/ui/dialog/DialogScrollContent.vue +60 -60
  18. package/dist/admin/components/ui/dialog/DialogTitle.vue +23 -23
  19. package/dist/admin/components/ui/dialog/DialogTrigger.vue +15 -15
  20. package/dist/admin/components/ui/dialog/index.js +1 -1
  21. package/dist/admin/components/ui/dropdown-menu/DropdownMenu.vue +19 -19
  22. package/dist/admin/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +39 -39
  23. package/dist/admin/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -39
  24. package/dist/admin/components/ui/dropdown-menu/DropdownMenuGroup.vue +15 -15
  25. package/dist/admin/components/ui/dropdown-menu/DropdownMenuItem.vue +31 -31
  26. package/dist/admin/components/ui/dropdown-menu/DropdownMenuLabel.vue +23 -23
  27. package/dist/admin/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +21 -21
  28. package/dist/admin/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +40 -40
  29. package/dist/admin/components/ui/dropdown-menu/DropdownMenuSeparator.vue +23 -23
  30. package/dist/admin/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -17
  31. package/dist/admin/components/ui/dropdown-menu/DropdownMenuSub.vue +18 -18
  32. package/dist/admin/components/ui/dropdown-menu/DropdownMenuSubContent.vue +27 -27
  33. package/dist/admin/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +31 -31
  34. package/dist/admin/components/ui/dropdown-menu/DropdownMenuTrigger.vue +17 -17
  35. package/dist/admin/components/ui/dropdown-menu/index.js +1 -1
  36. package/dist/admin/components/ui/input/Input.vue +183 -183
  37. package/dist/admin/components/ui/input/index.js +1 -1
  38. package/dist/admin/components/ui/label/Label.vue +26 -26
  39. package/dist/admin/components/ui/label/index.js +1 -1
  40. package/dist/admin/components/ui/scroll-area/ScrollArea.vue +33 -33
  41. package/dist/admin/components/ui/scroll-area/ScrollBar.vue +32 -32
  42. package/dist/admin/components/ui/scroll-area/index.js +1 -1
  43. package/dist/admin/components/ui/select/Select.vue +19 -19
  44. package/dist/admin/components/ui/select/SelectContent.vue +51 -51
  45. package/dist/admin/components/ui/select/SelectGroup.vue +15 -15
  46. package/dist/admin/components/ui/select/SelectItem.vue +53 -53
  47. package/dist/admin/components/ui/select/SelectItemText.vue +15 -15
  48. package/dist/admin/components/ui/select/SelectLabel.vue +17 -17
  49. package/dist/admin/components/ui/select/SelectScrollDownButton.vue +26 -26
  50. package/dist/admin/components/ui/select/SelectScrollUpButton.vue +26 -26
  51. package/dist/admin/components/ui/select/SelectSeparator.vue +19 -19
  52. package/dist/admin/components/ui/select/SelectTrigger.vue +44 -44
  53. package/dist/admin/components/ui/select/SelectValue.vue +15 -15
  54. package/dist/admin/components/ui/select/index.js +1 -1
  55. package/dist/admin/components/ui/separator/Separator.vue +29 -29
  56. package/dist/admin/components/ui/separator/index.js +1 -1
  57. package/dist/admin/components/ui/sheet/Sheet.vue +19 -19
  58. package/dist/admin/components/ui/sheet/SheetClose.vue +15 -15
  59. package/dist/admin/components/ui/sheet/SheetContent.vue +63 -63
  60. package/dist/admin/components/ui/sheet/SheetDescription.vue +21 -21
  61. package/dist/admin/components/ui/sheet/SheetFooter.vue +16 -16
  62. package/dist/admin/components/ui/sheet/SheetHeader.vue +15 -15
  63. package/dist/admin/components/ui/sheet/SheetOverlay.vue +21 -21
  64. package/dist/admin/components/ui/sheet/SheetTitle.vue +21 -21
  65. package/dist/admin/components/ui/sheet/SheetTrigger.vue +15 -15
  66. package/dist/admin/components/ui/sheet/index.js +1 -1
  67. package/dist/admin/components/ui/sonner/Toaster.vue +117 -117
  68. package/dist/admin/components/ui/sonner/index.js +1 -1
  69. package/dist/admin/components/ui/table/Table.vue +16 -16
  70. package/dist/admin/components/ui/table/TableBody.vue +17 -17
  71. package/dist/admin/components/ui/table/TableCaption.vue +17 -17
  72. package/dist/admin/components/ui/table/TableCell.vue +22 -22
  73. package/dist/admin/components/ui/table/TableEmpty.vue +34 -34
  74. package/dist/admin/components/ui/table/TableFooter.vue +17 -17
  75. package/dist/admin/components/ui/table/TableHead.vue +17 -17
  76. package/dist/admin/components/ui/table/TableHeader.vue +17 -17
  77. package/dist/admin/components/ui/table/TableRow.vue +17 -17
  78. package/dist/admin/components/ui/table/index.js +1 -1
  79. package/dist/admin/components/ui/tabs/Tabs.vue +24 -24
  80. package/dist/admin/components/ui/tabs/TabsContent.vue +21 -21
  81. package/dist/admin/components/ui/tabs/TabsList.vue +24 -24
  82. package/dist/admin/components/ui/tabs/TabsTrigger.vue +26 -26
  83. package/dist/admin/components/ui/tabs/index.js +1 -1
  84. package/dist/admin/components/ui/tooltip/Tooltip.vue +19 -19
  85. package/dist/admin/components/ui/tooltip/TooltipContent.vue +34 -34
  86. package/dist/admin/components/ui/tooltip/TooltipProvider.vue +14 -14
  87. package/dist/admin/components/ui/tooltip/TooltipTrigger.vue +15 -15
  88. package/dist/admin/components/ui/tooltip/index.js +1 -1
  89. package/dist/admin/components/vm-action-btn.vue +136 -136
  90. package/dist/admin/components/vm-aside-list-item.vue +136 -136
  91. package/dist/admin/components/vm-aside.vue +229 -229
  92. package/dist/admin/components/vm-auto-perm-dialog.vue +371 -371
  93. package/dist/admin/components/vm-card-grid.vue +37 -37
  94. package/dist/admin/components/vm-card-head.vue +45 -45
  95. package/dist/admin/components/vm-card-meta.vue +22 -22
  96. package/dist/admin/components/vm-card-title.vue +56 -56
  97. package/dist/admin/components/vm-card.vue +176 -176
  98. package/dist/admin/components/vm-check-tree.vue +340 -340
  99. package/dist/admin/components/vm-clamp-text.vue +39 -39
  100. package/dist/admin/components/vm-dept-tree.vue +497 -497
  101. package/dist/admin/components/vm-empty.vue +68 -68
  102. package/dist/admin/components/vm-eps-perm-picker.vue +123 -123
  103. package/dist/admin/components/vm-form-dialog.vue +632 -632
  104. package/dist/admin/components/vm-group-cascader.vue +560 -560
  105. package/dist/admin/components/vm-icon-btn.vue +88 -88
  106. package/dist/admin/components/vm-icon-picker.vue +647 -647
  107. package/dist/admin/components/vm-inline-search.vue +61 -61
  108. package/dist/admin/components/vm-loading.vue +114 -114
  109. package/dist/admin/components/vm-markdown.vue +405 -405
  110. package/dist/admin/components/vm-marquee.vue +265 -265
  111. package/dist/admin/components/vm-page-tabs.vue +94 -94
  112. package/dist/admin/components/vm-perm-section.vue +57 -57
  113. package/dist/admin/components/vm-ri-icon.vue +23 -23
  114. package/dist/admin/components/vm-role-picker.vue +314 -314
  115. package/dist/admin/components/vm-split-layout.vue +106 -106
  116. package/dist/admin/components/vm-thumb.vue +49 -49
  117. package/dist/admin/components/vm-view-path-picker.vue +59 -59
  118. package/dist/admin/crud/auto-fields.d.ts +7 -0
  119. package/dist/admin/crud/auto-fields.js +1 -1
  120. package/dist/admin/crud/components/vm-avatar.vue +104 -104
  121. package/dist/admin/crud/components/vm-color-picker.vue +1626 -1626
  122. package/dist/admin/crud/components/vm-column-custom.vue +193 -193
  123. package/dist/admin/crud/components/vm-context-menu.vue +105 -105
  124. package/dist/admin/crud/components/vm-copy-btn.vue +102 -102
  125. package/dist/admin/crud/components/vm-date-calendar-panel.vue +537 -537
  126. package/dist/admin/crud/components/vm-date-picker.vue +75 -75
  127. package/dist/admin/crud/components/vm-date-range.vue +88 -88
  128. package/dist/admin/crud/components/vm-date-text.vue +42 -42
  129. package/dist/admin/crud/components/vm-datetime-field.vue +215 -215
  130. package/dist/admin/crud/components/vm-datetime-picker.vue +9 -9
  131. package/dist/admin/crud/components/vm-dict-tag.vue +112 -112
  132. package/dist/admin/crud/components/vm-ellipsis-text.vue +34 -34
  133. package/dist/admin/crud/components/vm-form-hint.vue +81 -81
  134. package/dist/admin/crud/components/vm-json-code.vue +41 -41
  135. package/dist/admin/crud/components/vm-json-editor.vue +161 -161
  136. package/dist/admin/crud/components/vm-json-kv-editor.vue +318 -318
  137. package/dist/admin/crud/components/vm-kv-row.vue +51 -51
  138. package/dist/admin/crud/components/vm-multi-select.vue +279 -279
  139. package/dist/admin/crud/components/vm-number-range.vue +204 -204
  140. package/dist/admin/crud/components/vm-preview-viewer.vue +894 -894
  141. package/dist/admin/crud/components/vm-radio.vue +87 -87
  142. package/dist/admin/crud/components/vm-richtext.vue +313 -313
  143. package/dist/admin/crud/components/vm-select.vue +570 -570
  144. package/dist/admin/crud/components/vm-status-tag.vue +158 -158
  145. package/dist/admin/crud/components/vm-switch.vue +158 -158
  146. package/dist/admin/crud/components/vm-tag-list.vue +108 -108
  147. package/dist/admin/crud/components/vm-text-link.vue +52 -52
  148. package/dist/admin/crud/components/vm-toggle-switch.vue +84 -84
  149. package/dist/admin/crud/components/vm-tree-select.vue +450 -450
  150. package/dist/admin/crud/components/vm-upload-item.vue +298 -298
  151. package/dist/admin/crud/components/vm-upload.vue +436 -436
  152. package/dist/admin/crud/config.js +1 -1
  153. package/dist/admin/crud/confirm.js +1 -1
  154. package/dist/admin/crud/crud-page-request.js +1 -1
  155. package/dist/admin/crud/data-i18n.js +1 -1
  156. package/dist/admin/crud/dict.js +1 -1
  157. package/dist/admin/crud/export-fetch.js +1 -1
  158. package/dist/admin/crud/index.d.ts +1 -1
  159. package/dist/admin/crud/index.js +1 -0
  160. package/dist/admin/crud/key.js +1 -1
  161. package/dist/admin/crud/plugins.js +1 -1
  162. package/dist/admin/crud/span.js +1 -1
  163. package/dist/admin/crud/style.js +1 -1
  164. package/dist/admin/crud/validate.js +1 -1
  165. package/dist/admin/crud/vm-add-btn.vue +70 -70
  166. package/dist/admin/crud/vm-adv-search.vue +83 -83
  167. package/dist/admin/crud/vm-crud.vue +711 -711
  168. package/dist/admin/crud/vm-data-i18n-btn.vue +456 -456
  169. package/dist/admin/crud/vm-data-i18n-lang.vue +183 -183
  170. package/dist/admin/crud/vm-export-btn.vue +183 -183
  171. package/dist/admin/crud/vm-flex.vue +14 -14
  172. package/dist/admin/crud/vm-import-btn.vue +747 -747
  173. package/dist/admin/crud/vm-multi-delete-btn.vue +49 -49
  174. package/dist/admin/crud/vm-pagination.vue +212 -212
  175. package/dist/admin/crud/vm-refresh-btn.vue +35 -35
  176. package/dist/admin/crud/vm-row.vue +55 -55
  177. package/dist/admin/crud/vm-search-key.vue +78 -78
  178. package/dist/admin/crud/vm-search.vue +466 -466
  179. package/dist/admin/crud/vm-table.vue +1390 -1390
  180. package/dist/admin/crud/vm-tabs.vue +94 -94
  181. package/dist/admin/crud/vm-toolbar.vue +357 -357
  182. package/dist/admin/crud/vm-upsert.vue +765 -765
  183. package/dist/admin/directives/perm.js +1 -1
  184. package/dist/admin/env.d.ts +1 -1
  185. package/dist/admin/hooks/useUpload.js +1 -1
  186. package/dist/admin/hooks/useVome.d.ts +2 -2
  187. package/dist/admin/lib/browser.js +1 -1
  188. package/dist/admin/lib/cn.js +1 -1
  189. package/dist/admin/lib/dialog-float.js +1 -1
  190. package/dist/admin/lib/export-excel.js +1 -1
  191. package/dist/admin/lib/import-excel.js +1 -1
  192. package/dist/admin/lib/json.js +1 -1
  193. package/dist/admin/lib/menu.js +1 -1
  194. package/dist/admin/lib/micro-locale.js +1 -1
  195. package/dist/admin/lib/micro-theme.js +1 -1
  196. package/dist/admin/lib/shell.js +1 -1
  197. package/dist/admin/lib/theme-color-options.js +1 -1
  198. package/dist/admin/lib/tree.js +1 -1
  199. package/dist/admin/lib/upload.js +1 -1
  200. package/dist/admin/lib/video-frame.js +1 -1
  201. package/dist/admin/router/create.js +1 -1
  202. package/dist/admin/router/guards.js +1 -1
  203. package/dist/admin/stores/app.d.ts +13 -1
  204. package/dist/admin/stores/tags.d.ts +14 -1
  205. package/dist/admin/stores/user.d.ts +48 -1
  206. package/dist/admin/styles/base.css +121 -121
  207. package/dist/admin/vendor/browser-image-compression.js +1144 -1144
  208. package/dist/admin/vendor/decimal-js.js +12 -12
  209. package/dist/admin/vendor/xlsx.js +263 -263
  210. package/dist/ai/index.js +1 -1
  211. package/dist/client/ai-model-registry.js +1 -1
  212. package/dist/client/base.js +1 -1
  213. package/dist/client/configure.js +1 -1
  214. package/dist/client/dict.js +1 -1
  215. package/dist/client/eps-payload.js +1 -1
  216. package/dist/client/eps.d.ts +7 -4
  217. package/dist/client/eps.js +1 -1
  218. package/dist/client/index.d.ts +1 -1
  219. package/dist/client/index.js +1 -1
  220. package/dist/client/locale-change.js +1 -1
  221. package/dist/client/locale-store.d.ts +45 -1
  222. package/dist/client/locale-store.js +1 -1
  223. package/dist/client/path.js +1 -1
  224. package/dist/client/service.d.ts +8 -0
  225. package/dist/client/service.js +1 -1
  226. package/dist/client/vite-admin.js +1 -1
  227. package/dist/client/vite-auto-import.js +1 -1
  228. package/dist/client/vite-micro-proxy.js +1 -1
  229. package/dist/client/vite-plugin-eps.d.ts +5 -0
  230. package/dist/client/vite-plugin-eps.js +1 -1
  231. package/dist/client/vite-resolve.js +1 -1
  232. package/dist/config/index.js +1 -1
  233. package/dist/core/index.d.ts +2 -2
  234. package/dist/index.js +1 -1
  235. package/dist/orm/index.d.ts +2 -0
  236. package/dist/orm/patch-bun-sql-jsonb.d.ts +1 -0
  237. package/dist/routing/context.d.ts +12 -2
  238. package/dist/routing/eps.d.ts +4 -1
  239. package/dist/routing/index.d.ts +2 -1
  240. package/dist/routing/metadata.d.ts +7 -3
  241. package/dist/server/index.d.ts +4 -2
  242. package/dist/server/index.js +1 -1
  243. package/dist/shared/datetime-picker.js +1 -1
  244. package/dist/shared/decimal.js +1 -1
  245. package/dist/shared/excel.js +1 -1
  246. package/dist/shared/index.js +1 -1
  247. package/dist/shared/locale-json.js +1 -1
  248. package/dist/shared/tree.js +1 -1
  249. package/dist/typings/routing/eps.d.ts +3 -1
  250. package/dist/typings/routing/meta.d.ts +15 -0
  251. package/package.json +1 -1
  252. package/typings/admin/base/auth.ts +24 -24
  253. package/typings/admin/base/permission.ts +19 -19
  254. package/typings/admin/comm/cascader.ts +7 -7
  255. package/typings/admin/comm/check-tree.ts +7 -7
  256. package/typings/admin/comm/crud.ts +427 -427
  257. package/typings/admin/comm/eps.ts +88 -86
  258. package/typings/admin/comm/request.ts +16 -16
  259. package/typings/admin/comm/service.ts +43 -43
  260. package/typings/admin/comm/upload.ts +39 -39
  261. package/typings/admin/theme.ts +10 -10
  262. package/typings/admin/vome/browser.ts +11 -11
  263. package/typings/admin/vome/module.ts +13 -13
  264. package/typings/admin/vome/tags.ts +9 -9
  265. package/typings/comm/crud.ts +83 -83
  266. package/typings/comm/page.ts +18 -18
  267. package/typings/comm/scan.ts +6 -6
  268. package/typings/common.ts +4 -4
  269. package/typings/context/data.ts +10 -10
  270. package/typings/ext/market-seat.ts +42 -42
  271. package/typings/ext/market.ts +55 -55
  272. package/typings/ext/module.ts +64 -64
  273. package/typings/ext/plugin.ts +38 -38
  274. package/typings/ioc/metadata.ts +34 -34
  275. package/typings/logger/config.ts +6 -6
  276. package/typings/routing/crud.ts +199 -199
  277. package/typings/routing/eps.ts +90 -88
  278. package/typings/routing/meta.ts +80 -63
  279. package/typings/routing/register.ts +21 -21
  280. package/typings/routing/scan-context.ts +7 -7
@@ -1,570 +1,570 @@
1
- <template>
2
- <div
3
- ref="rootRef"
4
- class="vm-select"
5
- :class="{ 'is-full': isFullWidth, 'is-open': open, 'is-disabled': disabled }"
6
- :style="triggerStyle"
7
- >
8
- <button
9
- ref="triggerRef"
10
- type="button"
11
- class="vm-crud-select vm-select__trigger"
12
- :class="{ 'is-full': isFullWidth }"
13
- :disabled="disabled"
14
- @click="toggle"
15
- >
16
- <span
17
- class="vm-select__label"
18
- :class="{ 'is-placeholder': !displayLabel }"
19
- >
20
- {{ displayLabel || placeholder || crudLabel('pleaseSelect') }}
21
- </span>
22
- <i
23
- v-if="clearable && hasValue"
24
- class="ri-close-circle-fill vm-select__clear"
25
- @click.stop="clear"
26
- />
27
- <i class="ri-arrow-down-s-line vm-select__caret" aria-hidden="true" />
28
- </button>
29
-
30
- <Teleport to="body">
31
- <FocusScope
32
- v-if="open"
33
- as-child
34
- loop
35
- :trapped="true"
36
- @unmount-auto-focus="onPanelUnmountAutoFocus"
37
- >
38
- <div
39
- ref="panelRef"
40
- class="vm-select__panel vm-dialog-float"
41
- :style="panelStyle"
42
- @pointerdown.stop
43
- @mousedown.stop
44
- >
45
- <div v-if="filterable" class="vm-select__search">
46
- <i class="ri-search-line vm-select__search-ico" aria-hidden="true" />
47
- <input
48
- ref="searchRef"
49
- class="vm-select__search-input"
50
- :value="search"
51
- :placeholder="crudLabel('searchEllipsis')"
52
- @input="onSearchInput"
53
- @keydown.esc.stop="closePanel"
54
- />
55
- <i
56
- v-if="search"
57
- class="ri-close-circle-fill vm-select__search-clear"
58
- @click.stop="clearSearch"
59
- />
60
- </div>
61
- <div class="vm-select__list vm-scroll" @scroll.passive="onListScroll">
62
- <button
63
- v-for="opt in filtered"
64
- :key="String(opt[valueKey])"
65
- type="button"
66
- class="vm-select__option"
67
- :class="{ 'is-active': String(opt[valueKey]) === inner }"
68
- @click="pick(opt)"
69
- >
70
- {{ opt[labelKey] }}
71
- </button>
72
- <vm-loading
73
- v-if="lazy && loading"
74
- mode="footer"
75
- status="loading"
76
- />
77
- <p v-else-if="!filtered.length" class="vm-select__empty">{{ crudLabel('noMatch') }}</p>
78
- <vm-loading
79
- v-else-if="lazy && !hasMore"
80
- mode="footer"
81
- status="end"
82
- />
83
- </div>
84
- </div>
85
- </FocusScope>
86
- </Teleport>
87
- </div>
88
- </template>
89
-
90
- <script setup lang="ts">
91
- import { crudLabel } from '../style'
92
- import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
93
- import { FocusScope } from 'reka-ui'
94
- import { useCrudOptional } from '../useCrud'
95
- import { POPUP_TRIGGER_GAP } from '../../lib/dialog-float'
96
-
97
- defineOptions({ name: 'vm-select' })
98
-
99
- export type VmSelectLoadQuery = {
100
- page: number
101
- size: number
102
- keyWord: string
103
- /** true:业务页应追加到现有 options;false:整表替换 */
104
- append: boolean
105
- }
106
-
107
- const props = withDefaults(
108
- defineProps<{
109
- modelValue?: string | number | boolean | null
110
- options?: Array<Record<string, unknown>>
111
- prop?: string
112
- labelKey?: string
113
- valueKey?: string
114
- placeholder?: string
115
- /** 默认 100% 铺满;搜索栏可传 150px / 180px */
116
- width?: string
117
- disabled?: boolean
118
- refreshOnChange?: boolean
119
- api?: string | (() => Promise<unknown>)
120
- /**
121
- * 是否显示面板顶部搜索框;默认 true。
122
- * false 时 UI 与默认同构,仅隐藏搜索(不再走另一套原生 Select)。
123
- */
124
- filterable?: boolean
125
- /** 有值时展示清除图标,点击置空 */
126
- clearable?: boolean
127
- /**
128
- * 懒加载 / 翻页:打开、搜索、触底时 emit `load`,由页面请求后回推 `:options`。
129
- * 与静态 options、`api` 并存;开启后本地不做关键字过滤。
130
- */
131
- lazy?: boolean
132
- loading?: boolean
133
- hasMore?: boolean
134
- pageSize?: number
135
- }>(),
136
- {
137
- labelKey: 'label',
138
- valueKey: 'value',
139
- refreshOnChange: true,
140
- width: '100%',
141
- filterable: true,
142
- clearable: true,
143
- lazy: false,
144
- loading: false,
145
- hasMore: false,
146
- pageSize: 20,
147
- },
148
- )
149
-
150
- const emit = defineEmits<{
151
- 'update:modelValue': [v: string | number | boolean | '']
152
- change: [v: string | number | boolean | '']
153
- load: [q: VmSelectLoadQuery]
154
- }>()
155
-
156
- const crud = useCrudOptional()
157
- const remote = ref<Array<Record<string, unknown>>>([])
158
- const rootRef = ref<HTMLElement | null>(null)
159
- const triggerRef = ref<HTMLElement | null>(null)
160
- const panelRef = ref<HTMLElement | null>(null)
161
- const searchRef = ref<HTMLInputElement | null>(null)
162
- const open = ref(false)
163
- const search = ref('')
164
- const panelStyle = ref<Record<string, string>>({})
165
- const suppressOpen = ref(false)
166
- /** lazy 模式下已请求到的页码(下一次触底 = page + 1) */
167
- const loadedPage = ref(0)
168
- /** 已 emit load、等待业务回推;防触底连发 */
169
- const awaitingLoad = ref(false)
170
- let searchTimer: ReturnType<typeof setTimeout> | null = null
171
-
172
- const inner = computed(() =>
173
- props.modelValue == null || props.modelValue === ''
174
- ? ''
175
- : String(props.modelValue),
176
- )
177
-
178
- const hasValue = computed(() => inner.value !== '')
179
-
180
- const isFullWidth = computed(
181
- () => !props.width || props.width === '100%' || props.width === '100',
182
- )
183
-
184
- const triggerStyle = computed(() =>
185
- isFullWidth.value ? undefined : { width: props.width },
186
- )
187
-
188
- const normalized = computed(() => {
189
- const list = (props.options?.length ? props.options : remote.value) || []
190
- return list as Array<Record<string, unknown>>
191
- })
192
-
193
- const displayLabel = computed(() => {
194
- if (!inner.value) return ''
195
- const hit = normalized.value.find(
196
- (o) => String(o[props.valueKey]) === inner.value,
197
- )
198
- return hit ? String(hit[props.labelKey] ?? '') : inner.value
199
- })
200
-
201
- const filtered = computed(() => {
202
- // lazy:关键字交给业务页,列表即 options 全量
203
- if (props.lazy || !props.filterable) return normalized.value
204
- const q = search.value.trim().toLowerCase()
205
- if (!q) return normalized.value
206
- return normalized.value.filter((o) => {
207
- const label = String(o[props.labelKey] ?? '').toLowerCase()
208
- const value = String(o[props.valueKey] ?? '').toLowerCase()
209
- return label.includes(q) || value.includes(q)
210
- })
211
- })
212
-
213
- watch(
214
- () => props.api,
215
- async (api) => {
216
- if (!api || props.lazy) return
217
- try {
218
- const req =
219
- typeof api === 'function'
220
- ? api()
221
- : (crud?.service[api] as (() => Promise<unknown>) | undefined)?.()
222
- const res = await req
223
- remote.value = Array.isArray(res) ? res : []
224
- } catch {
225
- remote.value = []
226
- }
227
- },
228
- { immediate: true },
229
- )
230
-
231
- watch(open, (v) => {
232
- if (!v) {
233
- search.value = ''
234
- if (searchTimer) {
235
- clearTimeout(searchTimer)
236
- searchTimer = null
237
- }
238
- }
239
- })
240
-
241
- watch(
242
- () => [props.loading, props.options] as const,
243
- () => {
244
- if (props.loading) return
245
- awaitingLoad.value = false
246
- },
247
- { deep: true },
248
- )
249
-
250
- function emitLoad(page: number, append: boolean) {
251
- if (awaitingLoad.value || props.loading) return
252
- awaitingLoad.value = true
253
- emit('load', {
254
- page,
255
- size: props.pageSize,
256
- keyWord: search.value.trim(),
257
- append,
258
- })
259
- loadedPage.value = page
260
- }
261
-
262
- function requestFirstPage() {
263
- if (!props.lazy) return
264
- emitLoad(1, false)
265
- }
266
-
267
- function requestNextPage() {
268
- if (!props.lazy || !props.hasMore) return
269
- emitLoad(loadedPage.value + 1, true)
270
- }
271
-
272
- /** 提交时还原 options 里的原始类型 */
273
- function resolveOptionValue(v: unknown): string | number | boolean {
274
- const s = v == null ? '' : String(v)
275
- if (!s) return ''
276
- const hit = normalized.value.find(
277
- (o) => String(o[props.valueKey]) === s,
278
- )
279
- if (!hit) return s
280
- const raw = hit[props.valueKey]
281
- if (typeof raw === 'number' || typeof raw === 'boolean') return raw
282
- return s
283
- }
284
-
285
- function onChange(v: unknown) {
286
- const val = resolveOptionValue(v)
287
- emit('update:modelValue', val)
288
- emit('change', val)
289
- if (props.prop && props.refreshOnChange) {
290
- void crud?.refresh({ page: 1, [props.prop]: val === '' ? undefined : val })
291
- }
292
- }
293
-
294
- function pick(opt: Record<string, unknown>) {
295
- onChange(opt[props.valueKey])
296
- closePanel()
297
- }
298
-
299
- function onSearchInput(e: Event) {
300
- search.value = (e.target as HTMLInputElement).value
301
- if (!props.lazy) return
302
- if (searchTimer) clearTimeout(searchTimer)
303
- searchTimer = setTimeout(() => {
304
- searchTimer = null
305
- if (!open.value) return
306
- emitLoad(1, false)
307
- }, 300)
308
- }
309
-
310
- function clearSearch() {
311
- search.value = ''
312
- searchRef.value?.focus()
313
- if (props.lazy) emitLoad(1, false)
314
- }
315
-
316
- function clear() {
317
- closePanel()
318
- onChange('')
319
- }
320
-
321
- function onListScroll(e: Event) {
322
- if (!props.lazy || !props.hasMore) return
323
- const el = e.target as HTMLElement
324
- if (el.scrollHeight - el.scrollTop - el.clientHeight > 48) return
325
- requestNextPage()
326
- }
327
-
328
- function syncPanelPos() {
329
- const el = triggerRef.value
330
- if (!el) return
331
- const r = el.getBoundingClientRect()
332
- const width = Math.max(r.width, 200)
333
- panelStyle.value = {
334
- position: 'fixed',
335
- top: `${r.bottom + POPUP_TRIGGER_GAP}px`,
336
- left: `${r.left}px`,
337
- width: `${width}px`,
338
- zIndex: '200',
339
- }
340
- }
341
-
342
- function onPanelUnmountAutoFocus(event: Event) {
343
- event.preventDefault()
344
- }
345
-
346
- function closePanel() {
347
- if (!open.value) return
348
- suppressOpen.value = true
349
- open.value = false
350
- search.value = ''
351
- nextTick(() => {
352
- requestAnimationFrame(() => {
353
- suppressOpen.value = false
354
- })
355
- })
356
- }
357
-
358
- async function openPanel() {
359
- if (props.disabled || suppressOpen.value) return
360
- open.value = true
361
- await nextTick()
362
- syncPanelPos()
363
- if (props.filterable) searchRef.value?.focus()
364
- requestFirstPage()
365
- }
366
-
367
- async function toggle() {
368
- if (props.disabled) return
369
- if (open.value) {
370
- closePanel()
371
- return
372
- }
373
- await openPanel()
374
- }
375
-
376
- function isInside(el: Node | null) {
377
- if (!el) return false
378
- if (rootRef.value?.contains(el)) return true
379
- if (panelRef.value?.contains(el)) return true
380
- return el instanceof Element && Boolean(el.closest('.vm-select__panel'))
381
- }
382
-
383
- function onDocPointer(e: Event) {
384
- if (!open.value) return
385
- const t = e.target as Node | null
386
- if (!isInside(t)) closePanel()
387
- }
388
-
389
- function onWinChange() {
390
- if (open.value) syncPanelPos()
391
- }
392
-
393
- onMounted(() => {
394
- document.addEventListener('pointerdown', onDocPointer, true)
395
- window.addEventListener('resize', onWinChange)
396
- window.addEventListener('scroll', onWinChange, true)
397
- })
398
- onUnmounted(() => {
399
- if (searchTimer) clearTimeout(searchTimer)
400
- document.removeEventListener('pointerdown', onDocPointer, true)
401
- window.removeEventListener('resize', onWinChange)
402
- window.removeEventListener('scroll', onWinChange, true)
403
- })
404
- </script>
405
-
406
- <style lang="scss" scoped>
407
- .vm-crud-select {
408
- height: var(--vm-control-height);
409
- border-radius: var(--vm-control-radius);
410
- font-size: var(--vm-control-font-size);
411
-
412
- &.is-full {
413
- width: 100% !important;
414
- max-width: 100%;
415
- }
416
- }
417
-
418
- .vm-select {
419
- position: relative;
420
- box-sizing: border-box;
421
- min-width: 0;
422
-
423
- &.is-full {
424
- width: 100%;
425
- }
426
-
427
- &.is-disabled {
428
- opacity: 0.55;
429
- pointer-events: none;
430
- }
431
- }
432
-
433
- .vm-select__trigger {
434
- display: flex;
435
- box-sizing: border-box;
436
- width: 100%;
437
- align-items: center;
438
- justify-content: flex-start;
439
- gap: 6px;
440
- padding: 0 var(--vm-control-padding-x);
441
- border: 1px solid var(--input);
442
- background: var(--muted);
443
- color: var(--foreground);
444
- cursor: pointer;
445
- outline: none;
446
- }
447
-
448
- .vm-select__label {
449
- overflow: hidden;
450
- flex: 1 1 auto;
451
- min-width: 0;
452
- text-align: left;
453
- text-overflow: ellipsis;
454
- white-space: nowrap;
455
-
456
- &.is-placeholder {
457
- color: var(--muted-foreground);
458
- }
459
- }
460
-
461
- .vm-select__caret {
462
- flex: 0 0 auto;
463
- margin-left: auto;
464
- color: var(--muted-foreground);
465
- font-size: 14px;
466
- }
467
-
468
- .vm-select__clear {
469
- flex: 0 0 auto;
470
- margin-left: auto;
471
- color: var(--muted-foreground);
472
- font-size: 14px;
473
- cursor: pointer;
474
-
475
- &:hover {
476
- color: var(--foreground);
477
- }
478
- }
479
-
480
- .vm-select__clear + .vm-select__caret {
481
- margin-left: 0;
482
- }
483
-
484
- .vm-select__panel {
485
- display: flex;
486
- max-height: min(280px, 45vh);
487
- flex-direction: column;
488
- overflow: hidden;
489
- border: 1px solid var(--border);
490
- border-radius: var(--vm-control-radius);
491
- background: var(--card);
492
- box-shadow: var(--shadow-card);
493
- }
494
-
495
- .vm-select__search {
496
- display: flex;
497
- flex: 0 0 auto;
498
- align-items: center;
499
- gap: 6px;
500
- margin: 8px 8px 0;
501
- padding: 0 8px;
502
- border: 1px solid var(--input);
503
- border-radius: 8px;
504
- background: var(--muted);
505
- }
506
-
507
- .vm-select__search-ico {
508
- flex: 0 0 auto;
509
- color: var(--muted-foreground);
510
- font-size: 14px;
511
- }
512
-
513
- .vm-select__search-input {
514
- flex: 1 1 auto;
515
- min-width: 0;
516
- height: 28px;
517
- border: none;
518
- background: transparent;
519
- color: var(--foreground);
520
- font-size: 13px;
521
- outline: none;
522
- }
523
-
524
- .vm-select__search-clear {
525
- flex: 0 0 auto;
526
- color: var(--muted-foreground);
527
- font-size: 14px;
528
- cursor: pointer;
529
- }
530
-
531
- .vm-select__list {
532
- flex: 1 1 auto;
533
- min-height: 0;
534
- margin: 6px 0;
535
- padding: 0 6px 6px;
536
- overflow: auto;
537
- }
538
-
539
- .vm-select__option {
540
- display: block;
541
- box-sizing: border-box;
542
- width: 100%;
543
- padding: 7px 8px;
544
- border: none;
545
- border-radius: 8px;
546
- background: transparent;
547
- color: var(--foreground);
548
- font-size: 13px;
549
- text-align: left;
550
- cursor: pointer;
551
-
552
- &:hover {
553
- background: var(--muted);
554
- }
555
-
556
- &.is-active {
557
- background: color-mix(in srgb, var(--brand) 12%, var(--card));
558
- color: var(--brand);
559
- font-weight: 550;
560
- }
561
- }
562
-
563
- .vm-select__empty {
564
- margin: 0;
565
- padding: 12px 8px;
566
- color: var(--muted-foreground);
567
- font-size: 12px;
568
- text-align: center;
569
- }
570
- </style>
1
+ <template>
2
+ <div
3
+ ref="rootRef"
4
+ class="vm-select"
5
+ :class="{ 'is-full': isFullWidth, 'is-open': open, 'is-disabled': disabled }"
6
+ :style="triggerStyle"
7
+ >
8
+ <button
9
+ ref="triggerRef"
10
+ type="button"
11
+ class="vm-crud-select vm-select__trigger"
12
+ :class="{ 'is-full': isFullWidth }"
13
+ :disabled="disabled"
14
+ @click="toggle"
15
+ >
16
+ <span
17
+ class="vm-select__label"
18
+ :class="{ 'is-placeholder': !displayLabel }"
19
+ >
20
+ {{ displayLabel || placeholder || crudLabel('pleaseSelect') }}
21
+ </span>
22
+ <i
23
+ v-if="clearable && hasValue"
24
+ class="ri-close-circle-fill vm-select__clear"
25
+ @click.stop="clear"
26
+ />
27
+ <i class="ri-arrow-down-s-line vm-select__caret" aria-hidden="true" />
28
+ </button>
29
+
30
+ <Teleport to="body">
31
+ <FocusScope
32
+ v-if="open"
33
+ as-child
34
+ loop
35
+ :trapped="true"
36
+ @unmount-auto-focus="onPanelUnmountAutoFocus"
37
+ >
38
+ <div
39
+ ref="panelRef"
40
+ class="vm-select__panel vm-dialog-float"
41
+ :style="panelStyle"
42
+ @pointerdown.stop
43
+ @mousedown.stop
44
+ >
45
+ <div v-if="filterable" class="vm-select__search">
46
+ <i class="ri-search-line vm-select__search-ico" aria-hidden="true" />
47
+ <input
48
+ ref="searchRef"
49
+ class="vm-select__search-input"
50
+ :value="search"
51
+ :placeholder="crudLabel('searchEllipsis')"
52
+ @input="onSearchInput"
53
+ @keydown.esc.stop="closePanel"
54
+ />
55
+ <i
56
+ v-if="search"
57
+ class="ri-close-circle-fill vm-select__search-clear"
58
+ @click.stop="clearSearch"
59
+ />
60
+ </div>
61
+ <div class="vm-select__list vm-scroll" @scroll.passive="onListScroll">
62
+ <button
63
+ v-for="opt in filtered"
64
+ :key="String(opt[valueKey])"
65
+ type="button"
66
+ class="vm-select__option"
67
+ :class="{ 'is-active': String(opt[valueKey]) === inner }"
68
+ @click="pick(opt)"
69
+ >
70
+ {{ opt[labelKey] }}
71
+ </button>
72
+ <vm-loading
73
+ v-if="lazy && loading"
74
+ mode="footer"
75
+ status="loading"
76
+ />
77
+ <p v-else-if="!filtered.length" class="vm-select__empty">{{ crudLabel('noMatch') }}</p>
78
+ <vm-loading
79
+ v-else-if="lazy && !hasMore"
80
+ mode="footer"
81
+ status="end"
82
+ />
83
+ </div>
84
+ </div>
85
+ </FocusScope>
86
+ </Teleport>
87
+ </div>
88
+ </template>
89
+
90
+ <script setup lang="ts">
91
+ import { crudLabel } from '../style'
92
+ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
93
+ import { FocusScope } from 'reka-ui'
94
+ import { useCrudOptional } from '../useCrud'
95
+ import { POPUP_TRIGGER_GAP } from '../../lib/dialog-float'
96
+
97
+ defineOptions({ name: 'vm-select' })
98
+
99
+ export type VmSelectLoadQuery = {
100
+ page: number
101
+ size: number
102
+ keyWord: string
103
+ /** true:业务页应追加到现有 options;false:整表替换 */
104
+ append: boolean
105
+ }
106
+
107
+ const props = withDefaults(
108
+ defineProps<{
109
+ modelValue?: string | number | boolean | null
110
+ options?: Array<Record<string, unknown>>
111
+ prop?: string
112
+ labelKey?: string
113
+ valueKey?: string
114
+ placeholder?: string
115
+ /** 默认 100% 铺满;搜索栏可传 150px / 180px */
116
+ width?: string
117
+ disabled?: boolean
118
+ refreshOnChange?: boolean
119
+ api?: string | (() => Promise<unknown>)
120
+ /**
121
+ * 是否显示面板顶部搜索框;默认 true。
122
+ * false 时 UI 与默认同构,仅隐藏搜索(不再走另一套原生 Select)。
123
+ */
124
+ filterable?: boolean
125
+ /** 有值时展示清除图标,点击置空 */
126
+ clearable?: boolean
127
+ /**
128
+ * 懒加载 / 翻页:打开、搜索、触底时 emit `load`,由页面请求后回推 `:options`。
129
+ * 与静态 options、`api` 并存;开启后本地不做关键字过滤。
130
+ */
131
+ lazy?: boolean
132
+ loading?: boolean
133
+ hasMore?: boolean
134
+ pageSize?: number
135
+ }>(),
136
+ {
137
+ labelKey: 'label',
138
+ valueKey: 'value',
139
+ refreshOnChange: true,
140
+ width: '100%',
141
+ filterable: true,
142
+ clearable: true,
143
+ lazy: false,
144
+ loading: false,
145
+ hasMore: false,
146
+ pageSize: 20,
147
+ },
148
+ )
149
+
150
+ const emit = defineEmits<{
151
+ 'update:modelValue': [v: string | number | boolean | '']
152
+ change: [v: string | number | boolean | '']
153
+ load: [q: VmSelectLoadQuery]
154
+ }>()
155
+
156
+ const crud = useCrudOptional()
157
+ const remote = ref<Array<Record<string, unknown>>>([])
158
+ const rootRef = ref<HTMLElement | null>(null)
159
+ const triggerRef = ref<HTMLElement | null>(null)
160
+ const panelRef = ref<HTMLElement | null>(null)
161
+ const searchRef = ref<HTMLInputElement | null>(null)
162
+ const open = ref(false)
163
+ const search = ref('')
164
+ const panelStyle = ref<Record<string, string>>({})
165
+ const suppressOpen = ref(false)
166
+ /** lazy 模式下已请求到的页码(下一次触底 = page + 1) */
167
+ const loadedPage = ref(0)
168
+ /** 已 emit load、等待业务回推;防触底连发 */
169
+ const awaitingLoad = ref(false)
170
+ let searchTimer: ReturnType<typeof setTimeout> | null = null
171
+
172
+ const inner = computed(() =>
173
+ props.modelValue == null || props.modelValue === ''
174
+ ? ''
175
+ : String(props.modelValue),
176
+ )
177
+
178
+ const hasValue = computed(() => inner.value !== '')
179
+
180
+ const isFullWidth = computed(
181
+ () => !props.width || props.width === '100%' || props.width === '100',
182
+ )
183
+
184
+ const triggerStyle = computed(() =>
185
+ isFullWidth.value ? undefined : { width: props.width },
186
+ )
187
+
188
+ const normalized = computed(() => {
189
+ const list = (props.options?.length ? props.options : remote.value) || []
190
+ return list as Array<Record<string, unknown>>
191
+ })
192
+
193
+ const displayLabel = computed(() => {
194
+ if (!inner.value) return ''
195
+ const hit = normalized.value.find(
196
+ (o) => String(o[props.valueKey]) === inner.value,
197
+ )
198
+ return hit ? String(hit[props.labelKey] ?? '') : inner.value
199
+ })
200
+
201
+ const filtered = computed(() => {
202
+ // lazy:关键字交给业务页,列表即 options 全量
203
+ if (props.lazy || !props.filterable) return normalized.value
204
+ const q = search.value.trim().toLowerCase()
205
+ if (!q) return normalized.value
206
+ return normalized.value.filter((o) => {
207
+ const label = String(o[props.labelKey] ?? '').toLowerCase()
208
+ const value = String(o[props.valueKey] ?? '').toLowerCase()
209
+ return label.includes(q) || value.includes(q)
210
+ })
211
+ })
212
+
213
+ watch(
214
+ () => props.api,
215
+ async (api) => {
216
+ if (!api || props.lazy) return
217
+ try {
218
+ const req =
219
+ typeof api === 'function'
220
+ ? api()
221
+ : (crud?.service[api] as (() => Promise<unknown>) | undefined)?.()
222
+ const res = await req
223
+ remote.value = Array.isArray(res) ? res : []
224
+ } catch {
225
+ remote.value = []
226
+ }
227
+ },
228
+ { immediate: true },
229
+ )
230
+
231
+ watch(open, (v) => {
232
+ if (!v) {
233
+ search.value = ''
234
+ if (searchTimer) {
235
+ clearTimeout(searchTimer)
236
+ searchTimer = null
237
+ }
238
+ }
239
+ })
240
+
241
+ watch(
242
+ () => [props.loading, props.options] as const,
243
+ () => {
244
+ if (props.loading) return
245
+ awaitingLoad.value = false
246
+ },
247
+ { deep: true },
248
+ )
249
+
250
+ function emitLoad(page: number, append: boolean) {
251
+ if (awaitingLoad.value || props.loading) return
252
+ awaitingLoad.value = true
253
+ emit('load', {
254
+ page,
255
+ size: props.pageSize,
256
+ keyWord: search.value.trim(),
257
+ append,
258
+ })
259
+ loadedPage.value = page
260
+ }
261
+
262
+ function requestFirstPage() {
263
+ if (!props.lazy) return
264
+ emitLoad(1, false)
265
+ }
266
+
267
+ function requestNextPage() {
268
+ if (!props.lazy || !props.hasMore) return
269
+ emitLoad(loadedPage.value + 1, true)
270
+ }
271
+
272
+ /** 提交时还原 options 里的原始类型 */
273
+ function resolveOptionValue(v: unknown): string | number | boolean {
274
+ const s = v == null ? '' : String(v)
275
+ if (!s) return ''
276
+ const hit = normalized.value.find(
277
+ (o) => String(o[props.valueKey]) === s,
278
+ )
279
+ if (!hit) return s
280
+ const raw = hit[props.valueKey]
281
+ if (typeof raw === 'number' || typeof raw === 'boolean') return raw
282
+ return s
283
+ }
284
+
285
+ function onChange(v: unknown) {
286
+ const val = resolveOptionValue(v)
287
+ emit('update:modelValue', val)
288
+ emit('change', val)
289
+ if (props.prop && props.refreshOnChange) {
290
+ void crud?.refresh({ page: 1, [props.prop]: val === '' ? undefined : val })
291
+ }
292
+ }
293
+
294
+ function pick(opt: Record<string, unknown>) {
295
+ onChange(opt[props.valueKey])
296
+ closePanel()
297
+ }
298
+
299
+ function onSearchInput(e: Event) {
300
+ search.value = (e.target as HTMLInputElement).value
301
+ if (!props.lazy) return
302
+ if (searchTimer) clearTimeout(searchTimer)
303
+ searchTimer = setTimeout(() => {
304
+ searchTimer = null
305
+ if (!open.value) return
306
+ emitLoad(1, false)
307
+ }, 300)
308
+ }
309
+
310
+ function clearSearch() {
311
+ search.value = ''
312
+ searchRef.value?.focus()
313
+ if (props.lazy) emitLoad(1, false)
314
+ }
315
+
316
+ function clear() {
317
+ closePanel()
318
+ onChange('')
319
+ }
320
+
321
+ function onListScroll(e: Event) {
322
+ if (!props.lazy || !props.hasMore) return
323
+ const el = e.target as HTMLElement
324
+ if (el.scrollHeight - el.scrollTop - el.clientHeight > 48) return
325
+ requestNextPage()
326
+ }
327
+
328
+ function syncPanelPos() {
329
+ const el = triggerRef.value
330
+ if (!el) return
331
+ const r = el.getBoundingClientRect()
332
+ const width = Math.max(r.width, 200)
333
+ panelStyle.value = {
334
+ position: 'fixed',
335
+ top: `${r.bottom + POPUP_TRIGGER_GAP}px`,
336
+ left: `${r.left}px`,
337
+ width: `${width}px`,
338
+ zIndex: '200',
339
+ }
340
+ }
341
+
342
+ function onPanelUnmountAutoFocus(event: Event) {
343
+ event.preventDefault()
344
+ }
345
+
346
+ function closePanel() {
347
+ if (!open.value) return
348
+ suppressOpen.value = true
349
+ open.value = false
350
+ search.value = ''
351
+ nextTick(() => {
352
+ requestAnimationFrame(() => {
353
+ suppressOpen.value = false
354
+ })
355
+ })
356
+ }
357
+
358
+ async function openPanel() {
359
+ if (props.disabled || suppressOpen.value) return
360
+ open.value = true
361
+ await nextTick()
362
+ syncPanelPos()
363
+ if (props.filterable) searchRef.value?.focus()
364
+ requestFirstPage()
365
+ }
366
+
367
+ async function toggle() {
368
+ if (props.disabled) return
369
+ if (open.value) {
370
+ closePanel()
371
+ return
372
+ }
373
+ await openPanel()
374
+ }
375
+
376
+ function isInside(el: Node | null) {
377
+ if (!el) return false
378
+ if (rootRef.value?.contains(el)) return true
379
+ if (panelRef.value?.contains(el)) return true
380
+ return el instanceof Element && Boolean(el.closest('.vm-select__panel'))
381
+ }
382
+
383
+ function onDocPointer(e: Event) {
384
+ if (!open.value) return
385
+ const t = e.target as Node | null
386
+ if (!isInside(t)) closePanel()
387
+ }
388
+
389
+ function onWinChange() {
390
+ if (open.value) syncPanelPos()
391
+ }
392
+
393
+ onMounted(() => {
394
+ document.addEventListener('pointerdown', onDocPointer, true)
395
+ window.addEventListener('resize', onWinChange)
396
+ window.addEventListener('scroll', onWinChange, true)
397
+ })
398
+ onUnmounted(() => {
399
+ if (searchTimer) clearTimeout(searchTimer)
400
+ document.removeEventListener('pointerdown', onDocPointer, true)
401
+ window.removeEventListener('resize', onWinChange)
402
+ window.removeEventListener('scroll', onWinChange, true)
403
+ })
404
+ </script>
405
+
406
+ <style lang="scss" scoped>
407
+ .vm-crud-select {
408
+ height: var(--vm-control-height);
409
+ border-radius: var(--vm-control-radius);
410
+ font-size: var(--vm-control-font-size);
411
+
412
+ &.is-full {
413
+ width: 100% !important;
414
+ max-width: 100%;
415
+ }
416
+ }
417
+
418
+ .vm-select {
419
+ position: relative;
420
+ box-sizing: border-box;
421
+ min-width: 0;
422
+
423
+ &.is-full {
424
+ width: 100%;
425
+ }
426
+
427
+ &.is-disabled {
428
+ opacity: 0.55;
429
+ pointer-events: none;
430
+ }
431
+ }
432
+
433
+ .vm-select__trigger {
434
+ display: flex;
435
+ box-sizing: border-box;
436
+ width: 100%;
437
+ align-items: center;
438
+ justify-content: flex-start;
439
+ gap: 6px;
440
+ padding: 0 var(--vm-control-padding-x);
441
+ border: 1px solid var(--input);
442
+ background: var(--muted);
443
+ color: var(--foreground);
444
+ cursor: pointer;
445
+ outline: none;
446
+ }
447
+
448
+ .vm-select__label {
449
+ overflow: hidden;
450
+ flex: 1 1 auto;
451
+ min-width: 0;
452
+ text-align: left;
453
+ text-overflow: ellipsis;
454
+ white-space: nowrap;
455
+
456
+ &.is-placeholder {
457
+ color: var(--muted-foreground);
458
+ }
459
+ }
460
+
461
+ .vm-select__caret {
462
+ flex: 0 0 auto;
463
+ margin-left: auto;
464
+ color: var(--muted-foreground);
465
+ font-size: 14px;
466
+ }
467
+
468
+ .vm-select__clear {
469
+ flex: 0 0 auto;
470
+ margin-left: auto;
471
+ color: var(--muted-foreground);
472
+ font-size: 14px;
473
+ cursor: pointer;
474
+
475
+ &:hover {
476
+ color: var(--foreground);
477
+ }
478
+ }
479
+
480
+ .vm-select__clear + .vm-select__caret {
481
+ margin-left: 0;
482
+ }
483
+
484
+ .vm-select__panel {
485
+ display: flex;
486
+ max-height: min(280px, 45vh);
487
+ flex-direction: column;
488
+ overflow: hidden;
489
+ border: 1px solid var(--border);
490
+ border-radius: var(--vm-control-radius);
491
+ background: var(--card);
492
+ box-shadow: var(--shadow-card);
493
+ }
494
+
495
+ .vm-select__search {
496
+ display: flex;
497
+ flex: 0 0 auto;
498
+ align-items: center;
499
+ gap: 6px;
500
+ margin: 8px 8px 0;
501
+ padding: 0 8px;
502
+ border: 1px solid var(--input);
503
+ border-radius: 8px;
504
+ background: var(--muted);
505
+ }
506
+
507
+ .vm-select__search-ico {
508
+ flex: 0 0 auto;
509
+ color: var(--muted-foreground);
510
+ font-size: 14px;
511
+ }
512
+
513
+ .vm-select__search-input {
514
+ flex: 1 1 auto;
515
+ min-width: 0;
516
+ height: 28px;
517
+ border: none;
518
+ background: transparent;
519
+ color: var(--foreground);
520
+ font-size: 13px;
521
+ outline: none;
522
+ }
523
+
524
+ .vm-select__search-clear {
525
+ flex: 0 0 auto;
526
+ color: var(--muted-foreground);
527
+ font-size: 14px;
528
+ cursor: pointer;
529
+ }
530
+
531
+ .vm-select__list {
532
+ flex: 1 1 auto;
533
+ min-height: 0;
534
+ margin: 6px 0;
535
+ padding: 0 6px 6px;
536
+ overflow: auto;
537
+ }
538
+
539
+ .vm-select__option {
540
+ display: block;
541
+ box-sizing: border-box;
542
+ width: 100%;
543
+ padding: 7px 8px;
544
+ border: none;
545
+ border-radius: 8px;
546
+ background: transparent;
547
+ color: var(--foreground);
548
+ font-size: 13px;
549
+ text-align: left;
550
+ cursor: pointer;
551
+
552
+ &:hover {
553
+ background: var(--muted);
554
+ }
555
+
556
+ &.is-active {
557
+ background: color-mix(in srgb, var(--brand) 12%, var(--card));
558
+ color: var(--brand);
559
+ font-weight: 550;
560
+ }
561
+ }
562
+
563
+ .vm-select__empty {
564
+ margin: 0;
565
+ padding: 12px 8px;
566
+ color: var(--muted-foreground);
567
+ font-size: 12px;
568
+ text-align: center;
569
+ }
570
+ </style>