@gindow/element-go 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +260 -0
- package/dist/element-go.cjs +1 -0
- package/dist/element-go.d.ts +1 -0
- package/dist/element-go.mjs +2994 -0
- package/dist/resolver.cjs +1 -0
- package/dist/resolver.d.ts +1 -0
- package/dist/resolver.mjs +16 -0
- package/dist/styles/index.css +2 -0
- package/package.json +133 -0
- package/src/assets/avatar.png +0 -0
- package/src/assets/icon.png +0 -0
- package/src/components/ExAssetPreview.vue +55 -0
- package/src/components/ExButton.vue +47 -0
- package/src/components/ExEmpty.vue +26 -0
- package/src/components/ExForm.vue +95 -0
- package/src/components/ExFormField.vue +49 -0
- package/src/components/ExFormSearch.vue +50 -0
- package/src/components/ExFormViewer.vue +51 -0
- package/src/components/ExIcon.vue +33 -0
- package/src/components/ExInputPercentage.vue +36 -0
- package/src/components/ExLayout/account.vue +33 -0
- package/src/components/ExLayout/aside.vue +58 -0
- package/src/components/ExLayout/lang.vue +27 -0
- package/src/components/ExLayout.vue +91 -0
- package/src/components/ExLoading.vue +18 -0
- package/src/components/ExMenu.vue +80 -0
- package/src/components/ExPage.vue +66 -0
- package/src/components/ExPageHeader.vue +34 -0
- package/src/components/ExPagination.vue +34 -0
- package/src/components/ExSelect.vue +28 -0
- package/src/components/ExTable.vue +237 -0
- package/src/components/ExTableColumn.vue +160 -0
- package/src/components/ExUpload.vue +91 -0
- package/src/components/ExUploadAsset.vue +299 -0
- package/src/components/vIcon.vue +23 -0
- package/src/env.d.ts +7 -0
- package/src/hooks/useBreak.ts +23 -0
- package/src/hooks/useChat.ts +135 -0
- package/src/hooks/useIcon.ts +8 -0
- package/src/hooks/useMessage.ts +22 -0
- package/src/hooks/useNanoid.ts +9 -0
- package/src/hooks/useUpload.ts +60 -0
- package/src/index.ts +94 -0
- package/src/libs/auto-imports.d.ts +94 -0
- package/src/libs/components.d.ts +171 -0
- package/src/locale/en-US.ts +49 -0
- package/src/locale/index.ts +73 -0
- package/src/locale/zh-CN.ts +49 -0
- package/src/resolver.ts +26 -0
- package/src/styles/arco.css +179 -0
- package/src/styles/index.css +53 -0
- package/src/types/index.ts +77 -0
- package/src/utils/datetime.ts +42 -0
- package/src/utils/download.ts +11 -0
- package/src/utils/formatter.ts +42 -0
- package/src/utils/get.ts +10 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/params.ts +18 -0
- package/src/utils/platform.ts +38 -0
- package/src/utils/request.ts +144 -0
- package/src/utils/validate.ts +23 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-table-column v-if="is('selection')" :type :prop :width :minWidth :align :label :className />
|
|
3
|
+
<el-table-column v-else :prop :width :minWidth :align :label :className>
|
|
4
|
+
<template #default="{ row, column }">
|
|
5
|
+
<template v-for="val in [value && typeof value === 'function' ? value(row, column) : value ?? get(row, prop ?? '') ?? defaultValue]">
|
|
6
|
+
<slot name="front" :row />
|
|
7
|
+
<!-- 插槽 -->
|
|
8
|
+
<slot v-if="$slots.default" :row />
|
|
9
|
+
<!-- 排序 -->
|
|
10
|
+
<ex-icon icon="hamburger-button" v-else-if="is('sort')" />
|
|
11
|
+
<!-- 状态 -->
|
|
12
|
+
<div v-else-if="is('state')" class="flex-center-items">
|
|
13
|
+
<ex-icon icon="dot" :color="val ? 'var(--el-color-success)' : 'var(--el-color-warning)'" />
|
|
14
|
+
</div>
|
|
15
|
+
<!-- 图标 -->
|
|
16
|
+
<div v-else-if="is('icon')" class="flex-center-items"><ex-icon :icon="val" :size="iconSize" /></div>
|
|
17
|
+
<!-- 编号 -->
|
|
18
|
+
<div v-else-if="is('id')">{{ val ? Formatter.id(val) : defaultValue }}</div>
|
|
19
|
+
<!-- 电话 -->
|
|
20
|
+
<div v-else-if="is('phone')">{{ val ? Formatter.phone(val) : defaultValue }}</div>
|
|
21
|
+
<!-- 金额 -->
|
|
22
|
+
<div v-else-if="is('currency')">{{ val ? Formatter.price(val, currency ?? row.currency) : defaultValue }}</div>
|
|
23
|
+
<!-- 链接 -->
|
|
24
|
+
<el-link v-else-if="is('link')" :type="linkType" underline="never" @click="click(row)">{{ val }}</el-link>
|
|
25
|
+
<!-- 标签 -->
|
|
26
|
+
<el-space v-else-if="is('tags')" wrap>
|
|
27
|
+
<el-tag v-for="item in val" :round="tagRound" :type="(tagType as ITag)" :effect="tagEffect" :size="tagSize">{{ tagKey ? item[tagKey] : item }}</el-tag>
|
|
28
|
+
</el-space>
|
|
29
|
+
<!-- 标签 -->
|
|
30
|
+
<el-tag v-else-if="is('tag')" :round="tagRound" :type="typeof tagType === 'function' ? tagType(row) : tagType" :effect="tagEffect" :size="tagSize">{{ val }}</el-tag>
|
|
31
|
+
<!-- 日期 -->
|
|
32
|
+
<div v-else-if="is('date')">{{ val ? DateTime.date(val) : defaultValue }}</div>
|
|
33
|
+
<!-- 时间 -->
|
|
34
|
+
<template v-else-if="is('datetime')">
|
|
35
|
+
<div v-if="!val">{{ defaultValue }}</div>
|
|
36
|
+
<template v-else>
|
|
37
|
+
<div>{{ DateTime.date(val) }}</div>
|
|
38
|
+
<div class="text-light">{{ DateTime.time(val) }}</div>
|
|
39
|
+
</template>
|
|
40
|
+
</template>
|
|
41
|
+
<!-- 布尔 -->
|
|
42
|
+
<template v-else-if="is('boolean')">
|
|
43
|
+
<ex-icon icon="correct" v-if="val" />
|
|
44
|
+
<ex-icon icon="error" v-else />
|
|
45
|
+
</template>
|
|
46
|
+
<!-- 头像 -->
|
|
47
|
+
<el-avatar v-else-if="is('avatar')" :size="48" :src="val"><img :src="avatar" /></el-avatar>
|
|
48
|
+
<!-- 图片 -->
|
|
49
|
+
<el-image v-else-if="is('image')" class="size-[48px] flex-center-items" fit="cover" lazy :src="val">
|
|
50
|
+
<template #error>
|
|
51
|
+
<div class="flex items-center justify-center w-full h-full bg-gray-100">
|
|
52
|
+
<ex-icon icon="picture" :size="24" />
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
</el-image>
|
|
56
|
+
<!-- 操作 -->
|
|
57
|
+
<div v-else-if="is('action')" class="flex-center-end gap-2">
|
|
58
|
+
<ex-button v-for="{ label, icon, click, disabled, hidden } in show" link class="ml-0!" type="primary" :icon
|
|
59
|
+
:hidden="hidden && typeof(hidden) === 'function' ? hidden(row) : hidden ?? false"
|
|
60
|
+
:disabled="disabled && typeof(disabled) === 'function' ? disabled(row) : disabled ?? false"
|
|
61
|
+
@click="click(row)">{{ typeof(label) === 'function' ? label(row) : label }}</ex-button>
|
|
62
|
+
<el-dropdown v-if="more.length">
|
|
63
|
+
<ex-button link type="primary" icon="more" :icon-size="18" />
|
|
64
|
+
<template #dropdown>
|
|
65
|
+
<el-dropdown-menu>
|
|
66
|
+
<el-dropdown-item v-for="{ label, icon, divided, disabled, hidden, click } in more" :key="label" :icon="i(icon)" :divided="divided"
|
|
67
|
+
:hidden="hidden && typeof(hidden) === 'function' ? hidden(row) : hidden ?? false"
|
|
68
|
+
:disabled="disabled && typeof(disabled) === 'function' ? disabled(row) : disabled ?? false"
|
|
69
|
+
@click="click(row)">{{ typeof(label) === 'function' ? label(row) : label }}</el-dropdown-item>
|
|
70
|
+
</el-dropdown-menu>
|
|
71
|
+
</template>
|
|
72
|
+
</el-dropdown>
|
|
73
|
+
</div>
|
|
74
|
+
<!-- 文本 -->
|
|
75
|
+
<div v-else>{{ val || defaultValue }}</div>
|
|
76
|
+
</template>
|
|
77
|
+
</template>
|
|
78
|
+
</el-table-column>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<script setup lang="ts">
|
|
82
|
+
import type { PropType } from 'vue'
|
|
83
|
+
import type { IModel } from '../types'
|
|
84
|
+
import { DateTime, Formatter, get } from '../utils'
|
|
85
|
+
import { useIcon } from '../hooks/useIcon'
|
|
86
|
+
import { useLocale } from '../locale'
|
|
87
|
+
import avatar from '../assets/avatar.png'
|
|
88
|
+
import ExButton from './ExButton.vue'
|
|
89
|
+
import ExIcon from './ExIcon.vue'
|
|
90
|
+
|
|
91
|
+
export type IType = 'id' | 'selection' | 'icon' | 'link' | 'phone' | 'boolean' | 'state' | 'tags' | 'tag' | 'currency' | 'date' | 'datetime' | 'sort' | 'image' | 'avatar' | 'text' | 'action'
|
|
92
|
+
|
|
93
|
+
type ITag = 'primary' | 'success' | 'warning' | 'info' | 'danger'
|
|
94
|
+
|
|
95
|
+
const props = defineProps({
|
|
96
|
+
type: { type: String as PropType<IType>, default: 'text' },
|
|
97
|
+
prop: { type: String },
|
|
98
|
+
label: { type: String },
|
|
99
|
+
value: { type: [String, Number, Function] },
|
|
100
|
+
width: { type: [String, Number] },
|
|
101
|
+
minWidth: { type: [String, Number] },
|
|
102
|
+
className: { type: String },
|
|
103
|
+
align: { type: String },
|
|
104
|
+
linkType: { type: String as PropType<'primary' | 'success' | 'warning' | 'info' | 'danger' | 'default'>, default: 'primary' },
|
|
105
|
+
iconSize: { type: Number, default: 16 },
|
|
106
|
+
tagKey: { type: String },
|
|
107
|
+
tagType: { type: [String, Function] as PropType<ITag | ((row: any) => ITag | undefined)> },
|
|
108
|
+
tagEffect: { type: String as PropType<'dark' | 'light' | 'plain'> },
|
|
109
|
+
tagSize: { type: String as PropType<'default' | 'small' | 'large'>, default: 'default' },
|
|
110
|
+
tagRound: { type: Boolean, default: false },
|
|
111
|
+
currency: { type: String },
|
|
112
|
+
onClick: { type: Function },
|
|
113
|
+
defaultValue: { type: String },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
defineEmits(['sort'])
|
|
117
|
+
|
|
118
|
+
const { i } = useIcon()
|
|
119
|
+
const { t } = useLocale()
|
|
120
|
+
|
|
121
|
+
const is = (type: IType) => props.type === type
|
|
122
|
+
|
|
123
|
+
const show = inject('show') as IModel
|
|
124
|
+
const more = inject('more') as IModel
|
|
125
|
+
|
|
126
|
+
const template = {
|
|
127
|
+
id: { prop: 'id', width: 160 },
|
|
128
|
+
selection: { width: 30, align: 'center' },
|
|
129
|
+
sort: { width: 30, align: 'center' },
|
|
130
|
+
state: { prop: 'state', width: 40, align: 'center' },
|
|
131
|
+
link: {},
|
|
132
|
+
boolean: {},
|
|
133
|
+
phone: { prop: 'phone', label: t('core.phoneNumber') },
|
|
134
|
+
tags: { prop: 'tags' },
|
|
135
|
+
tag: {},
|
|
136
|
+
currency: { prop: 'amount', width: 140, align: 'right' },
|
|
137
|
+
date: { prop: 'created_at', label: t('core.createdAt'), width: 140, align: 'right' },
|
|
138
|
+
datetime: { prop: 'created_at', label: t('core.createdAt'), width: 140, align: 'right' },
|
|
139
|
+
image: { width: 60, align: 'center' },
|
|
140
|
+
avatar: { width: 60, align: 'center' },
|
|
141
|
+
text: {},
|
|
142
|
+
} as { [property: string]: { prop?: string; label?: string; width?: number; align?: string } }
|
|
143
|
+
|
|
144
|
+
const prop = computed(() => props.prop ?? template[props.type]?.prop)
|
|
145
|
+
const label = computed(() => props.label ?? template[props.type]?.label)
|
|
146
|
+
const width = computed(() => props.width ?? template[props.type]?.width)
|
|
147
|
+
const align = computed(() => props.align ?? template[props.type]?.align)
|
|
148
|
+
|
|
149
|
+
const click = (row: IModel) => props.onClick ? props.onClick(row) : null
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<style scoped>
|
|
153
|
+
.el-link { max-width: 100%; }
|
|
154
|
+
.el-link :deep(.el-link__inner) {
|
|
155
|
+
display: block;
|
|
156
|
+
overflow: hidden;
|
|
157
|
+
text-overflow: ellipsis;
|
|
158
|
+
white-space: nowrap;
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-upload ref="uploadRef" class="ex-upload" v-model:file-list="files" list-type="text"
|
|
3
|
+
:limit
|
|
4
|
+
:multiple
|
|
5
|
+
:show-file-list
|
|
6
|
+
:disabled="disabled || loading"
|
|
7
|
+
:class="{ disabled }"
|
|
8
|
+
:before-upload="beforeUpload"
|
|
9
|
+
:on-exceed="onExceed"
|
|
10
|
+
:on-preview="onPreview"
|
|
11
|
+
:on-change="onChange" v-bind="$attrs">
|
|
12
|
+
<slot v-if="slots.default" :loading="loading" />
|
|
13
|
+
<ex-icon v-else-if="$attrs['list-type']" icon="upload" :size="24" />
|
|
14
|
+
<el-button v-else :icon="i('upload')" :loading="loading">{{ t('core.upload') }}</el-button>
|
|
15
|
+
<template v-if="slots.file" #file="{ file }">
|
|
16
|
+
<slot name="file" :file="file" />
|
|
17
|
+
</template>
|
|
18
|
+
<el-progress v-if="showProgress && loading" type="circle" :width="100" color="#093" :percentage :format="(value: number) => value.toFixed(1) + '%'" />
|
|
19
|
+
<el-image-viewer v-if="showViewer" :url-list="files.map(({ url }: IModel) => url)" :max-scale="2" :min-scale="0.2" :initialIndex="index" teleported @close="showViewer = false" />
|
|
20
|
+
</el-upload>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script setup lang="ts">
|
|
24
|
+
import type { VNode } from 'vue'
|
|
25
|
+
import type { IUploadUserFile, IModel } from '../types'
|
|
26
|
+
import type { UploadRawFile, UploadInstance, UploadFile } from 'element-plus'
|
|
27
|
+
import { useLocale } from '../locale'
|
|
28
|
+
import { useMessage } from '../hooks/useMessage'
|
|
29
|
+
import { useIcon } from '../hooks/useIcon'
|
|
30
|
+
import { useUpload } from '../hooks/useUpload'
|
|
31
|
+
import ExIcon from './ExIcon.vue'
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits(['change'])
|
|
34
|
+
const files = defineModel('fileList', { type: Array as PropType<IUploadUserFile[]>, default: () => ([]) })
|
|
35
|
+
const { limit, disabled, multiple, showFileList, showProgress, compressor } = defineProps({
|
|
36
|
+
limit: { type: Number, default: 500 },
|
|
37
|
+
disabled: { type: Boolean, default: false },
|
|
38
|
+
multiple: { type: Boolean, default: true },
|
|
39
|
+
showFileList: { type: Boolean, default: false },
|
|
40
|
+
showProgress: { type: Boolean, default: true },
|
|
41
|
+
compressor: { type: Boolean, default: false },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const slots = useSlots() as { default?: () => VNode[]; file?: (props: { file: IUploadUserFile }) => VNode[] }
|
|
45
|
+
const loading = computed(() => files.value.some(file => ['uploading'].includes(file.status ?? ''))) // 上传中
|
|
46
|
+
const percentage = computed(() => files.value.reduce((prev, file) => prev + (file.percentage ?? 0), 0) / (files.value.length * 100) * 100) // 总进度
|
|
47
|
+
|
|
48
|
+
// 手动上传
|
|
49
|
+
const uploadRef = ref<UploadInstance>()
|
|
50
|
+
const submit = () => uploadRef.value!.submit()
|
|
51
|
+
|
|
52
|
+
defineExpose({ submit })
|
|
53
|
+
|
|
54
|
+
// 预先处理
|
|
55
|
+
const { i } = useIcon()
|
|
56
|
+
const { t } = useLocale()
|
|
57
|
+
const { error, warning } = useMessage()
|
|
58
|
+
const { handler } = useUpload()
|
|
59
|
+
const beforeUpload = (file: UploadRawFile) => {
|
|
60
|
+
if (file.size > 1024 * 1024 * 200) {
|
|
61
|
+
error(t('core.message.tooLarge')) // 限制尺寸
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
return handler(file, { compressor })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 数量限制
|
|
68
|
+
const onExceed = () => warning(t('core.message.fileExceed', { limit })) // 超出数量
|
|
69
|
+
|
|
70
|
+
const showViewer = ref(false)
|
|
71
|
+
const index = ref(0)
|
|
72
|
+
const onPreview = (file: UploadFile) => {
|
|
73
|
+
index.value = files.value.findIndex(({ url }) => url === file.url)
|
|
74
|
+
showViewer.value = true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const onChange = (file: IUploadUserFile, fileList: IUploadUserFile[]) => {
|
|
78
|
+
nextTick(() => {
|
|
79
|
+
if (!multiple && fileList.length > 1) files.value = [file]
|
|
80
|
+
emit('change', file, files.value)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<style scoped>
|
|
86
|
+
.ex-upload :deep(.el-upload-dragger) { border-radius: 0; }
|
|
87
|
+
.ex-upload.disabled :deep(.el-upload-dragger) { border-color: var(--el-border-color) !important; cursor: default; }
|
|
88
|
+
.ex-upload.disabled :deep(.el-upload-dragger .el-icon--upload) { color: #ccc; }
|
|
89
|
+
.ex-upload.disabled :deep(.el-upload-dragger .el-upload__text), .disabled :deep(.el-upload__text em) { color: #ccc; }
|
|
90
|
+
.el-progress { position: fixed; bottom: 20px; right: 20px; }
|
|
91
|
+
</style>
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<ex-upload ref="uploadRef" class="ex-upload-asset inline-flex" :class="{ border }" v-bind="$attrs" v-model:file-list="files"
|
|
3
|
+
:action
|
|
4
|
+
:headers
|
|
5
|
+
:data
|
|
6
|
+
:multiple
|
|
7
|
+
:auto-upload="false"
|
|
8
|
+
:before-upload="beforeUpload"
|
|
9
|
+
@change="onChange">
|
|
10
|
+
<template #default="{ loading }: { loading: boolean }" v-if="$slots.default">
|
|
11
|
+
<slot :loading="loading" />
|
|
12
|
+
</template>
|
|
13
|
+
<!-- 文件列表 -->
|
|
14
|
+
<template #file="{ file }: { file :IUploadUserFile }">
|
|
15
|
+
<!-- 图片模式 -->
|
|
16
|
+
<div v-if="$attrs['list-type']" class="w-full">
|
|
17
|
+
<el-image :src="file.asset?.shrink || file.url" fit="cover" class="w-full h-full" />
|
|
18
|
+
<!-- 自定义鼠标悬浮操作 -->
|
|
19
|
+
<div class="el-upload-list__item-actions" :class="{ mask: file.status !== 'success' }">
|
|
20
|
+
<!-- 上传中隐藏操作 -->
|
|
21
|
+
<div class="text-center">
|
|
22
|
+
<span class="el-upload-list__item-preview icon" @click="onView(file)" v-if="['success', 'fail'].includes(file.status!)">
|
|
23
|
+
<ex-icon icon="zoom-in" />
|
|
24
|
+
</span>
|
|
25
|
+
<span class="el-upload-list__item-delete icon" @click="remove(file)" v-if="['success', 'fail'].includes(file.status!)">
|
|
26
|
+
<ex-icon icon="delete" />
|
|
27
|
+
</span>
|
|
28
|
+
<div v-if="file.status === 'ready'">
|
|
29
|
+
<el-text type="primary">
|
|
30
|
+
<span class="text-white">等待上传</span>
|
|
31
|
+
</el-text>
|
|
32
|
+
</div>
|
|
33
|
+
<div v-else-if="file.status === 'fail'">
|
|
34
|
+
<el-text type="primary">
|
|
35
|
+
<span class="text-white">上传错误</span>
|
|
36
|
+
</el-text>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<!-- 上传中进度 -->
|
|
40
|
+
<el-progress type="circle" :percentage="file.percentage" v-if="file.status === 'uploading'">
|
|
41
|
+
<div class="text-white">{{ file.percentage }} % </div>
|
|
42
|
+
</el-progress>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<!-- 文字模式 -->
|
|
46
|
+
<el-row class="px-1" v-else>
|
|
47
|
+
<el-col class="flex-1! flex-center-v">
|
|
48
|
+
<el-link underline="never" @click="onView(file)">{{ file.name || file.title || file.asset?.title }}</el-link>
|
|
49
|
+
</el-col>
|
|
50
|
+
<el-col class="flex-0! flex-center-v">
|
|
51
|
+
<el-link underline="never"><ex-icon icon="delete" @click="remove(file)" /></el-link>
|
|
52
|
+
</el-col>
|
|
53
|
+
</el-row>
|
|
54
|
+
</template>
|
|
55
|
+
</ex-upload>
|
|
56
|
+
<ex-asset-preview v-if="preview" v-model:show="show" :asset :assets />
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
import type { IAsset, IUploadUserFile } from '../types'
|
|
61
|
+
import { catchError, filter, from, map, mergeMap, of } from 'rxjs'
|
|
62
|
+
import { request, DateTime } from '../utils'
|
|
63
|
+
import { useLocale } from '../locale'
|
|
64
|
+
import { useMessage } from '../hooks/useMessage'
|
|
65
|
+
import { useUpload } from '../hooks/useUpload'
|
|
66
|
+
import { useNanoid } from '../hooks/useNanoid'
|
|
67
|
+
import axios, { type AxiosResponse } from 'axios'
|
|
68
|
+
import ExAssetPreview from './ExAssetPreview.vue'
|
|
69
|
+
import ExIcon from './ExIcon.vue'
|
|
70
|
+
import ExUpload from './ExUpload.vue'
|
|
71
|
+
|
|
72
|
+
const ids = defineModel<string[] | string>('ids', { default: () => ([]) })
|
|
73
|
+
const files = defineModel<IUploadUserFile[]>('fileList', {
|
|
74
|
+
default: () => ([]),
|
|
75
|
+
get(value) {
|
|
76
|
+
if (!value) return []
|
|
77
|
+
else if (value && !Array.isArray(value)) return value = [value]
|
|
78
|
+
return value
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const { disk, place, data: model, compressor, multiple, autoUpload, onSuccess, preview } = defineProps({
|
|
83
|
+
disk: { type: String, default: 'local' },
|
|
84
|
+
place: { type: String, required: true },
|
|
85
|
+
data: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
|
|
86
|
+
compressor: { type: Boolean, default: false },
|
|
87
|
+
multiple: { type: Boolean, default: true },
|
|
88
|
+
autoUpload: { type: Boolean, default: true },
|
|
89
|
+
onSuccess: { type: Function as PropType<(value: IAsset[])=> void>, default: () => ({}) },
|
|
90
|
+
preview: { type: Boolean, default: true },
|
|
91
|
+
border: { type: Boolean, default: false },
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// 手动上传
|
|
95
|
+
const assets = computed(()=> {
|
|
96
|
+
return files.value.filter(item => item.asset || item.id).map(item => {
|
|
97
|
+
if (item.asset?.id) return item.asset
|
|
98
|
+
else if (item.id) return item
|
|
99
|
+
}).filter(item => item) as IAsset[]
|
|
100
|
+
})
|
|
101
|
+
const submit = () => upload().then(() => onSuccess(assets.value))
|
|
102
|
+
|
|
103
|
+
defineExpose({ submit })
|
|
104
|
+
|
|
105
|
+
// 自动上传(确保仅执行一次)
|
|
106
|
+
const onChange = useThrottleFn(() => nextTick(() => {
|
|
107
|
+
if (autoUpload) submit()
|
|
108
|
+
else files.value.forEach(async item => await beforeUpload(item, { compressor : false })) // HEIC 预览
|
|
109
|
+
}))
|
|
110
|
+
|
|
111
|
+
// 资源编号(集)
|
|
112
|
+
const assetIds = computed(() => files.value.filter(item => item.id || item.asset)?.map(item => item.id || item.asset?.id!) ?? [])
|
|
113
|
+
|
|
114
|
+
watch(() => assetIds.value, value => ids.value = multiple ? value : (value[value.length - 1] ?? ''))
|
|
115
|
+
|
|
116
|
+
// 删除文件
|
|
117
|
+
const { confirm } = useMessage()
|
|
118
|
+
const { t } = useLocale()
|
|
119
|
+
const remove = (file: IUploadUserFile) => confirm(t('core.message.delConfirm', { name: file.title ?? '' })).then(() => {
|
|
120
|
+
files.value = files.value.filter(item => {
|
|
121
|
+
if (file.id) return item.id !== file.id
|
|
122
|
+
else return item.asset?.id !== file.asset?.id
|
|
123
|
+
})
|
|
124
|
+
}).catch(() => {})
|
|
125
|
+
|
|
126
|
+
// 上传文件
|
|
127
|
+
const action = '/user/asset/upload'
|
|
128
|
+
const headers = { 'Authorization': `Bearer ${request.getToken()}` }
|
|
129
|
+
const data = { ...model, place }
|
|
130
|
+
const upload = () => {
|
|
131
|
+
return new Promise(async (resolve) => {
|
|
132
|
+
if (disk !== 'local') await signature()
|
|
133
|
+
const makeRequest = handlers[disk] || svrUpload
|
|
134
|
+
const requests$ = from(files.value).pipe( // 创建链路
|
|
135
|
+
filter(file => !(file.id || file.asset)), // 排除已上传文件
|
|
136
|
+
map(file => {
|
|
137
|
+
file.status = 'uploading'
|
|
138
|
+
file.percentage = 0
|
|
139
|
+
return file
|
|
140
|
+
}),
|
|
141
|
+
mergeMap(file => from(beforeUpload(file, { compressor })).pipe(
|
|
142
|
+
catchError(() => {
|
|
143
|
+
file.status = 'fail'
|
|
144
|
+
return of(file)
|
|
145
|
+
})
|
|
146
|
+
)),
|
|
147
|
+
filter(file => file.status !== 'fail'),
|
|
148
|
+
mergeMap(file => from(makeRequest(file)).pipe(
|
|
149
|
+
catchError(() => {
|
|
150
|
+
file.status = 'fail'
|
|
151
|
+
return of(file)
|
|
152
|
+
})
|
|
153
|
+
), 5) // 控制并发数
|
|
154
|
+
)
|
|
155
|
+
// 执行请求
|
|
156
|
+
requests$.subscribe({
|
|
157
|
+
next: (file: IUploadUserFile) => file.status = 'success',
|
|
158
|
+
error: (file: IUploadUserFile) => file.status = 'fail',
|
|
159
|
+
complete: () => resolve(true) //全部上传完回调
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 服务器上传
|
|
165
|
+
const svrUpload = (file: IUploadUserFile) => {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const formData = new FormData()
|
|
168
|
+
formData.append('file[]', file.raw!)
|
|
169
|
+
formData.append('place', place)
|
|
170
|
+
Object.keys(model).forEach((key: string) => formData.append(key, model[key] as string)) // 扩展数据
|
|
171
|
+
request.upload(action, formData).then(({ data }: AxiosResponse) => {
|
|
172
|
+
file.asset = data[0]
|
|
173
|
+
resolve(file)
|
|
174
|
+
}).catch(() => reject(file))
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 阿里云上传
|
|
179
|
+
const { numeric } = useNanoid()
|
|
180
|
+
const ossUpload = (file: IUploadUserFile) => {
|
|
181
|
+
return new Promise(async (resolve, reject) => {
|
|
182
|
+
const filename = DateTime.dayjs().format('YYYYMMDDHHmmss') + numeric(4) + file.name.substring(file.name.lastIndexOf('.'))
|
|
183
|
+
const { host, policy, access_id: OSSAccessKeyId, signature, dir, callback, user_id } = signatureData.value
|
|
184
|
+
const para: Record<string, any> = {
|
|
185
|
+
policy,
|
|
186
|
+
OSSAccessKeyId,
|
|
187
|
+
callback,
|
|
188
|
+
signature,
|
|
189
|
+
success_action_status: 200,
|
|
190
|
+
key: dir + filename,
|
|
191
|
+
file: file.raw,
|
|
192
|
+
'x:title': file.name,
|
|
193
|
+
'x:image_width': file.width,
|
|
194
|
+
'x:image_height': file.height,
|
|
195
|
+
'x:user_id': user_id,
|
|
196
|
+
'x:type': file.type,
|
|
197
|
+
'x:mimeType': file.mimeType,
|
|
198
|
+
}
|
|
199
|
+
Object.keys(model).forEach((key: string) => para[`x:${key}`] = model[key]) // 扩展数据
|
|
200
|
+
// 请求上传
|
|
201
|
+
axios.post(host, para, {
|
|
202
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
203
|
+
onUploadProgress: ({ loaded, total }) => file.percentage = 100 * loaded / total !
|
|
204
|
+
}).then(({ data }: AxiosResponse) => {
|
|
205
|
+
file.asset = data.data
|
|
206
|
+
resolve(file)
|
|
207
|
+
}).catch(() => reject(file))
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 华为云上传
|
|
212
|
+
const obsUpload = (file: IUploadUserFile) => {
|
|
213
|
+
return new Promise(async (resolve, reject) => {
|
|
214
|
+
const filename = DateTime.dayjs().format('YYYYMMDDHHmmss') + numeric(4) + file.name.substring(file.name.lastIndexOf('.'))
|
|
215
|
+
const { host, policy, access_id: AccessKeyId, signature, callback_url: callbackUrl, dir, callback_body: callbackBody, user_id } = signatureData.value
|
|
216
|
+
const para: Record<string, any> = {
|
|
217
|
+
key: dir + filename,
|
|
218
|
+
callbackUrl,
|
|
219
|
+
callbackBody,
|
|
220
|
+
callbackBodyType: 'application/x-www-form-urlencoded',
|
|
221
|
+
AccessKeyId,
|
|
222
|
+
policy,
|
|
223
|
+
signature,
|
|
224
|
+
'x:title': btoa(encodeURI(file.name)), // 不能有中文和&符号
|
|
225
|
+
'x:image_width': file.width ?? '', // 必须加空值处理
|
|
226
|
+
'x:image_height': file.height ?? '',
|
|
227
|
+
'x:user_id': user_id,
|
|
228
|
+
'x:type': file.type,
|
|
229
|
+
'x:mimeType': file.mimeType,
|
|
230
|
+
'x-obs-acl': 'public-read',
|
|
231
|
+
file: file.raw,
|
|
232
|
+
size: file.raw?.size,
|
|
233
|
+
}
|
|
234
|
+
Object.keys(model).forEach((key: string) => para[`x:${key}`] = model[key]) // 扩展数据
|
|
235
|
+
// 请求上传
|
|
236
|
+
axios.post(host, para, {
|
|
237
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
238
|
+
onUploadProgress: ({ loaded, total }) => file.percentage = 100 * loaded / total !
|
|
239
|
+
}).then(({ data }: AxiosResponse) => {
|
|
240
|
+
file.asset = data.data
|
|
241
|
+
resolve(file)
|
|
242
|
+
}).catch(() => reject(file))
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 上传处理器映射
|
|
247
|
+
const handlers: Record<string, typeof svrUpload> = {
|
|
248
|
+
local: svrUpload,
|
|
249
|
+
oss: ossUpload,
|
|
250
|
+
obs: obsUpload
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 获取签名
|
|
254
|
+
const signatureData = ref()
|
|
255
|
+
const signature = async () => {
|
|
256
|
+
const url = disk === 'oss' ? '/user/asset/upload/signature' : '/user/asset/obsSignature'
|
|
257
|
+
const { data } = await request.get(url, { place }, true)
|
|
258
|
+
signatureData.value = data
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 预先处理
|
|
262
|
+
const { error } = useMessage()
|
|
263
|
+
const { handler, getDimension, getFileType } = useUpload()
|
|
264
|
+
const beforeUpload = async (file: any, { compressor = true }): Promise<any> => {
|
|
265
|
+
if (file.id || file.asset) return file // 排除已上传文件
|
|
266
|
+
if (file.size > 1024 * 1024 * 200) return error('文件大小超出限制') // 限制文件大小
|
|
267
|
+
return new Promise(async resolve => {
|
|
268
|
+
try {
|
|
269
|
+
const { width, height } = await getDimension(file.raw) // 获取图片宽高
|
|
270
|
+
file.width = width
|
|
271
|
+
file.height = height
|
|
272
|
+
} catch {}
|
|
273
|
+
const isCompressor = compressor || file.size > 1024 * 1024 * 20 // 超过 20M 则压缩
|
|
274
|
+
file.raw = await handler(file.raw, { compressor: isCompressor })
|
|
275
|
+
const { mimeType } = await getFileType(file.raw)
|
|
276
|
+
file.type = mimeType.split('/')[0] ?? 'application'
|
|
277
|
+
file.mimeType = mimeType
|
|
278
|
+
file.url = URL.createObjectURL(file.raw)
|
|
279
|
+
resolve(file)
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const show = ref(false)
|
|
284
|
+
const asset =ref()
|
|
285
|
+
const onView = (value: IUploadUserFile) => {
|
|
286
|
+
if (!preview) return
|
|
287
|
+
if (value.id || value.asset?.id) {
|
|
288
|
+
show.value = true
|
|
289
|
+
asset.value = value.id ? value : value.asset
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
</script>
|
|
293
|
+
|
|
294
|
+
<style scoped>
|
|
295
|
+
.ex-upload-asset { border-color: var(--el-border-color-lighter); }
|
|
296
|
+
.ex-upload-asset :deep(.el-upload-list--text) { margin-top: 0; }
|
|
297
|
+
.ex-upload-asset :deep(.el-upload-list--text .el-upload-list__item) { margin: 0; background-color: transparent; user-select: none; }
|
|
298
|
+
.ex-upload-asset :deep(.el-upload-list--text .el-upload-list__item:first-child) { margin-top: 10px; }
|
|
299
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Icon :icon="name" :width="size" :height="size" :style="iconStyle" :aria-hidden="null" />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
import { Icon } from '@iconify/vue'
|
|
7
|
+
|
|
8
|
+
const props = defineProps({
|
|
9
|
+
vendor: { type: String, default: 'icon-park-outline' },
|
|
10
|
+
icon: { type: String, default: '' },
|
|
11
|
+
size: { type: Number, default: 16 },
|
|
12
|
+
strokeWidth: { type: Number, default: 3 },
|
|
13
|
+
color: { type: String },
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const name = computed(() => props.icon.indexOf(':') !== -1 ? props.icon : `${props.vendor}:${props.icon}`)
|
|
17
|
+
const iconStyle = computed(() => props.color ? { color: props.color } : undefined)
|
|
18
|
+
const strokeWidth = computed(() => props.strokeWidth)
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<style scoped>
|
|
22
|
+
:deep(g), :deep(path) { stroke-width: v-bind(strokeWidth) }
|
|
23
|
+
</style>
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
2
|
+
|
|
3
|
+
const MD = 768
|
|
4
|
+
|
|
5
|
+
export const useBreak = () => {
|
|
6
|
+
const matches = ref(typeof window !== 'undefined' ? window.matchMedia(`(min-width: ${MD}px)`).matches : true)
|
|
7
|
+
let mql: MediaQueryList | undefined
|
|
8
|
+
const onChange = (e: MediaQueryListEvent) => matches.value = e.matches
|
|
9
|
+
|
|
10
|
+
onMounted(() => {
|
|
11
|
+
if (typeof window === 'undefined') return
|
|
12
|
+
mql = window.matchMedia(`(min-width: ${MD}px)`)
|
|
13
|
+
matches.value = mql.matches
|
|
14
|
+
mql.addEventListener('change', onChange)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
onBeforeUnmount(() => mql?.removeEventListener('change', onChange))
|
|
18
|
+
|
|
19
|
+
const isDesktop = computed(() => matches.value)
|
|
20
|
+
const isMobile = computed(() => !matches.value)
|
|
21
|
+
|
|
22
|
+
return { isMobile, isDesktop }
|
|
23
|
+
}
|