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