vome-core 0.0.20 → 0.0.22

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 (58) hide show
  1. package/dist/admin/config/plugin-dev.js +1 -1
  2. package/dist/admin/crud/components/vm-audio.vue +217 -0
  3. package/dist/admin/crud/components/vm-column-custom.vue +50 -56
  4. package/dist/admin/crud/components/vm-file-preview.vue +90 -0
  5. package/dist/admin/crud/components/vm-image.vue +197 -0
  6. package/dist/admin/crud/components/vm-preview-viewer.vue +524 -0
  7. package/dist/admin/crud/components/vm-richtext.vue +275 -0
  8. package/dist/admin/crud/components/vm-upload-item.vue +92 -31
  9. package/dist/admin/crud/components/vm-upload.vue +2 -6
  10. package/dist/admin/crud/components/vm-user-select.vue +48 -49
  11. package/dist/admin/crud/config.js +1 -1
  12. package/dist/admin/crud/confirm.js +1 -1
  13. package/dist/admin/crud/dict.js +1 -1
  14. package/dist/admin/crud/index.d.ts +5 -3
  15. package/dist/admin/crud/index.js +1 -1
  16. package/dist/admin/crud/key.js +1 -1
  17. package/dist/admin/crud/mitt.js +1 -1
  18. package/dist/admin/crud/plugins.js +1 -1
  19. package/dist/admin/crud/span.js +1 -1
  20. package/dist/admin/crud/style.js +1 -1
  21. package/dist/admin/crud/validate.js +1 -1
  22. package/dist/admin/crud/vm-adv-search.vue +39 -32
  23. package/dist/admin/crud/vm-crud.vue +3 -8
  24. package/dist/admin/crud/{vm-flex1.vue → vm-flex.vue} +3 -3
  25. package/dist/admin/crud/vm-upsert.vue +9 -92
  26. package/dist/admin/directives/perm.js +1 -1
  27. package/dist/admin/hooks/useUpload.js +1 -1
  28. package/dist/admin/lib/browser.js +1 -1
  29. package/dist/admin/lib/cn.js +1 -1
  30. package/dist/admin/lib/dialog-float.d.ts +2 -0
  31. package/dist/admin/lib/dialog-float.js +1 -1
  32. package/dist/admin/lib/export-excel.js +1 -1
  33. package/dist/admin/lib/file-preview.d.ts +11 -0
  34. package/dist/admin/lib/file-preview.js +1 -0
  35. package/dist/admin/lib/menu.js +1 -1
  36. package/dist/admin/lib/tree.js +1 -1
  37. package/dist/admin/lib/upload.js +1 -1
  38. package/dist/admin/lib/video-frame.d.ts +9 -0
  39. package/dist/admin/lib/video-frame.js +1 -0
  40. package/dist/admin/styles/base.css +100 -0
  41. package/dist/index.js +1 -1
  42. package/dist/server/index.js +1 -1
  43. package/dist/shared/excel.js +1 -1
  44. package/dist/shared/index.js +1 -1
  45. package/dist/shared/tree.js +1 -1
  46. package/package.json +1 -1
  47. package/typings/admin/comm/crud.d.ts +2 -2
  48. package/typings/admin/comm/crud.ts +2 -3
  49. package/dist/admin/crud/components/vm-file-icon.vue +0 -55
  50. package/dist/admin/crud/vm-dialog.vue +0 -235
  51. package/dist/admin/static/fileicon/demo.css +0 -539
  52. package/dist/admin/static/fileicon/demo_index.html +0 -901
  53. package/dist/admin/static/fileicon/iconfont.css +0 -139
  54. package/dist/admin/static/fileicon/iconfont.js +0 -1
  55. package/dist/admin/static/fileicon/iconfont.json +0 -226
  56. package/dist/admin/static/fileicon/iconfont.ttf +0 -0
  57. package/dist/admin/static/fileicon/iconfont.woff +0 -0
  58. package/dist/admin/static/fileicon/iconfont.woff2 +0 -0
