项目初始化

This commit is contained in:
2026-03-18 15:54:43 +08:00
commit dfcd762e23
601 changed files with 57883 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>

View File

@@ -0,0 +1,47 @@
import { createVNode, render } from 'vue'
import ClickCaptchaConstructor from './index.vue'
import { shortUuid } from '/@/utils/random'
interface ClickCaptchaOptions {
// 验证码弹窗的自定义class
class?: string
// 前端验证成功时立即清理验证码数据,不可再进行二次验证,不开启则 600s 后自动清理数据
unset?: boolean
// 验证失败的提示信息
error?: string
// 验证成功的提示信息
success?: string
// 验证码 API 的基础 URL默认为当前服务端 URLVITE_AXIOS_BASE_URL
apiBaseURL?: string
}
/**
* 弹出点击验证码
* @param uuid 开发者自定义的唯一标识
* @param callback 验证成功的回调,业务接口可通过 captchaInfo 进行二次验证
* @param options
*/
const clickCaptcha = (uuid: string, callback?: (captchaInfo: string) => void, options: ClickCaptchaOptions = {}) => {
const container = document.createElement('div')
const vnode = createVNode(ClickCaptchaConstructor, {
uuid,
callback,
...options,
key: shortUuid(),
onDestroy: () => {
render(null, container)
},
})
render(vnode, container)
document.body.appendChild(container.firstElementChild!)
}
/**
* 组件的 props 类型定义
*/
export interface Props extends ClickCaptchaOptions {
uuid: string
callback?: (captchaInfo: string) => void
}
export default clickCaptcha

View File

@@ -0,0 +1,221 @@
<template>
<div :id="uuid">
<div class="ba-click-captcha" :class="props.class">
<div v-if="state.loading" class="loading">{{ i18n.global.t('utils.Loading') }}</div>
<div v-else class="captcha-img-box">
<img
class="captcha-img"
@click.prevent="onRecord($event)"
:src="state.captcha.base64"
:alt="i18n.global.t('validate.Captcha loading failed, please click refresh button')"
/>
<span
v-for="(item, index) in state.xy"
:key="index"
class="step"
@click="onCancelRecord(index)"
:style="`left:${parseFloat(item.split(',')[0]) - 13}px;top:${parseFloat(item.split(',')[1]) - 13}px`"
>
{{ index + 1 }}
</span>
</div>
<div class="captcha-prompt" v-if="state.tip">
{{ state.tip }}
</div>
<div v-else class="captcha-prompt">
{{ i18n.global.t('validate.Please click') }}
<span v-for="(text, index) in state.captcha.text" :key="index" :class="state.xy.length > index ? 'clicaptcha-clicked' : ''">
{{ text }}
</span>
</div>
<div class="captcha-refresh-box">
<div class="captcha-refresh-line captcha-refresh-line-l"></div>
<i class="fa fa-refresh captcha-refresh-btn" :title="i18n.global.t('Refresh')" @click="load"></i>
<div class="captcha-refresh-line captcha-refresh-line-r"></div>
</div>
</div>
<div class="ba-layout-shade" @click="onClose"></div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive } from 'vue'
import { Props } from './index'
import { checkClickCaptcha, getCaptchaData } from '/@/api/common'
import { i18n } from '/@/lang'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
const props = withDefaults(defineProps<Props>(), {
uuid: '',
callback: () => {},
class: '',
unset: false,
error: i18n.global.t('validate.The correct area is not clicked, please try again!'),
success: i18n.global.t('validate.Verification is successful!'),
apiBaseURL: '',
})
const state: {
loading: boolean
xy: string[]
tip: string
captcha: {
id: string
text: string
base64: string
width: number
height: number
}
} = reactive({
loading: true,
xy: [],
tip: '',
captcha: {
id: '',
text: '',
base64: '',
width: 350,
height: 200,
},
})
const emits = defineEmits<{
(e: 'destroy'): void
}>()
const load = () => {
state.loading = true
getCaptchaData(props.uuid, props.apiBaseURL).then((res) => {
state.xy = []
state.tip = ''
state.loading = false
state.captcha = res.data
})
}
const onRecord = (event: MouseEvent) => {
if (state.xy.length < state.captcha.text.length) {
state.xy.push(event.offsetX + ',' + event.offsetY)
if (state.xy.length == state.captcha.text.length) {
const captchaInfo = [state.xy.join('-'), (event.target as HTMLImageElement).width, (event.target as HTMLImageElement).height].join(';')
checkClickCaptcha(props.uuid, captchaInfo, props.unset, props.apiBaseURL)
.then(() => {
state.tip = props.success
setTimeout(() => {
props.callback?.(captchaInfo)
onClose()
}, 1500)
})
.catch(() => {
state.tip = props.error
setTimeout(() => {
load()
}, 1500)
})
}
}
}
const onCancelRecord = (index: number) => {
state.xy.splice(index, 1)
}
const onClose = () => {
emits('destroy')
}
const captchaBoxTop = computed(() => (state.captcha.height + 200) / 2 + 'px')
const captchaBoxLeft = computed(() => (state.captcha.width + 24) / 2 + 'px')
load()
</script>
<style scoped lang="scss">
.ba-click-captcha {
padding: 12px;
border: 1px solid var(--el-border-color-extra-light);
background-color: var(--el-color-white);
position: fixed;
z-index: v-bind('SYSTEM_ZINDEX');
left: calc(50% - v-bind('captchaBoxLeft'));
top: calc(50% - v-bind('captchaBoxTop'));
border-radius: 10px;
box-shadow:
0 0 0 1px hsla(0, 0%, 100%, 0.3) inset,
0 0.5em 1em rgba(0, 0, 0, 0.6);
.loading {
color: var(--el-color-info);
width: 350px;
text-align: center;
line-height: 200px;
}
.captcha-img-box {
position: relative;
.captcha-img {
width: v-bind('state.captcha.width') px;
height: v-bind('state.captcha.height') px;
border: none;
cursor: pointer;
}
.step {
box-sizing: border-box;
position: absolute;
width: 20px;
height: 20px;
line-height: 20px;
font-size: var(--el-font-size-small);
font-weight: bold;
text-align: center;
color: var(--el-color-white);
border: 1px solid var(--el-border-color-extra-light);
background-color: var(--el-color-primary);
border-radius: 30px;
box-shadow: 0 0 10px var(--el-color-white);
user-select: none;
cursor: pointer;
}
}
.captcha-prompt {
height: 40px;
line-height: 40px;
font-size: var(--el-font-size-base);
text-align: center;
color: var(--el-color-info);
span {
margin-left: 10px;
font-size: var(--el-font-size-medium);
font-weight: bold;
color: var(--el-color-error);
&.clicaptcha-clicked {
color: var(--el-color-primary);
}
}
}
.captcha-refresh-box {
position: relative;
margin-top: 10px;
.captcha-refresh-line {
position: absolute;
top: 16px;
width: 140px;
height: 1px;
background-color: #ccc;
}
.captcha-refresh-line-l {
left: 5px;
}
.captcha-refresh-line-r {
right: 5px;
}
.captcha-refresh-btn {
cursor: pointer;
display: block;
margin: 0 auto;
width: 32px;
height: 32px;
font-size: 32px;
color: var(--el-color-info);
}
}
}
</style>

View File

@@ -0,0 +1,154 @@
<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="onMenuItemClick(item)">
<Icon size="12" :name="item.icon" />
<span>{{ item.label }}</span>
</li>
</template>
</ul>
<span v-if="state.showArrow" class="el-popper__arrow" :style="{ left: `${state.arrowAxis}px` }"></span>
</div>
</transition>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { reactive, toRaw } from 'vue'
import type { Axis, ContextMenuItemClickEmitArg, Props } from './interface'
import { SYSTEM_ZINDEX } from '/@/stores/constant/common'
const props = withDefaults(defineProps<Props>(), {
width: 150,
items: () => [],
})
const emits = defineEmits<{
// 菜单项被点击
(e: 'menuClick', item: ContextMenuItemClickEmitArg): void
// 右击菜单隐藏回调,它可能在组件内部被触发,所以提供 emit
(e: 'hideContextmenu'): void
}>()
const state: {
show: boolean
axis: {
x: number
y: number
}
sourceData: any
showArrow: boolean
arrowAxis: number
} = reactive({
show: false,
axis: {
x: 0,
y: 0,
},
sourceData: null,
showArrow: true,
arrowAxis: 10,
})
/**
* 删除事件监听的函数
*/
const removeEventListenerFn: Record<string, () => void> = {
click: () => {},
scroll: () => {},
keydown: () => {},
}
/**
* 显示右击菜单
* @param sourceData 来源数据,开发者可于右击菜单项被点击的事件中访问到它
* @param axis 右击坐标信息
*/
const onShowContextmenu = (sourceData: any, axis: Axis) => {
state.showArrow = true
state.sourceData = sourceData
const yOffset = document.documentElement.clientHeight - axis.y - (props.items.length * 40 + 20)
const xOffset = document.documentElement.clientWidth - axis.x - (props.width + 20)
if (yOffset < 0) {
axis.y += yOffset
state.showArrow = false
}
if (xOffset < 0) {
axis.x += xOffset
state.showArrow = false
}
state.axis = axis
state.show = true
removeEventListenerFn.click = useEventListener(document, 'click', onHideContextmenu)
removeEventListenerFn.scroll = useEventListener(document, 'scroll', onHideContextmenu)
removeEventListenerFn.keydown = useEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape') {
onHideContextmenu()
}
})
}
/**
* 隐藏右击菜单
*/
const onHideContextmenu = () => {
state.show = false
for (const key in removeEventListenerFn) {
removeEventListenerFn[key]()
}
emits('hideContextmenu')
}
const onMenuItemClick = (item: ContextMenuItemClickEmitArg) => {
if (item.disabled) return
item.sourceData = toRaw(state.sourceData)
emits('menuClick', item)
}
defineExpose({
onShowContextmenu,
onHideContextmenu,
})
</script>
<style scoped lang="scss">
.ba-contextmenu {
position: fixed;
z-index: v-bind('SYSTEM_ZINDEX');
}
.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,20 @@
export interface Axis {
x: number
y: number
}
export interface ContextMenuItem {
name: string
label: string
icon?: string
disabled?: boolean
}
export interface ContextMenuItemClickEmitArg<T = any> extends ContextMenuItem {
sourceData?: T
}
export interface Props {
width?: number
items: ContextMenuItem[]
}

