vome-core 0.0.24 → 0.0.26

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 (51) hide show
  1. package/dist/admin/components/vm-action-btn.vue +109 -0
  2. package/dist/admin/components/vm-aside-list-item.vue +124 -0
  3. package/dist/admin/components/vm-card-grid.vue +36 -0
  4. package/dist/admin/components/vm-card.vue +100 -0
  5. package/dist/admin/components/vm-perm-section.vue +57 -0
  6. package/dist/admin/components/vm-split-layout.vue +85 -0
  7. package/dist/admin/config/plugin-dev.js +1 -1
  8. package/dist/admin/crud/components/vm-avatar.vue +103 -0
  9. package/dist/admin/crud/components/vm-copy-btn.vue +101 -0
  10. package/dist/admin/crud/components/vm-datetime-picker.vue +9 -0
  11. package/dist/admin/crud/components/vm-ellipsis-text.vue +34 -0
  12. package/dist/admin/crud/components/vm-form-hint.vue +63 -0
  13. package/dist/admin/crud/components/vm-json-code.vue +40 -0
  14. package/dist/admin/crud/components/vm-json-editor.vue +128 -0
  15. package/dist/admin/crud/components/vm-json-viewer.vue +147 -0
  16. package/dist/admin/crud/components/vm-preview-viewer.vue +39 -6
  17. package/dist/admin/crud/components/vm-status-tag.vue +157 -0
  18. package/dist/admin/crud/components/vm-tag-list.vue +82 -0
  19. package/dist/admin/crud/components/vm-text-link.vue +51 -0
  20. package/dist/admin/crud/config.js +1 -1
  21. package/dist/admin/crud/confirm.js +1 -1
  22. package/dist/admin/crud/dict.js +1 -1
  23. package/dist/admin/crud/index.d.ts +11 -1
  24. package/dist/admin/crud/index.js +1 -1
  25. package/dist/admin/crud/key.js +1 -1
  26. package/dist/admin/crud/mitt.js +1 -1
  27. package/dist/admin/crud/plugins.js +1 -1
  28. package/dist/admin/crud/registry.js +1 -0
  29. package/dist/admin/crud/span.js +1 -1
  30. package/dist/admin/crud/style.js +1 -1
  31. package/dist/admin/crud/validate.js +1 -1
  32. package/dist/admin/directives/perm.js +1 -1
  33. package/dist/admin/hooks/useUpload.js +1 -1
  34. package/dist/admin/lib/browser.js +1 -1
  35. package/dist/admin/lib/cn.js +1 -1
  36. package/dist/admin/lib/dialog-float.js +1 -1
  37. package/dist/admin/lib/export-excel.js +1 -1
  38. package/dist/admin/lib/file-preview.js +1 -1
  39. package/dist/admin/lib/json.d.ts +12 -0
  40. package/dist/admin/lib/json.js +1 -0
  41. package/dist/admin/lib/menu.js +1 -1
  42. package/dist/admin/lib/tree.js +1 -1
  43. package/dist/admin/lib/upload.js +1 -1
  44. package/dist/admin/lib/video-frame.js +1 -1
  45. package/dist/index.js +1 -1
  46. package/dist/server/index.js +1 -1
  47. package/dist/shared/excel.js +1 -1
  48. package/dist/shared/index.js +1 -1
  49. package/dist/shared/tree.js +1 -1
  50. package/package.json +1 -1
  51. package/dist/admin/crud/components/vm-audio.vue +0 -217
