feat: 更新一些新功能

This commit is contained in:
gary.fu
2026-08-18 17:59:01 +08:00
parent d7cbc2eebf
commit 20c446b191
23 changed files with 1703 additions and 107 deletions

View File

@@ -1,13 +1,82 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%VITE_APP_NAME%</title> <title>%VITE_APP_NAME%</title>
<style>
body {
margin: 0;
}
.index-loading-spinner {
top: 50%;
margin-top: calc((0px - 42px) / 2);
width: 100%;
text-align: center;
position: absolute;
}
.index-loading-spinner .circular {
display: inline;
height: 42px;
width: 42px;
animation: loading-rotate 2s linear infinite;
}
.index-loading-spinner .path {
animation: loading-dash 1.5s ease-in-out infinite;
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-width: 2;
stroke: #409EFFFF;
stroke-linecap: round;
}
@keyframes loading-rotate {
to {
transform: rotate(360deg)
}
}
@keyframes loading-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -40px
}
to {
stroke-dasharray: 90, 150;
stroke-dashoffset: -120px
}
}
.index-loading-spinner .el-loading-text {
color: #409EFFFF;
margin: 3px 0;
font-size: 14px;
}
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app">
<div class="index-loading-spinner">
<svg class="circular" viewBox="0 0 50 50">
<circle class="path" cx="25" cy="25" r="20" fill="none"></circle>
<p class="el-loading-text">系统加载中...</p>
<p class="el-loading-text">System Loading...</p>
</svg>
</div>
</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -52,8 +52,9 @@ export default [
{ {
url: '/simple/city/selectCities', url: '/simple/city/selectCities',
method: 'post', method: 'post',
response: request => { response: () => {
return { return {
success: true, success: true,
message: 'Success', message: 'Success',
resultData: { resultData: {

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, isVNode } from 'vue'
import { toLabelByKey, useInputType } from '@/components/utils' import { toLabelByKey, useInputType } from '@/components/utils'
import { isFunction } from 'lodash-es' import { isFunction } from 'lodash-es'
@@ -45,6 +45,19 @@ const tooltipFunc = ($event) => {
:readonly="option.readonly" :readonly="option.readonly"
v-bind="option.attrs" v-bind="option.attrs"
> >
<template
v-for="(slot, slotKey) in (option.slots||{})"
:key="slotKey"
#[slotKey]="scope"
>
<component
:is="scope[`__slotResult__${slotKey}`]"
v-if="isVNode(scope[`__slotResult__${slotKey}`] = slot(scope))"
/>
<template v-else>
{{ scope[`__slotResult__${slotKey}`] }}
</template>
</template>
{{ label }} {{ label }}
<el-tooltip <el-tooltip
v-if="option.tooltip||option.tooltipFunc" v-if="option.tooltip||option.tooltipFunc"
@@ -58,6 +71,8 @@ const tooltipFunc = ($event) => {
<span> <span>
<el-link <el-link
underline="never" underline="never"
:type="option.tooltipFunc ? 'primary' : 'default'"
v-bind="option.tooltipLinkAttrs"
@click="tooltipFunc($event)" @click="tooltipFunc($event)"
>&nbsp; >&nbsp;
<common-icon <common-icon

View File

@@ -3,7 +3,7 @@ import { computed, isVNode, ref, watch } from 'vue'
import { $i18nBundle } from '@/messages' import { $i18nBundle } from '@/messages'
import ControlChild from '@/components/common-form-control/control-child.vue' import ControlChild from '@/components/common-form-control/control-child.vue'
import { toLabelByKey, useInputType } from '@/components/utils' import { toLabelByKey, useInputType } from '@/components/utils'
import { cloneDeep, get, isFunction, set, isArray, isString } from 'lodash-es' import { cloneDeep, get, isFunction, set, isArray, isString, isEqual } from 'lodash-es'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -173,10 +173,15 @@ const rules = computed(() => {
}, ..._rules] }, ..._rules]
} }
} }
formItemRef.value && formItemRef.value.clearValidate()
return _rules return _rules
}) })
watch(rules, (newRules, oldRules) => {
if (formItemRef.value && !isEqual(newRules, oldRules)) {
formItemRef.value.clearValidate()
}
}, { deep: true })
const initFormModel = () => { const initFormModel = () => {
if (formModel.value) { if (formModel.value) {
const option = calcOption.value const option = calcOption.value
@@ -222,6 +227,15 @@ const formatResult = computed(() => {
return null return null
}) })
const tooltips = computed(() => {
if (calcOption.value.tooltips?.length) {
return calcOption.value.tooltips
} else if (calcOption.value.tooltip || calcOption.value.tooltipFunc) {
return [calcOption.value]
}
return []
})
</script> </script>
<template> <template>
@@ -251,23 +265,25 @@ const formatResult = computed(() => {
/> />
<slot name="afterLabel" /> <slot name="afterLabel" />
<el-tooltip <el-tooltip
v-if="calcOption.tooltip||calcOption.tooltipFunc" v-for="(tooltipOption, index) in tooltips"
:key="index"
class="box-item common-el-tooltip" class="box-item common-el-tooltip"
effect="dark" effect="dark"
:disabled="!calcOption.tooltip" :disabled="!tooltipOption.tooltip"
:content="calcOption.tooltip" :content="tooltipOption.tooltip"
placement="top-start" placement="top-start"
raw-content raw-content
v-bind="calcOption.tooltipAttrs" v-bind="tooltipOption.tooltipAttrs"
> >
<span> <span>
<el-link <el-link
v-bind="calcOption.tooltipLinkAttrs"
underline="never" underline="never"
@click="calcOption.tooltipFunc" :type="tooltipOption.tooltipFunc ? 'primary' : 'default'"
v-bind="tooltipOption.tooltipLinkAttrs"
@click="tooltipOption.tooltipFunc"
>&nbsp; >&nbsp;
<common-icon <common-icon
:icon="calcOption.tooltipIcon||'QuestionFilled'" :icon="tooltipOption.tooltipIcon||'QuestionFilled'"
/> />
</el-link> </el-link>
</span> </span>

View File

@@ -176,6 +176,11 @@ const goBack = (...args) => {
v-bind="{...$attrs, 'class':undefined}" v-bind="{...$attrs, 'class':undefined}"
@submit.prevent @submit.prevent
> >
<slot
:form="form"
:model="formModel"
name="before-options"
/>
<template <template
v-for="(option,index) in options" v-for="(option,index) in options"
:key="index" :key="index"
@@ -230,6 +235,11 @@ const goBack = (...args) => {
> >
{{ resetLabel||$t('common.label.reset') }} {{ resetLabel||$t('common.label.reset') }}
</el-button> </el-button>
<slot
:form="form"
:model="formModel"
name="buttons"
/>
<el-button <el-button
v-if="showBack||backUrl" v-if="showBack||backUrl"
:disabled="disableButtons" :disabled="disableButtons"
@@ -237,11 +247,6 @@ const goBack = (...args) => {
> >
{{ backLabel||$t('common.label.back') }} {{ backLabel||$t('common.label.back') }}
</el-button> </el-button>
<slot
:form="form"
:model="formModel"
name="buttons"
/>
</el-form-item> </el-form-item>
<slot <slot
:form="form" :form="form"

View File

@@ -83,6 +83,7 @@ export type PropsMap = {
'common-form-label': CommonFormLabelProps, 'common-form-label': CommonFormLabelProps,
'common-icon-select': IconSelectProps, 'common-icon-select': IconSelectProps,
'common-autocomplete': CommonAutocompleteProps, 'common-autocomplete': CommonAutocompleteProps,
'common-object': Record<string, any>,
[key:string]: InputProps [key:string]: InputProps
} }
@@ -146,8 +147,19 @@ export interface CommonFormOption extends FormControlTypeOption {
tooltipLinkAttrs?: LinkProps; tooltipLinkAttrs?: LinkProps;
/** 提示函数 */ /** 提示函数 */
tooltipFunc?: () => void; tooltipFunc?: () => void;
/** 多个提示信息配置 */
tooltips?: Array<{
tooltip?: string;
tooltipIcon?: string;
tooltipAttrs?: ElTooltipProps;
tooltipLinkAttrs?: LinkProps;
tooltipFunc?: () => void;
}>;
/** 子选项自定义插槽映射 */
slots?: Record<string, (scope: any) => any>;
/** 自动trim默认false**/ /** 自动trim默认false**/
trim?: boolean, trim?: boolean,
/** 自动upperCase默认false**/ /** 自动upperCase默认false**/
upperCase?: boolean, upperCase?: boolean,
/** 自动lowerCase默认false**/ /** 自动lowerCase默认false**/

View File

@@ -159,8 +159,8 @@ const selectIcon = icon => {
<el-backtop <el-backtop
v-common-tooltip="$t('common.label.backtop')" v-common-tooltip="$t('common.label.backtop')"
target=".scroller" target=".scroller"
:right="10" :right="40"
:bottom="10" :bottom="40"
/> />
</el-main> </el-main>
</el-container> </el-container>

View File

@@ -33,11 +33,9 @@ const activeRoutePath = computed(() => {
router router
> >
<slot name="before" /> <slot name="before" />
<template <common-menu-item
v-for="(menuItem, index) in menuItems" v-for="(menuItem, index) in menuItems"
:key="index" :key="index"
>
<common-menu-item
:menu-item="menuItem" :menu-item="menuItem"
:index="`${index}`" :index="`${index}`"
> >
@@ -45,7 +43,6 @@ const activeRoutePath = computed(() => {
<slot name="split" /> <slot name="split" />
</template> </template>
</common-menu-item> </common-menu-item>
</template>
<slot name="default" /> <slot name="default" />
</el-menu> </el-menu>
</template> </template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { ref, watch } from 'vue'
import CommonParamsEdit from '@/views/components/utils/CommonParamsEdit.vue'
import { isEqual } from 'lodash-es'
defineProps({
readonly: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
})
const vModel = defineModel({
type: Object,
default: () => ({})
})
const parsedModel = ref({
params: []
})
watch(vModel, value => {
parsedModel.value.params = Object.entries(value || {}).map(([k, v]) => {
return {
name: k,
value: v
}
})
}, { immediate: true })
const parseModelParams = params => {
const newVal = (params || []).filter(param => !!param.name).reduce((res, item) => {
res[item.name] = item.value
return res
}, {})
if (!isEqual(newVal, vModel.value)) {
vModel.value = newVal
}
}
watch(() => parsedModel.value.params, parseModelParams, { deep: true })
</script>
<template>
<el-container class="flex-column common-object">
<common-form
:model="parsedModel"
:show-buttons="false"
>
<common-params-edit
v-model="parsedModel.params"
class="form-edit-width-100"
form-prop="params"
name-required
:name-read-only="readonly||disabled"
:value-read-only="readonly||disabled"
:show-enable-switch="false"
:show-copy-button="false"
:show-add-button="!disabled&&!readonly"
:show-paste-button="!disabled&&!readonly"
:show-remove-button="!disabled&&!readonly"
/>
</common-form>
</el-container>
</template>
<style scoped>
</style>

View File

@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { toLabelByKey } from '@/components/utils' import { toLabelByKey } from '@/components/utils'
import { useRenderKey, useSortableParams } from '@/hooks/CommonHooks'
const props = defineProps({ const props = defineProps({
formOptions: { formOptions: {
@@ -22,6 +23,14 @@ const props = defineProps({
operationWidth: { operationWidth: {
type: String, type: String,
default: '110px' default: '110px'
},
formPropPrefix: {
type: String,
default: ''
},
sortable: {
type: Boolean,
default: false
} }
}) })
@@ -31,6 +40,12 @@ const dataList = computed(() => {
const emit = defineEmits(['delete', 'change']) const emit = defineEmits(['delete', 'change'])
const { renderKey } = useRenderKey()
const rowKey = (row) => renderKey(row)
const sortableState = props.sortable ? useSortableParams(dataList, '.el-table__row') : {}
const sortableRef = sortableState.sortableRef || ref()
const deleteItem = (item, index) => { const deleteItem = (item, index) => {
emit('delete', { emit('delete', {
item, index item, index
@@ -57,13 +72,36 @@ const options = computed(() => {
<template> <template>
<el-table <el-table
ref="sortableRef"
:data="dataList" :data="dataList"
:row-key="rowKey"
class="common-table-form" class="common-table-form"
> >
<el-table-column
v-if="sortable"
width="40px"
align="center"
>
<template #default>
<div
class="el-form-item"
style="display: flex; align-items: center; justify-content: center; height: 32px;"
>
<common-icon
:size="18"
icon="DragIndicatorFilled"
class="move-indicator"
style="cursor: move; color: var(--el-text-color-secondary);"
/>
</div>
</template>
</el-table-column>
<el-table-column <el-table-column
v-for="(option, index) in options" v-for="(option, index) in options"
:key="`${option.prop}__${index}`" :key="`${option.prop}__${index}`"
:width="option.width" :width="option.width"
:min-width="option.minWidth"
v-bind="option.columnAttrs"
> >
<template <template
v-if="option.headerSlot" v-if="option.headerSlot"
@@ -103,7 +141,7 @@ const options = computed(() => {
:model="row" :model="row"
label-width="0" label-width="0"
:option="option" :option="option"
:prop="`${dataListKey}.${$index}.${option.prop}`" :prop="formPropPrefix ? `${formPropPrefix}.${dataListKey}.${$index}.${option.prop}` : `${dataListKey}.${$index}.${option.prop}`"
@change="formChange($event, row, $index, option)" @change="formChange($event, row, $index, option)"
/> />
</template> </template>
@@ -145,5 +183,10 @@ const options = computed(() => {
</template> </template>
<style scoped> <style scoped>
.common-table-form :deep(.el-table__row .move-indicator) {
visibility: hidden;
}
.common-table-form :deep(.el-table__row:hover .move-indicator) {
visibility: visible;
}
</style> </style>

View File

@@ -65,6 +65,10 @@ const props = defineProps({
type: String, type: String,
default: '' default: ''
}, },
okLoading: {
type: Boolean,
default: false
},
okClick: { okClick: {
type: Function, type: Function,
default: null default: null
@@ -230,6 +234,7 @@ if (props.showFullscreen && props.dblclickToFullscreen) {
<el-button <el-button
v-if="showOk" v-if="showOk"
type="primary" type="primary"
:loading="okLoading"
@click="okButtonClick($event)" @click="okButtonClick($event)"
>{{ okLabel||$t('common.label.confirm') }}</el-button> >{{ okLabel||$t('common.label.confirm') }}</el-button>
<el-button <el-button

View File

@@ -17,6 +17,7 @@ import CommonAutocomplete from '@/components/common-autocomplete/index.vue'
import CommonSort from '@/components/common-sort/index.vue' import CommonSort from '@/components/common-sort/index.vue'
import CommonDescriptions from '@/components/common-descriptions/index.vue' import CommonDescriptions from '@/components/common-descriptions/index.vue'
import CommonSplit from '@/components/common-split/index.vue' import CommonSplit from '@/components/common-split/index.vue'
import CommonObject from '@/components/common-object/index.vue'
import CommonDirectives from '@/components/directives' import CommonDirectives from '@/components/directives'
/** /**
@@ -46,6 +47,7 @@ export default {
Vue.component('CommonSort', CommonSort) Vue.component('CommonSort', CommonSort)
Vue.component('CommonDescriptions', CommonDescriptions) Vue.component('CommonDescriptions', CommonDescriptions)
Vue.component('CommonSplit', CommonSplit) Vue.component('CommonSplit', CommonSplit)
Vue.component('CommonObject', CommonObject)
Vue.use(CommonDirectives) Vue.use(CommonDirectives)
} }
} }

View File

@@ -1,3 +1,4 @@
import { $i18nBundle } from '@/messages'
/** /**
* 全局布局模式 * 全局布局模式
* @readonly * @readonly
@@ -25,3 +26,44 @@ export const LoadSaveParamMode = {
BACK: 'back', BACK: 'back',
NEVER: 'never' NEVER: 'never'
} }
export const useFormStatus = (prop = 'status', activeValue = 1, inactiveValue = 0) => {
return {
labelKey: 'common.label.status',
prop,
type: 'switch',
attrs: {
activeValue,
inactiveValue,
style: '--el-switch-on-color: #67c23a; --el-switch-off-color: #f56c6c',
activeText: $i18nBundle('common.label.statusEnabled'),
inactiveText: $i18nBundle('common.label.statusDisabled')
}
}
}
export const useSearchStatus = ({ prop = 'status', activeValue = 1, inactiveValue = 0, change, ...option } = {}) => {
return {
labelKey: 'common.label.status',
prop,
type: 'select',
children: [
{ value: activeValue, label: $i18nBundle('common.label.statusEnabled') },
{ value: inactiveValue, label: $i18nBundle('common.label.statusDisabled') }
],
change,
...option
}
}
export const useFormDelay = (prop = 'delay') => {
return {
labelKey: 'common.label.delay',
tooltip: $i18nBundle('common.label.commonDelay', [100]),
type: 'input-number',
prop,
attrs: {
min: 0
}
}
}

View File

@@ -1,6 +1,21 @@
import { ref, watch } from 'vue' import { provide, inject, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue'
import { isFunction, isNumber } from 'lodash-es' import { isFunction, isNumber, uniqueId } from 'lodash-es'
import { useGlobalSearchParamStore } from '@/stores/GlobalSearchParamStore' import { useGlobalSearchParamStore } from '@/stores/GlobalSearchParamStore'
import { $coreHideLoading, $coreShowLoading } from '@/utils'
import { GLOBAL_LOADING } from '@/config'
import Sortable from 'sortablejs'
const defaultPageProcessor = (searchResult, searchParam) => {
const resultData = searchResult?.resultData || searchResult
const pageObj = searchResult?.page || resultData?.page || searchResult?.pageSetting || resultData?.pageSetting
if (pageObj && searchParam.value) {
if (searchParam.value.page) {
Object.assign(searchParam.value.page, pageObj)
} else if (searchParam.value.pageSetting) {
Object.assign(searchParam.value.pageSetting, pageObj)
}
}
}
/** /**
* 通用搜索表格表单封装 * 通用搜索表格表单封装
@@ -10,30 +25,30 @@ import { useGlobalSearchParamStore } from '@/stores/GlobalSearchParamStore'
export const useTableAndSearchForm = ({ export const useTableAndSearchForm = ({
searchMethod, searchMethod,
defaultParam = {}, defaultParam = {},
dataProcessor, dataProcessor = searchResult => searchResult.resultData,
pageProcessor, pageProcessor = defaultPageProcessor,
saveParam = true saveParam = true
}) => { }) => {
const globalSearchParamStore = useGlobalSearchParamStore() const globalSearchParamStore = useGlobalSearchParamStore()
const tableData = ref([]) const tableData = ref([])
const loading = ref(false) const loading = ref(false)
const searchParam = ref(saveParam ? globalSearchParamStore.getCurrentParam(defaultParam) : defaultParam) const searchParam = ref(saveParam ? globalSearchParamStore.getCurrentParam(defaultParam) : defaultParam)
const searchTableItems = async (pageNumber, newParams = {}) => { const searchTableItems = async (pageNumber, newParams = {}, saveConfig) => {
if (isNumber(pageNumber)) { if (isNumber(pageNumber) && searchParam.value) {
searchParam.value?.pageSetting && (searchParam.value.pageSetting.pageNumber = pageNumber) if (searchParam.value.page) {
searchParam.value.page.pageNumber = pageNumber
} else if (searchParam.value.pageSetting) {
searchParam.value.pageSetting.pageNumber = pageNumber
}
} }
loading.value = true loading.value = true
saveParam && globalSearchParamStore.saveCurrentParam(searchParam.value) saveParam && globalSearchParamStore.saveCurrentParam(searchParam.value, saveConfig)
const searchResult = await searchMethod({ ...searchParam.value, ...newParams }) const searchResult = await searchMethod({ ...searchParam.value, ...newParams })
.finally(() => { loading.value = false }) .finally(() => { loading.value = false })
loading.value = false loading.value = false
if (searchResult.success && searchResult.resultData) { if (searchResult.success && searchResult.resultData) {
const resultData = searchResult.resultData tableData.value = isFunction(dataProcessor) && dataProcessor?.(searchResult, searchParam)
tableData.value = isFunction(dataProcessor) && dataProcessor?.(resultData, searchParam) pageProcessor?.(searchResult, searchParam)
pageProcessor = pageProcessor || ((resultData, searchParam) => {
searchParam.value?.pageSetting && resultData.pageSetting && Object.assign(searchParam.value.pageSetting, resultData.pageSetting || {})
})
pageProcessor?.(resultData, searchParam)
} }
return searchResult return searchResult
} }
@@ -45,6 +60,30 @@ export const useTableAndSearchForm = ({
} }
} }
export const useInitLoadOnce = (loader, config = {}) => {
const initLoading = ref(false)
const initLoadOnce = async () => {
if (!initLoading.value) {
try {
initLoading.value = true
if (config.loading ?? GLOBAL_LOADING) {
$coreShowLoading()
}
await loader()
} finally {
initLoading.value = false
if (config.loading ?? GLOBAL_LOADING) {
$coreHideLoading()
}
}
}
}
return {
initLoading,
initLoadOnce
}
}
export const useDateStr = (watchFn) => { export const useDateStr = (watchFn) => {
const dateStr = ref(new Date().getTime()) const dateStr = ref(new Date().getTime())
watchFn && watch(watchFn, (update) => { watchFn && watch(watchFn, (update) => {
@@ -68,3 +107,201 @@ export const useGlobalSaveSearchParam = (defaultParam) => {
} }
} }
} }
export const useManagedArrayItems = () => {
const managedItems = ref([])
const context = ref(false)
const startContext = () => (context.value = true)
const pushItem = (item) => {
const index = managedItems.value.findIndex(it => item.id === it.id)
if (index < 0) {
managedItems.value.push(item)
return item
} else {
return goToItem(index)
}
}
const goToItem = index => {
let result = null
if (index > -1 && (result = managedItems.value[index])) {
context.value = true
managedItems.value = managedItems.value.slice(0, index + 1)
}
return result
}
const clearItems = () => {
if (!context.value) {
managedItems.value = []
}
context.value = false
}
return {
managedItems,
startContext,
pushItem,
goToItem,
clearItems
}
}
export const useSortableParams = (params, selector, moveCls = '.move-indicator') => {
let sortable = null
const sortableRef = ref()
const dragging = ref(false)
const hoverIndex = ref(-1)
const initSortable = () => {
let el = sortableRef.value?.$el || sortableRef.value
if (el && !sortable) {
const tbody = el.querySelector('tbody')
if (tbody) {
el = tbody
}
sortable = new Sortable(el, {
animation: 150,
draggable: selector,
handle: moveCls,
onStart () {
hoverIndex.value = -1
dragging.value = true
},
onEnd (event) {
const { oldIndex, newIndex } = event
params.value.splice(newIndex, 0, params.value.splice(oldIndex, 1)[0]) // 插入到 newIndex 位置
setTimeout(() => {
dragging.value = false
hoverIndex.value = newIndex
})
}
})
}
}
const destroySortable = () => {
if (sortable) {
sortable.destroy()
sortable = null
}
}
onMounted(() => {
initSortable()
})
watch(() => sortableRef.value, (val) => {
if (val) {
initSortable()
} else {
destroySortable()
}
})
onBeforeUnmount(() => {
destroySortable()
})
return {
dragging,
hoverIndex,
sortableRef
}
}
export const useRenderKey = () => {
const renderKeyMap = new WeakMap()
function renderKey (param) {
if (param.id != null) {
return `param-${param.id}`
}
if (!renderKeyMap.has(param)) {
renderKeyMap.set(param, uniqueId())
}
return `param-tmp-${renderKeyMap.get(param)}`
}
return {
renderKey
}
}
export const useProvideDataLoading = (loadingKey = 'dataLoading') => {
const dataLoading = ref(false)
const startLoading = (config = { delay: 100 }) => {
if (!dataLoading.value) {
dataLoading.value = true
$coreShowLoading(config)
}
}
provide(loadingKey, { dataLoading, startLoading })
return {
dataLoading,
startLoading
}
}
export const useInjectDataLoading = (loadingKey = 'dataLoading') => {
const { dataLoading, startLoading } = inject(loadingKey,
{ dataLoading: ref(false), startLoading: () => {} })
const endLoading = () => {
if (dataLoading?.value) {
setTimeout(() => {
$coreHideLoading()
dataLoading.value = false
})
} else {
$coreHideLoading()
}
}
return {
startLoading,
endLoading
}
}
export const useContextMenu = () => {
const showContextMenu = ref(false)
const contextMenuRef = ref(null)
const contextMenuHandlers = ref([])
const contextMenuItem = ref(null)
const contextMenuDropdownRef = ref()
const handleContextMenuVisibleChange = (visible) => {
if (!visible) {
showContextMenu.value = false
}
}
const handleRequestContextMenu = (event, item, buttons) => {
showContextMenu.value = false
contextMenuItem.value = item
contextMenuHandlers.value = buttons || []
contextMenuRef.value = {
getBoundingClientRect () {
return {
width: 0,
height: 0,
top: event.clientY,
bottom: event.clientY,
left: event.clientX,
right: event.clientX
}
}
}
nextTick(() => {
showContextMenu.value = true
nextTick(() => {
contextMenuDropdownRef.value?.handleOpen?.()
})
})
}
return {
showContextMenu,
contextMenuRef,
contextMenuHandlers,
contextMenuItem,
contextMenuDropdownRef,
handleContextMenuVisibleChange,
handleRequestContextMenu
}
}

57
src/hooks/useTabFocus.js Normal file
View File

@@ -0,0 +1,57 @@
import { onMounted, ref } from 'vue'
import { isNumber } from 'lodash-es'
/**
* Hook: 容器内 Tab / Shift+Tab 自动跳转
* @param {import('vue').Ref<HTMLElement>} containerRef - 当前容器 ref
* @param {string} selector - 可聚焦元素选择器,默认 input/textarea/select/button
*/
export function useTabFocus (containerRef, selector) {
containerRef = containerRef || ref()
selector = selector || 'input:not([disabled]):not([readonly]):not([type=checkbox]):not([type=file]), textarea:not([disabled]):not([readonly])'
const getFocusable = () => {
const containerEl = containerRef.value?.$el || containerRef.value
return containerEl ? Array.from(containerEl.querySelectorAll(selector)) : []
}
const focusNext = (currentEl) => {
const elements = getFocusable()
const index = isNumber(currentEl) ? currentEl : elements.indexOf(currentEl)
if (index === -1) return
// 优先寻找没有值的输入框
let targetIndex = -1
const len = elements.length
for (let i = 1; i < len; i++) {
const idx = (index + i) % len
if (!elements[idx].value) {
targetIndex = idx
break
}
}
// 如果都填了值,则按顺序跳转
const nextEl = targetIndex !== -1 ? elements[targetIndex] : (elements[index + 1] || elements[0])
nextEl.focus()
}
const focusPrev = (currentEl) => {
const elements = getFocusable()
const index = isNumber(currentEl) ? currentEl : elements.indexOf(currentEl)
if (index === -1) return
const prevEl = elements[index - 1] || elements[elements.length - 1]
prevEl.focus()
}
onMounted(() => {
const containerEl = containerRef.value?.$el || containerRef.value
if (!containerEl) return
containerEl.addEventListener('keydown', e => {
const target = e.target
if (!target.matches(selector)) return
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); focusNext(target) }
if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); focusPrev(target) }
})
})
return { containerRef, focusNext, focusPrev }
}

View File

@@ -67,17 +67,48 @@ common.label.modifier = '修改人'
common.label.status = '状态' common.label.status = '状态'
common.label.statusEnabled = '启用' common.label.statusEnabled = '启用'
common.label.statusDisabled = '禁用' common.label.statusDisabled = '禁用'
common.label.description = '描述'
common.label.delay = '延迟'
common.label.add = '添加'
common.label.paste = '粘贴'
common.label.name = '名称'
common.label.value = '值'
common.label.files = '文件'
common.label.input = '文本'
common.label.number = '数字'
common.label.date = '日期'
common.label.dateTime = '日期时间'
common.label.file = '文件'
common.label.yes = '是'
common.label.no = '否'
common.label.example = '示例'
common.label.params = '参数'
//= ============通用============ //= ============通用============
common.label.commonCode = '{0}代码' common.label.commonCode = '{0}代码'
common.label.commonConfig = '配置{0}' common.label.commonConfig = '配置{0}'
common.label.commonEdit = '{0}编辑' common.label.commonEdit = '{0}编辑'
common.label.commonAdd = '新增{0}' common.label.commonAdd = '新增{0}'
common.label.commonView = '查看{0}'
common.label.commonDelete = '删除{0}' common.label.commonDelete = '删除{0}'
common.label.commonParent = '上级{0}' common.label.commonParent = '上级{0}'
common.label.commonAdd1 = '添加{0}' common.label.commonAdd1 = '添加{0}'
common.label.commonCopy = '复制{0}' common.label.commonCopy = '复制{0}'
common.label.commonSwap = '交换{0}' common.label.commonSwap = '交换{0}'
common.label.commonTest = '测试{0}'
common.label.commonFormat = '格式化{0}'
common.label.commonSave = '保存{0}'
common.label.commonExpand = '展开{0}'
common.label.commonCollapse = '收起{0}'
common.label.commonEnable = '启用{0}'
common.label.commonDisable = '禁用{0}'
common.label.commonDelay = '延迟{0}毫秒'
common.label.commonDownload = '下载{0}'
common.label.commonExport = '导出为{0}'
common.label.commonGenerate = '生成{0}'
common.label.commonClear = '清空{0}'
common.label.commonBack = '回到{0}'
common.label.commonSelect = '选择{0}'
//* =======================msg=====================// //* =======================msg=====================//
common.msg.nonNull = '{0}不能为空' common.msg.nonNull = '{0}不能为空'
@@ -107,3 +138,6 @@ common.msg.notFound = '页面不存在,请访问其他页面'
common.msg.accessDenied = '没有权限访问该页面' common.msg.accessDenied = '没有权限访问该页面'
common.msg.emailError = '邮箱格式不正确' common.msg.emailError = '邮箱格式不正确'
common.msg.cannotEnterChineseCharacters = '{0}不能输入中文!' common.msg.cannotEnterChineseCharacters = '{0}不能输入中文!'
common.msg.copySuccess = '复制成功。'
common.msg.copyError = '复制失败或当前环境不支持!'
common.msg.pasteToProcess = '粘贴文本、JSON或URL参数自动解析'

View File

@@ -67,17 +67,48 @@ common.label.modifier = 'Modifier'
common.label.status = 'Status' common.label.status = 'Status'
common.label.statusEnabled = 'Enabled' common.label.statusEnabled = 'Enabled'
common.label.statusDisabled = 'Disabled' common.label.statusDisabled = 'Disabled'
common.label.description = 'Description'
common.label.delay = 'Delay'
common.label.add = 'Add'
common.label.paste = 'Paste'
common.label.name = 'Name'
common.label.value = 'Value'
common.label.files = 'Files'
common.label.input = 'Text'
common.label.number = 'Number'
common.label.date = 'Date'
common.label.dateTime = 'Date Time'
common.label.file = 'File'
common.label.yes = 'Yes'
common.label.no = 'No'
common.label.example = 'Example'
common.label.params = 'Params'
//= ============通用============ //= ============通用============
common.label.commonConfig = 'Config {0}' common.label.commonConfig = 'Config {0}'
common.label.commonCode = '{0} Code' common.label.commonCode = '{0} Code'
common.label.commonEdit = '{0} Edit' common.label.commonEdit = '{0} Edit'
common.label.commonAdd = 'Add {0}' common.label.commonAdd = 'Add {0}'
common.label.commonView = 'View {0}'
common.label.commonDelete = 'Delete {0}' common.label.commonDelete = 'Delete {0}'
common.label.commonParent = 'Parent {0}' common.label.commonParent = 'Parent {0}'
common.label.commonAdd1 = 'Add {0}' common.label.commonAdd1 = 'Add {0}'
common.label.commonCopy = 'Copy {0}' common.label.commonCopy = 'Copy {0}'
common.label.commonSwap = 'Swap {0}' common.label.commonSwap = 'Swap {0}'
common.label.commonTest = 'Test {0}'
common.label.commonFormat = 'Format {0}'
common.label.commonSave = 'Save {0}'
common.label.commonExpand = 'Expand {0}'
common.label.commonCollapse = 'Collapse {0}'
common.label.commonEnable = 'Enable {0}'
common.label.commonDisable = 'Disable {0}'
common.label.commonDelay = 'Delay {0}ms'
common.label.commonDownload = 'Download {0}'
common.label.commonExport = 'Export as {0}'
common.label.commonGenerate = 'Generate {0}'
common.label.commonClear = 'Clear {0}'
common.label.commonBack = 'Back to {0}'
common.label.commonSelect = 'Select {0}'
//* =======================msg=====================// //* =======================msg=====================//
common.msg.nonNull = '{0} is required.' common.msg.nonNull = '{0} is required.'
@@ -107,3 +138,6 @@ common.msg.notFound = 'Page not found, please visit other pages!'
common.msg.accessDenied = 'Page access denied' common.msg.accessDenied = 'Page access denied'
common.msg.emailError = 'E-mail format is invalid.' common.msg.emailError = 'E-mail format is invalid.'
common.msg.cannotEnterChineseCharacters = '{0} cannot enter Chinese characters.' common.msg.cannotEnterChineseCharacters = '{0} cannot enter Chinese characters.'
common.msg.copySuccess = 'Copied Successfully.'
common.msg.copyError = 'Copy failed or not supported!'
common.msg.pasteToProcess = 'Paste text, JSON or URL params to parse'

View File

@@ -1,6 +1,6 @@
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { markRaw, ref } from 'vue' import { markRaw, ref, nextTick } from 'vue'
import { isObject, isArray, set, isNumber } from 'lodash-es' import { isObject, isArray, set, isNumber, isFunction, isBoolean, isString, get, cloneDeep } from 'lodash-es'
import { ElLoading, ElMessageBox, ElMessage } from 'element-plus' import { ElLoading, ElMessageBox, ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue' import { QuestionFilled } from '@element-plus/icons-vue'
import numeral from 'numeral' import numeral from 'numeral'
@@ -12,7 +12,7 @@ import { useLoginConfigStore } from '@/stores/LoginConfigStore'
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore' import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
import { useGlobalSearchParamStore } from '@/stores/GlobalSearchParamStore' import { useGlobalSearchParamStore } from '@/stores/GlobalSearchParamStore'
import { useTabsViewStore } from '@/stores/TabsViewStore' import { useTabsViewStore } from '@/stores/TabsViewStore'
import { LoadSaveParamMode } from '@/consts/GlobalConstants' import { GlobalLayoutMode, LoadSaveParamMode } from '@/consts/GlobalConstants'
export const useSystemKey = () => { export const useSystemKey = () => {
return SYSTEM_KEY return SYSTEM_KEY
@@ -284,6 +284,21 @@ export const $coreConfirm = (message, title = $i18nBundle('common.label.reminder
options) options)
} }
export const $corePrompt = (message, title = $i18nBundle('common.label.reminder'), options = undefined) => {
if (isObject(title) && !options) {
options = title
title = null
}
options = Object.assign({
dangerouslyUseHTMLString: true,
draggable: true,
customClass: 'common-message-confirm'
}, options || {})
return ElMessageBox.prompt(message,
title || $i18nBundle('common.label.reminder'),
options)
}
export const $formatNumber = (value, format) => { export const $formatNumber = (value, format) => {
return numeral(value).format(format) return numeral(value).format(format)
} }
@@ -304,12 +319,12 @@ export const $currencyShort = (value, prefix) => {
return `${prefix || '¥'} ${$formatNumber(value, '0,0.[00]')}` return `${prefix || '¥'} ${$formatNumber(value, '0,0.[00]')}`
} }
/** /**
* @typedef {{text:string, success?:string, error?:string}} CopyTextConfig * @typedef {{text:string, success?:string, error?:string, successKey?:string, errorKey?:string}} CopyTextConfig
* @type {CopyTextConfig} * @type {CopyTextConfig}
*/ */
const defaultCopyConfig = { const defaultCopyConfig = {
success: 'Copied Successfully!', successKey: 'common.msg.copySuccess',
error: 'Copy Not supported!' errorKey: 'common.msg.copyError'
} }
/** /**
* @param text {string | CopyTextConfig} 需要复制的文本 * @param text {string | CopyTextConfig} 需要复制的文本
@@ -328,12 +343,12 @@ export const $copyText = (text) => {
if (isSupported) { if (isSupported) {
copy(config.text) copy(config.text)
ElMessage({ ElMessage({
message: config.success, message: config.success || $i18nBundle(config.successKey),
type: 'success' type: 'success'
}) })
} else { } else {
ElMessage({ ElMessage({
message: config.error, message: config.error || $i18nBundle(config.errorKey),
type: 'error' type: 'error'
}) })
} }
@@ -341,6 +356,213 @@ export const $copyText = (text) => {
} }
} }
export const checkShowColumn = (dataList, field) => {
let hasFieldData = false
dataList = dataList || []
let checkFun = (item, f) => {
const value = get(item, f)
return isNumber(value) || isBoolean(value) || !!value
}
if (isFunction(field)) {
checkFun = field
}
for (const item of dataList) {
if (checkFun(item, field)) {
hasFieldData = true
break
}
}
return hasFieldData
}
export const calcAffixOffset = (fix = 10) => {
let initValue = 60 + fix
useGlobalConfigStore().layoutMode === GlobalLayoutMode.TOP && useGlobalConfigStore().isShowBreadcrumb && (initValue += 40)
useTabsViewStore().isTabMode && (initValue += 48)
return initValue
}
/**
* 列表数据解析成树结构
* @template T
* @param items {T[]}数据集
* @param parent {T=} 上级第一次调用都传null
* @param config {Object=}配置信息
* @param config.parentKey {string} 父级字段名
* @param config.valueKey {string} 当前主字段名
* @param config.pre {(item) => void} 前置处理改变item的属性值
* @param config.after {(item) => void} 后置处理改变item的属性值
* @returns {T[]}
*/
export const processTreeData = (items, parent, config) => {
const results = []
const { parentKey, valueKey, clone } = Object.assign({
parentKey: 'parentId',
valueKey: 'id',
clone: true
}, config || {})
items.forEach(current => {
if (!parent) {
if (!current[parentKey]) { // 根节点
const currentNode = clone ? cloneDeep(current) : current
results.push(currentNode)
isFunction(config?.pre) && config.pre(currentNode)
processTreeData(items, currentNode, config)
isFunction(config?.after) && config.after(currentNode)
}
} else {
if (current[parentKey] === parent[valueKey]) {
const currentNode = clone ? cloneDeep(current) : current
parent.children = parent.children || []
parent.children.push(currentNode)
currentNode.parent = parent
isFunction(config?.pre) && config.pre(currentNode)
processTreeData(items, currentNode, config)
isFunction(config?.after) && config.after(currentNode)
}
}
return parent
})
return results
}
/**
* 产生随机字符,数字加字母
* @param len
* @returns {string}
*/
export const $randomStr = (len = 8) => {
const str = Math.random().toString(36).substring(2)
if (str.length >= len) {
return str.substring(0, len)
}
return str + $randomStr(len - str.length)
}
/**
* 下载链接点击
* @param downloadUrl
*/
export const $downloadWithLinkClick = (downloadUrl) => {
const downloadLink = document.createElement('a')
downloadLink.href = downloadUrl
downloadLink.download = 'download'
downloadLink.click()
}
/**
* 清空并设置
* @param valueRef
* @param newValue
* @param emptyValue
*/
export const clearAndSetValue = (valueRef, newValue, emptyValue) => {
valueRef.value = emptyValue
return nextTick(() => {
valueRef.value = newValue
})
}
export const getStyleGrow = flexGrow => ({
flexGrow,
minWidth: `calc(${flexGrow}0%)`
})
/**
* 检查是否包含数组中的任意一个子串(忽略大小写)
* @param {string|string[]} strOrArr - 要检查的字符串或数组
* @param {...string|string[]} searchItems - 要搜索的数据,可以是数组或可变参数
* @returns {boolean}
*/
export const includesAnyIgnoreCase = (strOrArr, ...searchItems) => {
if (!strOrArr) return false
const searches = isArray(searchItems[0]) ? searchItems[0] : searchItems
if (!searches || searches.length === 0) return false
if (isArray(strOrArr)) {
strOrArr = strOrArr.filter(s => !!s).map(s => isString(s) ? s.toLowerCase() : s)
} else {
strOrArr = strOrArr.toLowerCase()
}
return searches.some(item => item && strOrArr.includes(isString(item) ? item.toLowerCase() : item))
}
export const DATE_FAST_REG = /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})?)?$/
export const HAS_TIME_RE = /[ T]\d{2}:\d{2}/
export const isDateString = (val) => {
if (!isString(val)) return false
if (val.length < 10 || val.length > 35) return false
if (!DATE_FAST_REG.test(val)) return false
return dayjs(val).isValid()
}
export const formatDateSmart = (val) => {
const format = HAS_TIME_RE.test(val)
? 'YYYY-MM-DD HH:mm:ss'
: 'YYYY-MM-DD'
return formatDate(val, format)
}
/**
* 格式化文件大小为易读字符串 (B, KB, MB, GB, TB)
* @param {number} bytes 字节数
* @param {number} precision 保留小数位数默认2
* @returns {string}
*/
export const formatFileSize = (bytes, precision = 2) => {
if (!bytes || bytes <= 0 || isNaN(bytes)) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const k = 1024
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1)
const val = bytes / Math.pow(k, i)
const formatted = val % 1 === 0 ? val.toString() : val.toFixed(precision)
return `${formatted} ${units[i]}`
}
/**
* 搜索/输入建议生成函数
* @param keySuggestions
* @returns {(function(*, *): void)|*}
*/
export const calcSuggestionsFunc = (keySuggestions) => {
if (isFunction(keySuggestions)) {
return keySuggestions
} else if (isArray(keySuggestions)) {
return (queryString, cb) => {
const dataList = keySuggestions.map(value => isObject(value) ? value : ({ value }))
.filter(item => {
let valueStr = item?.value ?? ''
valueStr = isString(valueStr) ? valueStr : valueStr.toString()
return valueStr.toLowerCase?.().includes(queryString?.toLowerCase())
})
cb(dataList)
}
}
}
/**
* 组合多个Suggestions配置
* @param args
* @returns {(function(*, *): void)|*}
*/
export const concatValueSuggestions = (...args) => {
const suggestionsArr = args?.filter(suggestions => !!suggestions)
if (suggestionsArr?.length) {
return (queryString, cb) => {
const dataList = []
suggestionsArr.forEach(suggestions => {
const callback = items => isArray(items) && dataList.push(...items)
const suggestionsFunc = calcSuggestionsFunc(suggestions)
if (suggestionsFunc) {
suggestionsFunc(queryString, callback)
}
})
cb(dataList)
}
}
}
export default { export default {
install (app) { install (app) {
router = app.config.globalProperties.$router router = app.config.globalProperties.$router
@@ -354,6 +576,7 @@ export default {
$number, $number,
$currency, $currency,
$currencyShort, $currencyShort,
$formatFileSize: formatFileSize,
$coreShowLoading, $coreShowLoading,
$coreHideLoading, $coreHideLoading,
$coreAlert, $coreAlert,
@@ -361,8 +584,10 @@ export default {
$coreWarning, $coreWarning,
$coreError, $coreError,
$coreConfirm, $coreConfirm,
$corePrompt,
$openNewWin, $openNewWin,
$openWin $openWin,
$randomStr
}) })
} }
} }

View File

@@ -0,0 +1,705 @@
<script setup lang="jsx">
import { defineFormOptions } from '@/components/utils'
import { computed, ref } from 'vue'
import { $copyText, toFlatKeyValue, calcSuggestionsFunc, concatValueSuggestions } from '@/utils'
import { $i18nBundle, $i18nKey } from '@/messages'
import { ElMessage, ElButton } from 'element-plus'
import { isFunction, cloneDeep } from 'lodash-es'
import { useRenderKey, useSortableParams } from '@/hooks/CommonHooks'
import { useTabFocus } from '@/hooks/useTabFocus'
const props = defineProps({
formProp: {
type: String,
default: 'requestParams'
},
nameReadOnly: {
type: Boolean,
default: false
},
nameRequired: {
type: Boolean,
default: false
},
valueReadOnly: {
type: Boolean,
default: false
},
valueRequired: {
type: Boolean,
default: false
},
showEnableSwitch: {
type: Boolean,
default: true
},
showAddButton: {
type: Boolean,
default: true
},
showCopyButton: {
type: Boolean,
default: true
},
showPasteButton: {
type: Boolean,
default: true
},
showRemoveButton: {
type: Boolean,
default: true
},
showValueConfig: {
type: Boolean,
default: true
},
nameKey: {
type: String,
default: 'name'
},
valueKey: {
type: String,
default: 'value'
},
nameSuggestions: {
type: [Array, Function],
default: () => []
},
valueSuggestions: {
type: [Array, Function],
default: () => []
},
nameDynamicOption: {
type: Function,
default: undefined
},
valueDynamicOption: {
type: Function,
default: undefined
},
nameSpan: {
type: Number,
default: 8
},
valueSpan: {
type: Number,
default: 8
},
fileFlag: {
type: Boolean,
default: false
},
singleEnable: {
type: Boolean,
default: false
}
})
const params = defineModel('modelValue', {
type: Array,
default: () => []
})
const VALUE_TYPE_INPUT = 'input'
const VALUE_TYPE_NUMBER = 'number'
const VALUE_TYPE_DATE = 'date'
const VALUE_TYPE_DATETIME = 'datetime'
const VALUE_TYPE_FILE = 'file'
const LEGACY_VALUE_TYPE_INPUT = 'text'
const VALUE_TYPE_OPTIONS = [{
value: VALUE_TYPE_INPUT,
labelKey: 'common.label.input'
}, {
value: VALUE_TYPE_NUMBER,
labelKey: 'common.label.number'
}, {
value: VALUE_TYPE_DATE,
labelKey: 'common.label.date'
}, {
value: VALUE_TYPE_DATETIME,
labelKey: 'common.label.dateTime'
}, {
value: VALUE_TYPE_FILE,
labelKey: 'common.label.file'
}]
const getParamMeta = (param) => {
param.meta = param.meta || {}
return param.meta
}
const normalizeParamValueType = (param) => {
const meta = param.meta || {}
const valueType = meta.type ?? param.type
let result = valueType
if (!valueType || valueType === LEGACY_VALUE_TYPE_INPUT) {
result = VALUE_TYPE_INPUT
}
if (param.type && meta.type !== result) {
getParamMeta(param).type = result
}
return result
}
const isFileParam = param => normalizeParamValueType(param) === VALUE_TYPE_FILE
const isInputParam = param => normalizeParamValueType(param) === VALUE_TYPE_INPUT
const isNumberParam = param => normalizeParamValueType(param) === VALUE_TYPE_NUMBER
const isDateParam = param => [VALUE_TYPE_DATE, VALUE_TYPE_DATETIME].includes(normalizeParamValueType(param))
const getDateValueFormat = type => type === VALUE_TYPE_DATETIME ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'
params.value.forEach(param => {
param.enabled = param.enabled ?? true
})
const addRequestParam = () => {
params.value.push({
enabled: !props.singleEnable || !params.value.filter(param => param.enabled).length,
meta: {
type: VALUE_TYPE_INPUT
}
})
}
const validParams = computed(() => {
return params.value.filter(param => !!param.name && !isFileParam(param))
})
const copyParams = () => $copyText(JSON.stringify(validParams.value))
const calcPasteParams = value => {
let calcParams = []
if (value.startsWith('{')) { // json
try {
let objValue = JSON.parse(value)
if (objValue != null) {
objValue = toFlatKeyValue(objValue)
calcParams = Object.keys(objValue).map(key => {
return {
enabled: true,
[props.nameKey]: key,
[props.valueKey]: objValue[key]
}
})
}
} catch (e) {
ElMessage.error(e.message)
}
} else if (value.startsWith('[')) {
calcParams = JSON.parse(value)
} else {
if (value.indexOf('?') > -1) {
value = value.slice(value.indexOf('?') + 1)
}
calcParams = new URLSearchParams(value).entries().map(entry => {
return {
enabled: true,
[props.nameKey]: entry[0],
[props.valueKey]: entry[1]
}
})
}
return calcParams
}
const showTextModel = ref(false)
const inputTextModel = ref({
text: ''
})
const inputTextOption = {
tooltip: $i18nBundle('common.msg.pasteToProcess'),
prop: 'text',
labelWidth: '40px',
attrs: {
type: 'textarea'
},
change (value) {
if (value) {
const calcParams = calcPasteParams(value)
params.value = [...calcParams]
inputTextModel.value.text = ''
showTextModel.value = false
}
}
}
const showValueSuggestionsDialog = ref(false)
const valueSuggestionsModel = ref({
readonlyItems: [],
items: []
})
const currentValueSuggestionsParam = ref()
const valueTypeOptions = computed(() => {
return VALUE_TYPE_OPTIONS.filter(option => props.fileFlag || option.value !== VALUE_TYPE_FILE)
})
const normalizeValueSuggestion = (item) => {
if (item && typeof item === 'object') {
return {
value: item.value,
description: item.description
}
}
return {
value: item
}
}
const hasValueSuggestionText = value => value !== undefined && value !== ''
const formatValueSuggestion = (item = {}) => {
if (item.description && hasValueSuggestionText(item.value)) {
return `${item.value} - ${item.description}`
}
return item.description || item.value || ''
}
const openValueSuggestions = (item) => {
currentValueSuggestionsParam.value = item
valueSuggestionsModel.value.readonlyItems = (Array.isArray(item.valueSuggestions) ? item.valueSuggestions : [])
.map(normalizeValueSuggestion)
valueSuggestionsModel.value.items = (Array.isArray(item.meta?.valueSuggestions) ? item.meta.valueSuggestions : [])
.map(normalizeValueSuggestion)
showValueSuggestionsDialog.value = true
}
const addValueSuggestion = () => {
valueSuggestionsModel.value.items.push({})
}
const valueSuggestionItems = computed(() => [
...valueSuggestionsModel.value.readonlyItems.map(item => ({
...item,
readonly: true
})),
...valueSuggestionsModel.value.items.map((item, index) => ({
...item,
index,
readonly: false
}))
])
const saveValueSuggestions = () => {
const valueSuggestions = valueSuggestionsModel.value.items
.map(normalizeValueSuggestion)
.filter(item => hasValueSuggestionText(item.value) || item.description)
.map(item => {
const suggestion = {}
if (item.description) {
suggestion.description = item.description
}
if (hasValueSuggestionText(item.value)) {
suggestion.value = item.value
}
return suggestion
})
if (currentValueSuggestionsParam.value) {
const meta = getParamMeta(currentValueSuggestionsParam.value)
meta.valueSuggestions = valueSuggestions
}
}
const hasMetaConfig = item => {
return !!item.meta?.valueSuggestions?.length
}
const getValueOption = (param, paramValueSuggestions, nvSpan) => {
const valueType = normalizeParamValueType(param)
const option = {
labelKey: 'common.label.value',
prop: props.valueKey,
required: props.nameReadOnly || props.valueRequired || param.valueRequired,
colSpan: props.valueSpan || nvSpan,
disabled: props.valueReadOnly,
enabled: !isFileParam(param)
}
if (isDateParam(param)) {
const valueFormat = getDateValueFormat(valueType)
return {
...option,
type: 'date-picker',
attrs: {
type: valueType,
format: valueFormat,
valueFormat,
style: {
width: '100%'
}
}
}
}
if (isNumberParam(param)) {
return {
...option,
type: 'input-number',
attrs: {
controlsPosition: 'right',
style: {
width: '100%'
}
}
}
}
return {
...option,
type: paramValueSuggestions ? 'autocomplete' : 'input',
attrs: {
fetchSuggestions: paramValueSuggestions,
triggerOnFocus: true
},
slots: paramValueSuggestions
? {
default: ({ item }) => formatValueSuggestion(item)
}
: undefined,
dynamicOption: (item, ...args) => {
if (isFunction(item.dynamicOption)) {
return item.dynamicOption(item, ...args)
}
if (isFunction(props.valueDynamicOption)) {
return props.valueDynamicOption(item, ...args)
}
}
}
}
const calcSuggestions = (key = 'name') => {
const keySuggestions = props[`${key}Suggestions`]
return calcSuggestionsFunc(keySuggestions)
}
const paramsOptions = computed(() => {
const nameSuggestions = calcSuggestions('name')
const valueSuggestions = calcSuggestions('value')
return params.value.map((param) => {
const nvSpan = props.showEnableSwitch ? 8 : 10
const paramValueSuggestions = concatValueSuggestions(
param.meta?.valueSuggestions,
param.valueSuggestions,
valueSuggestions
)
return defineFormOptions([{
labelWidth: '30px',
prop: 'enabled',
disabled: props.nameReadOnly,
enabled: props.showEnableSwitch,
type: 'switch',
colSpan: 2,
dynamicOption (item) {
if (props.singleEnable) {
return {
change (value) {
if (value) {
params.value.filter(p => p !== item).forEach(p => (p.enabled = false))
}
}
}
}
}
}, {
labelKey: 'common.label.name',
prop: props.nameKey,
required: props.nameReadOnly || props.nameRequired || param.nameRequired || param.valueRequired,
disabled: props.nameReadOnly,
colSpan: props.nameSpan || nvSpan,
type: nameSuggestions ? 'autocomplete' : 'input',
attrs: {
fetchSuggestions: nameSuggestions,
triggerOnFocus: false
},
dynamicOption: (item, ...args) => {
if (isFunction(item.dynamicOption)) {
return item.dynamicOption(item, ...args)
}
if (isFunction(props.nameDynamicOption)) {
return props.nameDynamicOption(item, ...args)
}
}
}, {
labelWidth: '1px',
prop: 'meta.type',
type: 'select',
value: VALUE_TYPE_INPUT,
children: valueTypeOptions.value,
attrs: {
clearable: false,
style: {
paddingTop: '2px'
}
},
enabled: props.showValueConfig && valueTypeOptions.value.length > 1,
colSpan: 3,
change (value) {
param[props.valueKey] = value === VALUE_TYPE_FILE ? [] : (value === VALUE_TYPE_NUMBER ? undefined : '')
}
}, getValueOption(param, isInputParam(param) ? paramValueSuggestions : undefined, nvSpan), {
labelKey: 'common.label.files',
type: 'upload',
enabled: props.fileFlag && isFileParam(param),
attrs: {
fileList: param[props.valueKey],
'onUpdate:fileList': (files) => {
param[props.valueKey] = files
},
showFileList: true,
autoUpload: false
},
slots: {
trigger () {
return <ElButton type="primary" size="small">{$i18nBundle('common.label.select')}</ElButton>
}
},
colSpan: 6
}])
})
})
const { sortableRef, hoverIndex, dragging } = useSortableParams(params, '.common-params-item')
const { renderKey } = useRenderKey()
useTabFocus(sortableRef)
</script>
<template>
<el-container
ref="sortableRef"
class="flex-column common-params-edit"
style="line-height: normal;"
>
<el-row
v-for="(item, index) in params"
:key="renderKey(item)"
class="padding-bottom2 common-params-item"
@mouseenter="hoverIndex=index"
@mouseleave="hoverIndex=-1"
>
<template
v-for="(option, idx) in paramsOptions[index]"
:key="`${index}_${option.prop}_${option.type}`"
>
<el-col
v-if="option.enabled!==false"
:span="option.colSpan"
>
<common-form-control
label-width="80px"
:model="item"
:option="option"
:prop="`${formProp}.${index}.${option.prop}`"
>
<template #beforeLabel>
<common-icon
v-if="idx===0&&hoverIndex===index&&!dragging"
:size="20"
class="margin-top1 move-indicator"
icon="DragIndicatorFilled"
style="cursor: move;"
/>
</template>
</common-form-control>
</el-col>
</template>
<el-col
:span="3"
class="padding-left2 padding-top1 common-params-actions"
>
<el-button
v-if="item.array"
type="success"
size="small"
circle
@click="params.splice(index + 1, 0, cloneDeep(item))"
>
<common-icon icon="Plus" />
</el-button>
<el-button
v-if="showRemoveButton&&!nameReadOnly&&!valueReadOnly"
type="danger"
size="small"
circle
@click="params.splice(index, 1)"
>
<common-icon icon="Delete" />
</el-button>
<el-tooltip
v-if="props.showValueConfig && isInputParam(item)"
:content="$i18nKey('common.label.commonConfig', 'common.label.value')"
placement="top"
>
<el-button
:type="hasMetaConfig(item)?'warning':'info'"
size="small"
circle
@click="openValueSuggestions(item)"
>
<common-icon icon="Setting" />
</el-button>
</el-tooltip>
</el-col>
<el-col
v-if="$slots.item"
:span="24"
>
<slot
name="item"
:item="item"
:index="index"
/>
</el-col>
</el-row>
<el-row>
<el-col>
<el-button
v-if="showAddButton&&!nameReadOnly&&!valueReadOnly"
type="primary"
size="small"
@click="addRequestParam()"
>
<common-icon
class="margin-right1"
icon="Plus"
/>
{{ $t('common.label.add') }}
</el-button>
<el-button
v-if="showCopyButton&&validParams?.length"
type="success"
size="small"
@click="copyParams()"
>
<common-icon
class="margin-right1"
icon="DocumentCopy"
/>
{{ $i18nKey('common.label.commonCopy', 'common.label.params') }}
</el-button>
<el-button
v-if="showPasteButton&&!nameReadOnly&&!valueReadOnly"
:type="showTextModel?'success':'info'"
size="small"
@click="showTextModel=!showTextModel"
>
<common-icon
class="margin-right1"
icon="ContentPasteGoFilled"
/>
{{ $t('common.label.paste') }}
</el-button>
<common-form-control
v-if="showTextModel"
class="padding-top2"
:model="inputTextModel"
:option="inputTextOption"
/>
</el-col>
</el-row>
<common-window
v-model="showValueSuggestionsDialog"
:title="$i18nKey('common.label.commonConfig', 'common.label.value')"
width="650px"
:ok-click="saveValueSuggestions"
>
<el-container class="flex-column value-suggestions-window">
<el-row class="padding-bottom2 value-suggestions-header">
<el-col :span="10">
{{ $t('common.label.value') }}
</el-col>
<el-col
:span="11"
class="padding-left2"
>
{{ $t('common.label.description') }}
</el-col>
<el-col :span="3" />
</el-row>
<el-row
v-for="(suggestion, index) in valueSuggestionItems"
:key="`${suggestion.readonly?'readonly':'custom'}_${index}`"
class="padding-bottom2"
>
<el-col :span="10">
<el-input
v-if="suggestion.readonly"
:model-value="suggestion.value"
disabled
/>
<el-input
v-else
v-model="valueSuggestionsModel.items[suggestion.index].value"
clearable
/>
</el-col>
<el-col
:span="11"
class="padding-left2"
>
<el-input
v-if="suggestion.readonly"
:model-value="suggestion.description"
disabled
/>
<el-input
v-else
v-model="valueSuggestionsModel.items[suggestion.index].description"
clearable
/>
</el-col>
<el-col
:span="3"
class="padding-left2"
>
<el-button
v-if="!suggestion.readonly"
type="danger"
size="small"
circle
@click="valueSuggestionsModel.items.splice(suggestion.index, 1)"
>
<common-icon icon="Delete" />
</el-button>
</el-col>
</el-row>
<el-row>
<el-col>
<el-button
type="primary"
size="small"
@click="addValueSuggestion()"
>
<common-icon
class="margin-right1"
icon="Plus"
/>
{{ $t('common.label.add') }}
</el-button>
</el-col>
</el-row>
</el-container>
</common-window>
</el-container>
</template>
<style scoped>
.common-params-edit :deep(.common-params-actions) {
white-space: nowrap;
}
.common-params-edit :deep(.common-params-actions .el-tooltip) {
margin-left: 12px;
}
.value-suggestions-window {
padding-top: 4px;
}
.value-suggestions-header {
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>

View File

@@ -44,8 +44,8 @@ const chartConfig = {
<el-container class="container-center"> <el-container class="container-center">
<v-chart <v-chart
v-if="chartConfig" v-if="chartConfig"
class="chart"
:key="theme" :key="theme"
class="chart"
:theme="theme" :theme="theme"
:option="chartConfig" :option="chartConfig"
autoresize autoresize

View File

@@ -4,6 +4,7 @@ import { useCityAutocompleteConfig, useCitySelectPageConfig } from '@/services/c
import { $i18nMsg } from '@/messages' import { $i18nMsg } from '@/messages'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { defineFormOptions } from '@/components/utils' import { defineFormOptions } from '@/components/utils'
import { useFormStatus, useFormDelay } from '@/consts/GlobalConstants'
const defaultCity = ref({}) const defaultCity = ref({})
@@ -67,6 +68,10 @@ const formOptions = computed(() => {
value: '', value: '',
placeholder: '请输入用户名', placeholder: '请输入用户名',
required: true, required: true,
tooltips: [
{ tooltip: '用户名在2-6位之间' },
{ tooltip: '点击查看帮助', tooltipFunc: () => ElMessage.info('用户名规则帮助') }
],
rules: [ rules: [
{ {
min: 2, min: 2,
@@ -182,12 +187,29 @@ const formOptions = computed(() => {
maxlength: 100, maxlength: 100,
showWordLimit: true showWordLimit: true
} }
},
useFormStatus('status'),
useFormDelay('delay'),
{
label: '扩展参数',
prop: 'extraParams',
type: 'common-object',
value: { env: 'production', role: 'admin' }
}]) }])
}) })
const userDto = ref({ const userDto = ref({
status: 1,
delay: 200,
extraParams: {
env: 'production',
role: 'admin'
},
contacts: [{ contacts: [{
name: 'Jerry', name: 'Jerry',
phone: '1234567890' phone: '1234567890'
}, {
name: 'Tom',
phone: '1987654321'
}] }]
}) })
/** /**
@@ -204,10 +226,10 @@ const contactsOptions = ref([{
}]) }])
const addContact = () => { const addContact = () => {
if (userDto.value.contacts.length < 3) { if (userDto.value.contacts.length < 5) {
userDto.value.contacts.push({}) userDto.value.contacts.push({})
} else { } else {
ElMessage.error('联系人不能超过3个') ElMessage.error('联系人不能超过5个')
} }
} }
@@ -216,12 +238,11 @@ const deleteContact = idx => {
} }
const submitForm = (form) => { const submitForm = (form) => {
console.info(form)
form.validate((valid) => { form.validate((valid) => {
if (valid) { if (valid) {
console.log('submit!') ElMessage.success('表单校验通过并提交!')
} else { } else {
console.log('error submit!') ElMessage.warning('表单校验未通过')
return false return false
} }
}) })
@@ -237,37 +258,24 @@ const submitForm = (form) => {
@submit-form="submitForm" @submit-form="submitForm"
> >
<template #default> <template #default>
<el-form-item> <el-form-item label="联系人列表">
<el-button <el-button
type="primary" type="primary"
size="small" size="small"
class="margin-bottom2"
@click="addContact" @click="addContact"
> >
添加联系人 添加联系人
</el-button> </el-button>
</el-form-item> <common-table-form
<div class="form-edit-width-100"
v-for="(contact, index) in userDto.contacts" :model="userDto"
:key="index" :form-options="contactsOptions"
class="common-subform el-form--inline" data-list-key="contacts"
> sortable
<common-form-control @delete="deleteContact($event.index)"
v-for="(option, optIdx) in contactsOptions"
:key="`${index}-${optIdx}`"
:model="contact"
:option="option"
:prop="`contacts.${index}.${option.prop}`"
/> />
<el-form-item>
<el-button
type="danger"
size="small"
@click.prevent="deleteContact(index)"
>
Delete
</el-button>
</el-form-item> </el-form-item>
</div>
</template> </template>
<template <template
#buttons="{form}" #buttons="{form}"
@@ -277,8 +285,13 @@ const submitForm = (form) => {
</el-button> </el-button>
</template> </template>
</common-form> </common-form>
<div> <div class="margin-top4">
{{ userDto }} <el-card>
<template #header>
<span>表单数据预览 (JSON)</span>
</template>
<pre>{{ JSON.stringify(userDto, null, 2) }}</pre>
</el-card>
</div> </div>
</el-container> </el-container>
</template> </template>

View File

@@ -2,6 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { defineFormOptions } from '@/components/utils' import { defineFormOptions } from '@/components/utils'
const showWindow = ref(false) const showWindow = ref(false)
const okLoading = ref(false)
const userModel = ref({ const userModel = ref({
cert: {} cert: {}
@@ -42,9 +43,14 @@ const formOptions2 = defineFormOptions([
]) ])
const submitForm = ({ form }) => { const submitForm = ({ form }) => {
console.info('=============================', form)
form.validate(valid => { form.validate(valid => {
console.info('======================valid', valid) if (valid) {
okLoading.value = true
setTimeout(() => {
okLoading.value = false
showWindow.value = false
}, 1000)
}
}) })
return false return false
} }
@@ -62,6 +68,7 @@ const submitForm = ({ form }) => {
</el-button> </el-button>
<common-window <common-window
v-model="showWindow" v-model="showWindow"
:ok-loading="okLoading"
:ok-click="submitForm" :ok-click="submitForm"
show-fullscreen show-fullscreen
> >

View File

@@ -8,30 +8,33 @@ import eslint from 'vite-plugin-eslint'
import { visualizer } from 'rollup-plugin-visualizer' import { visualizer } from 'rollup-plugin-visualizer'
import packageJson from './package.json' import packageJson from './package.json'
const optionalPlugins = [{
plugin: visualizer({ open: true }),
enabled: false
}, {
plugin: viteMockServe({
mockPath: './mock'
}),
enabled: true
}].filter(p => p.enabled).map(p => p.plugin)
const JS_FILE_NAMES = 'js/[name]-[hash].js' const JS_FILE_NAMES = 'js/[name]-[hash].js'
const CSS_FILE_NAMES = 'css/[name]-[hash].css' const CSS_FILE_NAMES = 'css/[name]-[hash].css'
const IMG_EXT_LIST = ['.png', '.jpg', '.gif', '.svg', '.bmp', '.webp'] const IMG_EXT_LIST = ['.png', '.jpg', '.gif', '.svg', '.bmp', '.webp']
const IMG_FILE_NAMES = 'images/[name]-[hash].[ext]' const IMG_FILE_NAMES = 'images/[name]-[hash].[ext]'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default ({ mode }) => { export default ({ mode, command }) => {
const env = loadEnv(mode, process.cwd()) const env = loadEnv(mode, process.cwd())
const optionalPlugins = [{
plugin: visualizer({ open: true }),
enabled: false
}, {
plugin: viteMockServe({
mockPath: './mock',
enable: command === 'serve'
}),
enabled: command === 'serve'
}].filter(p => p.enabled).map(p => p.plugin)
return defineConfig({ return defineConfig({
base: env.VITE_APP_CONTEXT_PATH, base: env.VITE_APP_CONTEXT_PATH,
define: { define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version) 'import.meta.env.VITE_APP_VERSION': JSON.stringify(packageJson.version)
}, },
plugins: [vue(), vueJsx(), eslint(), ...optionalPlugins], plugins: [vue(), vueJsx(), eslint(), ...optionalPlugins],
esbuild: { esbuild: {
drop: mode === 'production' ? ['console', 'debugger'] : [] drop: mode === 'production' ? ['console', 'debugger'] : []
}, },