@@ -1 +1 @@
1
- const _0x3a99f4=_0x220d;function _0x3c90(){const _0x596d34=['57QV5yMn56UV','mJCYmta5nNj5q0HVwG','mtK2mZmYCMPIyu5o','mte5mJm5og9dshH6vG','yMfJA2vUza','Ahr0Chm6lY9NAxrLzs5JB20Vwu9vuL9puKCVwu9vuL9srvbpl3jHDY9Tyxn0zxiVCgX1z2LUlwjHy2TLBMqUEMLW','57QV5zco56UV','nZC3nvr4CfPkva','mte2mgrJrLb6va','6zkP5A2qic8G5PYn5yQH56UV5O+s5lU26isA5OMl5P6277YilNzVBwxVVjRKU4uGC2vYDMvY77Yj77Ym6ycc5zci5lIk5lYG44cb55+T5l+H44cb5PsV5lUy562j6io95yQB5OMP5Bgv44cc','5zco5y+W5B6U5BQu55sO6isA5OMl5P6277YilNzVBwxVVjRLKkSGD2vIl++8IE+8Jos7Psb3DwPPzsdMJilOVB3NI6ZNQ4VLIy3NQ6/POBxPNAlJGii','Ahr0Chm6lY92B21LlMrLDI9WBhvNAw5Zl3vWBg9Hza','mJC2mJi1nKHjzfjjyq','ndqWmJrUBwDlue4','5A6m5Pw05lIA5yQH5QIH5z2x6isA5OMl5P6277YilNzVBwxVVjRLKkSGC2vYDMvYios4JIb3zwlVViNVViZMNkZLNldLVidLJ5hMIzpLJixLKi7LJ5hLUipJGii','mZe1qvn2Eu9k','ndC1mJGWDKPOtgfI','zNjVBNrLBMq','5yMn56UVicSG5zco56UV'];_0x3c90=function(){return _0x596d34;};return _0x3c90();}function _0x220d(_0x5b515f,_0x4b6cf3){_0x5b515f=_0x5b515f-0x8e;const _0x3c9010=_0x3c90();let _0x220d37=_0x3c9010[_0x5b515f];if(_0x220d['LdPUpd']===undefined){var _0x3a8230=function(_0x4cd3a8){const _0x492e12='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0xf04ce5='',_0x430e77='';for(let _0x4721a8=0x0,_0x56e5ed,_0x5c2ca0,_0x2f313f=0x0;_0x5c2ca0=_0x4cd3a8['charAt'](_0x2f313f++);~_0x5c2ca0&&(_0x56e5ed=_0x4721a8%0x4?_0x56e5ed*0x40+_0x5c2ca0:_0x5c2ca0,_0x4721a8++%0x4)?_0xf04ce5+=String['fromCharCode'](0xff&_0x56e5ed>>(-0x2*_0x4721a8&0x6)):0x0){_0x5c2ca0=_0x492e12['indexOf'](_0x5c2ca0);}for(let _0x8c2bf7=0x0,_0x2a65cf=_0xf04ce5['length'];_0x8c2bf7<_0x2a65cf;_0x8c2bf7++){_0x430e77+='%'+('00'+_0xf04ce5['charCodeAt'](_0x8c2bf7)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x430e77);};_0x220d['lbAhLd']=_0x3a8230,_0x220d['MjaXPd']={},_0x220d['LdPUpd']=!![];}const _0x461a79=_0x3c9010[0x0],_0x3c46db=_0x5b515f+_0x461a79,_0x29c111=_0x220d['MjaXPd'][_0x3c46db];return!_0x29c111?(_0x220d37=_0x220d['lbAhLd'](_0x220d37),_0x220d['MjaXPd'][_0x3c46db]=_0x220d37):_0x220d37=_0x29c111,_0x220d37;}(function(_0x48c364,_0x2c771c){const _0x1397fe=_0x220d,_0x3f8ced=_0x48c364();while(!![]){try{const _0x4cc976=parseInt(_0x1397fe(0x9f))/0x1+parseInt(_0x1397fe(0x9a))/0x2+parseInt(_0x1397fe(0xa0))/0x3+-parseInt(_0x1397fe(0x92))/0x4*(-parseInt(_0x1397fe(0x91))/0x5)+-parseInt(_0x1397fe(0x9e))/0x6+-parseInt(_0x1397fe(0x96))/0x7+-parseInt(_0x1397fe(0x97))/0x8*(parseInt(_0x1397fe(0x99))/0x9);if(_0x4cc976===_0x2c771c)break;else _0x3f8ced['push'](_0x3f8ced['shift']());}catch(_0x5d24d8){_0x3f8ced['push'](_0x3f8ced['shift']());}}}(_0x3c90,0x3affb));export const pluginDev={'docsUrl':'https://vome.dev/docs/plugin','uploadUrl':_0x3a99f4(0x95),'scaffolds':{'full':{'key':'full','title':_0x3a99f4(0x9c),'desc':_0x3a99f4(0x98),'downloadUrl':'https://gitee.com/YOUR_ORG/YOUR_REPO/raw/master/plugin-full.zip'},'backend':{'key':_0x3a99f4(0x8e),'title':_0x3a99f4(0x90),'desc':_0x3a99f4(0x93),'downloadUrl':_0x3a99f4(0x8f)},'frontend':{'key':_0x3a99f4(0x9b),'title':_0x3a99f4(0x9d),'desc':_0x3a99f4(0x94),'downloadUrl':'https://gitee.com/YOUR_ORG/YOUR_REPO/raw/master/plugin-frontend.zip'}}};
1
+ const _0x4301da=_0x3003;(function(_0x3de5aa,_0x3e8744){const _0x5b5713=_0x3003,_0x891ce6=_0x3de5aa();while(!![]){try{const _0x56dfa4=-parseInt(_0x5b5713(0xd6))/0x1+-parseInt(_0x5b5713(0xd1))/0x2*(parseInt(_0x5b5713(0xd7))/0x3)+parseInt(_0x5b5713(0xca))/0x4+parseInt(_0x5b5713(0xd8))/0x5+parseInt(_0x5b5713(0xd0))/0x6+parseInt(_0x5b5713(0xd3))/0x7*(-parseInt(_0x5b5713(0xc9))/0x8)+parseInt(_0x5b5713(0xd4))/0x9*(-parseInt(_0x5b5713(0xce))/0xa);if(_0x56dfa4===_0x3e8744)break;else _0x891ce6['push'](_0x891ce6['shift']());}catch(_0x2a258d){_0x891ce6['push'](_0x891ce6['shift']());}}}(_0x538b,0x23234));function _0x3003(_0x337deb,_0x2ff32c){_0x337deb=_0x337deb-0xc6;const _0x538bfe=_0x538b();let _0x30037d=_0x538bfe[_0x337deb];if(_0x3003['gdBCmM']===undefined){var _0x54cc91=function(_0x596e1b){const _0x5073b5='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let _0x4a9d7a='',_0x2c9a4e='';for(let _0x20d39d=0x0,_0x59955a,_0x40e8b8,_0x5dd9ae=0x0;_0x40e8b8=_0x596e1b['charAt'](_0x5dd9ae++);~_0x40e8b8&&(_0x59955a=_0x20d39d%0x4?_0x59955a*0x40+_0x40e8b8:_0x40e8b8,_0x20d39d++%0x4)?_0x4a9d7a+=String['fromCharCode'](0xff&_0x59955a>>(-0x2*_0x20d39d&0x6)):0x0){_0x40e8b8=_0x5073b5['indexOf'](_0x40e8b8);}for(let _0x22a607=0x0,_0x1c0111=_0x4a9d7a['length'];_0x22a607<_0x1c0111;_0x22a607++){_0x2c9a4e+='%'+('00'+_0x4a9d7a['charCodeAt'](_0x22a607)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x2c9a4e);};_0x3003['PkbIDo']=_0x54cc91,_0x3003['bBASsV']={},_0x3003['gdBCmM']=!![];}const _0x4b84e3=_0x538bfe[0x0],_0x230fc3=_0x337deb+_0x4b84e3,_0x129b8e=_0x3003['bBASsV'][_0x230fc3];return!_0x129b8e?(_0x30037d=_0x3003['PkbIDo'](_0x30037d),_0x3003['bBASsV'][_0x230fc3]=_0x30037d):_0x30037d=_0x129b8e,_0x30037d;}export const pluginDev={'docsUrl':'https://vome.dev/docs/plugin','uploadUrl':_0x4301da(0xcc),'scaffolds':{'full':{'key':_0x4301da(0xc8),'title':_0x4301da(0xd5),'desc':'完整业务模块脚手架(.vome:含\x20server\x20与\x20web),本地开发打包后发布。','downloadUrl':_0x4301da(0xd2)},'backend':{'key':_0x4301da(0xcb),'title':_0x4301da(0xd9),'desc':_0x4301da(0xcd),'downloadUrl':_0x4301da(0xc6)},'frontend':{'key':'frontend','title':_0x4301da(0xc7),'desc':'后台微应用脚手架(.vome:含\x20web/),以\x20wujie\x20挂载独立前端页面。','downloadUrl':_0x4301da(0xcf)}}};function _0x538b(){const _0x1fc5e4=['mty3mJeZngTqwMTXsW','mKjWz0vdDG','Ahr0Chm6lY9NAxrLzs5JB20Vwu9vuL9puKCVwu9vuL9srvbpl3jHDY9Tyxn0zxiVCgX1z2LUlwz1BgWUEMLW','n0Llr3blCa','oteZnZqZsxnnruvM','5yMn56UVicSG5zco56UV','mtG2mJLIBhvkAhG','mJy5mtCYCurwEwjT','nte5odi1EuXnrvjr','57QV5zco56UV','Ahr0Chm6lY9NAxrLzs5JB20Vwu9vuL9puKCVwu9vuL9srvbpl3jHDY9Tyxn0zxiVCgX1z2LUlwjHy2TLBMqUEMLW','57QV5yMn56UV','zNvSBa','nJi5odaWrM1kv253','mtaXmtCXnNjKt0Lurq','yMfJA2vUza','Ahr0Chm6lY92B21LlMrLDI9WBhvNAw5Zl3vWBg9Hza','6zkP5A2qic8G5PYn5yQH56UV5O+s5lU26isA5OMl5P6277YilNzVBwxVVjRKU4uGC2vYDMvY77Yj77Ym6ycc5zci5lIk5lYG44cb55+T5l+H44cb5PsV5lUy562j6io95yQB5OMP5Bgv44cc','mZb2wKnjCNO','Ahr0Chm6lY9NAxrLzs5JB20Vwu9vuL9puKCVwu9vuL9srvbpl3jHDY9Tyxn0zxiVCgX1z2LUlwzYB250zw5KlNPPCa'];_0x538b=function(){return _0x1fc5e4;};return _0x538b();}
@@ -0,0 +1,217 @@
1
+ <template>
2
+ <div v-if="src" class="vm-audio">
3
+ <button
4
+ type="button"
5
+ class="vm-audio__play"
6
+ :disabled="!preview"
7
+ :title="preview ? '播放音频' : undefined"
8
+ @click="openPlayer"
9
+ >
10
+ <i class="ri-play-circle-line" aria-hidden="true" />
11
+ <span>{{ label }}</span>
12
+ </button>
13
+ </div>
14
+ <span v-else class="vm-audio is-empty">—</span>
15
+
16
+ <Teleport to="body">
17
+ <div
18
+ v-if="playerOpen"
19
+ class="vm-audio-player"
20
+ role="dialog"
21
+ aria-modal="true"
22
+ @click.self="closePlayer"
23
+ >
24
+ <button type="button" class="vm-audio-player__close" title="关闭" @click="closePlayer">
25
+ <i class="ri-close-line" />
26
+ </button>
27
+ <div class="vm-audio-player__panel">
28
+ <p v-if="titleText" class="vm-audio-player__title">{{ titleText }}</p>
29
+ <audio
30
+ ref="audioRef"
31
+ class="vm-audio-player__media"
32
+ :src="src"
33
+ controls
34
+ autoplay
35
+ playsinline
36
+ @ended="closePlayer"
37
+ />
38
+ </div>
39
+ </div>
40
+ </Teleport>
41
+ </template>
42
+
43
+ <script setup lang="ts">
44
+ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
45
+
46
+ defineOptions({ name: 'vm-audio' })
47
+
48
+ const props = withDefaults(
49
+ defineProps<{
50
+ /** 音频 URL(表格列绑定 modelValue) */
51
+ modelValue?: string | null
52
+ /** 按钮文案 */
53
+ label?: string
54
+ /** 弹层标题,默认取文件名 */
55
+ title?: string
56
+ /** 是否可播放 */
57
+ preview?: boolean
58
+ }>(),
59
+ {
60
+ label: '播放',
61
+ preview: true,
62
+ },
63
+ )
64
+
65
+ const playerOpen = ref(false)
66
+ const audioRef = ref<HTMLAudioElement | null>(null)
67
+
68
+ const src = computed(() => {
69
+ const v = props.modelValue
70
+ if (v == null) return ''
71
+ return String(v).trim()
72
+ })
73
+
74
+ const titleText = computed(() => {
75
+ if (props.title) return props.title
76
+ const s = src.value
77
+ if (!s) return ''
78
+ return s.split('?')[0]?.split('/').pop() || ''
79
+ })
80
+
81
+ function lockBody(lock: boolean) {
82
+ if (typeof document === 'undefined') return
83
+ document.body.style.overflow = lock ? 'hidden' : ''
84
+ }
85
+
86
+ function openPlayer() {
87
+ if (!props.preview || !src.value) return
88
+ playerOpen.value = true
89
+ lockBody(true)
90
+ void nextTick(() => {
91
+ audioRef.value?.play?.().catch(() => undefined)
92
+ })
93
+ }
94
+
95
+ function closePlayer() {
96
+ playerOpen.value = false
97
+ lockBody(false)
98
+ audioRef.value?.pause?.()
99
+ }
100
+
101
+ function onKeydown(e: KeyboardEvent) {
102
+ if (!playerOpen.value) return
103
+ if (e.key === 'Escape') closePlayer()
104
+ }
105
+
106
+ watch(playerOpen, (open) => {
107
+ if (typeof window === 'undefined') return
108
+ if (open) window.addEventListener('keydown', onKeydown)
109
+ else window.removeEventListener('keydown', onKeydown)
110
+ })
111
+
112
+ onUnmounted(() => {
113
+ lockBody(false)
114
+ if (typeof window !== 'undefined') {
115
+ window.removeEventListener('keydown', onKeydown)
116
+ }
117
+ })
118
+ </script>
119
+
120
+ <style lang="scss" scoped>
121
+ .vm-audio {
122
+ display: inline-flex;
123
+ vertical-align: middle;
124
+ }
125
+
126
+ .vm-audio.is-empty {
127
+ color: var(--muted-foreground);
128
+ font-size: 12px;
129
+ }
130
+
131
+ .vm-audio__play {
132
+ display: inline-flex;
133
+ align-items: center;
134
+ gap: 4px;
135
+ padding: 0;
136
+ border: none;
137
+ background: transparent;
138
+ color: var(--brand);
139
+ font-size: 13px;
140
+ font-weight: 600;
141
+ cursor: pointer;
142
+ transition: color 0.15s ease;
143
+
144
+ i {
145
+ font-size: 16px;
146
+ line-height: 1;
147
+ }
148
+
149
+ &:hover:not(:disabled) {
150
+ color: var(--brand-deep);
151
+ }
152
+
153
+ &:disabled {
154
+ cursor: default;
155
+ opacity: 0.55;
156
+ }
157
+ }
158
+ </style>
159
+
160
+ <style lang="scss">
161
+ .vm-audio-player {
162
+ position: fixed;
163
+ inset: 0;
164
+ z-index: 2200;
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ padding: 24px;
169
+ background: rgb(10 12 20 / 72%);
170
+ }
171
+
172
+ .vm-audio-player__close {
173
+ position: absolute;
174
+ top: 16px;
175
+ right: 16px;
176
+ z-index: 2;
177
+ display: inline-flex;
178
+ width: 36px;
179
+ height: 36px;
180
+ align-items: center;
181
+ justify-content: center;
182
+ border: none;
183
+ border-radius: 10px;
184
+ background: rgb(255 255 255 / 12%);
185
+ color: #fff;
186
+ font-size: 20px;
187
+ cursor: pointer;
188
+
189
+ &:hover {
190
+ background: rgb(255 255 255 / 22%);
191
+ }
192
+ }
193
+
194
+ .vm-audio-player__panel {
195
+ width: min(480px, calc(100vw - 48px));
196
+ padding: 20px 22px;
197
+ border-radius: 16px;
198
+ background: var(--card);
199
+ box-shadow: 0 24px 48px rgb(15 23 42 / 18%);
200
+ }
201
+
202
+ .vm-audio-player__title {
203
+ margin: 0 0 12px;
204
+ overflow: hidden;
205
+ text-overflow: ellipsis;
206
+ white-space: nowrap;
207
+ font-size: 14px;
208
+ font-weight: 650;
209
+ color: var(--foreground);
210
+ }
211
+
212
+ .vm-audio-player__media {
213
+ display: block;
214
+ width: 100%;
215
+ outline: none;
216
+ }
217
+ </style>
@@ -3,44 +3,58 @@
3
3
  <Button size="sm" variant="outline" class="h-8" @click="open = true">
4
4
  自定义列
5
5
  </Button>
6
- <vm-dialog v-model:open="open" title="自定义列" width="420px">
7
- <div class="vm-col-custom">
8
- <label
9
- v-for="(item, i) in list"
10
- :key="String(item.prop)"
11
- class="vm-col-custom__item"
12
- >
13
- <input v-model="item.checked" type="checkbox" />
14
- <span>{{ item.label }}</span>
15
- <span class="vm-col-custom__move">
16
- <button type="button" :disabled="i === 0" @click.prevent="move(i, -1)">↑</button>
17
- <button
18
- type="button"
19
- :disabled="i === list.length - 1"
20
- @click.prevent="move(i, 1)"
6
+ <Dialog :open="open" @update:open="onOpenChange">
7
+ <DialogContent
8
+ shell="crud"
9
+ class="vm-col-custom__dialog"
10
+ @pointer-down-outside="preventDialogOutsideClose"
11
+ @focus-outside="preventDialogOutsideClose"
12
+ @interact-outside="preventDialogOutsideClose"
13
+ >
14
+ <DialogHeader class="vm-crud-shell__head">
15
+ <DialogTitle class="vm-crud-shell__title">自定义列</DialogTitle>
16
+ </DialogHeader>
17
+ <div class="vm-crud-shell__body">
18
+ <div class="vm-col-custom">
19
+ <label
20
+ v-for="(item, i) in list"
21
+ :key="String(item.prop)"
22
+ class="vm-col-custom__item"
21
23
  >
22
-
23
- </button>
24
- </span>
25
- </label>
26
- </div>
27
- <template #footer>
28
- <button type="button" class="vm-col-custom__btn is-ghost" @click="open = false">
29
- 取消
30
- </button>
31
- <button type="button" class="vm-col-custom__btn is-danger" @click="reset">
32
- 重置
33
- </button>
34
- <button type="button" class="vm-col-custom__btn is-primary" @click="save">
35
- 保存
36
- </button>
37
- </template>
38
- </vm-dialog>
24
+ <input v-model="item.checked" type="checkbox" />
25
+ <span>{{ item.label }}</span>
26
+ <span class="vm-col-custom__move">
27
+ <button type="button" :disabled="i === 0" @click.prevent="move(i, -1)">↑</button>
28
+ <button
29
+ type="button"
30
+ :disabled="i === list.length - 1"
31
+ @click.prevent="move(i, 1)"
32
+ >
33
+
34
+ </button>
35
+ </span>
36
+ </label>
37
+ </div>
38
+ </div>
39
+ <DialogFooter class="vm-crud-shell__foot">
40
+ <button type="button" class="vm-crud-shell__btn is-ghost" @click="open = false">
41
+ 取消
42
+ </button>
43
+ <button type="button" class="vm-crud-shell__btn is-danger" @click="reset">
44
+ 重置
45
+ </button>
46
+ <button type="button" class="vm-crud-shell__btn is-primary" @click="save">
47
+ 保存
48
+ </button>
49
+ </DialogFooter>
50
+ </DialogContent>
51
+ </Dialog>
39
52
  </div>
40
53
  </template>
41
54
 
42
55
  <script setup lang="ts">
43
56
  import { ref, watch, inject } from 'vue'
57
+ import { preventDialogOutsideClose } from '../../lib/dialog-float'
44
58
  import { injectTableOptions, TABLE_API_KEY } from '../key'
45
59
  defineOptions({ name: 'vm-column-custom' })
46
60
 
@@ -63,6 +77,10 @@ const open = ref(false)
63
77
  const list = ref<Item[]>([])
64
78
  const storageKey = computedKey()
65
79
 
80
+ function onOpenChange(v: boolean) {
81
+ open.value = v
82
+ }
83
+
66
84
  function computedKey() {
67
85
  return `column-custom__${props.name || (typeof location !== 'undefined' ? location.pathname : 'default')}`
68
86
  }
@@ -170,28 +188,4 @@ function save() {
170
188
  }
171
189
  }
172
190
  }
173
-
174
- .vm-col-custom__btn {
175
- height: 36px;
176
- padding: 0 14px;
177
- border: none;
178
- border-radius: 12px;
179
- font-size: 13px;
180
- font-weight: 600;
181
- cursor: pointer;
182
-
183
- &.is-ghost {
184
- background: var(--muted);
185
- }
186
-
187
- &.is-danger {
188
- background: var(--danger-soft, #fceeee);
189
- color: var(--danger, #c45b5e);
190
- }
191
-
192
- &.is-primary {
193
- background: var(--brand);
194
- color: #fff;
195
- }
196
- }
197
191
  </style>
@@ -0,0 +1,90 @@
1
+ <template>
2
+ <div v-if="src" class="vm-file-preview">
3
+ <button
4
+ type="button"
5
+ class="vm-file-preview__btn"
6
+ :disabled="!preview"
7
+ @click="openViewer"
8
+ >
9
+ <i class="ri-file-search-line" aria-hidden="true" />
10
+ <span>{{ label }}</span>
11
+ </button>
12
+ </div>
13
+ <span v-else class="vm-file-preview is-empty">—</span>
14
+
15
+ <vm-preview-viewer v-model:open="viewerOpen" :src="src" :file-name="fileName" />
16
+ </template>
17
+
18
+ <script setup lang="ts">
19
+ import { computed, ref } from 'vue'
20
+ import VmPreviewViewer from './vm-preview-viewer.vue'
21
+
22
+ defineOptions({ name: 'vm-file-preview' })
23
+
24
+ const props = withDefaults(
25
+ defineProps<{
26
+ /** 文件 URL(表格列绑定 modelValue) */
27
+ modelValue?: string | null
28
+ /** 展示用文件名 */
29
+ fileName?: string
30
+ label?: string
31
+ preview?: boolean
32
+ }>(),
33
+ {
34
+ label: '查看',
35
+ preview: true,
36
+ },
37
+ )
38
+
39
+ const viewerOpen = ref(false)
40
+
41
+ const src = computed(() => {
42
+ const v = props.modelValue
43
+ if (v == null) return ''
44
+ return String(v).trim()
45
+ })
46
+
47
+ function openViewer() {
48
+ if (!props.preview || !src.value) return
49
+ viewerOpen.value = true
50
+ }
51
+ </script>
52
+
53
+ <style lang="scss" scoped>
54
+ .vm-file-preview {
55
+ display: inline-flex;
56
+ vertical-align: middle;
57
+ }
58
+
59
+ .vm-file-preview.is-empty {
60
+ color: var(--muted-foreground);
61
+ font-size: 12px;
62
+ }
63
+
64
+ .vm-file-preview__btn {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ gap: 4px;
68
+ padding: 0;
69
+ border: none;
70
+ background: transparent;
71
+ color: var(--brand);
72
+ font-size: 13px;
73
+ font-weight: 600;
74
+ cursor: pointer;
75
+
76
+ i {
77
+ font-size: 16px;
78
+ line-height: 1;
79
+ }
80
+
81
+ &:hover:not(:disabled) {
82
+ color: var(--brand-deep);
83
+ }
84
+
85
+ &:disabled {
86
+ opacity: 0.55;
87
+ cursor: default;
88
+ }
89
+ }
90
+ </style>
@@ -0,0 +1,197 @@
1
+ <template>
2
+ <div v-if="src" class="vm-image" :class="{ 'is-video': isVideoKind }">
3
+ <button
4
+ type="button"
5
+ class="vm-image__trigger"
6
+ :style="thumbStyle"
7
+ :disabled="!preview"
8
+ :title="preview ? (isVideoKind ? '播放视频' : '查看大图') : undefined"
9
+ @click="openViewer"
10
+ >
11
+ <img
12
+ v-if="!isVideoKind"
13
+ class="vm-image__media"
14
+ :src="src"
15
+ alt=""
16
+ :style="mediaStyle"
17
+ @error="onThumbError"
18
+ />
19
+ <template v-else>
20
+ <img
21
+ v-if="videoFrameSrc"
22
+ class="vm-image__media"
23
+ :src="videoFrameSrc"
24
+ alt=""
25
+ :style="mediaStyle"
26
+ />
27
+ <video
28
+ v-else-if="frameFailed"
29
+ class="vm-image__media"
30
+ :src="src"
31
+ muted
32
+ playsinline
33
+ preload="metadata"
34
+ :style="mediaStyle"
35
+ />
36
+ <span v-else class="vm-image__loading" aria-hidden="true" />
37
+ <span class="vm-image__play" aria-hidden="true">
38
+ <i class="ri-play-fill" />
39
+ </span>
40
+ </template>
41
+ </button>
42
+ </div>
43
+ <span v-else class="vm-image is-empty">—</span>
44
+
45
+ <vm-preview-viewer
46
+ v-model:open="viewerOpen"
47
+ :src="src"
48
+ :file-name="previewFileName"
49
+ :media-kind="previewMediaKind"
50
+ />
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import { computed, ref } from 'vue'
55
+ import { useVideoFrame } from '../../lib/video-frame'
56
+ import { fileDisplayName } from '../../lib/file-preview'
57
+ import type { MediaPreviewKind } from '../../lib/file-preview'
58
+ import VmPreviewViewer from './vm-preview-viewer.vue'
59
+
60
+ defineOptions({ name: 'vm-image' })
61
+
62
+ const VIDEO_EXT = /\.(mp4|webm|mov|m4v|ogv|ogg)(\?|#|$)/i
63
+
64
+ const props = withDefaults(
65
+ defineProps<{
66
+ /** 图片或视频 URL(表格列绑定 modelValue) */
67
+ modelValue?: string | null
68
+ /** image | video | auto(按后缀推断) */
69
+ type?: 'image' | 'video' | 'auto'
70
+ /** 缩略图边长,默认 40 */
71
+ size?: number | string
72
+ /** 圆角,默认 8 */
73
+ radius?: number | string
74
+ /** 是否可点击预览/播放 */
75
+ preview?: boolean
76
+ fit?: 'cover' | 'contain'
77
+ }>(),
78
+ {
79
+ type: 'auto',
80
+ size: 40,
81
+ radius: 8,
82
+ preview: true,
83
+ fit: 'cover',
84
+ },
85
+ )
86
+
87
+ const viewerOpen = ref(false)
88
+
89
+ const src = computed(() => {
90
+ const v = props.modelValue
91
+ if (v == null) return ''
92
+ return String(v).trim()
93
+ })
94
+
95
+ const isVideoKind = computed(() => {
96
+ if (props.type === 'video') return true
97
+ if (props.type === 'image') return false
98
+ return VIDEO_EXT.test(src.value)
99
+ })
100
+
101
+ const previewMediaKind = computed<MediaPreviewKind>(() =>
102
+ isVideoKind.value ? 'video' : 'image',
103
+ )
104
+
105
+ const previewFileName = computed(() => fileDisplayName(src.value, 'media'))
106
+
107
+ const videoThumbSource = computed(() => (isVideoKind.value ? src.value : ''))
108
+ const { videoFrameSrc, frameFailed } = useVideoFrame(videoThumbSource)
109
+
110
+ function toCssUnit(v: number | string) {
111
+ return typeof v === 'number' ? `${v}px` : v
112
+ }
113
+
114
+ const thumbStyle = computed(() => ({
115
+ width: toCssUnit(props.size),
116
+ height: toCssUnit(props.size),
117
+ borderRadius: toCssUnit(props.radius),
118
+ }))
119
+
120
+ const mediaStyle = computed(() => ({
121
+ objectFit: props.fit,
122
+ }))
123
+
124
+ function onThumbError() {
125
+ /* 缩略图失败仍允许点开预览 */
126
+ }
127
+
128
+ function openViewer() {
129
+ if (!props.preview || !src.value) return
130
+ viewerOpen.value = true
131
+ }
132
+ </script>
133
+
134
+ <style lang="scss" scoped>
135
+ .vm-image {
136
+ display: inline-flex;
137
+ vertical-align: middle;
138
+ }
139
+
140
+ .vm-image.is-empty {
141
+ color: var(--muted-foreground);
142
+ font-size: 12px;
143
+ }
144
+
145
+ .vm-image__trigger {
146
+ position: relative;
147
+ display: inline-flex;
148
+ flex-shrink: 0;
149
+ align-items: center;
150
+ justify-content: center;
151
+ padding: 0;
152
+ border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
153
+ background: var(--muted);
154
+ cursor: pointer;
155
+ overflow: hidden;
156
+ transition:
157
+ border-color 0.15s ease,
158
+ box-shadow 0.15s ease;
159
+
160
+ &:hover:not(:disabled) {
161
+ border-color: var(--focus-border);
162
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--brand) 12%, transparent);
163
+ }
164
+
165
+ &:disabled {
166
+ cursor: default;
167
+ }
168
+ }
169
+
170
+ .vm-image__media {
171
+ display: block;
172
+ width: 100%;
173
+ height: 100%;
174
+ }
175
+
176
+ .vm-image__loading {
177
+ display: block;
178
+ width: 100%;
179
+ height: 100%;
180
+ background: color-mix(in srgb, var(--muted) 70%, var(--foreground) 8%);
181
+ }
182
+
183
+ .vm-image__play {
184
+ position: absolute;
185
+ inset: 0;
186
+ display: grid;
187
+ place-items: center;
188
+ background: rgb(20 24 40 / 28%);
189
+ color: #fff;
190
+ pointer-events: none;
191
+
192
+ i {
193
+ font-size: 18px;
194
+ line-height: 1;
195
+ }
196
+ }
197
+ </style>