@@ -0,0 +1,101 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ class="vm-copy-btn"
5
+ :class="[`is-${size}`]"
6
+ :disabled="disabled || copying || !text"
7
+ @click="copy"
8
+ >
9
+ <i :class="copying ? 'ri-loader-4-line is-spin' : icon" aria-hidden="true" />
10
+ <span>{{ copying ? copyingText : label }}</span>
11
+ </button>
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { ref } from 'vue'
16
+
17
+ defineOptions({ name: 'vm-copy-btn' })
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ text?: string
22
+ label?: string
23
+ copyingText?: string
24
+ icon?: string
25
+ size?: 'sm' | 'md'
26
+ disabled?: boolean
27
+ }>(),
28
+ {
29
+ label: '复制',
30
+ copyingText: '复制中…',
31
+ icon: 'ri-file-copy-line',
32
+ size: 'md',
33
+ disabled: false,
34
+ },
35
+ )
36
+
37
+ const emit = defineEmits<{
38
+ copied: []
39
+ error: [e: unknown]
40
+ }>()
41
+
42
+ const copying = ref(false)
43
+
44
+ async function copy() {
45
+ if (!props.text || copying.value) return
46
+ copying.value = true
47
+ try {
48
+ await navigator.clipboard.writeText(props.text)
49
+ emit('copied')
50
+ } catch (e) {
51
+ emit('error', e)
52
+ } finally {
53
+ copying.value = false
54
+ }
55
+ }
56
+ </script>
57
+
58
+ <style lang="scss" scoped>
59
+ .vm-copy-btn {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 4px;
63
+ border: none;
64
+ border-radius: 8px;
65
+ background: color-mix(in srgb, var(--brand) 10%, var(--card));
66
+ color: var(--brand);
67
+ font-size: 12px;
68
+ font-weight: 600;
69
+ cursor: pointer;
70
+
71
+ &:hover:not(:disabled) {
72
+ background: color-mix(in srgb, var(--brand) 18%, var(--card));
73
+ }
74
+
75
+ &:disabled {
76
+ opacity: 0.45;
77
+ cursor: not-allowed;
78
+ }
79
+
80
+ &.is-md {
81
+ height: 32px;
82
+ padding: 0 12px;
83
+ }
84
+
85
+ &.is-sm {
86
+ height: 28px;
87
+ padding: 0 10px;
88
+ font-size: 11px;
89
+ }
90
+
91
+ i.is-spin {
92
+ animation: vm-copy-spin 0.8s linear infinite;
93
+ }
94
+ }
95
+
96
+ @keyframes vm-copy-spin {
97
+ to {
98
+ transform: rotate(360deg);
99
+ }
100
+ }
101
+ </style>
@@ -0,0 +1,9 @@
1
+ <template>
2
+ <vm-date-picker v-bind="$attrs" type="datetime" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import VmDatePicker from './vm-date-picker.vue'
7
+
8
+ defineOptions({ name: 'vm-datetime-picker', inheritAttrs: false })
9
+ </script>
@@ -0,0 +1,34 @@
1
+ <template>
2
+ <span class="vm-ellipsis-text" :title="titleText">{{ text }}</span>
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import { computed } from 'vue'
7
+
8
+ defineOptions({ name: 'vm-ellipsis-text' })
9
+
10
+ const props = defineProps<{
11
+ modelValue?: unknown
12
+ title?: string
13
+ }>()
14
+
15
+ const text = computed(() => {
16
+ const v = props.modelValue
17
+ if (v == null || v === '') return '—'
18
+ return String(v)
19
+ })
20
+
21
+ const titleText = computed(() => props.title ?? (text.value === '—' ? undefined : text.value))
22
+ </script>
23
+
24
+ <style lang="scss" scoped>
25
+ .vm-ellipsis-text {
26
+ display: inline-block;
27
+ max-width: 100%;
28
+ overflow: hidden;
29
+ text-overflow: ellipsis;
30
+ white-space: nowrap;
31
+ font-size: 12px;
32
+ color: var(--muted-foreground);
33
+ }
34
+ </style>
@@ -0,0 +1,63 @@
1
+ <template>
2
+ <p class="vm-form-hint" :class="[`is-${variant}`]">
3
+ <i v-if="icon" :class="icon" aria-hidden="true" />
4
+ <slot>{{ text }}</slot>
5
+ </p>
6
+ </template>
7
+
8
+ <script setup lang="ts">
9
+ defineOptions({ name: 'vm-form-hint' })
10
+
11
+ withDefaults(
12
+ defineProps<{
13
+ text?: string
14
+ variant?: 'info' | 'warn' | 'success'
15
+ icon?: string
16
+ }>(),
17
+ {
18
+ text: '',
19
+ variant: 'info',
20
+ icon: 'ri-information-line',
21
+ },
22
+ )
23
+ </script>
24
+
25
+ <style lang="scss" scoped>
26
+ .vm-form-hint {
27
+ display: flex;
28
+ align-items: flex-start;
29
+ gap: 8px;
30
+ margin: 0;
31
+ padding: 10px 12px;
32
+ border-radius: 10px;
33
+ font-size: 13px;
34
+ line-height: 1.5;
35
+ color: var(--muted-foreground);
36
+ background: color-mix(in srgb, var(--brand) 8%, var(--card));
37
+
38
+ i {
39
+ flex-shrink: 0;
40
+ margin-top: 2px;
41
+ font-size: 15px;
42
+ color: var(--brand);
43
+ }
44
+
45
+ &.is-warn {
46
+ background: color-mix(in srgb, #f59e0b 10%, var(--card));
47
+ color: color-mix(in srgb, var(--foreground) 72%, #d97706);
48
+
49
+ i {
50
+ color: #d97706;
51
+ }
52
+ }
53
+
54
+ &.is-success {
55
+ background: var(--success-soft);
56
+ color: var(--success);
57
+
58
+ i {
59
+ color: var(--success);
60
+ }
61
+ }
62
+ }
63
+ </style>
@@ -0,0 +1,40 @@
1
+ <template>
2
+ <pre class="vm-json-code vm-scroll">{{ body }}</pre>
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import { computed } from 'vue'
7
+ import { prettyJson } from '../../lib/json'
8
+
9
+ defineOptions({ name: 'vm-json-code' })
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ modelValue?: unknown
14
+ empty?: string
15
+ }>(),
16
+ {
17
+ empty: '(空)',
18
+ },
19
+ )
20
+
21
+ const body = computed(() => prettyJson(props.modelValue, props.empty))
22
+ </script>
23
+
24
+ <style lang="scss" scoped>
25
+ .vm-json-code {
26
+ flex: 1;
27
+ margin: 0;
28
+ min-height: 0;
29
+ overflow: auto;
30
+ padding: 16px 22px;
31
+ border-radius: var(--radius);
32
+ background: var(--muted);
33
+ color: var(--foreground);
34
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
35
+ font-size: 12px;
36
+ line-height: 1.55;
37
+ white-space: pre-wrap;
38
+ word-break: break-word;
39
+ }
40
+ </style>
@@ -0,0 +1,128 @@
1
+ <template>
2
+ <div class="vm-json-editor">
3
+ <textarea
4
+ class="vm-json-editor__input vm-scroll"
5
+ :class="{ 'is-error': Boolean(error) }"
6
+ :value="inner"
7
+ :disabled="disabled"
8
+ :placeholder="placeholder"
9
+ :rows="rows"
10
+ @input="onInput"
11
+ @blur="validate"
12
+ />
13
+ <p v-if="error" class="vm-json-editor__error">{{ error }}</p>
14
+ </div>
15
+ </template>
16
+
17
+ <script setup lang="ts">
18
+ import { ref, watch } from 'vue'
19
+ import { parseJsonInput, prettyJson, textOfJson } from '../../lib/json'
20
+
21
+ defineOptions({ name: 'vm-json-editor' })
22
+
23
+ const props = withDefaults(
24
+ defineProps<{
25
+ modelValue?: unknown
26
+ placeholder?: string
27
+ rows?: number
28
+ disabled?: boolean
29
+ /** blur 时自动格式化 */
30
+ formatOnBlur?: boolean
31
+ }>(),
32
+ {
33
+ placeholder: '请输入 JSON',
34
+ rows: 8,
35
+ formatOnBlur: true,
36
+ },
37
+ )
38
+
39
+ const emit = defineEmits<{
40
+ 'update:modelValue': [v: unknown]
41
+ change: [v: unknown]
42
+ error: [msg: string]
43
+ }>()
44
+
45
+ const inner = ref('')
46
+ const error = ref('')
47
+
48
+ function syncFromModel() {
49
+ inner.value = prettyJson(props.modelValue, '')
50
+ error.value = ''
51
+ }
52
+
53
+ watch(
54
+ () => props.modelValue,
55
+ () => syncFromModel(),
56
+ { immediate: true },
57
+ )
58
+
59
+ function onInput(e: Event) {
60
+ inner.value = (e.target as HTMLTextAreaElement).value
61
+ error.value = ''
62
+ }
63
+
64
+ function validate() {
65
+ const parsed = parseJsonInput(inner.value)
66
+ if (!parsed.ok) {
67
+ error.value = parsed.error
68
+ emit('error', parsed.error)
69
+ return
70
+ }
71
+ if (props.formatOnBlur) {
72
+ inner.value = prettyJson(parsed.value, '')
73
+ }
74
+ error.value = ''
75
+ emit('update:modelValue', parsed.value)
76
+ emit('change', parsed.value)
77
+ }
78
+
79
+ defineExpose({
80
+ validate,
81
+ getText: () => inner.value,
82
+ setText: (v: unknown) => {
83
+ inner.value = textOfJson(v)
84
+ },
85
+ })
86
+ </script>
87
+
88
+ <style lang="scss" scoped>
89
+ .vm-json-editor {
90
+ display: grid;
91
+ gap: 6px;
92
+ min-width: 0;
93
+ }
94
+
95
+ .vm-json-editor__input {
96
+ width: 100%;
97
+ min-height: 120px;
98
+ padding: 10px 12px;
99
+ border: 1px solid var(--border);
100
+ border-radius: var(--vm-control-radius);
101
+ background: var(--muted);
102
+ color: var(--foreground);
103
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
104
+ font-size: 12px;
105
+ line-height: 1.55;
106
+ resize: vertical;
107
+ outline: none;
108
+
109
+ &:focus {
110
+ border-color: var(--focus-border, var(--brand));
111
+ }
112
+
113
+ &.is-error {
114
+ border-color: var(--danger);
115
+ }
116
+
117
+ &:disabled {
118
+ opacity: 0.6;
119
+ cursor: not-allowed;
120
+ }
121
+ }
122
+
123
+ .vm-json-editor__error {
124
+ margin: 0;
125
+ font-size: 12px;
126
+ color: var(--danger);
127
+ }
128
+ </style>
@@ -0,0 +1,147 @@
1
+ <template>
2
+ <template v-if="mode === 'code'">
3
+ <vm-json-code :model-value="modelValue" :empty="empty" />
4
+ </template>
5
+ <template v-else>
6
+ <vm-text-link
7
+ v-if="hasContent"
8
+ :label="label"
9
+ @click="open = true"
10
+ />
11
+ <span v-else class="vm-json-viewer is-empty">—</span>
12
+ </template>
13
+
14
+ <Teleport to="body">
15
+ <div
16
+ v-if="open"
17
+ class="vm-json-viewer__dialog"
18
+ role="dialog"
19
+ aria-modal="true"
20
+ @click.self="open = false"
21
+ >
22
+ <header class="vm-json-viewer__head">
23
+ <span class="vm-json-viewer__title">{{ title }}</span>
24
+ <div class="vm-json-viewer__actions">
25
+ <vm-copy-btn :text="body" size="sm" label="复制" />
26
+ <button type="button" class="vm-json-viewer__close" title="关闭" @click="open = false">
27
+ <i class="ri-close-line" />
28
+ </button>
29
+ </div>
30
+ </header>
31
+ <div class="vm-json-viewer__body vm-scroll">
32
+ <vm-json-code :model-value="modelValue" :empty="empty" />
33
+ </div>
34
+ </div>
35
+ </Teleport>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed, ref } from 'vue'
40
+ import { hasJsonContent, prettyJson } from '../../lib/json'
41
+ import VmJsonCode from './vm-json-code.vue'
42
+ import VmTextLink from './vm-text-link.vue'
43
+ import VmCopyBtn from './vm-copy-btn.vue'
44
+
45
+ defineOptions({ name: 'vm-json-viewer' })
46
+
47
+ const props = withDefaults(
48
+ defineProps<{
49
+ modelValue?: unknown
50
+ title?: string
51
+ label?: string
52
+ /** button 打开浮层;code 仅渲染代码块(upsert fill) */
53
+ mode?: 'button' | 'code'
54
+ empty?: string
55
+ }>(),
56
+ {
57
+ title: 'JSON',
58
+ label: '查看',
59
+ mode: 'button',
60
+ empty: '(空)',
61
+ },
62
+ )
63
+
64
+ const open = ref(false)
65
+ const hasContent = computed(() => hasJsonContent(props.modelValue))
66
+ const body = computed(() => prettyJson(props.modelValue, props.empty))
67
+ </script>
68
+
69
+ <style lang="scss" scoped>
70
+ .vm-json-viewer.is-empty {
71
+ color: var(--muted-foreground);
72
+ }
73
+
74
+ .vm-json-viewer__dialog {
75
+ position: fixed;
76
+ inset: 0;
77
+ z-index: 5200;
78
+ display: flex;
79
+ flex-direction: column;
80
+ padding: 24px;
81
+ background: color-mix(in srgb, var(--foreground) 42%, transparent);
82
+ }
83
+
84
+ .vm-json-viewer__head {
85
+ display: flex;
86
+ flex-shrink: 0;
87
+ align-items: center;
88
+ justify-content: space-between;
89
+ gap: 12px;
90
+ margin: 0 auto;
91
+ width: min(960px, 100%);
92
+ padding: 12px 16px;
93
+ border-radius: 14px 14px 0 0;
94
+ background: var(--card);
95
+ border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
96
+ border-bottom: none;
97
+ }
98
+
99
+ .vm-json-viewer__title {
100
+ font-size: 14px;
101
+ font-weight: 700;
102
+ color: var(--foreground);
103
+ }
104
+
105
+ .vm-json-viewer__actions {
106
+ display: inline-flex;
107
+ align-items: center;
108
+ gap: 8px;
109
+ }
110
+
111
+ .vm-json-viewer__close {
112
+ display: inline-flex;
113
+ width: 32px;
114
+ height: 32px;
115
+ align-items: center;
116
+ justify-content: center;
117
+ border: none;
118
+ border-radius: 8px;
119
+ background: transparent;
120
+ color: var(--muted-foreground);
121
+ cursor: pointer;
122
+
123
+ &:hover {
124
+ background: var(--muted);
125
+ color: var(--foreground);
126
+ }
127
+ }
128
+
129
+ .vm-json-viewer__body {
130
+ flex: 1;
131
+ min-height: 0;
132
+ margin: 0 auto;
133
+ width: min(960px, 100%);
134
+ display: flex;
135
+ flex-direction: column;
136
+ border-radius: 0 0 14px 14px;
137
+ background: var(--card);
138
+ border: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent);
139
+ border-top: none;
140
+ overflow: hidden;
141
+
142
+ :deep(.vm-json-code) {
143
+ border-radius: 0;
144
+ background: var(--muted);
145
+ }
146
+ }
147
+ </style>
@@ -45,10 +45,11 @@
45
45
  type="button"
