项目初始化
This commit is contained in:
85
web/src/components/baInput/components/array.vue
Normal file
85
web/src/components/baInput/components/array.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="10" class="ba-array-key">{{ state.keyTitle }}</el-col>
|
||||
<el-col :span="10" class="ba-array-value">{{ state.valueTitle }}</el-col>
|
||||
</el-row>
|
||||
<el-row class="ba-array-item" v-for="(item, idx) in state.value" :gutter="10" :key="idx">
|
||||
<el-col :span="10">
|
||||
<el-input v-model="item.key"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="10">
|
||||
<el-input v-model="item.value"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button @click="onDelArrayItem(idx)" size="small" icon="el-icon-Delete" circle />
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="10" :offset="10">
|
||||
<el-button v-blur class="ba-add-array-item" @click="onAddArrayItem" icon="el-icon-Plus">{{ t('Add') }}</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type baInputArray = { key: string; value: string }
|
||||
interface Props {
|
||||
modelValue: baInputArray[]
|
||||
keyTitle?: string
|
||||
valueTitle?: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
keyTitle: '',
|
||||
valueTitle: '',
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
value: props.modelValue,
|
||||
keyTitle: props.keyTitle ? props.keyTitle : t('utils.ArrayKey'),
|
||||
valueTitle: props.valueTitle ? props.valueTitle : t('utils.ArrayValue'),
|
||||
})
|
||||
|
||||
const onAddArrayItem = () => {
|
||||
state.value.push({
|
||||
key: '',
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
|
||||
const onDelArrayItem = (idx: number) => {
|
||||
state.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
state.value = newVal
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-array-key,
|
||||
.ba-array-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.ba-array-item {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.ba-add-array-item {
|
||||
float: right;
|
||||
}
|
||||
</style>
|
||||
518
web/src/components/baInput/components/baUpload.vue
Normal file
518
web/src/components/baInput/components/baUpload.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="w100">
|
||||
<el-upload
|
||||
ref="upload"
|
||||
class="ba-upload"
|
||||
:class="[
|
||||
type,
|
||||
state.attrs.disabled ? 'is-disabled' : '',
|
||||
hideImagePlusOnOverLimit && state.attrs.limit && state.fileList.length >= state.attrs.limit ? 'hide-image-plus' : '',
|
||||
]"
|
||||
v-model:file-list="state.fileList"
|
||||
:auto-upload="false"
|
||||
@change="onElChange"
|
||||
@remove="onElRemove"
|
||||
@preview="onElPreview"
|
||||
@exceed="onElExceed"
|
||||
v-bind="state.attrs"
|
||||
:key="state.key"
|
||||
>
|
||||
<template v-if="!$slots.default" #default>
|
||||
<template v-if="type == 'image' || type == 'images'">
|
||||
<div v-if="!hideSelectFile" @click.stop="showSelectFile()" class="ba-upload-select-image">
|
||||
{{ $t('utils.choice') }}
|
||||
</div>
|
||||
<Icon class="ba-upload-icon" name="el-icon-Plus" size="30" color="#c0c4cc" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button v-blur type="primary">
|
||||
<Icon name="el-icon-Plus" color="#ffffff" />
|
||||
<span>{{ $t('Upload') }}</span>
|
||||
</el-button>
|
||||
<el-button v-blur v-if="!hideSelectFile" @click.stop="showSelectFile()" type="success">
|
||||
<Icon name="fa fa-th-list" size="14px" color="#ffffff" />
|
||||
<span class="ml-6">{{ $t('utils.choice') }}</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-for="(slot, name) in $slots" #[name]="scopedData">
|
||||
<slot :name="name" v-bind="scopedData"></slot>
|
||||
</template>
|
||||
</el-upload>
|
||||
<el-dialog v-model="state.preview.show" :append-to-body="true" :destroy-on-close="true" class="ba-upload-preview">
|
||||
<div class="ba-upload-preview-scroll ba-scroll-style">
|
||||
<img :src="state.preview.url" class="ba-upload-preview-img" alt="" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
<SelectFile v-model="state.selectFile.show" v-bind="state.selectFile" @choice="onChoice" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted, watch, useAttrs, nextTick, useTemplateRef } from 'vue'
|
||||
import { genFileId } from 'element-plus'
|
||||
import type { UploadUserFile, UploadProps, UploadRawFile, UploadFiles } from 'element-plus'
|
||||
import { stringToArray } from '/@/components/baInput/helper'
|
||||
import { fullUrl, arrayFullUrl, getFileNameFromPath, getArrayKey } from '/@/utils/common'
|
||||
import { fileUpload } from '/@/api/common'
|
||||
import SelectFile from '/@/components/baInput/components/selectFile.vue'
|
||||
import { uuid } from '/@/utils/random'
|
||||
import { cloneDeep, isEmpty } from 'lodash-es'
|
||||
import type { AxiosProgressEvent } from 'axios'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
// 禁用 Attributes 自动继承
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
interface Props extends /* @vue-ignore */ Partial<UploadProps> {
|
||||
type: 'image' | 'images' | 'file' | 'files'
|
||||
// 上传请求时的额外携带数据
|
||||
data?: anyObj
|
||||
modelValue: string | string[]
|
||||
// 返回绝对路径
|
||||
returnFullUrl?: boolean
|
||||
// 隐藏附件选择器
|
||||
hideSelectFile?: boolean
|
||||
// 可自定义 el-upload 的其他属性(已废弃,v2.2.0 删除,请直接传递为 props)
|
||||
attr?: Partial<Writeable<UploadProps>>
|
||||
// 强制上传到本地存储
|
||||
forceLocal?: boolean
|
||||
// 在上传数量达到限制时隐藏图片上传按钮
|
||||
hideImagePlusOnOverLimit?: boolean
|
||||
}
|
||||
interface UploadFileExt extends UploadUserFile {
|
||||
serverUrl?: string
|
||||
}
|
||||
interface UploadProgressEvent extends AxiosProgressEvent {
|
||||
percent: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'image',
|
||||
data: () => {
|
||||
return {}
|
||||
},
|
||||
modelValue: () => [],
|
||||
returnFullUrl: false,
|
||||
hideSelectFile: false,
|
||||
attr: () => {
|
||||
return {}
|
||||
},
|
||||
forceLocal: false,
|
||||
hideImagePlusOnOverLimit: false,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | string[]): void
|
||||
}>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const upload = useTemplateRef('upload')
|
||||
const state: {
|
||||
key: string
|
||||
// 返回值类型,通过v-model类型动态计算
|
||||
defaultReturnType: 'string' | 'array'
|
||||
// 预览弹窗
|
||||
preview: {
|
||||
show: boolean
|
||||
url: string
|
||||
}
|
||||
// 文件列表
|
||||
fileList: UploadFileExt[]
|
||||
// 绑定到 el-upload 的属性对象
|
||||
attrs: Partial<UploadProps>
|
||||
// 正在上传的文件数量
|
||||
uploading: number
|
||||
// 显示选择文件窗口
|
||||
selectFile: {
|
||||
show: boolean
|
||||
type?: 'image' | 'file'
|
||||
limit?: number
|
||||
returnFullUrl: boolean
|
||||
}
|
||||
events: anyObj
|
||||
} = reactive({
|
||||
key: uuid(),
|
||||
defaultReturnType: 'string',
|
||||
preview: {
|
||||
show: false,
|
||||
url: '',
|
||||
},
|
||||
fileList: [],
|
||||
attrs: {},
|
||||
uploading: 0,
|
||||
selectFile: {
|
||||
show: false,
|
||||
type: 'file',
|
||||
returnFullUrl: props.returnFullUrl,
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
/**
|
||||
* 需要管理的事件列表(使用 triggerEvent 触发)
|
||||
*/
|
||||
const eventNameMap = {
|
||||
// el-upload 的钩子函数(它们是 props,并不是 emit,以上已经使用,所以需要手动触发)
|
||||
change: ['onChange', 'on-change'],
|
||||
remove: ['onRemove', 'on-remove'],
|
||||
preview: ['onPreview', 'on-preview'],
|
||||
exceed: ['onExceed', 'on-exceed'],
|
||||
|
||||
// 由于自定义了上传方法,需要手动触发的钩子
|
||||
beforeUpload: ['beforeUpload', 'onBeforeUpload', 'before-upload', 'on-before-upload'],
|
||||
progress: ['onProgress', 'on-progress'],
|
||||
success: ['onSuccess', 'on-success'],
|
||||
error: ['onError', 'on-error'],
|
||||
}
|
||||
|
||||
const onElChange = (file: UploadFileExt, files: UploadFiles) => {
|
||||
// 将 file 换为 files 中的对象,以便修改属性等操作
|
||||
const fileIndex = getArrayKey(files, 'uid', file.uid!)
|
||||
if (fileIndex === false) return
|
||||
|
||||
file = files[fileIndex] as UploadFileExt
|
||||
if (!file || !file.raw) return
|
||||
if (triggerEvent('beforeUpload', [file]) === false) return
|
||||
let fd = new FormData()
|
||||
fd.append('file', file.raw)
|
||||
fd = formDataAppend(fd)
|
||||
|
||||
file.status = 'uploading'
|
||||
state.uploading++
|
||||
fileUpload(fd, { uuid: uuid() }, props.forceLocal, {
|
||||
onUploadProgress: (evt: AxiosProgressEvent) => {
|
||||
const progressEvt = evt as UploadProgressEvent
|
||||
if (evt.total && evt.total > 0 && ['ready', 'uploading'].includes(file.status!)) {
|
||||
progressEvt.percent = (evt.loaded / evt.total) * 100
|
||||
file.status = 'uploading'
|
||||
file.percentage = Math.round(progressEvt.percent)
|
||||
triggerEvent('progress', [progressEvt, file, files])
|
||||
}
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.code == 1) {
|
||||
file.serverUrl = res.data.file.url
|
||||
file.status = 'success'
|
||||
emits('update:modelValue', getAllUrls())
|
||||
triggerEvent('success', [res, file, files])
|
||||
} else {
|
||||
file.status = 'fail'
|
||||
files.splice(fileIndex, 1)
|
||||
triggerEvent('error', [res, file, files])
|
||||
}
|
||||
})
|
||||
.catch((res) => {
|
||||
file.status = 'fail'
|
||||
files.splice(fileIndex, 1)
|
||||
triggerEvent('error', [res, file, files])
|
||||
})
|
||||
.finally(() => {
|
||||
state.uploading--
|
||||
onChange(file, files)
|
||||
})
|
||||
}
|
||||
|
||||
const onElRemove = (file: UploadUserFile, files: UploadFiles) => {
|
||||
triggerEvent('remove', [file, files])
|
||||
onChange(file, files)
|
||||
nextTick(() => {
|
||||
emits('update:modelValue', getAllUrls())
|
||||
})
|
||||
}
|
||||
|
||||
const onElPreview = (file: UploadFileExt) => {
|
||||
triggerEvent('preview', [file])
|
||||
if (!file || !file.serverUrl) {
|
||||
return
|
||||
}
|
||||
if (props.type == 'file' || props.type == 'files') {
|
||||
window.open(fullUrl(file.serverUrl))
|
||||
return
|
||||
}
|
||||
state.preview.show = true
|
||||
state.preview.url = fullUrl(file.serverUrl)
|
||||
}
|
||||
|
||||
const onElExceed = (files: UploadUserFile[]) => {
|
||||
const file = files[0] as UploadRawFile
|
||||
file.uid = genFileId()
|
||||
upload.value!.handleStart(file)
|
||||
triggerEvent('exceed', [file, state.fileList])
|
||||
}
|
||||
|
||||
const onChoice = (files: string[]) => {
|
||||
let oldValArr = getAllUrls('array') as string[]
|
||||
files = oldValArr.concat(files)
|
||||
init(files)
|
||||
emits('update:modelValue', getAllUrls())
|
||||
onChange(files, state.fileList)
|
||||
state.selectFile.show = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化文件/图片的排序功能
|
||||
*/
|
||||
const initSort = () => {
|
||||
if (state.attrs.showFileList === false) {
|
||||
return false
|
||||
}
|
||||
nextTick(() => {
|
||||
let uploadListEl = upload.value?.$el.querySelector('.el-upload-list')
|
||||
let uploadItemEl = uploadListEl.getElementsByClassName('el-upload-list__item')
|
||||
if (uploadItemEl.length >= 2) {
|
||||
Sortable.create(uploadListEl, {
|
||||
animation: 200,
|
||||
draggable: '.el-upload-list__item',
|
||||
onEnd: (evt: Sortable.SortableEvent) => {
|
||||
if (evt.oldIndex != evt.newIndex) {
|
||||
state.fileList[evt.newIndex!] = [
|
||||
state.fileList[evt.oldIndex!],
|
||||
(state.fileList[evt.oldIndex!] = state.fileList[evt.newIndex!]),
|
||||
][0]
|
||||
emits('update:modelValue', getAllUrls())
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const triggerEvent = (name: string, args: any[]) => {
|
||||
const events = eventNameMap[name as keyof typeof eventNameMap]
|
||||
if (events) {
|
||||
for (const key in events) {
|
||||
// 执行函数,只在 false 时 return
|
||||
if (typeof state.events[events[key]] === 'function' && state.events[events[key]](...args) === false) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 即将废弃的 props.attr Start
|
||||
const addProps: anyObj = {}
|
||||
if (!isEmpty(props.attr)) {
|
||||
const evtArr = ['onPreview', 'onRemove', 'onSuccess', 'onError', 'onChange', 'onExceed', 'beforeUpload', 'onProgress']
|
||||
for (const key in props.attr) {
|
||||
if (evtArr.includes(key)) {
|
||||
state.events[key] = props.attr[key as keyof typeof props.attr]
|
||||
} else {
|
||||
addProps[key] = props.attr[key as keyof typeof props.attr]
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('图片/文件上传组件的 props.attr 已经弃用,并将于 v2.2.0 版本彻底删除,请将 props.attr 的部分直接作为 props 传递!')
|
||||
}
|
||||
// 即将废弃的 props.attr End
|
||||
|
||||
let events: string[] = []
|
||||
let bindAttrs: anyObj = {}
|
||||
for (const key in eventNameMap) {
|
||||
events = [...events, ...eventNameMap[key as keyof typeof eventNameMap]]
|
||||
}
|
||||
for (const attrKey in attrs) {
|
||||
if (events.includes(attrKey)) {
|
||||
state.events[attrKey] = attrs[attrKey]
|
||||
} else {
|
||||
bindAttrs[attrKey] = attrs[attrKey]
|
||||
}
|
||||
}
|
||||
|
||||
if (props.type == 'image' || props.type == 'file') {
|
||||
bindAttrs = { ...bindAttrs, limit: 1 }
|
||||
} else {
|
||||
bindAttrs = { ...bindAttrs, multiple: true }
|
||||
}
|
||||
|
||||
if (props.type == 'image' || props.type == 'images') {
|
||||
state.selectFile.type = 'image'
|
||||
bindAttrs = { ...bindAttrs, accept: 'image/*', listType: 'picture-card' }
|
||||
}
|
||||
|
||||
state.attrs = { ...bindAttrs, ...addProps }
|
||||
|
||||
// 设置附件选择器的 limit
|
||||
if (state.attrs.limit) {
|
||||
state.selectFile.limit = state.attrs.limit
|
||||
}
|
||||
|
||||
init(props.modelValue)
|
||||
|
||||
initSort()
|
||||
})
|
||||
|
||||
const limitExceed = () => {
|
||||
if (state.attrs.limit && state.fileList.length > state.attrs.limit) {
|
||||
state.fileList = state.fileList.slice(state.fileList.length - state.attrs.limit)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const init = (modelValue: string | string[]) => {
|
||||
let urls = stringToArray(modelValue as string)
|
||||
state.fileList = []
|
||||
state.defaultReturnType = typeof modelValue === 'string' || props.type == 'file' || props.type == 'image' ? 'string' : 'array'
|
||||
|
||||
for (const key in urls) {
|
||||
state.fileList.push({
|
||||
name: getFileNameFromPath(urls[key]),
|
||||
url: fullUrl(urls[key]),
|
||||
serverUrl: urls[key],
|
||||
})
|
||||
}
|
||||
|
||||
// 超出过滤 || 确定返回的URL完整
|
||||
if (limitExceed() || props.returnFullUrl) {
|
||||
emits('update:modelValue', getAllUrls())
|
||||
}
|
||||
state.key = uuid()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前所有图片路径的列表
|
||||
*/
|
||||
const getAllUrls = (returnType: string = state.defaultReturnType) => {
|
||||
limitExceed()
|
||||
let urlList = []
|
||||
for (const key in state.fileList) {
|
||||
if (state.fileList[key].serverUrl) urlList.push(state.fileList[key].serverUrl)
|
||||
}
|
||||
if (props.returnFullUrl) urlList = arrayFullUrl(urlList as string[])
|
||||
return returnType === 'string' ? urlList.join(',') : (urlList as string[])
|
||||
}
|
||||
|
||||
const formDataAppend = (fd: FormData) => {
|
||||
if (props.data && !isEmpty(props.data)) {
|
||||
for (const key in props.data) {
|
||||
fd.append(key, props.data[key])
|
||||
}
|
||||
}
|
||||
return fd
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件状态改变时的钩子,选择文件、上传成功和上传失败时都会被调用
|
||||
*/
|
||||
const onChange = (file: string | string[] | UploadFileExt, files: UploadFileExt[]) => {
|
||||
initSort()
|
||||
triggerEvent('change', [file, files])
|
||||
}
|
||||
|
||||
const getRef = () => {
|
||||
return upload.value
|
||||
}
|
||||
|
||||
const showSelectFile = () => {
|
||||
if (state.attrs.disabled) return
|
||||
state.selectFile.show = true
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getRef,
|
||||
showSelectFile,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (state.uploading > 0) return
|
||||
if (newVal === undefined || newVal === null) {
|
||||
return init('')
|
||||
}
|
||||
let newValArr = arrayFullUrl(stringToArray(cloneDeep(newVal)))
|
||||
let oldValArr = arrayFullUrl(getAllUrls('array'))
|
||||
if (newValArr.sort().toString() != oldValArr.sort().toString()) {
|
||||
init(newVal)
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-upload-select-image {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-top: 1px dashed transparent;
|
||||
width: var(--el-upload-picture-card-size);
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
border-radius: 6px;
|
||||
border-bottom-right-radius: 20px;
|
||||
border-bottom-left-radius: 20px;
|
||||
text-align: center;
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
color: var(--el-text-color-regular);
|
||||
user-select: none;
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
border: 1px dashed var(--el-color-primary);
|
||||
border-top: 1px dashed var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
.ba-upload :deep(.el-upload:hover .ba-upload-icon) {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
:deep(.ba-upload-preview) .el-dialog__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
}
|
||||
.ba-upload-preview-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
.ba-upload-preview-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
:deep(.el-dialog__headerbtn) {
|
||||
top: 2px;
|
||||
width: 37px;
|
||||
height: 37px;
|
||||
}
|
||||
.ba-upload.image :deep(.el-upload--picture-card),
|
||||
.ba-upload.images :deep(.el-upload--picture-card) {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ba-upload.file :deep(.el-upload-list),
|
||||
.ba-upload.files :deep(.el-upload-list) {
|
||||
margin-left: -10px;
|
||||
}
|
||||
.ba-upload.files,
|
||||
.ba-upload.images {
|
||||
:deep(.el-upload-list__item) {
|
||||
user-select: none;
|
||||
.el-upload-list__item-actions,
|
||||
.el-upload-list__item-name {
|
||||
cursor: move;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ml-6 {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.ba-upload.hide-image-plus :deep(.el-upload--picture-card) {
|
||||
display: none;
|
||||
}
|
||||
.ba-upload.is-disabled :deep(.el-upload),
|
||||
.ba-upload.is-disabled :deep(.el-upload) .el-button,
|
||||
.ba-upload.is-disabled :deep(.el-upload--picture-card) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
39
web/src/components/baInput/components/editor.vue
Normal file
39
web/src/components/baInput/components/editor.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- 多编辑器共存支持 -->
|
||||
<!-- 所有编辑器的代码位于 /@/components/mixins/editor 文件夹,一个文件为一种编辑器,文件名则为编辑器名称 -->
|
||||
<!-- 向本组件传递 editorType(文件名/编辑器名称)自动加载对应的编辑器进行渲染 -->
|
||||
<template>
|
||||
<div>
|
||||
<component v-bind="$attrs" :is="mixins[state.editorType]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
interface Props {
|
||||
editorType?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
editorType: 'default',
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
editorType: props.editorType,
|
||||
})
|
||||
|
||||
const mixins: Record<string, Component> = {}
|
||||
const mixinComponents: Record<string, any> = import.meta.glob('../../mixins/editor/**.vue', { eager: true })
|
||||
for (const key in mixinComponents) {
|
||||
const fileName = key.replace('../../mixins/editor/', '').replace('.vue', '')
|
||||
mixins[fileName] = mixinComponents[key].default
|
||||
|
||||
// 未安装富文本编辑器时,值为 default,安装之后,则值为最后一个编辑器的名称
|
||||
if (props.editorType == 'default' && fileName != 'default') {
|
||||
state.editorType = fileName
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
286
web/src/components/baInput/components/iconSelector.vue
Normal file
286
web/src/components/baInput/components/iconSelector.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<el-popover :placement="placement" trigger="focus" :hide-after="0" :width="state.selectorWidth" :visible="state.popoverVisible">
|
||||
<div @mouseover.stop="state.iconSelectorMouseover = true" @mouseout.stop="state.iconSelectorMouseover = false" class="icon-selector">
|
||||
<div class="icon-selector-box">
|
||||
<div class="selector-header">
|
||||
<div class="selector-title">{{ title ? title : $t('utils.Please select an icon') }}</div>
|
||||
<div class="selector-tab">
|
||||
<span
|
||||
:title="'Element Puls ' + $t('utils.Icon')"
|
||||
@click="onChangeTab('ele')"
|
||||
:class="state.iconType == 'ele' ? 'active' : ''"
|
||||
>
|
||||
ele
|
||||
</span>
|
||||
<span
|
||||
:title="'Font Awesome ' + $t('utils.Icon')"
|
||||
@click="onChangeTab('awe')"
|
||||
:class="state.iconType == 'awe' ? 'active' : ''"
|
||||
>
|
||||
awe
|
||||
</span>
|
||||
<span :title="$t('utils.Ali iconcont Icon')" @click="onChangeTab('ali')" :class="state.iconType == 'ali' ? 'active' : ''">
|
||||
ali
|
||||
</span>
|
||||
<span :title="$t('utils.Local icon title')" @click="onChangeTab('local')" :class="state.iconType == 'local' ? 'active' : ''">
|
||||
local
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-scrollbar class="selector-body">
|
||||
<div v-if="renderFontIconNames.length > 0">
|
||||
<div class="icon-selector-item" :title="item" @click="onIcon(item)" v-for="(item, key) in renderFontIconNames" :key="key">
|
||||
<Icon :name="item" />
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
<template #reference>
|
||||
<el-input
|
||||
v-model="state.inputValue"
|
||||
:size="size"
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('Search') + $t('utils.Icon')"
|
||||
ref="selectorInput"
|
||||
@focus="onInputFocus"
|
||||
@blur="onInputBlur"
|
||||
:class="'size-' + size"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="icon-prepend">
|
||||
<Icon :key="'icon' + state.iconKey" :name="state.prependIcon ? state.prependIcon : state.defaultModelValue" />
|
||||
<div v-if="showIconName" class="name">{{ state.prependIcon ? state.prependIcon : state.defaultModelValue }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #append>
|
||||
<Icon @click="onInputRefresh" name="el-icon-RefreshRight" />
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import type { Placement } from 'element-plus'
|
||||
import { computed, nextTick, onMounted, reactive, useTemplateRef, watch } from 'vue'
|
||||
import { getAwesomeIconfontNames, getElementPlusIconfontNames, getIconfontNames, getLocalIconfontNames } from '/@/utils/iconfont'
|
||||
|
||||
type IconType = 'ele' | 'awe' | 'ali' | 'local'
|
||||
|
||||
interface Props {
|
||||
size?: 'default' | 'small' | 'large'
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
type?: IconType
|
||||
placement?: Placement
|
||||
modelValue?: string
|
||||
showIconName?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
title: '',
|
||||
type: 'ele',
|
||||
placement: 'bottom',
|
||||
modelValue: '',
|
||||
showIconName: false,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
|
||||
const selectorInput = useTemplateRef('selectorInput')
|
||||
const state: {
|
||||
iconType: IconType
|
||||
selectorWidth: number
|
||||
popoverVisible: boolean
|
||||
inputFocus: boolean
|
||||
iconSelectorMouseover: boolean
|
||||
fontIconNames: string[]
|
||||
inputValue: string
|
||||
prependIcon: string
|
||||
defaultModelValue: string
|
||||
iconKey: number
|
||||
} = reactive({
|
||||
iconType: props.type,
|
||||
selectorWidth: 0,
|
||||
popoverVisible: false,
|
||||
inputFocus: false,
|
||||
iconSelectorMouseover: false,
|
||||
fontIconNames: [],
|
||||
inputValue: '',
|
||||
prependIcon: props.modelValue,
|
||||
defaultModelValue: props.modelValue || 'fa fa-circle-o',
|
||||
iconKey: 0, // 给icon标签准备个key,以随时使用 h 函数重新生成元素
|
||||
})
|
||||
|
||||
const onInputFocus = () => {
|
||||
state.inputFocus = state.popoverVisible = true
|
||||
}
|
||||
const onInputBlur = () => {
|
||||
state.inputFocus = false
|
||||
state.popoverVisible = state.iconSelectorMouseover
|
||||
}
|
||||
const onInputRefresh = () => {
|
||||
state.iconKey++
|
||||
state.prependIcon = state.defaultModelValue
|
||||
state.inputValue = ''
|
||||
emits('update:modelValue', state.defaultModelValue)
|
||||
emits('change', state.defaultModelValue)
|
||||
}
|
||||
const onChangeTab = (name: IconType) => {
|
||||
state.iconType = name
|
||||
state.fontIconNames = []
|
||||
if (name == 'ele') {
|
||||
getElementPlusIconfontNames().then((res) => {
|
||||
state.fontIconNames = res
|
||||
})
|
||||
} else if (name == 'awe') {
|
||||
getAwesomeIconfontNames().then((res) => {
|
||||
state.fontIconNames = res.map((name) => `fa ${name}`)
|
||||
})
|
||||
} else if (name == 'ali') {
|
||||
getIconfontNames().then((res) => {
|
||||
state.fontIconNames = res.map((name) => `iconfont ${name}`)
|
||||
})
|
||||
} else if (name == 'local') {
|
||||
getLocalIconfontNames().then((res) => {
|
||||
state.fontIconNames = res
|
||||
})
|
||||
}
|
||||
}
|
||||
const onIcon = (icon: string) => {
|
||||
state.iconSelectorMouseover = state.popoverVisible = false
|
||||
state.iconKey++
|
||||
state.prependIcon = icon
|
||||
state.inputValue = ''
|
||||
emits('update:modelValue', icon)
|
||||
emits('change', icon)
|
||||
nextTick(() => {
|
||||
selectorInput.value?.blur()
|
||||
})
|
||||
}
|
||||
|
||||
const renderFontIconNames = computed(() => {
|
||||
if (!state.inputValue) return state.fontIconNames
|
||||
|
||||
let inputValue = state.inputValue.trim().toLowerCase()
|
||||
return state.fontIconNames.filter((icon: string) => {
|
||||
if (icon.toLowerCase().indexOf(inputValue) !== -1) {
|
||||
return icon
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 获取 input 的宽度
|
||||
const getInputWidth = () => {
|
||||
nextTick(() => {
|
||||
state.selectorWidth = selectorInput.value?.$el.offsetWidth < 260 ? 260 : selectorInput.value?.$el.offsetWidth
|
||||
})
|
||||
}
|
||||
|
||||
const popoverVisible = () => {
|
||||
state.popoverVisible = state.inputFocus || state.iconSelectorMouseover ? true : false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
state.iconKey++
|
||||
if (props.modelValue != state.prependIcon) state.defaultModelValue = props.modelValue
|
||||
if (props.modelValue == '') state.defaultModelValue = 'fa fa-circle-o'
|
||||
state.prependIcon = props.modelValue
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 1. 图标选择面板一旦显示就监听 document 的点击事件
|
||||
* 2. 点击后输入框和面板会失去焦点,面板将自动隐藏
|
||||
* 3. 面板隐藏后删除点击事件监听
|
||||
*/
|
||||
let removeClickHidePopoverListenerFn = () => {}
|
||||
watch(
|
||||
() => state.popoverVisible,
|
||||
() => {
|
||||
if (state.popoverVisible) {
|
||||
removeClickHidePopoverListenerFn = useEventListener(document, 'click', popoverVisible)
|
||||
} else {
|
||||
removeClickHidePopoverListenerFn()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
getInputWidth()
|
||||
getElementPlusIconfontNames().then((res) => {
|
||||
state.fontIconNames = res
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.size-small {
|
||||
height: 24px;
|
||||
}
|
||||
.size-large {
|
||||
height: 40px;
|
||||
}
|
||||
.size-default {
|
||||
height: 32px;
|
||||
}
|
||||
.icon-prepend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.name {
|
||||
padding-left: 5px;
|
||||
}
|
||||
}
|
||||
.selector-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.selector-tab {
|
||||
margin-left: auto;
|
||||
span {
|
||||
padding: 0 5px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
&.active,
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
.selector-body {
|
||||
height: 250px;
|
||||
}
|
||||
.icon-selector-item {
|
||||
display: inline-block;
|
||||
padding: 10px 10px 6px 10px;
|
||||
margin: 3px;
|
||||
border: 1px solid var(--ba-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
.icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
&:hover {
|
||||
border: 1px solid var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
:deep(.el-input-group__prepend) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
:deep(.el-input-group__append) {
|
||||
padding: 0 10px;
|
||||
}
|
||||
</style>
|
||||
352
web/src/components/baInput/components/remoteSelect.vue
Normal file
352
web/src/components/baInput/components/remoteSelect.vue
Normal file
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div class="w100">
|
||||
<!-- el-select 的远程下拉只在有搜索词时,才会加载数据(显示出 option 列表) -->
|
||||
<!-- 使用 el-popover 在无数据/无搜索词时,显示一个无数据的提醒 -->
|
||||
<el-popover
|
||||
width="100%"
|
||||
placement="bottom"
|
||||
popper-class="remote-select-popper"
|
||||
:visible="state.focusStatus && !state.loading && !state.keyword && !state.options.length"
|
||||
:teleported="false"
|
||||
:content="$t('utils.No data')"
|
||||
:hide-after="0"
|
||||
>
|
||||
<template #reference>
|
||||
<el-select
|
||||
ref="selectRef"
|
||||
class="w100"
|
||||
remote
|
||||
clearable
|
||||
filterable
|
||||
automatic-dropdown
|
||||
remote-show-suffix
|
||||
v-model="state.value"
|
||||
:loading="state.loading"
|
||||
:disabled="props.disabled || !state.initializeFlag"
|
||||
@blur="onBlur"
|
||||
@focus="onFocus"
|
||||
@clear="onClear"
|
||||
@change="onChangeSelect"
|
||||
@keydown.esc.capture="onKeyDownEsc"
|
||||
:remote-method="onRemoteMethod"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<el-option
|
||||
class="remote-select-option"
|
||||
v-for="item in state.options"
|
||||
:label="item[field]"
|
||||
:value="item[state.primaryKey].toString()"
|
||||
:key="item[state.primaryKey]"
|
||||
>
|
||||
<el-tooltip placement="right" effect="light" v-if="!isEmpty(tooltipParams)">
|
||||
<template #content>
|
||||
<p v-for="(tooltipParam, key) in tooltipParams" :key="key">{{ key }}: {{ item[tooltipParam] }}</p>
|
||||
</template>
|
||||
<div>{{ item[field] }}</div>
|
||||
</el-tooltip>
|
||||
</el-option>
|
||||
<template v-if="state.total && props.pagination" #footer>
|
||||
<el-pagination class="select-pagination" @current-change="onSelectCurrentPageChange" v-bind="getPaginationAttr()" />
|
||||
</template>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ElSelect, PaginationProps } from 'element-plus'
|
||||
import { debounce, isEmpty } from 'lodash-es'
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, toRaw, useAttrs, useTemplateRef, watch } from 'vue'
|
||||
import { InputAttr } from '../index'
|
||||
import { getSelectData } from '/@/api/common'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import { getArrayKey } from '/@/utils/common'
|
||||
import { shortUuid } from '/@/utils/random'
|
||||
|
||||
const attrs = useAttrs()
|
||||
const config = useConfig()
|
||||
const selectRef = useTemplateRef('selectRef')
|
||||
type ElSelectProps = Omit<Partial<InstanceType<typeof ElSelect>['$props']>, 'modelValue'>
|
||||
type valueTypes = string | number | string[] | number[]
|
||||
|
||||
interface Props extends /* @vue-ignore */ ElSelectProps {
|
||||
pk?: string
|
||||
field?: string
|
||||
params?: anyObj
|
||||
remoteUrl: string
|
||||
modelValue: valueTypes | null
|
||||
pagination?: boolean | PaginationProps
|
||||
tooltipParams?: anyObj
|
||||
labelFormatter?: (optionData: anyObj, optionKey: string) => string
|
||||
// 按下 ESC 键时直接使下拉框脱焦(默认是清理搜索词或关闭下拉面板,并且不会脱焦,造成 dialog 的按下 ESC 关闭失效)
|
||||
escBlur?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pk: 'id',
|
||||
field: 'name',
|
||||
params: () => {
|
||||
return {}
|
||||
},
|
||||
remoteUrl: '',
|
||||
modelValue: '',
|
||||
tooltipParams: () => {
|
||||
return {}
|
||||
},
|
||||
pagination: true,
|
||||
disabled: false,
|
||||
escBlur: true,
|
||||
})
|
||||
|
||||
/**
|
||||
* 点击清空按钮后的值,同时也是缺省值
|
||||
*/
|
||||
const valueOnClear = computed(() => {
|
||||
let valueOnClear = attrs.valueOnClear as InputAttr['valueOnClear']
|
||||
if (valueOnClear === undefined) {
|
||||
valueOnClear = attrs.multiple ? () => [] : () => null
|
||||
}
|
||||
return typeof valueOnClear == 'function' ? valueOnClear() : valueOnClear
|
||||
})
|
||||
|
||||
/**
|
||||
* 被认为是空值的值列表
|
||||
*/
|
||||
const emptyValues = computed(() => (attrs.emptyValues as InputAttr['emptyValues']) || [null, undefined, ''])
|
||||
|
||||
const state: {
|
||||
// 主表字段名(不带表别名)
|
||||
primaryKey: string
|
||||
options: anyObj[]
|
||||
loading: boolean
|
||||
total: number
|
||||
currentPage: number
|
||||
pageSize: number
|
||||
params: anyObj
|
||||
keyword: string
|
||||
value: valueTypes
|
||||
initializeFlag: boolean
|
||||
optionValidityFlag: boolean
|
||||
focusStatus: boolean
|
||||
} = reactive({
|
||||
primaryKey: props.pk,
|
||||
options: [],
|
||||
loading: false,
|
||||
total: 0,
|
||||
currentPage: props.params.page || 1,
|
||||
pageSize: props.params.limit || 10,
|
||||
params: props.params,
|
||||
keyword: '',
|
||||
value: valueOnClear.value,
|
||||
initializeFlag: false,
|
||||
optionValidityFlag: false,
|
||||
focusStatus: false,
|
||||
})
|
||||
|
||||
let io: IntersectionObserver | null = null
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: valueTypes): void
|
||||
(e: 'row', value: any): void
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 获取分页组件属性
|
||||
*/
|
||||
const getPaginationAttr = (): Partial<PaginationProps> => {
|
||||
const defaultPaginationAttr: Partial<PaginationProps> = {
|
||||
pagerCount: 5,
|
||||
total: state.total,
|
||||
pageSize: state.pageSize,
|
||||
currentPage: state.currentPage,
|
||||
layout: 'total, ->, prev, pager, next',
|
||||
size: config.layout.shrink ? 'small' : 'default',
|
||||
}
|
||||
|
||||
if (typeof props.pagination === 'boolean') {
|
||||
return defaultPaginationAttr
|
||||
}
|
||||
|
||||
return { ...defaultPaginationAttr, ...props.pagination }
|
||||
}
|
||||
|
||||
const onChangeSelect = (val: valueTypes) => {
|
||||
val = updateValue(val)
|
||||
if (typeof instance?.vnode.props?.onRow == 'function') {
|
||||
if (typeof val == 'number' || typeof val == 'string') {
|
||||
const dataKey = getArrayKey(state.options, state.primaryKey, '' + val)
|
||||
emits('row', dataKey !== false ? toRaw(state.options[dataKey]) : {})
|
||||
} else {
|
||||
const valueArr = []
|
||||
for (const key in val) {
|
||||
const dataKey = getArrayKey(state.options, state.primaryKey, '' + val[key])
|
||||
if (dataKey !== false) {
|
||||
valueArr.push(toRaw(state.options[dataKey]))
|
||||
}
|
||||
}
|
||||
emits('row', valueArr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDownEsc = (e: KeyboardEvent) => {
|
||||
if (props.escBlur) {
|
||||
e.stopPropagation()
|
||||
selectRef.value?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
state.focusStatus = true
|
||||
if (!state.optionValidityFlag) {
|
||||
getData()
|
||||
}
|
||||
}
|
||||
|
||||
const onClear = () => {
|
||||
// 点击清理按钮后,内部 input 呈聚焦状态,但选项面板不会展开,特此处理(element-plus@2.9.1)
|
||||
nextTick(() => {
|
||||
selectRef.value?.blur()
|
||||
selectRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
state.keyword = ''
|
||||
state.focusStatus = false
|
||||
}
|
||||
|
||||
const onRemoteMethod = (q: string) => {
|
||||
if (state.keyword != q) {
|
||||
state.keyword = q
|
||||
state.currentPage = 1
|
||||
getData()
|
||||
}
|
||||
}
|
||||
|
||||
const getData = debounce((initValue: valueTypes = '') => {
|
||||
state.loading = true
|
||||
state.params.page = state.currentPage
|
||||
state.params.initKey = props.pk
|
||||
state.params.initValue = initValue
|
||||
getSelectData(props.remoteUrl, state.keyword, state.params)
|
||||
.then((res) => {
|
||||
let opts = res.data.options ? res.data.options : res.data.list
|
||||
if (typeof props.labelFormatter === 'function') {
|
||||
for (const key in opts) {
|
||||
opts[key][props.field] = props.labelFormatter(opts[key], key)
|
||||
}
|
||||
}
|
||||
state.options = opts
|
||||
state.total = res.data.total ?? 0
|
||||
state.optionValidityFlag = state.keyword || (typeof initValue === 'object' ? !isEmpty(initValue) : initValue) ? false : true
|
||||
})
|
||||
.finally(() => {
|
||||
state.loading = false
|
||||
state.initializeFlag = true
|
||||
})
|
||||
}, 100)
|
||||
|
||||
const onSelectCurrentPageChange = (val: number) => {
|
||||
state.currentPage = val
|
||||
getData()
|
||||
}
|
||||
|
||||
const updateValue = (newVal: any) => {
|
||||
if (emptyValues.value.includes(newVal)) {
|
||||
state.value = valueOnClear.value
|
||||
} else {
|
||||
state.value = newVal
|
||||
|
||||
// number[] 转 string[] 确保默认值能够选中
|
||||
if (typeof state.value === 'object') {
|
||||
for (const key in state.value) {
|
||||
state.value[key] = '' + state.value[key]
|
||||
}
|
||||
} else if (typeof state.value === 'number') {
|
||||
state.value = '' + state.value
|
||||
}
|
||||
}
|
||||
emits('update:modelValue', state.value)
|
||||
return state.value
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 避免两个远程下拉组件共存时,可能带来的重复请求自动取消
|
||||
state.params.uuid = shortUuid()
|
||||
|
||||
// 去除主键中的表名
|
||||
let pkArr = props.pk.split('.')
|
||||
state.primaryKey = pkArr[pkArr.length - 1]
|
||||
|
||||
// 初始化值
|
||||
updateValue(props.modelValue)
|
||||
getData(state.value)
|
||||
|
||||
setTimeout(() => {
|
||||
if (window?.IntersectionObserver) {
|
||||
io = new IntersectionObserver((entries) => {
|
||||
for (const key in entries) {
|
||||
if (!entries[key].isIntersecting) selectRef.value?.blur()
|
||||
}
|
||||
})
|
||||
if (selectRef.value?.$el instanceof Element) {
|
||||
io.observe(selectRef.value.$el)
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
io?.disconnect()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
/**
|
||||
* 防止 number 到 string 的类型转换触发默认值多次初始化
|
||||
* 相当于忽略数据类型进行比较 [1, 2] == ['1', '2']
|
||||
*/
|
||||
if (getString(state.value) != getString(newVal)) {
|
||||
updateValue(newVal)
|
||||
getData(state.value)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const getString = (val: valueTypes | null) => {
|
||||
// 确保 [] 和 '' 的返回值不一样
|
||||
return `${typeof val}:${String(val)}`
|
||||
}
|
||||
|
||||
const getRef = () => {
|
||||
return selectRef.value
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
selectRef.value?.focus()
|
||||
}
|
||||
|
||||
const blur = () => {
|
||||
selectRef.value?.blur()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
blur,
|
||||
focus,
|
||||
getRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.remote-select-popper) {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.remote-select-option {
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
246
web/src/components/baInput/components/selectFile.vue
Normal file
246
web/src/components/baInput/components/selectFile.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-dialog
|
||||
@close="emits('update:modelValue', false)"
|
||||
width="60%"
|
||||
:model-value="modelValue"
|
||||
class="ba-upload-select-dialog"
|
||||
:title="t('utils.Select File')"
|
||||
:append-to-body="true"
|
||||
:destroy-on-close="true"
|
||||
top="4vh"
|
||||
>
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('utils.Original name') })"
|
||||
>
|
||||
<el-tooltip :content="t('utils.choice')" placement="top">
|
||||
<el-button
|
||||
@click="onChoice"
|
||||
:disabled="baTable.table.selection!.length > 0 ? false : true"
|
||||
v-blur
|
||||
class="table-header-operate"
|
||||
type="primary"
|
||||
>
|
||||
<Icon name="fa fa-check" />
|
||||
<span class="table-header-operate-text">{{ t('utils.choice') }}</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<div class="ml-10" v-if="limit !== 0">
|
||||
{{ t('utils.You can also select') }}
|
||||
<span class="selection-count">{{ limit - baTable.table.selection!.length }}</span>
|
||||
{{ t('utils.items') }}
|
||||
</div>
|
||||
</TableHeader>
|
||||
|
||||
<Table ref="tableRef" @selection-change="onSelectionChange" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted, provide, watch, nextTick, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import { previewRenderFormatter } from '/@/views/backend/routine/attachment'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
|
||||
interface Props {
|
||||
type?: 'image' | 'file'
|
||||
limit?: number
|
||||
modelValue: boolean
|
||||
returnFullUrl?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'file',
|
||||
limit: 0,
|
||||
modelValue: false,
|
||||
returnFullUrl: false,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'choice', value: string[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const state = reactive({
|
||||
ready: false,
|
||||
tableSelectable: true,
|
||||
})
|
||||
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const optBtn: OptButton[] = [
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'choice',
|
||||
text: t('utils.choice'),
|
||||
type: 'primary',
|
||||
icon: 'fa fa-check',
|
||||
class: 'table-row-choice',
|
||||
disabledTip: false,
|
||||
click: (row: TableRow) => {
|
||||
const elTableRef = tableRef.value?.getRef()
|
||||
elTableRef?.clearSelection()
|
||||
emits('choice', props.returnFullUrl ? [row.full_url] : [row.url])
|
||||
},
|
||||
},
|
||||
]
|
||||
const baTable = new baTableClass(new baTableApi('/admin/routine.Attachment/'), {
|
||||
acceptQuery: false,
|
||||
column: [
|
||||
{
|
||||
type: 'selection',
|
||||
selectable: (row: TableRow) => {
|
||||
if (props.limit == 0) return true
|
||||
if (baTable.table.selection) {
|
||||
for (const key in baTable.table.selection) {
|
||||
if (row.id == baTable.table.selection[key].id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return state.tableSelectable
|
||||
},
|
||||
align: 'center',
|
||||
operator: false,
|
||||
},
|
||||
{ label: t('Id'), prop: 'id', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), width: 70 },
|
||||
{ label: t('utils.Breakdown'), prop: 'topic', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{
|
||||
label: t('utils.preview'),
|
||||
prop: 'suffix',
|
||||
align: 'center',
|
||||
formatter: previewRenderFormatter,
|
||||
render: 'image',
|
||||
operator: false,
|
||||
},
|
||||
{
|
||||
label: t('utils.type'),
|
||||
prop: 'mimetype',
|
||||
align: 'center',
|
||||
operator: 'LIKE',
|
||||
showOverflowTooltip: true,
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('utils.size'),
|
||||
prop: 'size',
|
||||
align: 'center',
|
||||
formatter: (row: TableRow, column: TableColumn, cellValue: string) => {
|
||||
var size = parseFloat(cellValue)
|
||||
var i = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return parseInt((size / Math.pow(1024, i)).toFixed(i < 2 ? 0 : 2)) * 1 + ' ' + ['B', 'KB', 'MB', 'GB', 'TB'][i]
|
||||
},
|
||||
operator: 'RANGE',
|
||||
sortable: 'custom',
|
||||
operatorPlaceholder: 'bytes',
|
||||
},
|
||||
{
|
||||
label: t('utils.Last upload time'),
|
||||
prop: 'last_upload_time',
|
||||
align: 'center',
|
||||
render: 'datetime',
|
||||
operator: 'RANGE',
|
||||
width: 160,
|
||||
sortable: 'custom',
|
||||
},
|
||||
{
|
||||
show: false,
|
||||
label: t('utils.Upload (Reference) times'),
|
||||
prop: 'quote',
|
||||
align: 'center',
|
||||
width: 150,
|
||||
operator: 'RANGE',
|
||||
sortable: 'custom',
|
||||
},
|
||||
{
|
||||
label: t('utils.Original name'),
|
||||
prop: 'name',
|
||||
align: 'center',
|
||||
showOverflowTooltip: true,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
width: '100',
|
||||
render: 'buttons',
|
||||
buttons: optBtn,
|
||||
operator: false,
|
||||
},
|
||||
],
|
||||
defaultOrder: { prop: 'last_upload_time', order: 'desc' },
|
||||
})
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
const getData = () => {
|
||||
if (props.type == 'image') {
|
||||
baTable.table.filter!.search = [{ field: 'mimetype', val: 'image', operator: 'LIKE' }]
|
||||
}
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.table.filter!.limit = 8
|
||||
baTable.getData()?.then(() => {
|
||||
baTable.initSort()
|
||||
})
|
||||
state.ready = true
|
||||
}
|
||||
|
||||
const onChoice = () => {
|
||||
if (baTable.table.selection?.length) {
|
||||
let files: string[] = []
|
||||
for (const key in baTable.table.selection) {
|
||||
files.push(props.returnFullUrl ? baTable.table.selection[key].full_url : baTable.table.selection[key].url)
|
||||
}
|
||||
emits('choice', files)
|
||||
const elTableRef = tableRef.value?.getRef()
|
||||
elTableRef?.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectionChange = (selection: TableRow[]) => {
|
||||
if (props.limit == 0) return
|
||||
if (selection.length > props.limit) {
|
||||
const elTableRef = tableRef.value?.getRef()
|
||||
elTableRef?.toggleRowSelection(selection[selection.length - 1], false)
|
||||
}
|
||||
state.tableSelectable = !(selection.length >= props.limit)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
baTable.mount()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal && !state.ready) {
|
||||
nextTick(() => {
|
||||
getData()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ba-upload-select-dialog .el-dialog__body {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.table-header-operate-text {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.ml-10 {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.selection-count {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
206
web/src/components/baInput/helper.ts
Normal file
206
web/src/components/baInput/helper.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { FieldData } from './index'
|
||||
|
||||
export const npuaFalse = () => {
|
||||
return {
|
||||
null: false,
|
||||
primaryKey: false,
|
||||
unsigned: false,
|
||||
autoIncrement: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有 Input 支持的类型对应的数据字段类型等数据(默认/示例设计)
|
||||
*/
|
||||
export const fieldData: FieldData = {
|
||||
string: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
password: {
|
||||
type: 'varchar',
|
||||
length: 32,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
number: {
|
||||
type: 'int',
|
||||
length: 10,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
radio: {
|
||||
type: 'enum',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
checkbox: {
|
||||
type: 'set',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
switch: {
|
||||
type: 'tinyint',
|
||||
length: 1,
|
||||
precision: 0,
|
||||
default: '0',
|
||||
defaultType: 'INPUT',
|
||||
...npuaFalse(),
|
||||
unsigned: true,
|
||||
},
|
||||
textarea: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
array: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
datetime: {
|
||||
type: 'bigint',
|
||||
length: 16,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
unsigned: true,
|
||||
},
|
||||
year: {
|
||||
type: 'year',
|
||||
length: 4,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
date: {
|
||||
type: 'date',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
time: {
|
||||
type: 'time',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
select: {
|
||||
type: 'enum',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
selects: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
remoteSelect: {
|
||||
type: 'int',
|
||||
length: 10,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
unsigned: true,
|
||||
},
|
||||
remoteSelects: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
editor: {
|
||||
type: 'text',
|
||||
length: 0,
|
||||
precision: 0,
|
||||
defaultType: 'NULL',
|
||||
...npuaFalse(),
|
||||
null: true,
|
||||
},
|
||||
city: {
|
||||
type: 'varchar',
|
||||
length: 100,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
image: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
images: {
|
||||
type: 'varchar',
|
||||
length: 1500,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
file: {
|
||||
type: 'varchar',
|
||||
length: 255,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
files: {
|
||||
type: 'varchar',
|
||||
length: 1500,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
icon: {
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
color: {
|
||||
type: 'varchar',
|
||||
length: 50,
|
||||
precision: 0,
|
||||
defaultType: 'EMPTY STRING',
|
||||
...npuaFalse(),
|
||||
},
|
||||
}
|
||||
|
||||
export const stringToArray = (val: string | string[]) => {
|
||||
if (typeof val === 'string') {
|
||||
return val == '' ? [] : val.split(',')
|
||||
} else {
|
||||
return val as string[]
|
||||
}
|
||||
}
|
||||
218
web/src/components/baInput/index.ts
Normal file
218
web/src/components/baInput/index.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type { Component, CSSProperties } from 'vue'
|
||||
|
||||
/**
|
||||
* 支持的输入框类型
|
||||
* 若您正在设计数据表,可以找到 ./helper.ts 文件来参考对应类型的:数据字段设计示例
|
||||
*/
|
||||
export const inputTypes = [
|
||||
'string',
|
||||
'password',
|
||||
'number',
|
||||
'radio',
|
||||
'checkbox',
|
||||
'switch',
|
||||
'textarea',
|
||||
'array',
|
||||
'datetime',
|
||||
'year',
|
||||
'date',
|
||||
'time',
|
||||
'select',
|
||||
'selects',
|
||||
'remoteSelect',
|
||||
'remoteSelects',
|
||||
'editor',
|
||||
'city',
|
||||
'image',
|
||||
'images',
|
||||
'file',
|
||||
'files',
|
||||
'icon',
|
||||
'color',
|
||||
]
|
||||
export type ModelValueTypes = string | number | boolean | object
|
||||
|
||||
export interface InputData {
|
||||
// 内容,比如radio的选项列表数据,格式为对象或者数组:{ a: '选项1', b: '选项2' } or [{value: '1', label: 2, disabled: false}, {...}]
|
||||
content?: any
|
||||
// 需要生成子级元素时,子级元素属性(比如radio)
|
||||
childrenAttr?: anyObj
|
||||
// 城市选择器等级,1=省,2=市,3=区
|
||||
level?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* input可用属性,用于代码提示,渲染不同输入组件时,需要的属性是不一样的
|
||||
* https://element-plus.org/zh-CN/component/input.html#input-属性
|
||||
*/
|
||||
export interface InputAttr extends InputData {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
maxlength?: string | number
|
||||
minlength?: string | number
|
||||
showWordLimit?: boolean
|
||||
clearable?: boolean
|
||||
showPassword?: boolean
|
||||
disabled?: boolean
|
||||
size?: 'large' | 'default' | 'small'
|
||||
prefixIcon?: string | Component
|
||||
suffixIcon?: string | Component
|
||||
rows?: number
|
||||
border?: boolean
|
||||
autosize?: boolean | anyObj
|
||||
autocomplete?: string
|
||||
readonly?: boolean
|
||||
max?: string | number
|
||||
min?: string | number
|
||||
step?: string | number
|
||||
resize?: 'none' | 'both' | 'horizontal' | 'vertical'
|
||||
autofocus?: boolean
|
||||
form?: string
|
||||
label?: string
|
||||
tabindex?: string | number
|
||||
validateEvent?: boolean
|
||||
inputStyle?: anyObj
|
||||
activeValue?: string | number | boolean
|
||||
inactiveValue?: string | number | boolean
|
||||
emptyValues?: any[]
|
||||
valueOnClear?: string | number | boolean | Function
|
||||
// DateTimePicker属性
|
||||
editable?: boolean
|
||||
startPlaceholder?: string
|
||||
endPlaceholder?: string
|
||||
timeArrowControl?: boolean
|
||||
format?: string
|
||||
popperClass?: string
|
||||
rangeSeparator?: string
|
||||
defaultValue?: Date
|
||||
defaultTime?: Date | Date[]
|
||||
valueFormat?: string
|
||||
unlinkPanels?: boolean
|
||||
clearIcon?: string | Component
|
||||
shortcuts?: { text: string; value: Date | Function }[]
|
||||
disabledDate?: Function
|
||||
cellClassName?: Function
|
||||
teleported?: boolean
|
||||
// select属性
|
||||
multiple?: boolean
|
||||
valueKey?: string
|
||||
collapseTags?: string
|
||||
collapseTagsTooltip?: boolean
|
||||
multipleLimit?: number
|
||||
effect?: 'dark' | 'light'
|
||||
filterable?: boolean
|
||||
allowCreate?: boolean
|
||||
filterMethod?: Function
|
||||
remote?: false // 禁止使用远程搜索,如需使用请使用单独封装好的 remoteSelect 组件
|
||||
remoteMethod?: false
|
||||
labelFormatter?: (optionData: anyObj, optionKey: string) => string
|
||||
noMatchText?: string
|
||||
noDataText?: string
|
||||
reserveKeyword?: boolean
|
||||
defaultFirstOption?: boolean
|
||||
popperAppendToBody?: boolean
|
||||
persistent?: boolean
|
||||
automaticDropdown?: boolean
|
||||
fitInputWidth?: boolean
|
||||
tagType?: 'success' | 'info' | 'warning' | 'danger'
|
||||
params?: anyObj
|
||||
// 远程select属性
|
||||
pk?: string
|
||||
field?: string
|
||||
remoteUrl?: string
|
||||
tooltipParams?: anyObj
|
||||
escBlur?: boolean
|
||||
// 图标选择器属性
|
||||
showIconName?: boolean
|
||||
placement?: string
|
||||
title?: string
|
||||
// 颜色选择器
|
||||
showAlpha?: boolean
|
||||
colorFormat?: string
|
||||
predefine?: string[]
|
||||
// 图片文件上传属性
|
||||
action?: string
|
||||
headers?: anyObj
|
||||
method?: string
|
||||
data?: anyObj
|
||||
withCredentials?: boolean
|
||||
showFileList?: boolean
|
||||
drag?: boolean
|
||||
accept?: string
|
||||
listType?: string
|
||||
autoUpload?: boolean
|
||||
limit?: number
|
||||
hideSelectFile?: boolean
|
||||
returnFullUrl?: boolean
|
||||
forceLocal?: boolean
|
||||
hideImagePlusOnOverLimit?: boolean
|
||||
// editor属性
|
||||
height?: string
|
||||
mode?: string
|
||||
editorStyle?: CSSProperties
|
||||
style?: CSSProperties
|
||||
toolbarConfig?: anyObj
|
||||
editorConfig?: anyObj
|
||||
editorType?: string
|
||||
preview?: boolean
|
||||
language?: string
|
||||
theme?: 'light' | 'dark'
|
||||
toolbarsExclude?: string[]
|
||||
fileForceLocal?: boolean
|
||||
// array组件属性
|
||||
keyTitle?: string
|
||||
valueTitle?: string
|
||||
// 返回数据类型
|
||||
dataType?: string
|
||||
// 是否渲染为 button(radio 和 checkbox)
|
||||
button?: boolean
|
||||
// 事件
|
||||
onPreview?: Function
|
||||
onRemove?: Function
|
||||
onSuccess?: Function
|
||||
onError?: Function
|
||||
onProgress?: Function
|
||||
onExceed?: Function
|
||||
onBeforeUpload?: Function
|
||||
onBeforeRemove?: Function
|
||||
onChange?: Function
|
||||
onInput?: Function
|
||||
onVisibleChange?: Function
|
||||
onRemoveTag?: Function
|
||||
onClear?: Function
|
||||
onBlur?: Function
|
||||
onFocus?: Function
|
||||
onCalendarChange?: Function
|
||||
onPanelChange?: Function
|
||||
onActiveChange?: Function
|
||||
onRow?: Function
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Input 支持的类型对应的数据字段设计数据
|
||||
*/
|
||||
export interface FieldData {
|
||||
[key: string]: {
|
||||
// 数据类型
|
||||
type: string
|
||||
// 长度
|
||||
length: number
|
||||
// 小数点
|
||||
precision: number
|
||||
// 默认值
|
||||
default?: string
|
||||
// 默认值类型:INPUT=输入,EMPTY STRING=空字符串,NULL=NULL,NONE=无
|
||||
defaultType: 'INPUT' | 'EMPTY STRING' | 'NULL' | 'NONE'
|
||||
// 允许 null
|
||||
null: boolean
|
||||
// 主键
|
||||
primaryKey: boolean
|
||||
// 无符号
|
||||
unsigned: boolean
|
||||
// 自动递增
|
||||
autoIncrement: boolean
|
||||
}
|
||||
}
|
||||
525
web/src/components/baInput/index.vue
Normal file
525
web/src/components/baInput/index.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<script lang="ts">
|
||||
import { isArray, isString } from 'lodash-es'
|
||||
import type { PropType, VNode } from 'vue'
|
||||
import { computed, createVNode, defineComponent, reactive, resolveComponent } from 'vue'
|
||||
import { getArea } from '/@/api/common'
|
||||
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
|
||||
import { inputTypes } from '/@/components/baInput'
|
||||
import Array from '/@/components/baInput/components/array.vue'
|
||||
import BaUpload from '/@/components/baInput/components/baUpload.vue'
|
||||
import Editor from '/@/components/baInput/components/editor.vue'
|
||||
import IconSelector from '/@/components/baInput/components/iconSelector.vue'
|
||||
import RemoteSelect from '/@/components/baInput/components/remoteSelect.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'baInput',
|
||||
props: {
|
||||
// 输入框类型,支持的输入框见 inputTypes
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value: string) => {
|
||||
return inputTypes.includes(value)
|
||||
},
|
||||
},
|
||||
// 双向绑定值
|
||||
modelValue: {
|
||||
type: null,
|
||||
required: true,
|
||||
},
|
||||
// 输入框的附加属性
|
||||
attr: {
|
||||
type: Object as PropType<InputAttr>,
|
||||
default: () => {},
|
||||
},
|
||||
// 额外数据,radio、checkbox的选项等数据
|
||||
data: {
|
||||
type: Object as PropType<InputData>,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit, slots }) {
|
||||
// 合并 props.attr 和 props.data
|
||||
const attrs = computed(() => {
|
||||
return { ...props.attr, ...props.data }
|
||||
})
|
||||
|
||||
// 通用值更新函数
|
||||
const onValueUpdate = (value: ModelValueTypes) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// 基础用法 string textarea password
|
||||
const bases = () => {
|
||||
return () =>
|
||||
createVNode(
|
||||
resolveComponent('el-input'),
|
||||
{
|
||||
type: props.type == 'string' ? 'text' : props.type,
|
||||
...attrs.value,
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
slots
|
||||
)
|
||||
}
|
||||
// radio checkbox
|
||||
const rc = () => {
|
||||
if (!attrs.value.content) {
|
||||
console.warn('请传递 ' + props.type + ' 的 content')
|
||||
}
|
||||
|
||||
const vNodes = computed(() => {
|
||||
const vNode: VNode[] = []
|
||||
const contentIsArray = isArray(attrs.value.content)
|
||||
const type = attrs.value.button ? props.type + '-button' : props.type
|
||||
for (const key in attrs.value.content) {
|
||||
let nodeProps = {}
|
||||
if (contentIsArray) {
|
||||
if (typeof attrs.value.content[key].value == 'number') {
|
||||
console.warn(props.type + ' 的 content.value 不能是数字')
|
||||
}
|
||||
|
||||
nodeProps = {
|
||||
...attrs.value.content[key],
|
||||
border: attrs.value.border ? attrs.value.border : false,
|
||||
...(attrs.value.childrenAttr || {}),
|
||||
}
|
||||
} else {
|
||||
nodeProps = {
|
||||
value: key,
|
||||
label: attrs.value.content[key],
|
||||
border: attrs.value.border ? attrs.value.border : false,
|
||||
...(attrs.value.childrenAttr || {}),
|
||||
}
|
||||
}
|
||||
vNode.push(createVNode(resolveComponent('el-' + type), nodeProps, slots))
|
||||
}
|
||||
return vNode
|
||||
})
|
||||
|
||||
return () => {
|
||||
const valueComputed = computed(() => {
|
||||
if (props.type == 'radio') {
|
||||
if (props.modelValue == undefined) return ''
|
||||
return '' + props.modelValue
|
||||
} else {
|
||||
let modelValueArr: anyObj = []
|
||||
for (const key in props.modelValue) {
|
||||
modelValueArr[key] = '' + props.modelValue[key]
|
||||
}
|
||||
return modelValueArr
|
||||
}
|
||||
})
|
||||
return createVNode(
|
||||
resolveComponent('el-' + props.type + '-group'),
|
||||
{
|
||||
...attrs.value,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
() => vNodes.value
|
||||
)
|
||||
}
|
||||
}
|
||||
// select selects
|
||||
const select = () => {
|
||||
if (!attrs.value.content) {
|
||||
console.warn('请传递 ' + props.type + '的 content')
|
||||
}
|
||||
|
||||
const vNodes = computed(() => {
|
||||
const vNode: VNode[] = []
|
||||
for (const key in attrs.value.content) {
|
||||
vNode.push(
|
||||
createVNode(
|
||||
resolveComponent('el-option'),
|
||||
{
|
||||
key: key,
|
||||
label: attrs.value.content[key],
|
||||
value: key,
|
||||
...(attrs.value.childrenAttr || {}),
|
||||
},
|
||||
slots
|
||||
)
|
||||
)
|
||||
}
|
||||
return vNode
|
||||
})
|
||||
|
||||
return () => {
|
||||
const valueComputed = computed(() => {
|
||||
if (props.type == 'select') {
|
||||
if (props.modelValue == undefined) return ''
|
||||
return '' + props.modelValue
|
||||
} else {
|
||||
let modelValueArr: anyObj = []
|
||||
for (const key in props.modelValue) {
|
||||
modelValueArr[key] = '' + props.modelValue[key]
|
||||
}
|
||||
return modelValueArr
|
||||
}
|
||||
})
|
||||
return createVNode(
|
||||
resolveComponent('el-select'),
|
||||
{
|
||||
class: 'w100',
|
||||
multiple: props.type == 'select' ? false : true,
|
||||
clearable: true,
|
||||
...attrs.value,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
() => vNodes.value
|
||||
)
|
||||
}
|
||||
}
|
||||
// datetime
|
||||
const datetime = () => {
|
||||
let valueFormat = 'YYYY-MM-DD HH:mm:ss'
|
||||
switch (props.type) {
|
||||
case 'date':
|
||||
valueFormat = 'YYYY-MM-DD'
|
||||
break
|
||||
case 'year':
|
||||
valueFormat = 'YYYY'
|
||||
break
|
||||
}
|
||||
return () =>
|
||||
createVNode(
|
||||
resolveComponent('el-date-picker'),
|
||||
{
|
||||
class: 'w100',
|
||||
type: props.type,
|
||||
'value-format': valueFormat,
|
||||
...attrs.value,
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
slots
|
||||
)
|
||||
}
|
||||
// upload
|
||||
const upload = () => {
|
||||
return () =>
|
||||
createVNode(
|
||||
BaUpload,
|
||||
{
|
||||
type: props.type,
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...attrs.value,
|
||||
},
|
||||
slots
|
||||
)
|
||||
}
|
||||
|
||||
// remoteSelect remoteSelects
|
||||
const remoteSelect = () => {
|
||||
return () =>
|
||||
createVNode(
|
||||
RemoteSelect,
|
||||
{
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
multiple: props.type == 'remoteSelect' ? false : true,
|
||||
...attrs.value,
|
||||
},
|
||||
slots
|
||||
)
|
||||
}
|
||||
|
||||
const buildFun = new Map([
|
||||
['string', bases],
|
||||
[
|
||||
'number',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(
|
||||
resolveComponent('el-input-number'),
|
||||
{
|
||||
class: 'w100',
|
||||
'controls-position': 'right',
|
||||
...attrs.value,
|
||||
modelValue: isString(props.modelValue) ? Number(props.modelValue) : props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
['textarea', bases],
|
||||
['password', bases],
|
||||
['radio', rc],
|
||||
['checkbox', rc],
|
||||
[
|
||||
'switch',
|
||||
() => {
|
||||
// 值类型:string,number,boolean,custom
|
||||
const valueType = computed(() => {
|
||||
if (typeof attrs.value.activeValue !== 'undefined' && typeof attrs.value.inactiveValue !== 'undefined') {
|
||||
return 'custom'
|
||||
}
|
||||
return typeof props.modelValue
|
||||
})
|
||||
|
||||
// 要传递给 el-switch 组件的绑定值,该组件对传入值有限制,先做处理
|
||||
const valueComputed = computed(() => {
|
||||
if (valueType.value === 'boolean' || valueType.value === 'custom') {
|
||||
return props.modelValue
|
||||
} else {
|
||||
let valueTmp = parseInt(props.modelValue as string)
|
||||
return isNaN(valueTmp) || valueTmp <= 0 ? false : true
|
||||
}
|
||||
})
|
||||
return () =>
|
||||
createVNode(
|
||||
resolveComponent('el-switch'),
|
||||
{
|
||||
...attrs.value,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': (value: boolean) => {
|
||||
let newValue: boolean | string | number = value
|
||||
switch (valueType.value) {
|
||||
case 'string':
|
||||
newValue = value ? '1' : '0'
|
||||
break
|
||||
case 'number':
|
||||
newValue = value ? 1 : 0
|
||||
}
|
||||
emit('update:modelValue', newValue)
|
||||
},
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
['datetime', datetime],
|
||||
[
|
||||
'year',
|
||||
() => {
|
||||
return () => {
|
||||
const valueComputed = computed(() => (!props.modelValue ? null : '' + props.modelValue))
|
||||
return createVNode(
|
||||
resolveComponent('el-date-picker'),
|
||||
{
|
||||
class: 'w100',
|
||||
type: props.type,
|
||||
'value-format': 'YYYY',
|
||||
...attrs.value,
|
||||
modelValue: valueComputed.value,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
slots
|
||||
)
|
||||
}
|
||||
},
|
||||
],
|
||||
['date', datetime],
|
||||
[
|
||||
'time',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(
|
||||
resolveComponent('el-time-picker'),
|
||||
{
|
||||
class: 'w100',
|
||||
clearable: true,
|
||||
format: 'HH:mm:ss',
|
||||
valueFormat: 'HH:mm:ss',
|
||||
...attrs.value,
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
['select', select],
|
||||
['selects', select],
|
||||
[
|
||||
'array',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(
|
||||
Array,
|
||||
{
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...attrs.value,
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
['remoteSelect', remoteSelect],
|
||||
['remoteSelects', remoteSelect],
|
||||
[
|
||||
'city',
|
||||
() => {
|
||||
type Node = { value?: number; label?: string; leaf?: boolean }
|
||||
let maxLevel = attrs.value.level ? attrs.value.level - 1 : 2
|
||||
const lastLazyValue: {
|
||||
value: string | number[] | unknown
|
||||
nodes: Node[]
|
||||
key: string
|
||||
currentRequest: any
|
||||
} = reactive({
|
||||
value: 'ready',
|
||||
nodes: [],
|
||||
key: '',
|
||||
currentRequest: null,
|
||||
})
|
||||
|
||||
// 请求到的node备份-s
|
||||
let nodeEbak: anyObj = {}
|
||||
const getNodes = (level: number, key: string) => {
|
||||
if (nodeEbak[level] && nodeEbak[level][key]) {
|
||||
return nodeEbak[level][key]
|
||||
}
|
||||
return false
|
||||
}
|
||||
const setNodes = (level: number, key: string, nodes: Node[] = []) => {
|
||||
if (!nodeEbak[level]) {
|
||||
nodeEbak[level] = {}
|
||||
}
|
||||
nodeEbak[level][key] = nodes
|
||||
}
|
||||
// 请求到的node备份-e
|
||||
|
||||
return () =>
|
||||
createVNode(
|
||||
resolveComponent('el-cascader'),
|
||||
{
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
class: 'w100',
|
||||
clearable: true,
|
||||
// city 数据使用 varchar 存储,所以清空时使用 empty string 而不是 null
|
||||
valueOnClear: '',
|
||||
props: {
|
||||
lazy: true,
|
||||
lazyLoad(node: any, resolve: any) {
|
||||
// lazyLoad会频繁触发,在本地存储请求结果,供重复触发时直接读取
|
||||
const { level, pathValues } = node
|
||||
let key = pathValues.join(',')
|
||||
key = key ? key : 'init'
|
||||
|
||||
let locaNode = getNodes(level, key)
|
||||
if (locaNode) {
|
||||
return resolve(locaNode)
|
||||
}
|
||||
|
||||
if (lastLazyValue.key == key && lastLazyValue.value == props.modelValue) {
|
||||
if (lastLazyValue.currentRequest) {
|
||||
return lastLazyValue.currentRequest
|
||||
}
|
||||
return resolve(lastLazyValue.nodes)
|
||||
}
|
||||
|
||||
let nodes: Node[] = []
|
||||
lastLazyValue.key = key
|
||||
lastLazyValue.value = props.modelValue
|
||||
lastLazyValue.currentRequest = getArea(pathValues).then((res) => {
|
||||
let toStr = false
|
||||
if (props.modelValue && typeof (props.modelValue as anyObj)[0] === 'string') {
|
||||
toStr = true
|
||||
}
|
||||
for (const key in res.data) {
|
||||
if (toStr) {
|
||||
res.data[key].value = res.data[key].value.toString()
|
||||
}
|
||||
res.data[key].leaf = level >= maxLevel
|
||||
nodes.push(res.data[key])
|
||||
}
|
||||
lastLazyValue.nodes = nodes
|
||||
lastLazyValue.currentRequest = null
|
||||
setNodes(level, key, nodes)
|
||||
resolve(nodes)
|
||||
})
|
||||
},
|
||||
},
|
||||
...attrs.value,
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
['image', upload],
|
||||
['images', upload],
|
||||
['file', upload],
|
||||
['files', upload],
|
||||
[
|
||||
'icon',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(
|
||||
IconSelector,
|
||||
{
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...attrs.value,
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
[
|
||||
'color',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(
|
||||
resolveComponent('el-color-picker'),
|
||||
{
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': (newValue: string | null) => {
|
||||
// color 数据使用 varchar 存储,点击清空时的 null 值使用 empty string 代替
|
||||
emit('update:modelValue', newValue === null ? '' : newValue)
|
||||
},
|
||||
...attrs.value,
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
[
|
||||
'editor',
|
||||
() => {
|
||||
return () =>
|
||||
createVNode(
|
||||
Editor,
|
||||
{
|
||||
class: 'w100',
|
||||
modelValue: props.modelValue,
|
||||
'onUpdate:modelValue': onValueUpdate,
|
||||
...attrs.value,
|
||||
},
|
||||
slots
|
||||
)
|
||||
},
|
||||
],
|
||||
[
|
||||
'default',
|
||||
() => {
|
||||
console.warn('暂不支持' + props.type + '的输入框类型,你可以自行在 BaInput 组件内添加逻辑')
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
let action = buildFun.get(props.type) || buildFun.get('default')
|
||||
return action!.call(this)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ba-upload-image :deep(.el-upload--picture-card) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ba-upload-file :deep(.el-upload-list) {
|
||||
margin-left: -10px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user