first commit

This commit is contained in:
仲么了
2023-12-21 16:42:39 +08:00
commit 0f7b59f55b
79 changed files with 7638 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,430 @@
<template>
<div class="w100">
<el-upload
ref="upload"
class="ba-upload"
:class="type"
v-model:file-list="state.fileList"
:auto-upload="false"
@change="onElChange"
@remove="onElRemove"
@preview="onElPreview"
@exceed="onElExceed"
v-bind="state.attr"
:key="state.key"
>
<!-- 插槽支持不加 if el-upload 样式会错乱 -->
<template v-if="slots.default" #default><slot name="default"></slot></template>
<template v-else #default>
<template v-if="type == 'image' || type == 'images'">
<div v-if="!hideSelectFile" @click.stop="state.selectFile.show = true" 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="state.selectFile.show = true" 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-if="slots.trigger" #trigger><slot name="trigger"></slot></template>
<template v-if="slots.tip" #tip><slot name="tip"></slot></template>
<template v-if="slots.file" #file><slot name="file"></slot></template>
</el-upload>
<el-dialog v-model="state.preview.show" 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 { ref, reactive, onMounted, watch, useSlots, nextTick } from 'vue'
import { genFileId } from 'element-plus'
import type { UploadInstance, 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'
type Writeable<T> = { -readonly [P in keyof T]: T[P] }
interface Props {
type: 'image' | 'images' | 'file' | 'files'
// 上传请求时的额外携带数据
data?: anyObj
modelValue: string | string[]
// 返回绝对路径
returnFullUrl?: boolean
// 隐藏附件选择器
hideSelectFile?: boolean
// 可自定义el-upload的其他属性
attr?: Partial<Writeable<UploadProps>>
// 强制上传到本地存储
forceLocal?: 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,
})
const emits = defineEmits<{
(e: 'update:modelValue', value: string | string[]): void
}>()
const slots = useSlots()
const upload = ref<UploadInstance>()
const state: {
key: string
// 返回值类型通过v-model类型动态计算
defaultReturnType: 'string' | 'array'
// 预览弹窗
preview: {
show: boolean
url: string
}
// 文件列表
fileList: UploadFileExt[]
// el-upload的属性对象
attr: 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: [],
attr: {},
uploading: 0,
selectFile: {
show: false,
type: 'file',
returnFullUrl: props.returnFullUrl,
},
events: [],
})
const onElChange = (file: UploadFileExt, files: UploadFiles) => {
// 将 file 换为 files 中的对象,以便修改属性等操作
const fileIndex = getArrayKey(files, 'uid', file.uid!)
if (!fileIndex) return
file = files[fileIndex] as UploadFileExt
if (!file || !file.raw) return
if (typeof state.events['beforeUpload'] == 'function' && state.events['beforeUpload'](file) === false) return
let fd = new FormData()
fd.append('file', file.raw)
fd = formDataAppend(fd)
state.uploading++
fileUpload(fd, { uuid: uuid() }, props.forceLocal, {
onUploadProgress: (evt: AxiosProgressEvent) => {
const progressEvt = evt as UploadProgressEvent
if (evt.total && evt.total > 0) {
progressEvt.percent = (evt.loaded / evt.total) * 100
file.status = 'uploading'
file.percentage = Math.round(progressEvt.percent)
typeof state.events['onProgress'] == 'function' && state.events['onProgress'](progressEvt, file, files)
}
},
})
.then((res) => {
if (res.code == 1) {
file.serverUrl = res.data.file.url
file.status = 'success'
emits('update:modelValue', getAllUrls())
typeof state.events['onSuccess'] == 'function' && state.events['onSuccess'](res, file, files)
} else {
file.status = 'fail'
files.splice(fileIndex, 1)
typeof state.events['onError'] == 'function' && state.events['onError'](res, file, files)
}
})
.catch((res) => {
file.status = 'fail'
files.splice(fileIndex, 1)
typeof state.events['onError'] == 'function' && state.events['onError'](res, file, files)
})
.finally(() => {
state.uploading--
onChange(file, files)
})
}
const onElRemove = (file: UploadUserFile, files: UploadFiles) => {
typeof state.events['onRemove'] == 'function' && state.events['onRemove'](file, files)
onChange(file, files)
emits('update:modelValue', getAllUrls())
}
const onElPreview = (file: UploadFileExt) => {
typeof state.events['onPreview'] == 'function' && state.events['onPreview'](file)
if (!file || !file.url) {
return
}
if (props.type == 'file' || props.type == 'files') {
window.open(fullUrl(file.url))
return
}
state.preview.show = true
state.preview.url = file.url
}
const onElExceed = (files: UploadUserFile[]) => {
const file = files[0] as UploadRawFile
file.uid = genFileId()
upload.value!.handleStart(file)
typeof state.events['onExceed'] == 'function' && state.events['onExceed'](file, files)
}
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 = () => {
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())
}
},
})
}
})
}
onMounted(() => {
if (props.type == 'image' || props.type == 'file') {
state.attr = { ...state.attr, limit: 1 }
} else {
state.attr = { ...state.attr, multiple: true }
}
if (props.type == 'image' || props.type == 'images') {
state.selectFile.type = 'image'
state.attr = { ...state.attr, accept: 'image/*', listType: 'picture-card' }
}
const addProps: anyObj = {}
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]
}
}
state.attr = { ...state.attr, ...addProps }
if (state.attr.limit) state.selectFile.limit = state.attr.limit
init(props.modelValue)
initSort()
})
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)
}
}
)
const limitExceed = () => {
if (state.attr.limit && state.fileList.length > state.attr.limit) {
state.fileList = state.fileList.slice(state.fileList.length - state.attr.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()
typeof state.events['onChange'] == 'function' && state.events['onChange'](file, files)
}
const getUploadRef = () => {
return upload.value
}
const showSelectFile = () => {
state.selectFile.show = true
}
defineExpose({
getUploadRef,
showSelectFile,
})
</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);
&: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 {
max-height: 70vh;
overflow: auto;
}
.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;
}
</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,296 @@
<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"
>
<transition name="el-zoom-in-center">
<div class="icon-selector-box">
<div class="selector-header">
<div class="selector-title">{{ title ? title : '请选择图标' }}</div>
<!-- <div class="selector-tab">
<span
:title="'Element Puls ' + 'utils.Icon'"
@click="onChangeTab('ele')"
:class="state.iconType == 'ele' ? 'active' : ''"
>ele</span
>
<span
:title="'Font Awesome ' + 'utils.Icon'"
@click="onChangeTab('awe')"
:class="state.iconType == 'awe' ? 'active' : ''"
>awe</span
>
<span :title="'utils.Ali iconcont Icon'" @click="onChangeTab('ali')" :class="state.iconType == 'ali' ? 'active' : ''"
>ali</span
>
<span
:title="'utils.Local icon title'"
@click="onChangeTab('local')"
:class="state.iconType == 'local' ? 'active' : ''"
>local</span
>
</div> -->
</div>
<div class="selector-body">
<el-scrollbar ref="selectorScrollbarRef">
<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>
</transition>
</div>
<template #reference>
<el-input
v-model="state.inputValue"
:size="size"
:disabled="disabled"
placeholder="搜索图标"
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 { reactive, ref, onMounted, nextTick, watch, computed } from 'vue'
import { getElementPlusIconfontNames } from '@/utils/iconfont'
import { useEventListener } from '@vueuse/core'
import type { Placement } from 'element-plus'
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 = ref()
const selectorScrollbarRef = ref()
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
}
)
onMounted(() => {
getInputWidth()
useEventListener(document, 'click', popoverVisible)
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,310 @@
<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')"
>
<template #reference>
<el-select
ref="selectRef"
class="w100"
@focus="onFocus"
@blur="onBlur"
:loading="state.loading || state.accidentBlur"
:filterable="true"
:remote="true"
clearable
remote-show-suffix
:remote-method="onLogKeyword"
v-model="state.value"
@change="onChangeSelect"
:multiple="multiple"
:key="state.selectKey"
@clear="onClear"
@visible-change="onVisibleChange"
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>
<el-pagination
v-if="state.total"
:currentPage="state.currentPage"
:page-size="state.pageSize"
class="select-pagination"
layout="->, prev, next"
:total="state.total"
@current-change="onSelectCurrentPageChange"
/>
</el-select>
</template>
</el-popover>
</div>
</template>
<script setup lang="ts">
import { reactive, watch, onMounted, onUnmounted, ref, nextTick, getCurrentInstance, toRaw } from 'vue'
import { getSelectData } from '@/api/common'
import { uuid } from '@/utils/random'
import type { ElSelect } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { getArrayKey } from '@/utils/common'
const selectRef = ref<InstanceType<typeof ElSelect> | undefined>()
type ElSelectProps = Partial<InstanceType<typeof ElSelect>['$props']>
type valType = string | number | string[] | number[]
interface Props extends /* @vue-ignore */ ElSelectProps {
pk?: string
field?: string
params?: anyObj
multiple?: boolean
remoteUrl: string
modelValue: valType
labelFormatter?: (optionData: anyObj, optionKey: string) => string
tooltipParams?: anyObj
}
const props = withDefaults(defineProps<Props>(), {
pk: 'id',
field: 'name',
params: () => {
return {}
},
remoteUrl: '',
modelValue: '',
multiple: false,
tooltipParams: () => {
return {}
},
})
const state: {
// 主表字段名(不带表别名)
primaryKey: string
options: anyObj[]
loading: boolean
total: number
currentPage: number
pageSize: number
params: anyObj
keyword: string
value: valType
selectKey: string
initializeData: boolean
accidentBlur: boolean
focusStatus: boolean
} = reactive({
primaryKey: props.pk,
options: [],
loading: false,
total: 0,
currentPage: 1,
pageSize: 10,
params: props.params,
keyword: '',
value: props.modelValue ? props.modelValue : '',
selectKey: uuid(),
initializeData: false,
accidentBlur: false,
focusStatus: false,
})
let io: null | IntersectionObserver = null
const instance = getCurrentInstance()
const emits = defineEmits<{
(e: 'update:modelValue', value: valType): void
(e: 'row', value: any): void
}>()
const onChangeSelect = (val: valType) => {
emits('update:modelValue', val)
if (typeof instance?.vnode.props?.onRow == 'function') {
let pkArr = props.pk.split('.')
let pk = pkArr[pkArr.length - 1]
if (typeof val == 'number' || typeof val == 'string') {
const dataKey = getArrayKey(state.options, pk, val.toString())
emits('row', dataKey ? toRaw(state.options[dataKey]) : {})
} else {
const valueArr = []
for (const key in val) {
let dataKey = getArrayKey(state.options, pk, val[key].toString())
if (dataKey) valueArr.push(toRaw(state.options[dataKey]))
}
emits('row', valueArr)
}
}
}
const onVisibleChange = (val: boolean) => {
// 保持面板状态和焦点状态一致
if (!val) {
nextTick(() => {
selectRef.value?.blur()
})
}
}
const onFocus = () => {
state.focusStatus = true
if (selectRef.value?.query != state.keyword) {
state.keyword = ''
state.initializeData = false
// el-select 自动清理搜索词会产生意外的脱焦
state.accidentBlur = true
}
if (!state.initializeData) {
getData()
}
}
const onBlur = () => {
state.focusStatus = false
}
const onClear = () => {
state.keyword = ''
state.initializeData = false
}
const onLogKeyword = (q: string) => {
if (state.keyword != q) {
state.keyword = q
getData()
}
}
const getData = (initValue: valType = '') => {
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 initializeData = true
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
if (initValue) {
// 重新渲染组件,确保在赋值前,opts已加载到-兼容 modelValue 更新
state.selectKey = uuid()
initializeData = false
}
state.loading = false
state.initializeData = initializeData
if (state.accidentBlur) {
nextTick(() => {
const inputEl = selectRef.value?.$el.querySelector('.el-select__tags .el-select__input')
inputEl && inputEl.focus()
state.accidentBlur = false
})
}
})
.catch(() => {
state.loading = false
})
}
const onSelectCurrentPageChange = (val: number) => {
state.currentPage = val
getData()
}
const initDefaultValue = () => {
if (state.value) {
// number[]转string[]确保默认值能够选中
if (typeof state.value === 'object') {
for (const key in state.value as string[]) {
state.value[key] = state.value[key].toString()
}
} else if (typeof state.value === 'number') {
state.value = state.value.toString()
}
getData(state.value)
}
}
onMounted(() => {
if (props.pk.indexOf('.') > 0) {
let pk = props.pk.split('.')
state.primaryKey = pk[1] ? pk[1] : pk[0]
}
initDefaultValue()
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) => {
if (String(state.value) != String(newVal)) {
state.value = newVal ? newVal : ''
initDefaultValue()
}
}
)
const getSelectRef = () => {
return selectRef.value
}
const focus = () => {
selectRef.value?.focus()
}
const blur = () => {
selectRef.value?.blur()
}
defineExpose({
blur,
focus,
getSelectRef,
})
</script>
<style scoped lang="scss">
:deep(.remote-select-popper) {
text-align: center;
}
.remote-select-option {
white-space: pre;
}
</style>