View File

@@ -0,0 +1,304 @@
<!-- 一个用于创建 FormItem 数据的组件 -->
<!-- 使用场景举例系统配置->添加配置项 -->
<template>
<div>
<!-- 本组件不被 el-form 包含方便您在其他 el-form 的任意位置使用且没有带了一个 el-form 时会出现的负担 -->
<!-- formitem 已经设置了 prop 属性以便外部 el-form 添加表单验证规则 -->
<FormItem
v-if="form.name.show"
:label="form.name.title"
type="string"
v-model="form.name.value"
:placeholder="t('Please input field', { field: form.name.title })"
:input-attr="{
onChange: updateValue,
...props.options?.name?.inputAttr,
}"
prop="name"
/>
<FormItem
v-if="form.title.show"
:label="form.title.title"
type="string"
v-model="form.title.value"
:placeholder="t('Please input field', { field: form.title.title })"
:input-attr="{
onChange: updateValue,
...props.options?.title?.inputAttr,
}"
prop="title"
/>
<FormItem
v-if="form.type.show"
:label="form.type.title"
type="select"
v-model="form.type.value"
:placeholder="t('Please select field', { field: form.type.title })"
:input-attr="{
onChange: updateValue,
content: state.inputTypes,
...props.options?.type?.inputAttr,
}"
prop="type"
/>
<FormItem
v-if="form.dict.show && dictExistsType.includes(form.type.value!)"
:label="form.dict.title"
type="textarea"
v-model="form.dict.value"
:input-attr="{
rows: 3,
placeholder: t('utils.One line at a time, without quotation marks, for example: key1=value1'),
onChange: updateValue,
...props.options?.dict?.inputAttr,
}"
prop="dict"
@keyup.enter.stop=""
/>
<FormItem
v-if="form.tip.show"
:label="form.tip.title"
type="string"
v-model="form.tip.value"
:placeholder="t('Please input field', { field: form.tip.title })"
:input-attr="{
onChange: updateValue,
...props.options?.tip?.inputAttr,
}"
prop="tip"
/>
<FormItem
v-if="form.rule.show"
:label="form.rule.title"
type="selects"
v-model="form.rule.value"
:placeholder="t('Please select field', { field: form.rule.title })"
:input-attr="{
onChange: updateValue,
content: state.validators,
...props.options?.rule?.inputAttr,
}"
prop="rule"
/>
<FormItem
v-if="form.extend.show"
:label="form.extend.title"
type="textarea"
v-model="form.extend.value"
:input-attr="{
onChange: updateValue,
placeholder: t('utils.One attribute per line without quotation marks(formitem)'),
...props.options?.extend?.inputAttr,
}"
prop="extend"
@keyup.enter.stop=""
/>
<FormItem
v-if="form.inputExtend.show"
:label="form.inputExtend.title"
type="textarea"
v-model="form.inputExtend.value"
:input-attr="{
onChange: updateValue,
placeholder: t('utils.Extended properties of Input, one line without quotation marks, such as: size=large'),
...props.options?.inputExtend?.inputAttr,
}"
prop="inputExtend"
@keyup.enter.stop=""
/>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import FormItem from '/@/components/formItem/index.vue'
import { inputTypes } from '/@/components/baInput'
import { validatorType } from '/@/utils/validate'
import type { InputAttr } from '/@/components/baInput'
import { i18n } from '/@/lang'
const { t } = i18n.global
type OptionItem = {
// 是否显示(被创建的数据是否需要这一项)
show?: boolean
// 被创建数据的标题默认使用props.dataTitle + 本 title自定义此 title 后,单使用此 title
title?: string
// 输入框额外属性
inputAttr?: InputAttr
}
type ValidatesOptionItem = Omit<OptionItem, 'value'> & {
value?: string[]
}
interface Props {
// 被创建数据的标题,作为所有表单项的前缀(默认值)
dataTitle?: string
// 默认值
modelValue: {
name?: string
title?: string
type?: string
tip?: string
rule?: string[]
extend?: string
dict?: string
inputExtend?: string
}
// 表单项配置
options?: {
// 变量名
name?: OptionItem
// 标题
title?: OptionItem
// 类型
type?: OptionItem
// 提示信息
tip?: OptionItem
// 验证规则
rule?: ValidatesOptionItem
// FormItem 的扩展属性
extend?: OptionItem
// 字典数据(单选、复选等类型的字典)
dict?: OptionItem
// Input 的扩展属性
inputExtend?: OptionItem
}
excludeInputTypes?: string[]
excludeValidatorRule?: string[]
}
const props = withDefaults(defineProps<Props>(), {
dataTitle: i18n.global.t('utils.Var'),
modelValue: () => {
return {
name: '',
title: '',
type: '',
tip: '',
rule: [],
extend: '',
dict: '',
inputExtend: '',
}
},
name: () => {
return {}
},
title: () => {
return {}
},
type: () => {
return {}
},
tip: () => {
return {}
},
rule: () => {
return {}
},
extend: () => {
return {}
},
dict: () => {
return {}
},
inputExtend: () => {
return {}
},
excludeInputTypes: () => [],
excludeValidatorRule: () => [],
})
const dictExistsType = ['radio', 'checkbox', 'select', 'selects']
const form = reactive({
name: {
show: props.options?.name?.show === false ? false : true,
value: props.modelValue.name,
title: props.options?.name?.title ?? props.dataTitle + t('utils.Name'), // 变量名
},
title: {
show: props.options?.title?.show === false ? false : true,
value: props.modelValue.title,
title: props.options?.title?.title ?? props.dataTitle + t('utils.Title'), // 变量标题
},
type: {
show: props.options?.type?.show === false ? false : true,
value: props.modelValue.type,
title: props.options?.type?.title ?? props.dataTitle + t('utils.type'), // 变量类型
},
tip: {
show: props.options?.tip?.show === false ? false : true,
value: props.modelValue.tip,
title: props.options?.tip?.title ?? t('utils.Tip'), // 提示信息
},
rule: {
show: props.options?.rule?.show === false ? false : true,
value: props.modelValue.rule,
title: props.options?.rule?.title ?? t('utils.Rule'), // 验证规则
},
extend: {
show: props.options?.extend?.show === false ? false : true,
value: props.modelValue.extend,
title: props.options?.extend?.title ?? 'FormItem ' + t('utils.Extend'), // FormItem 扩展属性
},
dict: {
show: props.options?.dict?.show === false ? false : true,
value: props.modelValue.dict,
title: props.options?.dict?.title ?? t('utils.Dict'), // 字典数据
},
inputExtend: {
show: props.options?.inputExtend?.show === false ? false : true,
value: props.modelValue.inputExtend,
title: props.options?.inputExtend?.title ?? 'Input ' + t('utils.Extend'), // Input 扩展属性
},
})
const state = reactive({
validators: {},
inputTypes: {},
})
const emits = defineEmits<{
(e: 'update:modelValue', value: Props['modelValue']): void
}>()
const updateValue = () => {
emits('update:modelValue', {
name: form.name.value ?? '',
title: form.title.value ?? '',
type: form.type.value ?? '',
tip: form.tip.value ?? '',
rule: form.rule.value ?? [],
extend: form.extend.value ?? '',
dict: dictExistsType.includes(form.type.value ?? '') ? (form.dict.value ?? '') : '',
inputExtend: form.inputExtend.value ?? '',
})
}
const dataPretreatment = () => {
let inputTypesKey: anyObj = {}
for (const key in inputTypes) {
if (!props.excludeInputTypes.includes(inputTypes[key])) {
inputTypesKey[inputTypes[key]] = inputTypes[key]
}
}
state.inputTypes = inputTypesKey
let validators: anyObj = {}
for (const key in validatorType) {
if (!props.excludeValidatorRule.includes(key)) {
validators[key] = validatorType[key as keyof typeof validatorType]
}
}
state.validators = validators
updateValue()
}
dataPretreatment()
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,13 @@
import type { CSSProperties } from 'vue'
import type { FormItemProps, ElTooltipProps } from 'element-plus'
export interface FormItemAttr extends Partial<Writeable<FormItemProps>> {
// 通用属性名称的键入提示
id?: string
class?: string
style?: CSSProperties
// 块级输入帮助信息
blockHelp?: string
// 输入提示信息(使用 el-tooltip 渲染)
tip?: string | Partial<ElTooltipProps>
}