46
46
  class="vm-preview-viewer__btn"
47
47
  :disabled="!preview"
48
+ :title="preview ? triggerTitle : undefined"
48
49
  @click="openViewer"
49
50
  >
50
- <i class="ri-file-search-line" aria-hidden="true" />
51
- <span>{{ label }}</span>
51
+ <i :class="triggerIcon" aria-hidden="true" />
52
+ <span>{{ displayLabel }}</span>
52
53
  </button>
53
54
  </div>
54
55
  <span v-else-if="!src" class="vm-preview-viewer is-empty">—</span>
@@ -132,9 +133,12 @@
132
133
 
133
134
  <audio
134
135
  v-else-if="kind === 'audio'"
136
+ ref="audioRef"
135
137
  class="vm-file-preview-viewer__audio"
136
138
  :src="src"
137
139
  controls
140
+ autoplay
141
+ playsinline
138
142
  />
139
143
 
140
144
  <iframe
@@ -214,8 +218,8 @@ const props = withDefaults(
214
218
  mediaKind?: MediaPreviewKind
215
219
  /** thumb | button | auto:触发方式 */
216
220
  trigger?: 'thumb' | 'button' | 'auto'
217
- /** image | video | auto(缩略图列) */
218
- type?: 'image' | 'video' | 'auto'
221
+ /** image | video | audio | auto(缩略图列或类型覆盖) */
222
+ type?: 'image' | 'video' | 'audio' | 'auto'
219
223
  size?: number | string
220
224
  radius?: number | string
221
225
  preview?: boolean
@@ -243,6 +247,7 @@ const excelHtml = ref('')
243
247
  const scale = ref(1)
244
248
  const rotate = ref(0)
245
249
  const videoRef = ref<HTMLVideoElement | null>(null)
250
+ const audioRef = ref<HTMLAudioElement | null>(null)
246
251
 
247
252
  const src = computed(() => {
248
253
  const raw = props.embedded ? props.src : props.modelValue
@@ -252,12 +257,19 @@ const src = computed(() => {
252
257
 
253
258
  const isVideoKind = computed(() => {
254
259
  if (props.mediaKind === 'video') return true
255
- if (props.mediaKind === 'image') return false
260
+ if (props.mediaKind === 'image' || props.mediaKind === 'audio') return false
256
261
  if (props.type === 'video') return true
257
- if (props.type === 'image') return false
262
+ if (props.type === 'image' || props.type === 'audio') return false
258
263
  return VIDEO_EXT.test(src.value)
259
264
  })
260
265
 
266
+ const isAudioKind = computed(() => {
267
+ if (props.mediaKind === 'audio') return true
268
+ if (props.type === 'audio') return true
269
+ if (props.mediaKind || props.type === 'image' || props.type === 'video') return false
270
+ return getMediaPreviewKind(props.fileName || src.value) === 'audio'
271
+ })
272
+
261
273
  const showThumb = computed(() => {
262
274
  if (props.embedded) return false
263
275
  if (props.trigger === 'button') return false
@@ -279,11 +291,26 @@ const showButton = computed(() => {
279
291
 
280
292
  const kind = computed((): MediaPreviewKind => {
281
293
  if (props.mediaKind) return props.mediaKind
294
+ if (props.type === 'audio' || isAudioKind.value) return 'audio'
282
295
  if (props.type === 'video' || isVideoKind.value) return 'video'
283
296
  if (props.type === 'image') return 'image'
284
297
  return getMediaPreviewKind(props.fileName || src.value)
285
298
  })
286
299
 
300
+ const displayLabel = computed(() => {
301
+ if (props.label !== '查看') return props.label
302
+ if (kind.value === 'audio') return '播放'
303
+ return props.label
304
+ })
305
+
306
+ const triggerIcon = computed(() =>
307
+ kind.value === 'audio' ? 'ri-play-circle-line' : 'ri-file-search-line',
308
+ )
309
+
310
+ const triggerTitle = computed(() =>
311
+ kind.value === 'audio' ? '播放音频' : '查看文件',
312
+ )
313
+
287
314
  const kindLabel = computed(() => KIND_LABEL[kind.value])
288
315
 
289
316
  const displayName = computed(() =>
@@ -399,6 +426,11 @@ watch(viewerOpen, (open) => {
399
426
  videoRef.value?.play?.().catch(() => undefined)
400
427
  })
401
428
  }
429
+ if (kind.value === 'audio') {
430
+ void nextTick(() => {
431
+ audioRef.value?.play?.().catch(() => undefined)
432
+ })
433
+ }
402
434
  } else {
403
435
  window.removeEventListener('keydown', onKeydown)
404
436
  loading.value = false
@@ -406,6 +438,7 @@ watch(viewerOpen, (open) => {
406
438
  textContent.value = ''
407
439
  excelHtml.value = ''
408
440
  videoRef.value?.pause?.()
441
+ audioRef.value?.pause?.()
409
442
  }
410
443
  })
411
444