项目初始化

This commit is contained in:
2026-03-18 17:19:03 +08:00
commit ac6079b9ff
602 changed files with 58291 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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[]
}
}

View 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
// 是否渲染为 buttonradio 和 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
}
}

View 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>