View File

@@ -0,0 +1,163 @@
<script lang="ts">
import { formItemProps } from 'element-plus'
import type { PropType, VNode } from 'vue'
import { computed, createVNode, defineComponent, resolveComponent } from 'vue'
import type { InputAttr, InputData, ModelValueTypes } from '/@/components/baInput'
import { inputTypes } from '/@/components/baInput'
import BaInput from '/@/components/baInput/index.vue'
import type { FormItemAttr } from '/@/components/formItem'
export default defineComponent({
name: 'formItem',
props: {
// 输入框类型,支持的输入框见 inputTypes
type: {
type: String,
required: true,
validator: (value: string) => {
return inputTypes.includes(value)
},
},
// 双向绑定值
modelValue: {
required: true,
},
// 输入框的附加属性
inputAttr: {
type: Object as PropType<InputAttr>,
default: () => {},
},
blockHelp: {
type: String,
default: '',
},
tip: [String, Object],
// el-form-item 的附加属性(还可以直接通过当前组件的 props 传递)
attr: {
type: Object as PropType<FormItemAttr>,
default: () => {},
},
// 额外数据(已和 props.inputAttr 合并,还可以通过它进行传递)
data: {
type: Object as PropType<InputData>,
default: () => {},
},
// 内部输入框的 placeholder相当于 props.inputAttr.placeholder 的别名)
placeholder: {
type: String,
default: '',
},
...formItemProps,
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
// 通过 props 和 props.attr 两种方式传递的属性汇总为 attrs
const excludeProps = ['type', 'modelValue', 'inputAttr', 'attr', 'data', 'placeholder']
const attrs = computed(() => {
const newAttrs = props.attr || {}
for (const key in props) {
const propValue: any = props[key as keyof typeof props]
if (!excludeProps.includes(key) && (propValue || propValue === false)) {
newAttrs[key as keyof typeof props.attr] = propValue
}
}
return newAttrs
})
const onValueUpdate = (value: ModelValueTypes) => {
emit('update:modelValue', value)
}
// el-form-item 的插槽
const formItemSlots: { [key: string]: () => VNode | VNode[] } = {}
// default 插槽
formItemSlots.default = () => {
let inputNode = createVNode(
BaInput,
{
type: props.type,
attr: { placeholder: props.placeholder, ...props.inputAttr, ...props.data },
modelValue: props.modelValue,
'onUpdate:modelValue': onValueUpdate,
},
slots
)
if (attrs.value.blockHelp) {
return [
inputNode,
createVNode(
'div',
{
class: 'block-help',
},
attrs.value.blockHelp
),
]
}
return inputNode
}
if (attrs.value.tip) {
const createTipNode = () => {
const tipProps = typeof attrs.value.tip === 'string' ? { content: attrs.value.tip, placement: 'top' } : attrs.value.tip
return createVNode(resolveComponent('el-tooltip'), tipProps, {
default: () => [
createVNode('i', {
class: 'fa fal fa-question-circle',
}),
],
})
}
// label 插槽
formItemSlots.label = () => {
return createVNode(
'span',
{
class: 'ba-form-item-label',
},
[
createVNode('span', null, attrs.value.label),
createVNode(
'span',
{
class: 'ba-form-item-label-tip',
},
[createTipNode()]
),
]
)
}
}
return () =>
createVNode(
resolveComponent('el-form-item'),
{
class: 'ba-input-item-' + props.type,
...attrs.value,
},
formItemSlots
)
},
})
</script>
<style scoped lang="scss">
.ba-form-item-label-tip {
padding-left: 6px;
color: var(--el-text-color-secondary);
i {
cursor: pointer;
}
}
.ba-form-item-not-support {
line-height: 15px;
}
.ba-input-item-array :deep(.el-form-item__content) {
display: block;
padding-bottom: 32px;
}
</style>

View File