View File

@@ -0,0 +1,244 @@
<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 { ref, reactive, onMounted, provide, watch, nextTick } 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 tableRef = ref()
const { t } = useI18n()
const state = reactive({
ready: false,
tableSelectable: true,
})
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/'), {
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',
renderFormatter: 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 getIndex = () => {
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.getIndex()?.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(() => {
getIndex()
})
}
}
)
</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,200 @@
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: 200,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
password: {
type: 'varchar',
length: 32,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
number: {
type: 'int',
length: 10,
precision: 0,
default: '0',
...npuaFalse(),
},
radio: {
type: 'enum',
length: 0,
precision: 0,
default: '',
...npuaFalse(),
},
checkbox: {
type: 'set',
length: 0,
precision: 0,
default: '',
...npuaFalse(),
},
switch: {
type: 'tinyint',
length: 1,
precision: 0,
default: '1',
...npuaFalse(),
unsigned: true,
},
textarea: {
type: 'varchar',
length: 255,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
array: {
type: 'varchar',
length: 255,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
datetime: {
type: 'bigint',
length: 16,
precision: 0,
default: 'null',
...npuaFalse(),
null: true,
unsigned: true,
},
year: {
type: 'year',
length: 4,
precision: 0,
default: 'null',
...npuaFalse(),
null: true,
},
date: {
type: 'date',
length: 0,
precision: 0,
default: 'null',
...npuaFalse(),
null: true,
},
time: {
type: 'time',
length: 0,
precision: 0,
default: 'null',
...npuaFalse(),
null: true,
},
select: {
type: 'enum',
length: 0,
precision: 0,
default: '',
...npuaFalse(),
},
selects: {
type: 'varchar',
length: 100,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
remoteSelect: {
type: 'int',
length: 10,
precision: 0,
default: '0',
...npuaFalse(),
unsigned: true,
},
remoteSelects: {
type: 'varchar',
length: 100,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
editor: {
type: 'text',
length: 0,
precision: 0,
default: 'empty string',
...npuaFalse(),
null: true,
},
city: {
type: 'varchar',
length: 100,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
image: {
type: 'varchar',
length: 200,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
images: {
type: 'varchar',
length: 255,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
file: {
type: 'varchar',
length: 200,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
files: {
type: 'varchar',
length: 255,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
icon: {
type: 'varchar',
length: 50,
precision: 0,
default: 'empty string',
...npuaFalse(),
},
color: {
type: 'varchar',
length: 30,
precision: 0,
default: '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,204 @@
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 {
// 标题
title?: string
// 内容,比如radio的选项列表数据 content: { a: '选项1', b: '选项2' }
content?: any
// 提示信息
tip?: string
// 需要生成子级元素时,子级元素属性(比如radio)
childrenAttr?: anyObj
// 城市选择器等级,1=省,2=市,3=区
level?: number
}
/**
* input可用属性,用于代码提示,渲染不同输入组件时,需要的属性是不一样的
* https://element-plus.org/zh-CN/component/input.html#input-属性
*/
export interface InputAttr {
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
// 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
// 图标选择器属性
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
// 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
// 事件
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 // 默认值
null: boolean // 允许 null
primaryKey: boolean // 主键
unsigned: boolean // 无符号
autoIncrement: boolean // 自动递增
}
}

View File

@@ -0,0 +1,430 @@
<script lang="ts">
import type { PropType, VNode } from 'vue'
import type { modelValueTypes, InputAttr, InputData } from '@/components/baInput'
import { createVNode, resolveComponent, defineComponent, computed, reactive } from 'vue'
import { inputTypes } from '@/components/baInput'
import Array from '@/components/baInput/components/array.vue'
import RemoteSelect from '@/components/baInput/components/remoteSelect.vue'
import IconSelector from '@/components/baInput/components/iconSelector.vue'
import Editor from '@/components/baInput/components/editor.vue'
import BaUpload from '@/components/baInput/components/baUpload.vue'
import { getArea } from '@/api/common'
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 }) {
const onValueUpdate = (value: modelValueTypes) => {
emit('update:modelValue', value)
}
// 子级元素属性
let childrenAttr = props.data && props.data.childrenAttr ? props.data.childrenAttr : {}
// string number textarea password
const sntp = () => {
return () =>
createVNode(resolveComponent('el-input'), {
type: props.type == 'string' ? 'text' : props.type,
...props.attr,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
})
}
// radio checkbox
const rc = () => {
if (!props.data || !props.data.content) {
console.warn('请传递 ' + props.type + '的 content')
}
let vNode: VNode[] = []
for (const key in props.data.content) {
vNode.push(
createVNode(
resolveComponent('el-' + props.type),
{
label: key,
...childrenAttr,
},
() => props.data.content[key]
)
)
}
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'),
{
...props.attr,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
},
() => vNode
)
}
}
// select selects
const select = () => {
let vNode: VNode[] = []
if (!props.data || !props.data.content) {
console.warn('请传递 ' + props.type + '的 content')
}
for (const key in props.data.content) {
vNode.push(
createVNode(resolveComponent('el-option'), {
key: key,
label: props.data.content[key],
value: key,
...childrenAttr,
})
)
}
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,
...props.attr,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
},
() => vNode
)
}
}
// 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,
...props.attr,
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
})
}
// upload
const upload = () => {
return () =>
createVNode(BaUpload, {
type: props.type,
data: props.attr ? props.attr.data : {},
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
returnFullUrl: props.attr ? props.attr.returnFullUrl || props.attr['return-full-url'] : false,
hideSelectFile: props.attr ? props.attr.hideSelectFile || props.attr['hide-select-file'] : false,
attr: props.attr,
forceLocal: props.attr ? props.attr.forceLocal || props.attr['force-local'] : false,
})
}
// remoteSelect remoteSelects
const remoteSelect = () => {
return () =>
createVNode(RemoteSelect, {
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
multiple: props.type == 'remoteSelect' ? false : true,
...props.attr,
})
}
const buildFun = new Map([
['string', sntp],
['number', sntp],
['textarea', sntp],
['password', sntp],
['radio', rc],
['checkbox', rc],
[
'switch',
() => {
const valueType = computed(() => typeof props.modelValue)
const valueComputed = computed(() => {
if (valueType.value === 'boolean') {
return props.modelValue
} else {
let valueTmp = parseInt(props.modelValue as string)
return isNaN(valueTmp) || valueTmp <= 0 ? false : true
}
})
return () =>
createVNode(resolveComponent('el-switch'), {
...props.attr,
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)
},
})
},
],
['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',
...props.attr,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
})
}
},
],
['date', datetime],
[
'time',
() => {
const valueComputed = computed(() => {
if (props.modelValue instanceof Date) {
return props.modelValue
} else if (!props.modelValue) {
return ''
} else {
let date = new Date()
return new Date(date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate() + ' ' + props.modelValue)
}
})
return () =>
createVNode(resolveComponent('el-time-picker'), {
class: 'w100',
clearable: true,
format: 'HH:mm:ss',
...props.attr,
modelValue: valueComputed.value,
'onUpdate:modelValue': onValueUpdate,
})
},
],
['select', select],
['selects', select],
[
'array',
() => {
return () =>
createVNode(Array, {
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...props.attr,
})
},
],
['remoteSelect', remoteSelect],
['remoteSelects', remoteSelect],
[
'city',
() => {
type Node = { value?: number; label?: string; leaf?: boolean }
let maxLevel = props.data && props.data.level ? props.data.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,
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)
})
},
},
...props.attr,
})
},
],
['image', upload],
['images', upload],
['file', upload],
['files', upload],
[
'icon',
() => {
return () =>
createVNode(IconSelector, {
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...props.attr,
})
},
],
[
'color',
() => {
return () =>
createVNode(resolveComponent('el-color-picker'), {
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...props.attr,
})
},
],
[
'editor',
() => {
return () =>
createVNode(Editor, {
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
...props.attr,
})
},
],
[
'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>

View File

@@ -0,0 +1,108 @@
<template>
<transition name="el-zoom-in-center">
<div
class="el-popper is-pure is-light el-dropdown__popper ba-contextmenu"
:style="`top: ${state.axis.y + 5}px;left: ${state.axis.x - 14}px;width:${props.width}px`"
:key="Math.random()"
v-show="state.show"
aria-hidden="false"
data-popper-placement="bottom"
>
<ul class="el-dropdown-menu">
<template v-for="(item, idx) in props.items" :key="idx">
<li class="el-dropdown-menu__item" :class="item.disabled ? 'is-disabled' : ''" tabindex="-1" @click="onContextmenuItem(item)">
<Icon size="12" :name="item.icon" />
<span>{{ item.label }}</span>
</li>
</template>
</ul>
<span class="el-popper__arrow" :style="{ left: `${state.arrowAxis}px` }"></span>
</div>
</transition>
</template>
<script setup lang="ts">
import { onMounted, reactive, toRaw } from 'vue'
import type { Axis, ContextmenuItemClickEmitArg, Props } from './interface'
import type { RouteLocationNormalized } from 'vue-router'
import { useEventListener } from '@vueuse/core'
const props = withDefaults(defineProps<Props>(), {
width: 150,
items: () => [],
})
const emits = defineEmits<{
(e: 'contextmenuItemClick', item: ContextmenuItemClickEmitArg): void
}>()
const state: {
show: boolean
axis: {
x: number
y: number
}
menu: RouteLocationNormalized | undefined
arrowAxis: number
} = reactive({
show: false,
axis: {
x: 0,
y: 0,
},
menu: undefined,
arrowAxis: 10,
})
const onShowContextmenu = (menu: RouteLocationNormalized, axis: Axis) => {
state.menu = menu
state.axis = axis
state.show = true
}
const onContextmenuItem = (item: ContextmenuItemClickEmitArg) => {
if (item.disabled) return
item.menu = toRaw(state.menu)
emits('contextmenuItemClick', item)
}
const onHideContextmenu = () => {
state.show = false
}
defineExpose({
onShowContextmenu,
onHideContextmenu,
})
onMounted(() => {
useEventListener(document, 'click', onHideContextmenu)
})
</script>
<style scoped lang="scss">
.ba-contextmenu {
z-index: 9999;
}
.el-popper,
.el-popper.is-light .el-popper__arrow::before {
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border: none;
}
.el-dropdown-menu__item {
padding: 8px 20px;
user-select: none;
}
.el-dropdown-menu__item .icon {
margin-right: 5px;
}
.el-dropdown-menu__item:not(.is-disabled) {
&:hover {
background-color: var(--el-dropdown-menuItem-hover-fill);
color: var(--el-dropdown-menuItem-hover-color);
.fa {
color: var(--el-dropdown-menuItem-hover-color) !important;
}
}
}
</style>

View File

@@ -0,0 +1,22 @@
import type { RouteLocationNormalized } from 'vue-router'
export interface Axis {
x: number
y: number
}
export interface ContextMenuItem {
name: string
label: string
icon?: string
disabled?: boolean
}
export interface ContextmenuItemClickEmitArg extends ContextMenuItem {
menu?: RouteLocationNormalized
}
export interface Props {
width?: number
items: ContextMenuItem[]
}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { createVNode, resolveComponent, defineComponent, computed, type CSSProperties } from 'vue'
import svg from '@/components/icon/svg/index.vue'
import { isExternal } from '@/utils/common'
export default defineComponent({
name: 'Icon',
props: {
name: {
type: String,
required: true
},
size: {
type: String,
default: '18px'
},
color: {
type: String,
default: '#000000'
}
},
setup(props) {
const iconStyle = computed((): CSSProperties => {
const { size, color } = props
let s = `${size.replace('px', '')}px`
return {
fontSize: s,
color: color
}
})
if (props.name.indexOf('el-icon-') === 0) {
return () =>
createVNode('el-icon', { class: 'icon el-icon', style: iconStyle.value }, [
createVNode(resolveComponent(props.name))
])
} else if (props.name.indexOf('local-') === 0 || isExternal(props.name)) {
return () => createVNode(svg, { name: props.name, size: props.size, color: props.color })
} else {
return () => createVNode('i', { class: [props.name, 'icon'], style: iconStyle.value })
}
}
})
</script>

View File

@@ -0,0 +1,71 @@
import { readFileSync, readdirSync } from 'fs'
let idPerfix = ''
const iconNames: string[] = []
const svgTitle = /<svg([^>+].*?)>/
const clearHeightWidth = /(width|height)="([^>+].*?)"/g
const hasViewBox = /(viewBox="[^>+].*?")/g
const clearReturn = /(\r)|(\n)/g
// 清理 svg 的 fill
const clearFill = /(fill="[^>+].*?")/g
function findSvgFile(dir: string): string[] {
const svgRes = []
const dirents = readdirSync(dir, {
withFileTypes: true,
})
for (const dirent of dirents) {
iconNames.push(`${idPerfix}-${dirent.name.replace('.svg', '')}`)
if (dirent.isDirectory()) {
svgRes.push(...findSvgFile(dir + dirent.name + '/'))
} else {
const svg = readFileSync(dir + dirent.name)
.toString()
.replace(clearReturn, '')
.replace(clearFill, 'fill=""')
.replace(svgTitle, ($1, $2) => {
let width = 0
let height = 0
let content = $2.replace(clearHeightWidth, (s1: string, s2: string, s3: number) => {
if (s2 === 'width') {
width = s3
} else if (s2 === 'height') {
height = s3
}
return ''
})
if (!hasViewBox.test($2)) {
content += `viewBox="0 0 ${width} ${height}"`
}
return `<symbol id="${idPerfix}-${dirent.name.replace('.svg', '')}" ${content}>`
})
.replace('</svg>', '</symbol>')
svgRes.push(svg)
}
}
return svgRes
}
export const svgBuilder = (path: string, perfix = 'local') => {
if (path === '') return
idPerfix = perfix
const res = findSvgFile(path)
return {
name: 'svg-transform',
transformIndexHtml(html: string) {
/* eslint-disable */
return html.replace(
'<body>',
`
<body>
<svg id="local-icon" data-icon-name="${iconNames.join(
','
)}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">
${res.join('')}
</svg>
`
)
/* eslint-enable */
},
}
}

View File

@@ -0,0 +1,49 @@
<template>
<div v-if="isUrl" :style="urlIconStyle" class="url-svg svg-icon icon" />
<svg v-else class="svg-icon icon" :style="iconStyle">
<use :href="iconName" />
</svg>
</template>
<script setup lang="ts">
import { computed, type CSSProperties } from 'vue'
import { isExternal } from '@/utils/common'
interface Props {
name: string
size: string
color: string
}
const props = withDefaults(defineProps<Props>(), {
name: '',
size: '18px',
color: '#000000',
})
const s = `${props.size.replace('px', '')}px`
const iconName = computed(() => `#${props.name}`)
const iconStyle = computed((): CSSProperties => {
return {
color: props.color,
fontSize: s,
}
})
const isUrl = computed(() => isExternal(props.name))
const urlIconStyle = computed(() => {
return {
width: s,
height: s,
mask: `url(${props.name}) no-repeat 50% 50%`,
'-webkit-mask': `url(${props.name}) no-repeat 50% 50%`,
}
})
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
fill: currentColor;
overflow: hidden;
}
</style>