This commit is contained in:
Your Name
2026-08-11 17:39:41 +08:00
parent cfe4c82c90
commit 25467b9d91
350 changed files with 201115 additions and 132208 deletions
+49 -49
View File
@@ -1,49 +1,49 @@
<template>
<el-date-picker
v-model="content"
:type="pickerType"
range-separator="-"
:start-placeholder="startPlaceholder"
:end-placeholder="endPlaceholder"
:value-format="valueFormat"
clearable
@change="emit('change', $event)"
></el-date-picker>
</template>
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
startTime?: string
endTime?: string
pickerType?: 'daterange' | 'datetimerange'
valueFormat?: string
startPlaceholder?: string
endPlaceholder?: string
}>(),
{
startTime: '',
endTime: '',
pickerType: 'datetimerange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
}
)
const emit = defineEmits(['update:startTime', 'update:endTime', 'change'])
const content = computed<any>({
get: () => {
return [props.startTime, props.endTime]
},
set: (value: Event | any) => {
if (value === null) {
emit('update:startTime', '')
emit('update:endTime', '')
} else {
emit('update:startTime', value[0])
emit('update:endTime', value[1])
}
}
})
</script>
<template>
<el-date-picker
v-model="content"
:type="pickerType"
range-separator="-"
:start-placeholder="startPlaceholder"
:end-placeholder="endPlaceholder"
:value-format="valueFormat"
clearable
@change="emit('change', $event)"
></el-date-picker>
</template>
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
startTime?: string
endTime?: string
pickerType?: 'daterange' | 'datetimerange'
valueFormat?: string
startPlaceholder?: string
endPlaceholder?: string
}>(),
{
startTime: '',
endTime: '',
pickerType: 'datetimerange',
valueFormat: 'YYYY-MM-DD HH:mm:ss',
startPlaceholder: '开始时间',
endPlaceholder: '结束时间'
}
)
const emit = defineEmits(['update:startTime', 'update:endTime', 'change'])
const content = computed<any>({
get: () => {
return [props.startTime, props.endTime]
},
set: (value: Event | any) => {
if (value === null) {
emit('update:startTime', '')
emit('update:endTime', '')
} else {
emit('update:startTime', value[0])
emit('update:endTime', value[1])
}
}
})
</script>
+157 -157
View File
@@ -1,157 +1,157 @@
<template>
<div class="export-data">
<popup
ref="popupRef"
title="导出设置"
width="500px"
confirm-button-text="确认导出"
@confirm="handleConfirm"
:async="true"
@open="getData"
>
<template #trigger>
<el-button>导出</el-button>
</template>
<div>
<p v-if="props.exportHint" class="text-sm text-gray-500 mb-3 leading-relaxed">{{ props.exportHint }}</p>
<el-form ref="formRef" :model="formData" label-width="120px" :rules="formRules">
<el-form-item label="数据量:">
预计导出{{ exportData.count }}条数据 {{ exportData.sum_page }}每页{{
exportData.page_size
}}条数据
</el-form-item>
<el-form-item label="导出限制:">
每次导出最大允许{{ exportData.max_page }}{{
exportData.all_max_size
}}条数据
</el-form-item>
<el-form-item prop="page_type" label="导出范围:" required>
<el-radio-group v-model="formData.page_type">
<el-radio :value="0">全部导出</el-radio>
<el-radio :value="1">分页导出</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="分页范围:" v-if="formData.page_type == 1">
<div class="flex">
<el-form-item prop="page_start">
<el-input
style="width: 140px"
v-model.number="formData.page_start"
placeholder=""
></el-input>
</el-form-item>
<span class="flex-none ml-2 mr-2"></span>
<el-form-item prop="page_end">
<el-input
style="width: 140px"
v-model.number="formData.page_end"
placeholder=""
></el-input>
</el-form-item>
</div>
</el-form-item>
<el-form-item label="导出文件名称:" prop="file_name">
<el-input
v-model="formData.file_name"
placeholder="请输入导出文件名称"
></el-input>
</el-form-item>
</el-form>
</div>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import Popup from '@/components/popup/index.vue'
import feedback from '@/utils/feedback'
const formRef = shallowRef<FormInstance>()
const props = defineProps({
params: {
type: Object,
default: () => ({})
},
pageSize: {
type: Number,
default: 25
},
fetchFun: {
type: Function,
required: true
},
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
exportHint: {
type: String,
default: ''
}
})
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const formData = reactive({
page_type: 0,
page_start: 1,
page_end: 200,
file_name: ''
})
const formRules = {
page_start: [
{ required: true, message: '请输入起始页码' },
{ type: 'number', message: '页码必须是整数' },
{
validator: (rule: any, value: any, callback: any) => {
if (value <= 0) return callback(new Error('页码必须大于0'))
callback()
}
}
],
page_end: [
{ required: true, message: '请输入结束页码' },
{ type: 'number', message: '页码必须是整数' },
{
validator: (rule: any, value: any, callback: any) => {
if (value <= 0) return callback(new Error('页码必须大于0'))
callback()
}
}
]
}
const exportData = reactive({
count: 0,
sum_page: 0,
page_size: 0,
max_page: 0,
all_max_size: 0
})
const getData = async () => {
const res = await props.fetchFun({
...props.params,
page_size: props.pageSize,
export: 1
})
Object.assign(exportData, res)
formData.file_name = res.file_name
formData.page_end = res.page_end
formData.page_start = res.page_start
}
const handleConfirm = async () => {
await formRef.value?.validate()
feedback.loading('正在导出中...')
try {
await props.fetchFun({
...props.params,
...formData,
page_size: props.pageSize,
export: 2
})
popupRef.value?.close()
feedback.closeLoading()
} catch (error) {
feedback.closeLoading()
}
}
getData()
</script>
<template>
<div class="export-data">
<popup
ref="popupRef"
title="导出设置"
width="500px"
confirm-button-text="确认导出"
@confirm="handleConfirm"
:async="true"
@open="getData"
>
<template #trigger>
<el-button>导出</el-button>
</template>
<div>
<p v-if="props.exportHint" class="text-sm text-gray-500 mb-3 leading-relaxed">{{ props.exportHint }}</p>
<el-form ref="formRef" :model="formData" label-width="120px" :rules="formRules">
<el-form-item label="数据量:">
预计导出{{ exportData.count }}条数据 {{ exportData.sum_page }}每页{{
exportData.page_size
}}条数据
</el-form-item>
<el-form-item label="导出限制:">
每次导出最大允许{{ exportData.max_page }}{{
exportData.all_max_size
}}条数据
</el-form-item>
<el-form-item prop="page_type" label="导出范围:" required>
<el-radio-group v-model="formData.page_type">
<el-radio :value="0">全部导出</el-radio>
<el-radio :value="1">分页导出</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="分页范围:" v-if="formData.page_type == 1">
<div class="flex">
<el-form-item prop="page_start">
<el-input
style="width: 140px"
v-model.number="formData.page_start"
placeholder=""
></el-input>
</el-form-item>
<span class="flex-none ml-2 mr-2"></span>
<el-form-item prop="page_end">
<el-input
style="width: 140px"
v-model.number="formData.page_end"
placeholder=""
></el-input>
</el-form-item>
</div>
</el-form-item>
<el-form-item label="导出文件名称:" prop="file_name">
<el-input
v-model="formData.file_name"
placeholder="请输入导出文件名称"
></el-input>
</el-form-item>
</el-form>
</div>
</popup>
</div>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'element-plus'
import Popup from '@/components/popup/index.vue'
import feedback from '@/utils/feedback'
const formRef = shallowRef<FormInstance>()
const props = defineProps({
params: {
type: Object,
default: () => ({})
},
pageSize: {
type: Number,
default: 25
},
fetchFun: {
type: Function,
required: true
},
/** 可选:导出弹窗内提示文案(如说明与列表筛选一致) */
exportHint: {
type: String,
default: ''
}
})
const popupRef = shallowRef<InstanceType<typeof Popup>>()
const formData = reactive({
page_type: 0,
page_start: 1,
page_end: 200,
file_name: ''
})
const formRules = {
page_start: [
{ required: true, message: '请输入起始页码' },
{ type: 'number', message: '页码必须是整数' },
{
validator: (rule: any, value: any, callback: any) => {
if (value <= 0) return callback(new Error('页码必须大于0'))
callback()
}
}
],
page_end: [
{ required: true, message: '请输入结束页码' },
{ type: 'number', message: '页码必须是整数' },
{
validator: (rule: any, value: any, callback: any) => {
if (value <= 0) return callback(new Error('页码必须大于0'))
callback()
}
}
]
}
const exportData = reactive({
count: 0,
sum_page: 0,
page_size: 0,
max_page: 0,
all_max_size: 0
})
const getData = async () => {
const res = await props.fetchFun({
...props.params,
page_size: props.pageSize,
export: 1
})
Object.assign(exportData, res)
formData.file_name = res.file_name
formData.page_end = res.page_end
formData.page_start = res.page_start
}
const handleConfirm = async () => {
await formRef.value?.validate()
feedback.loading('正在导出中...')
try {
await props.fetchFun({
...props.params,
...formData,
page_size: props.pageSize,
export: 2
})
popupRef.value?.close()
feedback.closeLoading()
} catch (error) {
feedback.closeLoading()
}
}
getData()
</script>
+488 -488
View File
@@ -1,488 +1,488 @@
<template>
<div class="material-select">
<popup
ref="popupRef"
width="1050px"
custom-class="body-padding"
:title="`选择${tipsText}`"
@confirm="handleConfirm"
@close="handleClose"
>
<template v-if="!hiddenUpload" #trigger>
<div
class="material-select__trigger clearfix"
:class="{ 'is-drop-hover': dropActive }"
tabindex="0"
@click.stop
@dragenter.prevent.stop="onTriggerDragEnter"
@dragleave.prevent.stop="onTriggerDragLeave"
@dragover.prevent.stop="onTriggerDragOver"
@drop.prevent.stop="onTriggerDrop"
@paste="onTriggerPaste"
>
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
<template v-slot:item="{ element, index }">
<div
class="material-preview"
:class="{
'is-disabled': disabled,
'is-one': limit == 1
}"
@click="showPopup(index)"
>
<del-wrap @close="deleteImg(index)">
<file-item
:uri="excludeDomain ? getImageUrl(element) : element"
:file-size="size"
:width="width"
:height="height"
:type="type"
></file-item>
</del-wrap>
<div class="operation-btns text-xs text-center">
<span>修改</span>
|
<span @click.stop="handlePreview(element)">查看</span>
</div>
</div>
</template>
</draggable>
<div
class="material-upload"
@click="showPopup(-1)"
v-show="showUpload"
:class="{
'is-disabled': disabled,
'is-one': limit == 1,
[uploadClass]: true
}"
>
<slot name="upload">
<div
class="upload-btn"
:style="{
width: width || size,
height: height || size
}"
>
<icon :size="25" name="el-icon-Plus" />
<span>添加</span>
</div>
</slot>
</div>
</div>
</template>
<el-scrollbar>
<div class="material-wrap">
<material
ref="materialRef"
:type="type"
:file-size="fileSize"
:limit="meterialLimit"
@change="selectChange"
/>
</div>
</el-scrollbar>
</popup>
<preview v-model="showPreview" :url="previewUrl" :type="type" />
</div>
</template>
<script lang="ts">
import { useThrottleFn } from '@vueuse/core'
import Draggable from 'vuedraggable'
import { uploadMaterialFile } from '@/api/file'
import Popup from '@/components/popup/index.vue'
import useAppStore from '@/stores/modules/app'
import feedback from '@/utils/feedback'
import FileItem from './file.vue'
import Material from './index.vue'
import Preview from './preview.vue'
export default defineComponent({
components: {
Popup,
Draggable,
FileItem,
Material,
Preview
},
props: {
modelValue: {
type: [String, Array],
default: () => []
},
// 文件类型
type: {
type: String,
default: 'image'
},
// 选择器尺寸
size: {
type: String,
default: '100px'
},
// 选择器尺寸-宽度(不传则是使用size
width: {
type: String,
default: ''
},
// 选择器尺寸-高度(不传则是使用size
height: {
type: String,
default: ''
},
// 文件尺寸
fileSize: {
type: String,
default: '100px'
},
// 选择数量限制
limit: {
type: Number,
default: 1
},
// 禁用选择
disabled: {
type: Boolean,
default: false
},
// 隐藏上传框*(目前在富文本中使用到)
hiddenUpload: {
type: Boolean,
default: false
},
uploadClass: {
type: String,
default: ''
},
//选择的url排出域名
excludeDomain: {
type: Boolean,
default: false
},
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
uploadCid: {
type: [Number, String],
default: 0
}
},
emits: ['change', 'update:modelValue'],
setup(props, { emit }) {
const popupRef = ref<InstanceType<typeof Popup>>()
const materialRef = ref<InstanceType<typeof Material>>()
const previewUrl = ref('')
const showPreview = ref(false)
const fileList = ref<any[]>([])
const select = ref<any[]>([])
const isAdd = ref(true)
const currentIndex = ref(-1)
const dragDepth = ref(0)
const externalUploading = ref(false)
const { disabled, limit, modelValue } = toRefs(props)
const appStore = useAppStore()
const getImageUrl = (url: string) => appStore.getImageUrl(url)
const allowDropPaste = computed(
() =>
!props.hiddenUpload &&
(props.type === 'image' || props.type === 'video' || props.type === 'file')
)
const dropActive = computed(() => dragDepth.value > 0)
function hasFileDrag(dt: DataTransfer | null) {
if (!dt) return false
const types = Array.from(dt.types as unknown as string[])
return types.includes('Files') || types.includes('application/x-moz-file')
}
function isAcceptedExtension(fileName: string, uploadType: string): boolean {
if (uploadType === 'file') return true
const ext = fileName.includes('.')
? fileName.split('.').pop()?.toLowerCase() || ''
: ''
if (uploadType === 'image') {
if (!ext) return true
return ['jpg', 'jpeg', 'png', 'gif', 'ico'].includes(ext)
}
if (uploadType === 'video') {
if (!ext) return true
return ['wmv', 'avi', 'mpg', 'mpeg', '3gp', 'mov', 'mp4', 'flv', 'rmvb', 'mkv'].includes(
ext
)
}
return true
}
function toRelativeStoredUrl(fullUrl: string): string {
if (!fullUrl || !fullUrl.startsWith('http')) return fullUrl
const oss = String(appStore.config?.oss_domain || '')
.trim()
.replace(/\/$/, '')
if (oss && fullUrl.startsWith(oss)) {
return fullUrl.slice(oss.length).replace(/^\//, '')
}
const origin =
typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : ''
if (origin && fullUrl.startsWith(origin)) {
return fullUrl.slice(origin.length).replace(/^\//, '')
}
try {
return new URL(fullUrl).pathname.replace(/^\//, '')
} catch {
return fullUrl
}
}
function clipboardFiles(e: ClipboardEvent): File[] {
const uploadType = props.type
const fromFiles = Array.from(e.clipboardData?.files || []).filter((f) =>
isAcceptedExtension(f.name, uploadType)
)
if (fromFiles.length) return fromFiles
if (uploadType !== 'image') return []
const items = e.clipboardData?.items
if (!items) return []
const out: File[] = []
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile()
if (f) out.push(f)
}
}
return out
}
async function runExternalUpload(files: File[]) {
if (externalUploading.value) return
const maxSlots =
limit.value === -1 ? Infinity : Math.max(0, limit.value - fileList.value.length)
if (!maxSlots) {
feedback.msgWarning('已达到上传数量上限')
return
}
const queue = files.slice(0, maxSlots)
if (!queue.length) return
const uploadType =
props.type === 'video' ? 'video' : props.type === 'file' ? 'file' : 'image'
externalUploading.value = true
try {
const paths: string[] = []
for (const file of queue) {
const data = await uploadMaterialFile(file, uploadType, props.uploadCid)
paths.push(props.excludeDomain ? toRelativeStoredUrl(data.url) : data.url)
}
fileList.value = [...fileList.value, ...paths]
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
emit('update:modelValue', valueImg)
emit('change', valueImg)
handleClose()
} catch (err: any) {
feedback.msgError(err?.message || '上传失败')
} finally {
externalUploading.value = false
}
}
const onTriggerDragEnter = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (!hasFileDrag(e.dataTransfer)) return
dragDepth.value++
}
const onTriggerDragLeave = () => {
if (!allowDropPaste.value) return
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
const onTriggerDragOver = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (hasFileDrag(e.dataTransfer)) e.preventDefault()
}
const onTriggerDrop = async (e: DragEvent) => {
dragDepth.value = 0
if (!allowDropPaste.value || disabled.value) return
const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
isAcceptedExtension(f.name, props.type)
)
if (!files.length) return
await runExternalUpload(files)
}
const onTriggerPaste = async (e: ClipboardEvent) => {
if (!allowDropPaste.value || disabled.value) return
const files = clipboardFiles(e)
if (!files.length) return
e.preventDefault()
await runExternalUpload(files)
}
const tipsText = computed(() => {
switch (props.type) {
case 'image':
return '图片'
case 'video':
return '视频'
default:
return ''
}
})
const showUpload = computed(() => {
return props.limit - fileList.value.length > 0
})
const meterialLimit: any = computed(() => {
if (!isAdd.value) {
return 1
}
if (limit.value == -1) return null
return limit.value - fileList.value.length
})
const handleConfirm = useThrottleFn(
() => {
const selectUri = select.value.map((item) =>
props.excludeDomain ? item.uri : item.url
)
if (!isAdd.value) {
fileList.value.splice(currentIndex.value, 1, selectUri.shift())
} else {
fileList.value = [...fileList.value, ...selectUri]
}
handleChange()
},
1000,
false
)
const showPopup = (index: number) => {
if (disabled.value) return
if (index >= 0) {
isAdd.value = false
currentIndex.value = index
} else {
isAdd.value = true
}
popupRef.value?.open()
}
const selectChange = (val: any[]) => {
select.value = val
}
const handleChange = () => {
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
emit('update:modelValue', valueImg)
emit('change', valueImg)
handleClose()
}
const deleteImg = (index: number) => {
fileList.value.splice(index, 1)
handleChange()
}
const handlePreview = (url: string) => {
previewUrl.value = props.excludeDomain ? getImageUrl(url) : url
showPreview.value = true
}
const handleClose = () => {
nextTick(() => {
if (props.hiddenUpload) fileList.value = []
materialRef.value?.clearSelect()
})
}
watch(
modelValue,
(val: any[] | string) => {
fileList.value = Array.isArray(val) ? val : val == '' ? [] : [val]
},
{
immediate: true
}
)
provide('limit', props.limit)
provide('hiddenUpload', props.hiddenUpload)
return {
popupRef,
materialRef,
fileList,
tipsText,
handleConfirm,
meterialLimit,
showUpload,
showPopup,
selectChange,
deleteImg,
previewUrl,
showPreview,
handlePreview,
handleClose,
getImageUrl,
dropActive,
onTriggerDragEnter,
onTriggerDragLeave,
onTriggerDragOver,
onTriggerDrop,
onTriggerPaste
}
}
})
</script>
<style scoped lang="scss">
.material-select {
.material-select__trigger {
outline: none;
&.is-drop-hover {
border-radius: 4px;
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
}
.material-upload,
.material-preview {
position: relative;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
margin-bottom: 8px;
box-sizing: border-box;
float: left;
&.is-disabled {
cursor: not-allowed;
}
&.is-one {
margin-bottom: 0;
}
&:hover {
.operation-btns {
display: block;
}
}
.operation-btns {
display: none;
position: absolute;
bottom: 0;
border-radius: 4px;
width: 100%;
line-height: 2;
color: #fff;
background-color: rgba(0, 0, 0, 0.3);
}
}
.material-upload {
:deep(.upload-btn) {
@apply text-tx-secondary box-border rounded border-br border-dashed border flex flex-col justify-center items-center;
}
}
}
.material-wrap {
min-width: 720px;
height: 560px;
@apply border-t border-b border-br;
}
</style>
<template>
<div class="material-select">
<popup
ref="popupRef"
width="1050px"
custom-class="body-padding"
:title="`选择${tipsText}`"
@confirm="handleConfirm"
@close="handleClose"
>
<template v-if="!hiddenUpload" #trigger>
<div
class="material-select__trigger clearfix"
:class="{ 'is-drop-hover': dropActive }"
tabindex="0"
@click.stop
@dragenter.prevent.stop="onTriggerDragEnter"
@dragleave.prevent.stop="onTriggerDragLeave"
@dragover.prevent.stop="onTriggerDragOver"
@drop.prevent.stop="onTriggerDrop"
@paste="onTriggerPaste"
>
<draggable class="draggable" v-model="fileList" animation="300" item-key="id">
<template v-slot:item="{ element, index }">
<div
class="material-preview"
:class="{
'is-disabled': disabled,
'is-one': limit == 1
}"
@click="showPopup(index)"
>
<del-wrap @close="deleteImg(index)">
<file-item
:uri="excludeDomain ? getImageUrl(element) : element"
:file-size="size"
:width="width"
:height="height"
:type="type"
></file-item>
</del-wrap>
<div class="operation-btns text-xs text-center">
<span>修改</span>
|
<span @click.stop="handlePreview(element)">查看</span>
</div>
</div>
</template>
</draggable>
<div
class="material-upload"
@click="showPopup(-1)"
v-show="showUpload"
:class="{
'is-disabled': disabled,
'is-one': limit == 1,
[uploadClass]: true
}"
>
<slot name="upload">
<div
class="upload-btn"
:style="{
width: width || size,
height: height || size
}"
>
<icon :size="25" name="el-icon-Plus" />
<span>添加</span>
</div>
</slot>
</div>
</div>
</template>
<el-scrollbar>
<div class="material-wrap">
<material
ref="materialRef"
:type="type"
:file-size="fileSize"
:limit="meterialLimit"
@change="selectChange"
/>
</div>
</el-scrollbar>
</popup>
<preview v-model="showPreview" :url="previewUrl" :type="type" />
</div>
</template>
<script lang="ts">
import { useThrottleFn } from '@vueuse/core'
import Draggable from 'vuedraggable'
import { uploadMaterialFile } from '@/api/file'
import Popup from '@/components/popup/index.vue'
import useAppStore from '@/stores/modules/app'
import feedback from '@/utils/feedback'
import FileItem from './file.vue'
import Material from './index.vue'
import Preview from './preview.vue'
export default defineComponent({
components: {
Popup,
Draggable,
FileItem,
Material,
Preview
},
props: {
modelValue: {
type: [String, Array],
default: () => []
},
// 文件类型
type: {
type: String,
default: 'image'
},
// 选择器尺寸
size: {
type: String,
default: '100px'
},
// 选择器尺寸-宽度(不传则是使用size
width: {
type: String,
default: ''
},
// 选择器尺寸-高度(不传则是使用size
height: {
type: String,
default: ''
},
// 文件尺寸
fileSize: {
type: String,
default: '100px'
},
// 选择数量限制
limit: {
type: Number,
default: 1
},
// 禁用选择
disabled: {
type: Boolean,
default: false
},
// 隐藏上传框*(目前在富文本中使用到)
hiddenUpload: {
type: Boolean,
default: false
},
uploadClass: {
type: String,
default: ''
},
//选择的url排出域名
excludeDomain: {
type: Boolean,
default: false
},
/** 拖拽/粘贴直传时的素材分组 id(与弹窗内「本地上传」一致,默认未分组) */
uploadCid: {
type: [Number, String],
default: 0
}
},
emits: ['change', 'update:modelValue'],
setup(props, { emit }) {
const popupRef = ref<InstanceType<typeof Popup>>()
const materialRef = ref<InstanceType<typeof Material>>()
const previewUrl = ref('')
const showPreview = ref(false)
const fileList = ref<any[]>([])
const select = ref<any[]>([])
const isAdd = ref(true)
const currentIndex = ref(-1)
const dragDepth = ref(0)
const externalUploading = ref(false)
const { disabled, limit, modelValue } = toRefs(props)
const appStore = useAppStore()
const getImageUrl = (url: string) => appStore.getImageUrl(url)
const allowDropPaste = computed(
() =>
!props.hiddenUpload &&
(props.type === 'image' || props.type === 'video' || props.type === 'file')
)
const dropActive = computed(() => dragDepth.value > 0)
function hasFileDrag(dt: DataTransfer | null) {
if (!dt) return false
const types = Array.from(dt.types as unknown as string[])
return types.includes('Files') || types.includes('application/x-moz-file')
}
function isAcceptedExtension(fileName: string, uploadType: string): boolean {
if (uploadType === 'file') return true
const ext = fileName.includes('.')
? fileName.split('.').pop()?.toLowerCase() || ''
: ''
if (uploadType === 'image') {
if (!ext) return true
return ['jpg', 'jpeg', 'png', 'gif', 'ico'].includes(ext)
}
if (uploadType === 'video') {
if (!ext) return true
return ['wmv', 'avi', 'mpg', 'mpeg', '3gp', 'mov', 'mp4', 'flv', 'rmvb', 'mkv'].includes(
ext
)
}
return true
}
function toRelativeStoredUrl(fullUrl: string): string {
if (!fullUrl || !fullUrl.startsWith('http')) return fullUrl
const oss = String(appStore.config?.oss_domain || '')
.trim()
.replace(/\/$/, '')
if (oss && fullUrl.startsWith(oss)) {
return fullUrl.slice(oss.length).replace(/^\//, '')
}
const origin =
typeof window !== 'undefined' ? window.location.origin.replace(/\/$/, '') : ''
if (origin && fullUrl.startsWith(origin)) {
return fullUrl.slice(origin.length).replace(/^\//, '')
}
try {
return new URL(fullUrl).pathname.replace(/^\//, '')
} catch {
return fullUrl
}
}
function clipboardFiles(e: ClipboardEvent): File[] {
const uploadType = props.type
const fromFiles = Array.from(e.clipboardData?.files || []).filter((f) =>
isAcceptedExtension(f.name, uploadType)
)
if (fromFiles.length) return fromFiles
if (uploadType !== 'image') return []
const items = e.clipboardData?.items
if (!items) return []
const out: File[] = []
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (it.kind === 'file' && it.type.startsWith('image/')) {
const f = it.getAsFile()
if (f) out.push(f)
}
}
return out
}
async function runExternalUpload(files: File[]) {
if (externalUploading.value) return
const maxSlots =
limit.value === -1 ? Infinity : Math.max(0, limit.value - fileList.value.length)
if (!maxSlots) {
feedback.msgWarning('已达到上传数量上限')
return
}
const queue = files.slice(0, maxSlots)
if (!queue.length) return
const uploadType =
props.type === 'video' ? 'video' : props.type === 'file' ? 'file' : 'image'
externalUploading.value = true
try {
const paths: string[] = []
for (const file of queue) {
const data = await uploadMaterialFile(file, uploadType, props.uploadCid)
paths.push(props.excludeDomain ? toRelativeStoredUrl(data.url) : data.url)
}
fileList.value = [...fileList.value, ...paths]
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
emit('update:modelValue', valueImg)
emit('change', valueImg)
handleClose()
} catch (err: any) {
feedback.msgError(err?.message || '上传失败')
} finally {
externalUploading.value = false
}
}
const onTriggerDragEnter = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (!hasFileDrag(e.dataTransfer)) return
dragDepth.value++
}
const onTriggerDragLeave = () => {
if (!allowDropPaste.value) return
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
const onTriggerDragOver = (e: DragEvent) => {
if (!allowDropPaste.value || disabled.value) return
if (hasFileDrag(e.dataTransfer)) e.preventDefault()
}
const onTriggerDrop = async (e: DragEvent) => {
dragDepth.value = 0
if (!allowDropPaste.value || disabled.value) return
const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
isAcceptedExtension(f.name, props.type)
)
if (!files.length) return
await runExternalUpload(files)
}
const onTriggerPaste = async (e: ClipboardEvent) => {
if (!allowDropPaste.value || disabled.value) return
const files = clipboardFiles(e)
if (!files.length) return
e.preventDefault()
await runExternalUpload(files)
}
const tipsText = computed(() => {
switch (props.type) {
case 'image':
return '图片'
case 'video':
return '视频'
default:
return ''
}
})
const showUpload = computed(() => {
return props.limit - fileList.value.length > 0
})
const meterialLimit: any = computed(() => {
if (!isAdd.value) {
return 1
}
if (limit.value == -1) return null
return limit.value - fileList.value.length
})
const handleConfirm = useThrottleFn(
() => {
const selectUri = select.value.map((item) =>
props.excludeDomain ? item.uri : item.url
)
if (!isAdd.value) {
fileList.value.splice(currentIndex.value, 1, selectUri.shift())
} else {
fileList.value = [...fileList.value, ...selectUri]
}
handleChange()
},
1000,
false
)
const showPopup = (index: number) => {
if (disabled.value) return
if (index >= 0) {
isAdd.value = false
currentIndex.value = index
} else {
isAdd.value = true
}
popupRef.value?.open()
}
const selectChange = (val: any[]) => {
select.value = val
}
const handleChange = () => {
const valueImg = limit.value != 1 ? fileList.value : fileList.value[0] || ''
emit('update:modelValue', valueImg)
emit('change', valueImg)
handleClose()
}
const deleteImg = (index: number) => {
fileList.value.splice(index, 1)
handleChange()
}
const handlePreview = (url: string) => {
previewUrl.value = props.excludeDomain ? getImageUrl(url) : url
showPreview.value = true
}
const handleClose = () => {
nextTick(() => {
if (props.hiddenUpload) fileList.value = []
materialRef.value?.clearSelect()
})
}
watch(
modelValue,
(val: any[] | string) => {
fileList.value = Array.isArray(val) ? val : val == '' ? [] : [val]
},
{
immediate: true
}
)
provide('limit', props.limit)
provide('hiddenUpload', props.hiddenUpload)
return {
popupRef,
materialRef,
fileList,
tipsText,
handleConfirm,
meterialLimit,
showUpload,
showPopup,
selectChange,
deleteImg,
previewUrl,
showPreview,
handlePreview,
handleClose,
getImageUrl,
dropActive,
onTriggerDragEnter,
onTriggerDragLeave,
onTriggerDragOver,
onTriggerDrop,
onTriggerPaste
}
}
})
</script>
<style scoped lang="scss">
.material-select {
.material-select__trigger {
outline: none;
&.is-drop-hover {
border-radius: 4px;
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
}
}
.material-upload,
.material-preview {
position: relative;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
margin-bottom: 8px;
box-sizing: border-box;
float: left;
&.is-disabled {
cursor: not-allowed;
}
&.is-one {
margin-bottom: 0;
}
&:hover {
.operation-btns {
display: block;
}
}
.operation-btns {
display: none;
position: absolute;
bottom: 0;
border-radius: 4px;
width: 100%;
line-height: 2;
color: #fff;
background-color: rgba(0, 0, 0, 0.3);
}
}
.material-upload {
:deep(.upload-btn) {
@apply text-tx-secondary box-border rounded border-br border-dashed border flex flex-col justify-center items-center;
}
}
}
.material-wrap {
min-width: 720px;
height: 560px;
@apply border-t border-b border-br;
}
</style>
File diff suppressed because it is too large Load Diff