@@ -0,0 +1,41 @@
<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,69 @@
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) {
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>
`
)
},
}
}

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>

View File

@@ -0,0 +1,11 @@
import type { AxiosRequestConfig } from 'axios'
export const state: () => 'disable' | 'enable' = () => 'disable'
export function fileUpload(fd: FormData, params: anyObj = {}, config: AxiosRequestConfig = {}): ApiPromise {
// 上传扩展,定义此函数,并将上方的 state 设定为 enable系统可自动使用此函数进行上传
return new Promise((resolve, reject) => {
console.log(fd, params, config)
reject('未定义')
})
}

View File

@@ -0,0 +1,11 @@
<template>
<div class="tips">{{ $t('utils.Please install editor') }}</div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss">
.tips {
color: var(--el-text-color-placeholder);
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<div class="login-footer-buried-point"></div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,4 @@
export default function loginMounted(): Promise<boolean> {
// 通常用于会员登录页初始化时接受各种回调或收参跳转,返回 true 将终止会员登录页初始化
return new Promise((resolve) => resolve(false))
}

View File

@@ -0,0 +1,11 @@
interface UserMountedRet {
type: 'jump' | 'break' | 'continue' | 'reload'
[key: string]: any
}
export default function userMounted(): Promise<UserMountedRet> {
// 通常用于会员中心初始化时接受各种回调或收参跳转,返回 true 将终止会员中心初始化
return new Promise((resolve) => {
resolve({ type: 'continue' })
})
}

View File

@@ -0,0 +1,7 @@
<template>
<div></div>
</template>
<script setup lang="ts"></script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="table-com-search-wrapper">
<div class="table-com-search">
<el-form
@submit.prevent=""
@keyup.enter="baTable.onTableAction('com-search', { event: 'submit-com-search-form' })"
label-position="top"
:model="baTable.comSearch.form"
>
<el-row>
<template v-for="(item, idx) in baTable.table.column" :key="idx">
<template v-if="item.operator !== false">
<!-- 自定义渲染 componentslot -->
<el-col
v-if="item.comSearchRender == 'customRender' || item.comSearchRender == 'slot'"
v-bind="{
xs: item.comSearchColAttr?.xs ? item.comSearchColAttr?.xs : 24,
sm: item.comSearchColAttr?.sm ? item.comSearchColAttr?.sm : 6,
...item.comSearchColAttr,
}"
>
<!-- 外部可以使用 :deep() 选择器修改css样式 -->
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<div class="com-search-col-input">
<!-- 自定义组件/函数渲染 -->
<component
v-if="item.comSearchRender == 'customRender'"
:is="item.comSearchCustomRender"
:renderRow="item"
:renderField="item.prop!"
:renderValue="baTable.comSearch.form[item.prop!]"
/>
<!-- 自定义渲染-slot -->
<slot v-else-if="item.comSearchRender == 'slot'" :name="item.comSearchSlotName"></slot>
</div>
</div>
</el-col>
<!-- 时间日期范围 -->
<el-col
v-else-if="
(item.render == 'datetime' || item.comSearchRender == 'datetime' || item.comSearchRender == 'date') &&
(item.operator == 'RANGE' || item.operator == 'NOT RANGE')
"
:xs="24"
:sm="12"
>
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label w16" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<div class="com-search-col-input-range w83">
<el-date-picker
class="datetime-picker w100"
v-model="baTable.comSearch.form[item.prop!]"
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
:type="item.comSearchRender == 'date' ? 'daterange' : 'datetimerange'"
:range-separator="$t('To')"
:start-placeholder="getPlaceholder(item.operatorPlaceholder, 0, $t('el.datepicker.startDate'))"
:end-placeholder="getPlaceholder(item.operatorPlaceholder, 1, $t('el.datepicker.endDate'))"
:value-format="item.comSearchRender == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
:teleported="false"
v-bind="item.comSearchInputAttr"
/>
</div>
</div>
</el-col>
<!-- 时间范围 -->
<el-col
v-else-if="item.comSearchRender == 'time' && (item.operator == 'RANGE' || item.operator == 'NOT RANGE')"
:xs="24"
:sm="12"
>
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label w16" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<div class="com-search-col-input-range w83">
<el-time-picker
class="time-picker w100"
v-model="baTable.comSearch.form[item.prop!]"
is-range
:default-value="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
:range-separator="$t('To')"
:start-placeholder="getPlaceholder(item.operatorPlaceholder, 0, $t('el.datepicker.startTime'))"
:end-placeholder="getPlaceholder(item.operatorPlaceholder, 1, $t('el.datepicker.endTime'))"
value-format="HH:mm:ss"
v-bind="item.comSearchInputAttr"
/>
</div>
</div>
</el-col>
<!-- 其他 -->
<el-col v-else :xs="24" :sm="6">
<div class="com-search-col" :class="item.prop">
<div class="com-search-col-label" v-if="item.comSearchShowLabel !== false">{{ item.label }}</div>
<!-- 数字范围 -->
<div v-if="item.operator == 'RANGE' || item.operator == 'NOT RANGE'" class="com-search-col-input-range">
<el-input
:placeholder="getPlaceholder(item.operatorPlaceholder)"
type="string"
v-model="baTable.comSearch.form[item.prop! + '-start']"
:clearable="true"
v-bind="item.comSearchInputAttr"
></el-input>
<div class="range-separator">{{ $t('To') }}</div>
<el-input
:placeholder="getPlaceholder(item.operatorPlaceholder, 1)"
type="string"
v-model="baTable.comSearch.form[item.prop! + '-end']"
:clearable="true"
v-bind="item.comSearchInputAttr"
></el-input>
</div>
<!-- 是否 [NOT] NULL -->
<div v-else-if="item.operator == 'NULL' || item.operator == 'NOT NULL'" class="com-search-col-input">
<el-checkbox
v-model="baTable.comSearch.form[item.prop!]"
:label="item.operator"
size="large"
v-bind="item.comSearchInputAttr"
></el-checkbox>
</div>
<div v-else-if="item.operator" class="com-search-col-input">
<!-- 时间日期筛选 -->
<el-date-picker
class="datetime-picker w100"
v-if="item.render == 'datetime' || item.comSearchRender == 'date' || item.comSearchRender == 'datetime'"
v-model="baTable.comSearch.form[item.prop!]"
:type="item.comSearchRender == 'date' ? 'date' : 'datetime'"
:value-format="item.comSearchRender == 'date' ? 'YYYY-MM-DD' : 'YYYY-MM-DD HH:mm:ss'"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
:teleported="false"
v-bind="item.comSearchInputAttr"
/>
<!-- 时间筛选 -->
<el-time-picker
class="time-picker w100"
v-if="item.comSearchRender == 'time'"
v-model="baTable.comSearch.form[item.prop!]"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
value-format="HH:mm:ss"
v-bind="item.comSearchInputAttr"
/>
<!-- tag、tags、select -->
<el-select
class="w100"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
v-else-if="
(item.render == 'tag' || item.render == 'tags' || item.comSearchRender == 'select') &&
item.replaceValue
"
v-model="baTable.comSearch.form[item.prop!]"
:multiple="item.operator == 'IN' || item.operator == 'NOT IN'"
:clearable="true"
v-bind="item.comSearchInputAttr"
>
<el-option v-for="(opt, okey) in item.replaceValue" :key="item.prop! + okey" :label="opt" :value="okey" />
</el-select>
<!-- 远程 select -->
<BaInput
v-else-if="item.comSearchRender == 'remoteSelect'"
type="remoteSelect"
v-model="baTable.comSearch.form[item.prop!]"
:attr="{ ...item.remote, ...item.comSearchInputAttr }"
:placeholder="getPlaceholder(item.operatorPlaceholder)"
/>
<!-- 开关 -->
<el-select
:placeholder="getPlaceholder(item.operatorPlaceholder)"
v-else-if="item.render == 'switch'"
v-model="baTable.comSearch.form[item.prop!]"
:clearable="true"
class="w100"
v-bind="item.comSearchInputAttr"
>
<template v-if="!isEmpty(item.replaceValue)">
<el-option
v-for="(opt, okey) in item.replaceValue"
:key="item.prop! + okey"
:label="opt"
:value="okey"
/>
</template>
<template v-else>
<el-option :label="$t('utils.open')" value="1" />
<el-option :label="$t('utils.close')" value="0" />
</template>
</el-select>
<!-- 字符串 -->
<el-input
:placeholder="getPlaceholder(item.operatorPlaceholder)"
v-else
type="string"
v-model="baTable.comSearch.form[item.prop!]"
:clearable="true"
v-bind="item.comSearchInputAttr"
></el-input>
</div>
</div>
</el-col>
</template>
</template>
<el-col :xs="24" :sm="6">
<div class="com-search-col pl-20">
<el-button v-blur @click="baTable.onTableAction('com-search', { event: 'submit-com-search-form' })" type="primary">
{{ $t('Search') }}
</el-button>
<el-button @click="onResetForm()">{{ $t('Reset') }}</el-button>
</div>
</el-col>
</el-row>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type baTableClass from '/@/utils/baTable'
import { isArray, isEmpty, isUndefined } from 'lodash-es'
import BaInput from '/@/components/baInput/index.vue'
const baTable = inject('baTable') as baTableClass
const onResetForm = () => {
/**
* 封装好的 /utils/common.js/onResetForm 工具在此处不能使用,因为未使用 el-form-item
* 改用公共搜索重新初始化函数
*/
baTable.initComSearch()
// 通知 baTable 发起公共搜索
baTable.onTableAction('com-search', { event: 'reset-com-search-form' })
}
const getPlaceholder = (placeholder: string | string[] | undefined, key = 0, defaultValue = '') => {
if (isUndefined(placeholder)) {
return defaultValue
} else if (isArray(placeholder)) {
return placeholder[key]
} else {
return placeholder
}
}
</script>
<style scoped lang="scss">
.table-com-search {
box-sizing: border-box;
width: 100%;
max-width: 100%;
background-color: var(--ba-bg-color-overlay);
border: 1px solid var(--ba-border-color);
border-bottom: none;
padding: 13px 15px;
font-size: 14px;
.com-search-col {
display: flex;
align-items: center;
padding-top: 8px;
color: var(--el-text-color-regular);
font-size: 13px;
}
.com-search-col-label {
width: 33.33%;
padding: 0 15px;
text-align: right;
overflow: hidden;
white-space: nowrap;
}
.com-search-col-input {
padding: 0 15px;
width: 66.66%;
}
.com-search-col-input-range {
display: flex;
align-items: center;
padding: 0 15px;
width: 66.66%;
.range-separator {
padding: 0 5px;
}
}
}
.pl-20 {
padding-left: 20px;
}
.w16 {
width: 16.5% !important;
}
.w83 {
width: 83.5% !important;
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div v-memo="[field]">
<template v-for="(btn, idx) in field.buttons" :key="idx">
<template v-if="btn.display ? btn.display(row, field) : true">
<!-- 常规按钮 -->
<el-button
v-if="btn.render == 'basicButton'"
v-blur
@click="onButtonClick(btn)"
:class="btn.class"
size="small"
class="ba-table-render-buttons-item buttons-ml-6"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
<!-- 带提示信息的按钮 -->
<el-tooltip
v-if="btn.render == 'tipButton' && ((btn.name == 'edit' && baTable.auth('edit')) || btn.name != 'edit')"
:disabled="btn.title && !btn.disabledTip ? false : true"
:content="getTranslation(btn.title)"
placement="top"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tooltip, { row, field, cellValue: btn, column, index })"
>
<el-button
v-blur
@click="onButtonClick(btn)"
:class="btn.class"
size="small"
class="ba-table-render-buttons-item buttons-ml-6"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
</el-tooltip>
<!-- 带确认框的按钮 -->
<el-popconfirm
v-if="btn.render == 'confirmButton' && ((btn.name == 'delete' && baTable.auth('del')) || btn.name != 'delete')"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.popconfirm, { row, field, cellValue: btn, column, index })"
@confirm="onButtonClick(btn)"
>
<template #reference>
<div class="buttons-popconfirm-reference-box buttons-ml-6">
<el-tooltip
:disabled="btn.title ? false : true"
:content="getTranslation(btn.title)"
placement="top"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tooltip, { row, field, cellValue: btn, column, index })"
>
<el-button
v-blur
:class="btn.class"
size="small"
class="ba-table-render-buttons-item"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
<!-- 带提示的可拖拽按钮 -->
<el-tooltip
v-if="btn.render == 'moveButton' && ((btn.name == 'weigh-sort' && baTable.auth('sortable')) || btn.name != 'weigh-sort')"
:disabled="btn.title && !btn.disabledTip ? false : true"
:content="getTranslation(btn.title)"
placement="top"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tooltip, { row, field, cellValue: btn, column, index })"
>
<el-button
:class="btn.class"
size="small"
class="ba-table-render-buttons-item move-button buttons-ml-6"
:type="btn.type"
:loading="btn.loading && btn.loading(row, field)"
:disabled="btn.disabled && btn.disabled(row, field)"
v-bind="invokeTableContextDataFun(btn.attr, { row, field, cellValue: btn, column, index })"
>
<Icon v-if="btn.icon" size="14" color="var(--ba-bg-color-overlay)" :name="btn.icon" />
<div v-if="btn.text" class="text">{{ getTranslation(btn.text) }}</div>
</el-button>
</el-tooltip>
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { inject } from 'vue'
import { useI18n } from 'vue-i18n'
import { invokeTableContextDataFun } from '/@/components/table/index'
import type baTableClass from '/@/utils/baTable'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const { t, te } = useI18n()
const props = defineProps<Props>()
const baTable = inject('baTable') as baTableClass
const onButtonClick = (btn: OptButton) => {
if (typeof btn.click === 'function') {
btn.click(props.row, props.field)
return
}
baTable.onTableAction(btn.name as BaTableActionEventName, props)
}
const getTranslation = (key?: string) => {
if (!key) return ''
return te(key) ? t(key) : key
}
</script>
<style scoped lang="scss">
.ba-table-render-buttons-item {
.text {
font-size: 14px;
}
.icon + .text {
padding-left: 5px;
}
&.el-button--small {
padding: 4px 5px;
height: auto;
}
}
.ba-table-render-buttons-move {
cursor: move;
}
.buttons-popconfirm-reference-box {
display: inline-flex;
vertical-align: middle;
}
.buttons-ml-6 + .buttons-ml-6 {
margin-left: 6px;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div>
<div :style="{ background: cellValue }" class="ba-table-render-color"></div>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>
<style scoped lang="scss">
.ba-table-render-color {
height: 25px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div>
<component
:is="field.customRender"
:renderRow="row"
:renderField="field"
:renderValue="cellValue"
:renderColumn="column"
:renderIndex="index"
/>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<div>
<div v-html="field.customTemplate ? field.customTemplate(row, field, cellValue, column, index) : ''"></div>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div>
{{ !cellValue ? '-' : timeFormat(cellValue, field.timeFormat ?? 'yyyy-mm-dd hh:MM:ss') }}
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
import { timeFormat } from '/@/utils/common'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div>
<el-tag effect="dark" type="danger">Field renderer not found</el-tag>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<template>
<div>
<Icon
color="var(--el-text-color-primary)"
:name="cellValue"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.icon, { row, field, cellValue, column, index })"
/>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div>
<el-image
v-if="cellValue"
:hide-on-click-modal="true"
:preview-teleported="true"
:preview-src-list="[fullUrl(cellValue)]"
:src="fullUrl(cellValue)"
class="ba-table-render-image"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.image, { row, field, cellValue, column, index })"
></el-image>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
import { fullUrl } from '/@/utils/common'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>
<style scoped lang="scss">
.ba-table-render-image {
height: 36px;
width: 36px;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div>
<template v-if="isArray(cellValue) && cellValue.length">
<el-image
v-for="(item, idx) in cellValue"
:key="idx"
:initial-index="idx"
:preview-teleported="true"
:preview-src-list="arrayFullUrl(cellValue)"
class="ba-table-render-images-item"
:src="fullUrl(item)"
:hide-on-click-modal="true"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.image, { row, field, cellValue, column, index })"
></el-image>
</template>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { isArray } from 'lodash-es'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
import { arrayFullUrl, fullUrl } from '/@/utils/common'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
</script>
<style scoped lang="scss">
.ba-table-render-images-item {
height: 36px;
width: 36px;
margin: 0 5px;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div>
<el-switch
v-if="field.prop"
@change="onChange"
:model-value="cellValue"
:loading="loading"
active-value="1"
inactive-value="0"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.switch, { row, field, cellValue, column, index })"
/>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { inject, ref } from 'vue'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
import type baTableClass from '/@/utils/baTable'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const loading = ref(false)
const props = defineProps<Props>()
const baTable = inject('baTable') as baTableClass
const cellValue = ref(getCellValue(props.row, props.field, props.column, props.index))
if (typeof cellValue.value === 'number') {
cellValue.value = cellValue.value.toString()
}
const onChange = (value: string | number | boolean) => {
loading.value = true
baTable.api
.postData('edit', {
[baTable.table.pk!]: props.row[baTable.table.pk!],
[props.field.prop!]: value,
})
.then(() => {
cellValue.value = value
baTable.onTableAction('field-change', { value: value, ...props })
})
.finally(() => {
loading.value = false
})
}
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div>
<el-tag
v-if="![null, undefined, ''].includes(cellValue)"
:type="getTagType(cellValue, field.custom)"
:effect="field.effect ?? 'light'"
:size="field.size ?? 'default'"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tag, { row, field, cellValue, column, index })"
>
{{ !isEmpty(field.replaceValue) ? (field.replaceValue[cellValue] ?? cellValue) : cellValue }}
</el-tag>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx, TagProps } from 'element-plus'
import { isEmpty } from 'lodash-es'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
const getTagType = (value: string, custom: any): TagProps['type'] => {
return !isEmpty(custom) && custom[value] ? custom[value] : 'primary'
}
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div>
<template v-if="isArray(cellValue)">
<template v-for="(tag, idx) in cellValue" :key="idx">
<el-tag
v-if="![null, undefined, ''].includes(tag)"
class="m-4"
:type="getTagType(tag, field.custom)"
:effect="field.effect ?? 'light'"
:size="field.size ?? 'default'"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tag, { row, field, cellValue, column, index })"
>
{{ !isEmpty(field.replaceValue) ? (field.replaceValue[tag] ?? tag) : tag }}
</el-tag>
</template>
</template>
<template v-else>
<el-tag
v-if="![null, undefined, ''].includes(cellValue)"
:type="getTagType(cellValue, field.custom)"
:effect="field.effect ?? 'light'"
:size="field.size ?? 'default'"
v-bind="invokeTableContextDataFun(field.customRenderAttr?.tag, { row, field, cellValue, column, index })"
>
{{ !isEmpty(field.replaceValue) ? (field.replaceValue[cellValue] ?? cellValue) : cellValue }}
</el-tag>
</template>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx, TagProps } from 'element-plus'
import { isArray, isEmpty } from 'lodash-es'
import { getCellValue, invokeTableContextDataFun } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
const getTagType = (value: string, custom: any): TagProps['type'] => {
return !isEmpty(custom) && custom[value] ? custom[value] : 'primary'
}
</script>
<style scoped lang="scss">
.m-4 {
margin: 4px;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<div>
<el-input :model-value="cellValue" :placeholder="$t('Link address')">
<template #append>
<el-button @click="openUrl(cellValue, field)">
<Icon color="#606266" name="el-icon-Position" />
</el-button>
</template>
</el-input>
</div>
</template>
<script setup lang="ts">
import { TableColumnCtx } from 'element-plus'
import { getCellValue } from '/@/components/table/index'
interface Props {
row: TableRow
field: TableColumn
column: TableColumnCtx<TableRow>
index: number
}
const props = defineProps<Props>()
if (props.field.click) {
console.warn('baTable.table.column.click 即将废弃,请使用 el-table 的 @cell-click 或单元格自定义渲染代替')
}
const cellValue = getCellValue(props.row, props.field, props.column, props.index)
const openUrl = (url: string, field: TableColumn) => {
if (field.target == '_blank') {
window.open(url)
} else {
window.location.href = url
}
}
</script>

View File

@@ -0,0 +1,243 @@
<template>
<!-- 公共搜索 -->
<el-collapse-transition>
<ComSearch v-if="props.buttons.includes('comSearch') && baTable.table.showComSearch">
<template v-for="(slot, idx) in $slots" :key="idx" #[idx]>
<slot :name="idx"></slot>
</template>
</ComSearch>
</el-collapse-transition>
<!-- 操作按钮组 -->
<div v-bind="$attrs" class="table-header ba-scroll-style">
<slot name="refreshPrepend"></slot>
<el-tooltip v-if="props.buttons.includes('refresh')" :content="t('Refresh')" placement="top">
<el-button v-blur @click="onAction('refresh', { loading: true })" color="#40485b" class="table-header-operate btns-ml-12" type="info">
<Icon name="fa fa-refresh" />
</el-button>
</el-tooltip>
<slot name="refreshAppend"></slot>
<el-tooltip v-if="props.buttons.includes('add') && baTable.auth('add')" :content="t('Add')" placement="top">
<el-button v-blur @click="onAction('add')" class="table-header-operate btns-ml-12" type="primary">
<Icon name="fa fa-plus" />
<span class="table-header-operate-text">{{ t('Add') }}</span>
</el-button>
</el-tooltip>
<el-tooltip v-if="props.buttons.includes('edit') && baTable.auth('edit')" :content="t('Edit selected row')" placement="top">
<el-button v-blur @click="onAction('edit')" :disabled="!enableBatchOpt" class="table-header-operate btns-ml-12" type="primary">
<Icon name="fa fa-pencil" />
<span class="table-header-operate-text">{{ t('Edit') }}</span>
</el-button>
</el-tooltip>
<el-popconfirm
v-if="props.buttons.includes('delete') && baTable.auth('del')"
@confirm="onAction('delete')"
:confirm-button-text="t('Delete')"
:cancel-button-text="t('Cancel')"
confirmButtonType="danger"
:title="t('Are you sure to delete the selected record?')"
:disabled="!enableBatchOpt"
>
<template #reference>
<div class="btns-ml-12">
<el-tooltip :content="t('Delete selected row')" placement="top">
<el-button v-blur :disabled="!enableBatchOpt" class="table-header-operate" type="danger">
<Icon name="fa fa-trash" />
<span class="table-header-operate-text">{{ t('Delete') }}</span>
</el-button>
</el-tooltip>
</div>
</template>
</el-popconfirm>
<el-tooltip
v-if="props.buttons.includes('unfold')"
:content="(baTable.table.expandAll ? t('Shrink') : t('Open')) + t('All submenus')"
placement="top"
>
<el-button
v-blur
@click="baTable.onTableHeaderAction('unfold', { unfold: !baTable.table.expandAll })"
class="table-header-operate btns-ml-12"
:type="baTable.table.expandAll ? 'danger' : 'warning'"
>
<span class="table-header-operate-text">{{ baTable.table.expandAll ? t('Shrink all') : t('Expand all') }}</span>
</el-button>
</el-tooltip>
<!-- slot -->
<slot></slot>
<!-- 右侧搜索框和工具按钮 -->
<div class="table-search">
<slot name="quickSearchPrepend"></slot>
<el-input
v-if="props.buttons.includes('quickSearch')"
v-model="baTable.table.filter!.quickSearch"
class="xs-hidden quick-search"
@input="onSearchInput"
:placeholder="quickSearchPlaceholder ? quickSearchPlaceholder : t('Search')"
clearable
/>
<div class="table-search-button-group" v-if="props.buttons.includes('columnDisplay') || props.buttons.includes('comSearch')">
<el-dropdown v-if="props.buttons.includes('columnDisplay')" :max-height="380" :hide-on-click="false">
<el-button
class="table-search-button-item"
:class="props.buttons.includes('comSearch') ? 'right-border' : ''"
color="#dcdfe6"
plain
v-blur
>
<Icon size="14" name="el-icon-Grid" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="(item, idx) in columnDisplay" :key="idx">
<el-checkbox
v-if="item.prop"
@change="onChangeShowColumn($event, item.prop!)"
:checked="!item.show"
:model-value="item.show"
size="small"
:label="item.label"
/>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-tooltip
v-if="props.buttons.includes('comSearch')"
:disabled="baTable.table.showComSearch"
:content="t('Expand generic search')"
placement="top"
>
<el-button
class="table-search-button-item"
@click="baTable.table.showComSearch = !baTable.table.showComSearch"
color="#dcdfe6"
plain
v-blur
>
<Icon size="14" name="el-icon-Search" />
</el-button>
</el-tooltip>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import ComSearch from '/@/components/table/comSearch/index.vue'
import type baTableClass from '/@/utils/baTable'
const { t } = useI18n()
const baTable = inject('baTable') as baTableClass
interface Props {
buttons: HeaderOptButton[]
quickSearchPlaceholder?: string
}
const props = withDefaults(defineProps<Props>(), {
buttons: () => {
return ['refresh', 'add', 'edit', 'delete']
},
quickSearchPlaceholder: '',
})
const columnDisplay = computed(() => {
let columnDisplayArr = []
for (let item of baTable.table.column) {
item.type === 'selection' || item.render === 'buttons' || item.enableColumnDisplayControl === false ? '' : columnDisplayArr.push(item)
}
return columnDisplayArr
})
const enableBatchOpt = computed(() => (baTable.table.selection!.length > 0 ? true : false))
const onAction = (event: BaTableHeaderActionEventName, data: anyObj = {}) => {
baTable.onTableHeaderAction(event, data)
}
const onSearchInput = debounce(() => {
baTable.onTableHeaderAction('quick-search', { keyword: baTable.table.filter!.quickSearch })
}, 500)
const onChangeShowColumn = (value: string | number | boolean, field: string) => {
baTable.onTableHeaderAction('change-show-column', { field: field, value: value })
}
</script>
<style scoped lang="scss">
.table-header {
position: relative;
overflow-x: auto;
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
max-width: 100%;
background-color: var(--ba-bg-color-overlay);
border: 1px solid var(--ba-border-color);
border-bottom: none;
padding: 13px 15px;
font-size: 14px;
.table-header-operate-text {
margin-left: 6px;
}
}
.btns-ml-12 + .btns-ml-12 {
margin-left: 12px;
}
.table-search {
display: flex;
margin-left: auto;
.quick-search {
width: auto;
}
}
.table-search-button-group {
display: flex;
margin-left: 12px;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
overflow: hidden;
button:focus,
button:active {
background-color: var(--ba-bg-color-overlay);
}
button:hover {
background-color: var(--el-color-info-light-7);
}
.table-search-button-item {
height: 30px;
border: none;
border-radius: 0;
}
.el-button + .el-button {
margin: 0;
}
.right-border {
border-right: 1px solid var(--el-border-color);
}
}
html.dark {
.table-search-button-group {
button:focus,
button:active {
background-color: var(--el-color-info-dark-2);
}
button:hover {
background-color: var(--el-color-info-light-7);
}
button {
background-color: var(--ba-bg-color-overlay);
el-icon {
color: white !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,141 @@
import { TableColumnCtx } from 'element-plus'
import { isUndefined } from 'lodash-es'
import { i18n } from '/@/lang/index'
/**
* 获取单元格值
*/
export const getCellValue = (row: TableRow, field: TableColumn, column: TableColumnCtx<TableRow>, index: number) => {
if (!field.prop) return ''
const prop = field.prop
let cellValue: any = row[prop]
// 字段 prop 带 . 比如 user.nickname
if (prop.indexOf('.') > -1) {
const fieldNameArr = prop.split('.')
cellValue = row[fieldNameArr[0]]
for (let index = 1; index < fieldNameArr.length; index++) {
cellValue = cellValue ? (cellValue[fieldNameArr[index]] ?? '') : ''
}
}
// 若无值,尝试取默认值
if ([undefined, null, ''].includes(cellValue) && field.default !== undefined) {
cellValue = field.default
}
// 渲染前格式化
if (field.renderFormatter && typeof field.renderFormatter == 'function') {
cellValue = field.renderFormatter(row, field, cellValue, column, index)
console.warn('baTable.table.column.renderFormatter 即将废弃,请直接使用兼容 el-table 的 baTable.table.column.formatter 代替')
}
if (field.formatter && typeof field.formatter == 'function') {
cellValue = field.formatter(row, column, cellValue, index)
}
return cellValue
}
/*
* 默认按钮组
*/
export const defaultOptButtons = (optButType: DefaultOptButType[] = ['weigh-sort', 'edit', 'delete']): OptButton[] => {
const optButtonsPre: Map<string, OptButton> = new Map([
[
'weigh-sort',
{
render: 'moveButton',
name: 'weigh-sort',
title: 'Drag sort',
text: '',
type: 'info',
icon: 'fa fa-arrows',
class: 'table-row-weigh-sort',
disabledTip: false,
},
],
[
'edit',
{
render: 'tipButton',
name: 'edit',
title: 'Edit',
text: '',
type: 'primary',
icon: 'fa fa-pencil',
class: 'table-row-edit',
disabledTip: false,
},
],
[
'delete',
{
render: 'confirmButton',
name: 'delete',
title: 'Delete',
text: '',
type: 'danger',
icon: 'fa fa-trash',
class: 'table-row-delete',
popconfirm: {
confirmButtonText: i18n.global.t('Delete'),
cancelButtonText: i18n.global.t('Cancel'),
confirmButtonType: 'danger',
title: i18n.global.t('Are you sure to delete the selected record?'),
},
disabledTip: false,
},
],
])
const optButtons: OptButton[] = []
for (const key in optButType) {
if (optButtonsPre.has(optButType[key])) {
optButtons.push(optButtonsPre.get(optButType[key])!)
}
}
return optButtons
}
/**
* 将带children的数组降维然后寻找index所在的行
*/
export const findIndexRow = (data: TableRow[], findIdx: number, keyIndex: number | TableRow = -1): number | TableRow => {
for (const key in data) {
if (typeof keyIndex == 'number') {
keyIndex++
}
if (keyIndex == findIdx) {
return data[key]
}
if (data[key].children) {
keyIndex = findIndexRow(data[key].children!, findIdx, keyIndex)
if (typeof keyIndex != 'number') {
return keyIndex
}
}
}
return keyIndex
}
/**
* 调用一个接受表格上下文数据的任意属性计算函数
*/
export const invokeTableContextDataFun = <T>(
fun: TableContextDataFun<T> | undefined,
context: TableContextData,
defaultValue: any = {}
): Partial<T> => {
if (isUndefined(fun)) {
return defaultValue
} else if (typeof fun === 'function') {
return fun(context)
}
return fun
}
type DefaultOptButType = 'weigh-sort' | 'edit' | 'delete'

View File

@@ -0,0 +1,246 @@
<template>
<div>
<slot name="neck"></slot>
<el-table
ref="tableRef"
class="ba-data-table w100"
header-cell-class-name="table-header-cell"
:default-expand-all="baTable.table.expandAll"
:data="baTable.table.data"
:row-key="baTable.table.pk"
:border="true"
v-loading="baTable.table.loading"
stripe
@select-all="onSelectAll"
@select="onSelect"
@selection-change="onSelectionChange"
@sort-change="onSortChange"
@row-dblclick="baTable.onTableDblclick"
v-bind="$attrs"
>
<slot name="columnPrepend"></slot>
<template v-for="(item, key) in baTable.table.column">
<template v-if="item.show !== false">
<!-- 渲染为 slot -->
<slot v-if="item.render == 'slot'" :name="item.slotName"></slot>
<el-table-column
v-else
:key="key + '-column'"
v-bind="item"
:column-key="(item['columnKey'] ? item['columnKey'] : `table-column-${item.prop}`) || shortUuid()"
>
<!-- ./fieldRender/ 文件夹内的每个组件为一种字段渲染器组件名称为渲染器名称 -->
<template v-if="item.render" #default="scope">
<component
:row="scope.row"
:field="item"
:column="scope.column"
:index="scope.$index"
:is="fieldRenderer[item.render] ?? fieldRenderer['default']"
:key="getRenderKey(key, item, scope)"
/>
</template>
</el-table-column>
</template>
</template>
<slot name="columnAppend"></slot>
</el-table>
<div v-if="props.pagination" class="table-pagination">
<el-pagination
:currentPage="baTable.table.filter!.page"
:page-size="baTable.table.filter!.limit"
:page-sizes="pageSizes"
background
:layout="config.layout.shrink ? 'prev, next, jumper' : 'sizes,total, ->, prev, pager, next, jumper'"
:total="baTable.table.total"
@size-change="onTableSizeChange"
@current-change="onTableCurrentChange"
></el-pagination>
</div>
<slot name="footer"></slot>
</div>
</template>
<script setup lang="ts">
import type { ElTable } from 'element-plus'
import type { Component } from 'vue'
import { computed, inject, nextTick, useTemplateRef } from 'vue'
import { useConfig } from '/@/stores/config'
import type baTableClass from '/@/utils/baTable'
import { shortUuid } from '/@/utils/random'
const config = useConfig()
const tableRef = useTemplateRef('tableRef')
const baTable = inject('baTable') as baTableClass
type ElTableProps = Partial<InstanceType<typeof ElTable>['$props']>
interface Props extends /* @vue-ignore */ ElTableProps {
pagination?: boolean
}
const props = withDefaults(defineProps<Props>(), {
pagination: true,
})
const fieldRenderer: Record<string, Component> = {}
const fieldRendererComponents: Record<string, any> = import.meta.glob('./fieldRender/**.vue', { eager: true })
for (const key in fieldRendererComponents) {
const fileName = key.replace('./fieldRender/', '').replace('.vue', '')
fieldRenderer[fileName] = fieldRendererComponents[key].default
}
const getRenderKey = (key: number, item: TableColumn, scope: any) => {
if (item.getRenderKey && typeof item.getRenderKey == 'function') {
return item.getRenderKey(scope.row, item, scope.column, scope.$index)
}
if (item.render == 'switch') {
return item.render + item.prop
}
return key + scope.$index + '-' + item.render + '-' + (item.prop ? '-' + item.prop + '-' + scope.row[item.prop] : '')
}
const onTableSizeChange = (val: number) => {
baTable.onTableAction('page-size-change', { size: val })
}
const onTableCurrentChange = (val: number) => {
baTable.onTableAction('current-page-change', { page: val })
}
const onSortChange = ({ order, prop }: { order: string; prop: string }) => {
baTable.onTableAction('sort-change', { prop: prop, order: order ? (order == 'ascending' ? 'asc' : 'desc') : '' })
}
const pageSizes = computed(() => {
let defaultSizes = [10, 20, 50, 100]
if (baTable.table.filter!.limit) {
if (!defaultSizes.includes(baTable.table.filter!.limit)) {
defaultSizes.push(baTable.table.filter!.limit)
}
}
return defaultSizes
})
/*
* 全选和取消全选
* 实现子级同时选择和取消选中
*/
const onSelectAll = (selection: TableRow[]) => {
if (isSelectAll(selection.map((row: TableRow) => row[baTable.table.pk!].toString()))) {
selection.map((row: TableRow) => {
if (row.children) {
selectChildren(row.children, true)
}
})
} else {
tableRef.value?.clearSelection()
}
}
/*
* 是否是全选操作
* 只检查第一个元素是否被选择
* 全选时selectIds为所有元素的id
* 取消全选时selectIds为所有子元素的id
*/
const isSelectAll = (selectIds: string[]) => {
let data = baTable.table.data as TableRow[]
for (const key in data) {
return selectIds.includes(data[key][baTable.table.pk!].toString())
}
return false
}
/*
* 选择子项-递归
*/
const selectChildren = (children: TableRow[], type: boolean) => {
children.map((j: TableRow) => {
toggleSelection(j, type)
if (j.children) {
selectChildren(j.children, type)
}
})
}
/*
* 执行选择操作
*/
const toggleSelection = (row: TableRow, type: boolean) => {
if (row) {
nextTick(() => {
tableRef.value?.toggleRowSelection(row, type)
})
}
}
/*
* 手动选择时,同时选择子级
*/
const onSelect = (selection: TableRow[], row: TableRow) => {
if (
selection.some((item: TableRow) => {
return row[baTable.table.pk!] === item[baTable.table.pk!]
})
) {
if (row.children) {
selectChildren(row.children, true)
}
} else {
if (row.children) {
selectChildren(row.children, false)
}
}
}
/*
* 记录选择的项
*/
const onSelectionChange = (selection: TableRow[]) => {
baTable.onTableAction('selection-change', selection)
}
/*
* 设置折叠所有-递归
*/
const setUnFoldAll = (children: TableRow[], unfold: boolean) => {
for (const key in children) {
tableRef.value?.toggleRowExpansion(children[key], unfold)
if (children[key].children) {
setUnFoldAll(children[key].children!, unfold)
}
}
}
/*
* 折叠所有
*/
const unFoldAll = (unfold: boolean) => {
setUnFoldAll(baTable.table.data!, unfold)
}
const getRef = () => {
return tableRef.value
}
defineExpose({
unFoldAll,
getRef,
})
</script>
<style scoped lang="scss">
.ba-data-table :deep(.table-header-cell) .cell {
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.table-pagination {
box-sizing: border-box;
width: 100%;
max-width: 100%;
background-color: var(--ba-bg-color-overlay);
padding: 13px 15px;
}
</style>

View File

@@ -0,0 +1,439 @@
<template>
<div>
<el-dialog v-model="terminal.state.show" :title="t('terminal.Terminal')" class="ba-terminal-dialog main-dialog">
<el-scrollbar ref="terminalScrollbarRef" :max-height="500" class="terminal-scrollbar">
<el-alert
class="terminal-warning-alert"
v-if="!terminal.state.phpDevelopmentServer"
:title="t('terminal.The current terminal is not running under the installation service, and some commands may not be executed')"
type="error"
/>
<el-timeline class="terminal-timeline" v-if="terminal.state.taskList.length">
<el-timeline-item
v-for="(item, idx) in terminal.state.taskList"
:key="idx"
class="task-item"
:class="'task-status-' + item.status"
:type="getTaskStatus(item.status)['statusType']"
center
:timestamp="item.createTime"
placement="top"
>
<el-card>
<div>
<el-tag :type="getTaskStatus(item.status)['statusType']">{{ getTaskStatus(item.status)['statusText'] }}</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="(item.status == taskStatus.Failed || item.status == taskStatus.Unknown) && item.blockOnFailure"
type="warning"
>
{{ t('terminal.Failure to execute this command will block the execution of the queue') }}
</el-tag>
<el-tag
class="block-on-failure-tag"
v-if="item.status == taskStatus.Executing || item.status == taskStatus.Connecting"
type="danger"
>
{{ t('terminal.Do not refresh the browser') }}
</el-tag>
<span class="command">{{ item.command }}</span>
<div class="task-opt">
<el-button
:title="t('Retry')"
v-if="item.status == taskStatus.Failed || item.status == taskStatus.Unknown"
size="small"
v-blur
type="warning"
icon="el-icon-RefreshRight"
circle
@click="terminal.retryTask(idx)"
/>
<el-button
@click="terminal.delTask(idx)"
:title="t('Delete')"
size="small"
v-blur
type="danger"
icon="el-icon-Delete"
circle
/>
</div>
</div>
<template v-if="item.status != taskStatus.Waiting">
<div
v-if="item.status != taskStatus.Connecting && item.status != taskStatus.Executing"
@click="terminal.setTaskShowMessage(idx)"
class="toggle-message-display"
>
<span>{{ t('terminal.Command run log') }}</span>
<Icon :name="item.showMessage ? 'el-icon-ArrowUp' : 'el-icon-ArrowDown'" size="16" color="#909399" />
</div>
<div
v-if="
item.status == taskStatus.Connecting ||
item.status == taskStatus.Executing ||
(item.status > taskStatus.Executing && item.showMessage)
"
class="exec-message"
:class="'exec-message-' + item.uuid"
>
<pre v-for="(msg, index) in item.message" :key="index" class="message-item">{{ msg }}</pre>
</div>
</template>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else :image-size="80" :description="t('terminal.No mission yet')" />
</el-scrollbar>
<div class="terminal-buttons">
<el-button class="terminal-menu-item" icon="el-icon-MagicStick" v-blur @click="addTerminalTask('test', true, false)">
{{ t('terminal.Test command') }}
</el-button>
<el-dropdown class="terminal-menu-item">
<el-button icon="el-icon-Download" v-blur>
{{ t('terminal.Install dependent packages') }}
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="addTerminalTask('web-install', true)" v-if="terminal.state.packageManager != 'none'">
{{ terminal.state.packageManager }} run install
</el-dropdown-item>
<el-dropdown-item @click="addTerminalTask('composer.update', false)">composer update</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button class="terminal-menu-item" icon="el-icon-Sell" v-blur @click="webBuild()">{{ t('terminal.Republish') }}</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Delete" v-blur @click="terminal.clearSuccessTask()">
{{ t('terminal.Clean up task list') }}
</el-button>
<el-button class="terminal-menu-item" icon="el-icon-Tools" v-blur @click="terminal.toggleConfigDialog()">
{{ t('terminal.Terminal settings') }}
</el-button>
</div>
</el-dialog>
<el-dialog
@close="terminal.toggleConfigDialog(false)"
:model-value="terminal.state.showConfig"
class="ba-terminal-dialog"
:title="t('terminal.Terminal settings')"
>
<el-form label-position="left" label-width="140">
<FormItem
:label="'NPM ' + t('terminal.Source')"
:model-value="terminal.state.npmRegistry"
:key="terminal.state.npmRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'npm'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('npm'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'npm'),
}"
/>
<FormItem
:label="'Composer ' + t('terminal.Source')"
:model-value="terminal.state.composerRegistry"
:key="terminal.state.composerRegistry"
v-loading="state.registryLoading && state.registryLoadingType == 'composer'"
type="select"
:input-attr="{
border: true,
content: getSourceContent('composer'),
teleported: false,
onChange: (val: string) => changeRegistry(val, 'composer'),
}"
/>
<FormItem
:label="t('terminal.NPM package manager')"
:model-value="terminal.state.packageManager"
v-loading="state.packageManagerLoading"
type="select"
:input-attr="{
border: true,
content: { npm: 'NPM', cnpm: 'CNPM', pnpm: 'PNPM', yarn: 'YARN', ni: 'NI', none: t('terminal.Manual execution') },
teleported: false,
onChange: (val: string) => changePackageManager(val),
}"
:tip="t('terminal.NPM package manager tip')"
/>
<FormItem
:label="t('terminal.Clear successful task')"
:model-value="terminal.state.automaticCleanupTask"
type="radio"
:input-attr="{
border: true,
content: { '0': t('Disable'), '1': t('Enable') },
onChange: terminal.changeAutomaticCleanupTask,
}"
:tip="t('terminal.Clear successful task tip')"
/>
</el-form>
<div class="config-buttons">
<el-button @click="terminal.toggleConfigDialog(false)">{{ t('terminal.Back to terminal') }}</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import type { TimelineItemProps } from 'element-plus'
import { ElMessageBox, ElScrollbar } from 'element-plus'
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { postChangeTerminalConfig } from '/@/api/common'
import FormItem from '/@/components/formItem/index.vue'
import { taskStatus } from '/@/stores/constant/terminalTaskStatus'
import { useTerminal } from '/@/stores/terminal'
import { changeListenDirtyFileSwitch } from '/@/utils/vite'
type SourceType = 'npm' | 'composer'
const { t } = useI18n()
const terminal = useTerminal()
const terminalScrollbarRef = useTemplateRef('terminalScrollbarRef')
const state = reactive({
registryLoading: false,
registryLoadingType: 'npm',
packageManagerLoading: false,
})
const getTaskStatus = (status: number) => {
let statusText = t('terminal.unknown')
let statusType: TimelineItemProps['type'] = 'info'
switch (status) {
case taskStatus.Waiting:
statusText = t('terminal.Waiting for execution')
statusType = 'info'
break
case taskStatus.Connecting:
statusText = t('terminal.Connecting')
statusType = 'warning'
break
case taskStatus.Executing:
statusText = t('terminal.Executing')
statusType = 'warning'
break
case taskStatus.Success:
statusText = t('terminal.Successful execution')
statusType = 'success'
break
case taskStatus.Failed:
statusText = t('terminal.Execution failed')
statusType = 'danger'
break
case taskStatus.Unknown:
statusText = t('terminal.Unknown execution result')
statusType = 'danger'
break
}
return {
statusText: statusText,
statusType: statusType,
}
}
const addTerminalTask = (command: string, pm: boolean, blockOnFailure = true, extend = '', callback: Function = () => {}) => {
if (pm) {
terminal.addTaskPM(command, blockOnFailure, extend, callback)
} else {
terminal.addTask(command, blockOnFailure, extend, callback)
}
// 任务列表滚动条滚动到底部
nextTick(() => {
if (terminalScrollbarRef.value && terminalScrollbarRef.value.wrapRef) {
terminalScrollbarRef.value.setScrollTop(terminalScrollbarRef.value.wrapRef.scrollHeight)
}
})
}
const webBuild = () => {
ElMessageBox.confirm(t('terminal.Are you sure you want to republish?'), t('Reminder'), {
confirmButtonText: t('Confirm'),
cancelButtonText: t('Cancel'),
type: 'warning',
}).then(() => {
changeListenDirtyFileSwitch(false)
addTerminalTask('web-build', true, true, '', () => {
changeListenDirtyFileSwitch(true)
})
})
}
const changePackageManager = (val: string) => {
state.packageManagerLoading = true
postChangeTerminalConfig({ manager: val })
.then((res) => {
if (res.code == 1) {
terminal.changePackageManager(val)
}
})
.finally(() => {
state.packageManagerLoading = false
})
}
const changeRegistry = (val: string, type: SourceType) => {
const oldVal = type == 'npm' ? terminal.state.npmRegistry : terminal.state.composerRegistry
terminal.changeRegistry(val, type)
state.registryLoading = true
state.registryLoadingType = type
terminal.addTask(`set-${type}-registry.${val}`, false, '', (res: taskStatus) => {
state.registryLoading = false
if (res == taskStatus.Failed || res == taskStatus.Unknown) {
ElMessageBox.confirm(t('terminal.Failed to modify the source command, Please try again manually'), t('Reminder'), {
confirmButtonText: t('Confirm'),
showCancelButton: false,
type: 'warning',
}).then(() => {
terminal.changeRegistry(oldVal, type)
})
}
})
}
const getSourceContent = (type: SourceType) => {
let content: anyObj = {}
if (type == 'npm') {
content = { npm: 'npm', taobao: 'taobao', tencent: 'tencent' }
} else if (type == 'composer') {
content = {
composer: 'composer',
huawei: 'huawei',
aliyun: 'aliyun',
tencent: 'tencent',
kkame: 'kkame',
}
}
// 如果值为 unknown则 unknown 选项
if (terminal.state[type == 'npm' ? 'npmRegistry' : 'composerRegistry'] == 'unknown') {
content.unknown = t('Unknown')
}
return content
}
onMounted(() => {
terminal.init()
})
</script>
<style scoped lang="scss">
.terminal-warning-alert {
margin: 0 0 20px 0;
}
.terminal-timeline {
padding: 0 15px;
}
.command {
font-size: var(--el-font-size-large);
font-weight: bold;
margin-left: 10px;
}
.exec-message {
color: var(--ba-bg-color-overlay);
font-size: 12px;
line-height: 16px;
padding: 6px;
background-color: #424251;
margin-top: 10px;
min-height: 30px;
max-height: 200px;
overflow: auto;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #c8c9cc;
border-radius: 4px;
box-shadow: none;
-webkit-box-shadow: none;
}
&::-webkit-scrollbar-track {
background: var(--ba-bg-color);
}
&:hover {
&::-webkit-scrollbar-thumb:hover {
background: #909399;
}
}
}
@supports not (selector(::-webkit-scrollbar)) {
.exec-message {
scrollbar-width: thin;
scrollbar-color: #c8c9cc #eaeaea;
}
}
.toggle-message-display {
padding-top: 10px;
font-size: 13px;
color: var(--el-text-color-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.task-opt {
display: none;
float: right;
}
.task-item.task-status-0:hover,
.task-item.task-status-3:hover,
.task-item.task-status-4:hover,
.task-item.task-status-5:hover {
.task-opt {
display: inline;
}
}
.block-on-failure-tag {
margin-left: 10px;
}
.terminal-menu-item {
margin-bottom: 12px;
}
.terminal-menu-item + .terminal-menu-item {
margin-left: 12px;
margin-bottom: 12px;
}
.terminal-buttons {
display: block;
width: fit-content;
margin: 0 auto;
padding-top: 12px;
}
.config-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
padding-top: 20px;
padding-right: 20px;
}
:deep(.main-dialog) {
--el-dialog-padding-primary: 16px 16px 0 16px;
.el-dialog__body {
margin-top: 16px;
}
}
:deep(.ba-terminal-dialog) {
--el-dialog-width: 46% !important;
.el-loading-spinner {
--el-loading-spinner-size: 20px;
}
}
@media screen and (max-width: 768px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 80% !important;
}
}
@media screen and (max-width: 540px) {
:deep(.ba-terminal-dialog) {
--el-dialog-width: 94% !important;
}
}
</style>