1. 升级依赖版本

This commit is contained in:
Gary Fu
2024-03-30 20:56:30 +08:00
parent 5fa3fdbc78
commit 3d803b4ef5
41 changed files with 3340 additions and 663 deletions

4
.env
View File

@@ -1,7 +1,7 @@
# 程序名称
VITE_APP_NAME=Simple Element+
# 接口地址
VITE_APP_API_BASE_URL=https://www.fastmock.site/mock/80793bea9d60828fda74202f7017e953/simple
VITE_APP_API_BASE_URL=/simple
# 超时配置
VITE_APP_API_TIMEOUT=10000
VITE_APP_SYSTEM_KEY=SIMPLE-ELEMENT

75
mock/MockCity.js Normal file
View File

@@ -0,0 +1,75 @@
import Mock from 'mockjs'
export default [
{
url: '/simple/city/autoCities',
method: 'post',
response: request => {
return {
success: true,
message: 'Success',
resultData: function () {
let pageSize = 10
if (request.body.page) {
pageSize = request.body.page.pageSize || 10
}
const total = 99
const pageCount = parseInt((total + pageSize - 1) / pageSize)
const result = {
page: {
pageSize: function () {
return pageSize
},
pageNumber: function () {
if (request.body.page) {
return request.body.page.pageNumber || 1
}
return 1
},
pageCount,
totalCount: total
}
}
let size = 10
if (request.body.page) {
size = request.body.page.pageSize
}
result['cityList|' + size] = [{
code: function () {
return Mock.Random.string('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 3)
},
nameCn: function () {
return Mock.Random.city()
},
nameEn: function () {
return 'En' + this.nameCn
}
}]
return Mock.mock(result)
}
}
}
},
{
url: '/simple/city/selectCities',
method: 'post',
response: request => {
return {
success: true,
message: 'Success',
resultData: {
'cityList|20-70': [{
code: function () {
return Mock.Random.string('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 3)
},
nameCn: function () {
return Mock.Random.city()
},
nameEn: function () {
return 'En' + this.nameCn
}
}]
}
}
}
}
]

31
mock/MockLogin.js Normal file
View File

@@ -0,0 +1,31 @@
/**
*/
export default [{
url: '/simple/login',
method: 'post',
response: (request) => {
return {
success: function () {
return request.body.userName === 'admin' && request.body.userPassword === '123456'
},
message: function () {
return this.success ? '登录成功' : '用户名或密码错误'
},
resultData: function () {
if (this.success) {
return {
account: {
userNameEN: 'Tom',
userNameCN: '汤姆',
gender: 'male',
email: 'tomcat@fugary.com'
},
accessToken: 'abcdefghijklmn'
}
} else {
return null
}
}
}
}
}]

159
mock/MockMenus.js Normal file
View File

@@ -0,0 +1,159 @@
const allMenus = [
{
id: 1,
iconCls: 'setting',
nameCn: '系统管理',
nameEn: 'System'
},
{
id: 11,
parentId: 1,
iconCls: 'user',
nameCn: '用户管理',
nameEn: 'Users',
menuUrl: '/admin/users'
},
{
id: 12,
parentId: 1,
iconCls: 'GroupOutlined',
nameCn: '角色管理',
nameEn: 'Roles',
menuUrl: '/admin/roles'
},
{
id: 13,
parentId: 1,
iconCls: 'lock',
nameCn: '权限管理',
nameEn: 'Authority',
menuUrl: '/admin/authority'
},
{
id: 14,
parentId: 1,
iconCls: 'GroupsOutlined',
nameCn: '用户组管理',
nameEn: 'Groups',
menuUrl: '/admin/groups'
},
{
id: 15,
parentId: 1,
iconCls: 'SupervisedUserCircleOutlined',
nameCn: '租户管理',
nameEn: 'Tenants',
menuUrl: '/admin/tenants'
},
{
id: 16,
parentId: 1,
iconCls: 'menu',
nameCn: '菜单管理',
nameEn: 'Menus',
menuUrl: '/admin/menus'
},
{
id: 2,
iconCls: 'BuildFilled',
nameCn: '常用工具',
nameEn: 'Tools'
},
{
id: 21,
parentId: 2,
iconCls: 'InsertEmoticonOutlined',
nameCn: '图标管理',
nameEn: 'Icons',
menuUrl: '/icons'
},
{
id: 22,
parentId: 2,
iconCls: 'TableRowsFilled',
nameCn: '表单示例',
nameEn: 'Forms',
menuUrl: '/forms'
},
{
id: 23,
parentId: 2,
iconCls: 'Grid',
nameCn: '表格示例',
nameEn: 'Tables',
menuUrl: '/tables'
},
{
id: 24,
parentId: 2,
iconCls: 'TipsAndUpdatesOutlined',
nameCn: '其他示例',
nameEn: 'Others',
menuUrl: '/tests'
}
]
export default [{
url: '/simple/api/menus',
method: 'post',
response: () => {
return {
success: true,
message: 'Success',
resultData: {
menuList: allMenus
}
}
}
}, {
url: '/simple/api/searchMenus',
method: 'post',
response: request => {
return {
success: true,
message: 'Success',
resultData: function () {
const menuList = allMenus
let pageSize = 10
let pageNumber = 1
if (request.body.page) {
pageSize = +request.body.page.pageSize || 10
pageNumber = +request.body.page.pageNumber || 1
}
const total = menuList.length
const pageCount = (total + pageSize - 1) / pageSize
const result = {
page: {
pageSize: function () {
return pageSize
},
pageNumber: function ({ request }) {
if (request.body.page) {
return request.body.page.pageNumber || 1
}
return 1
},
pageCount,
totalCount: total
}
}
result.menuList = menuList.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
return result
}
}
}
}, {
url: '/simple/api/menus/:id',
method: 'get',
response: request => {
return {
success: true,
message: 'Success',
resultData: function () {
return {
menu: allMenus.filter(menu => menu.id === +request.query.id)[0]
}
}
}
}
}]

74
mock/MockUsers.js Normal file
View File

@@ -0,0 +1,74 @@
import Mock from 'mockjs'
export default [
{
url: '/simple/api/users',
method: 'post',
response: request => {
return {
success: true,
message: 'Success',
resultData: function () {
let pageSize = 10
if (request.body.page) {
pageSize = +request.body.page.pageSize || 10
}
const total = 999
const pageCount = (total + pageSize - 1) / pageSize
const result = {
page: {
pageSize: function () {
return pageSize
},
pageNumber: function () {
if (request.body.page) {
return request.body.page.pageNumber || 1
}
return 1
},
pageCount,
totalCount: total
}
}
let size = 10
if (request.body.page) {
size = request.body.page.pageSize
}
result['userList|' + size] = [{
id: '@id',
'gender|1': ['male', 'female'],
nameCn: '@cname',
nameEn: '@name',
address: function () {
return Mock.Random.city(true)
},
birthday: '@date'
}]
return Mock.mock(result)
}
}
}
}, {
url: '/simple/api/users/:id',
method: 'get',
response: request => {
return {
success: true,
message: 'Success',
resultData: {
user: {
id: function () {
return request.query.id
},
'gender|1': ['male', 'female'],
nameCn: '@cname',
nameEn: '@name',
address: function () {
return Mock.Random.city(true)
},
birthday: '@date'
}
}
}
}
}
]

1445
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,27 +11,29 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vicons/material": "^0.12.0",
"@vueuse/core": "^10.7.0",
"axios": "^1.6.3",
"@vueuse/core": "^10.9.0",
"axios": "^1.6.8",
"dayjs": "^1.11.10",
"element-plus": "^2.4.4",
"element-plus": "^2.6.3",
"lodash": "^4.17.21",
"mockjs": "^1.1.0",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"vue": "^3.3.13",
"vue-i18n": "^9.8.0",
"vue-router": "^4.2.5",
"vite-plugin-mock": "^3.0.1",
"vue": "^3.4.21",
"vue-i18n": "^9.10.2",
"vue-router": "^4.3.0",
"vue-virtual-scroller": "^2.0.0-beta.8"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.6.1",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@vitejs/plugin-vue": "^4.5.2",
"@rushstack/eslint-patch": "^1.10.1",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-standard": "^8.0.1",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"typescript": "^5.3.3",
"vite": "^5.0.10"
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.24.0",
"typescript": "^5.4.3",
"vite": "^5.2.7"
}
}

View File

@@ -19,23 +19,62 @@ html, body, #app, .index-container {
border-right: 0 none;
}
.common-el-tooltip {
max-width: 500px;
}
.common-dropdown .el-dropdown-link {
display: flex;
align-items: center;
}
.el-dropdown-link:focus {
outline: none;
}
.el-menu-left:not(.el-menu--collapse) {
width: 250px;
min-height: 400px;
}
.no-padding {
padding: 0;
}
.padding-5 {
padding: 5px;
}
.padding-10 {
padding: 10px;
}
.padding-15 {
padding: 15px;
}
.padding-main {
padding: var(--el-main-padding);
}
.padding-left1 {
padding-left: 5px;
}
.padding-left2 {
padding-left: 10px;
}
.padding-left3 {
padding-left: 15px;
}
.padding-right1 {
padding-right: 5px;
}
.padding-right2 {
padding-right: 10px;
}
.padding-right3 {
padding-right: 15px;
}
.padding-top1 {
padding-top: 5px;
@@ -43,12 +82,59 @@ html, body, #app, .index-container {
.padding-top2 {
padding-top: 10px;
}
.padding-top3 {
padding-top: 15px;
}
.padding-bottom1 {
padding-bottom: 5px;
}
.padding-bottom2 {
padding-bottom: 10px;
}
.padding-bottom3 {
padding-bottom: 15px;
}
.margin-bottom1 {
margin-bottom: 5px;
}
.margin-bottom2 {
margin-bottom: 10px;
}
.margin-bottom3 {
margin-bottom: 15px;
}
.margin-left1 {
margin-left: 5px;
}
.margin-left2 {
margin-left: 10px;
}
.margin-left3 {
margin-left: 15px;
}
.margin-right1 {
margin-right: 5px;
}
.margin-right2 {
margin-right: 10px;
}
.margin-right3 {
margin-right: 15px;
}
.margin-top1 {
margin-top: 5px;
}
.margin-top2 {
margin-top: 10px;
}
.margin-top3 {
margin-top: 15px;
}
.text-center {
text-align: center;
}
@@ -94,6 +180,10 @@ html, body, #app, .index-container {
flex-direction: column;
}
.flex-start {
align-items: flex-start;
}
.container-center {
display: flex;
justify-content: center;
@@ -101,22 +191,63 @@ html, body, #app, .index-container {
padding-top: 20px;
}
.common-form.el-form--inline .el-input{
--el-input-width: 220px;
.reason-code-container .el-radio__label{
white-space: break-spaces;
}
.common-tabs .el-tabs__new-tab {
width: 50%;
border: none;
margin: 10px 10px 0 10px;
justify-content: right;
}
.el-tabs__item.is-active {
font-weight: bold;
}
.common-form.el-form--inline .el-select,
.common-subform.el-form--inline .el-select{
--el-select-width: 200px;
}
.common-form.el-form--inline .el-input,
.common-subform.el-form--inline .el-input{
--el-input-width: 220px;
--el-input-width: 200px;
--el-date-editor-width: 200px;
}
.common-form-small.common-form.el-form--inline .el-select{
--el-select-width: 160px;
}
.common-form-small.common-form.el-form--inline .el-input{
--el-input-width: 160px;
--el-date-editor-width: 160px;
}
.form-edit-width-70 {
width:70%
}
.form-edit-width-90 {
width:90%
}
.form-edit-width-100 {
width:100%
}
.form-edit-width-70 .el-select:not(:is(.el-form--inline .el-select,.el-pagination .el-select)),
.form-edit-width-90 .el-select:not(:is(.el-form--inline .el-select,.el-pagination .el-select)),
.form-edit-width-100 .el-select:not(:is(.el-form--inline .el-select,.el-pagination .el-select)){
width: 100%;
}
.pointer {
cursor: pointer;
}
.common-autocomplete .el-popover__title{
font-size: 14px;
font-weight: 600;
@@ -133,6 +264,8 @@ html, body, #app, .index-container {
.common-autocomplete .common-select-page .el-tabs__item {
height: 30px;
padding-left: 10px !important;
padding-right: 10px !important;
}
.common-autocomplete .common-select-page .common-select-page-btn {
@@ -146,6 +279,90 @@ html, body, #app, .index-container {
padding: 5px;
}
.common-hide-expand.el-table .el-table__expand-icon {
display: none;
}
.exchange-button {
position: absolute;
right: -20px;
}
.small-card .el-card__header{
padding: 10px;
}
.small-card.el-card {
margin-bottom: 15px;
}
.table-card .el-card__body {
padding: 0;
}
.product-book-card .el-card__body {
padding: 10px;
}
.operation-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.common-data-row label {
font-weight: 600 ;
}
.common-data-row .el-col {
padding: 3px 0;
}
.el-dropdown+.el-button {
margin-left: 12px;
}
.segment-label {
font-weight: 600;
}
.common-form-auto {
display: flex;
flex-wrap: wrap;
}
.common-form-auto .el-form-item {
flex-grow: 1;
flex-basis: 0;
}
.common-form-auto .el-form-item__content {
height: 32px;
}
.common-form-auto .el-form--inline .el-input,
.common-form-auto.el-form--inline .el-input,
.common-form-auto .el-form--inline .el-select,
.common-form-auto.el-form--inline .el-select {
--el-input-width: 100%;
--el-date-editor-width: 100%;
}
.common-form-auto.el-form--inline .el-form-item,
.common-form-auto .el-form--inline .el-form-item {
margin-right: 10px;
}
.el-form-item.is-required .common-form-label .common-form-label-text:before {
content: "*";
color: var(--el-color-danger);
margin-right: 4px;
}
.flex-grow2 {
flex-grow: 2;
}
/**
* slide-fade动画
*/

View File

@@ -1,8 +1,7 @@
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { debounce, isEmpty, isObject } from 'lodash'
import { debounce, isEmpty, isObject, cloneDeep, chunk } from 'lodash'
import { onClickOutside, onKeyStroke, useVModel } from '@vueuse/core'
import chunk from 'lodash/chunk'
import { UPDATE_MODEL_EVENT, CHANGE_EVENT, useFormItem } from 'element-plus'
/**
@@ -51,7 +50,7 @@ const props = defineProps({
},
inputWidth: {
type: String,
default: '200px'
default: ''
},
autocompleteConfig: {
type: Object,
@@ -128,10 +127,13 @@ const selectPageData = ref({})
const selectPageTab = ref(null)
const popoverVisible = ref(false)
const autocompletePopover = ref()
const autoPage = ref({
pageSize: 8,
const defaultAutoPage = {
pageSize: props.autocompleteConfig?.pageSize || 8,
pageNumber: 1
})
}
const autoPage = ref(props.autocompleteConfig?.frontendPaging
? null
: cloneDeep(defaultAutoPage))
const loadingData = ref(false)
const idProp = computed(() => props.autocompleteConfig?.idProp || props.idProp)
@@ -194,10 +196,21 @@ const onInputKeywords = debounce((input) => {
}
}, props.debounceTime)
const calcDefaultLabelFunc = () => {
if (!props.useIdModel) {
const value = props.modelValue
return value && isObject(value) ? value[labelProp.value] : ''
}
return props.defaultLabel
}
const calcDefaultLabel = computed(calcDefaultLabelFunc)
onMounted(() => {
onClickOutside(autocompletePopover.value?.popperRef?.contentRef, () => {
popoverVisible.value = false
})
setAutocompleteLabel(calcDefaultLabel.value)
})
watch(() => popoverVisible.value, (val) => {
@@ -211,7 +224,6 @@ watch(() => popoverVisible.value, (val) => {
})
watch(() => props.modelValue, (value) => {
console.info('=====================value', value)
if (!props.useIdModel) {
setAutocompleteLabel(value && isObject(value) ? value[labelProp.value] : '')
if (isEmpty(value)) {
@@ -220,13 +232,7 @@ watch(() => props.modelValue, (value) => {
}
})
watch(() => {
if (!props.useIdModel) {
const value = props.modelValue
return value && isObject(value) ? value[labelProp.value] : ''
}
return props.defaultLabel
}, (label) => {
watch(calcDefaultLabelFunc, (label) => {
setAutocompleteLabel(label)
})
@@ -287,7 +293,6 @@ const moveSelection = function (down) {
currentOnIndex.value = -1
currentOnRow.value = null
}
console.info('=================', tableRef.value.table, currentOnIndex.value, currentOnRow.value)
tableRef.value.table?.setCurrentRow(currentOnRow.value)
}
@@ -349,6 +354,15 @@ watch(() => props.selectPageConfig, () => {
selectPageTab.value = null
})
watch(() => props.autocompleteConfig, (autocompleteConfig) => {
defaultAutoPage.pageSize = autocompleteConfig.pageSize || 8
if (autocompleteConfig.frontendPaging) {
autoPage.value = null
} else {
autoPage.value = cloneDeep(defaultAutoPage)
}
})
</script>
<template>
@@ -443,6 +457,8 @@ watch(() => props.selectPageConfig, () => {
:empty-text="autocompleteConfig.emptyMessage"
:data="dataList"
:page-attrs="pageAttrs"
:frontend-paging="autocompleteConfig.frontendPaging"
:frontend-page-size="defaultAutoPage.pageSize"
@row-click="onSelectData($event)"
@current-page-change="onInputKeywords(false)"
>

View File

@@ -19,6 +19,18 @@ export interface CommonSelectPageOption {
}
export interface CommonAutocompleteOption {
/** id属性名 */
labelProp?: string;
/** label属性名 */
idProp?: string;
/**
* 分页数
*/
pageSize: number;
/**
* 前端分页模式
*/
frontendPaging: boolean;
/** 自动完成表格列配置 */
columns: Array<CommonTableColumn>;
/** 空数据提示信息 */

View File

@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useTabsViewStore } from '@/stores/TabsViewStore'
import { useRoute } from 'vue-router'
import { useMenuInfo, useMenuName } from '@/components/utils'
import { parsePathParams, useMenuInfo, useMenuName } from '@/components/utils'
const tabsViewStore = useTabsViewStore()
@@ -20,7 +20,7 @@ const breadcrumbs = computed(() => {
icon = item.meta.icon
}
return {
path: item.path,
path: parsePathParams(item.path, route.params),
menuName: useMenuName(item),
icon
}
@@ -32,11 +32,11 @@ const breadcrumbs = computed(() => {
return notExist && !item.menuName.endsWith('Base')
})
})
</script>
<template>
<el-breadcrumb
v-bind="$attrs"
class="common-breadcrumb"
>
<el-breadcrumb-item

View File

@@ -0,0 +1,136 @@
<script setup>
import { get, isArray, isObject, set } from 'lodash'
import { computed, onMounted, useSlots } from 'vue'
import cloneDeep from 'lodash/cloneDeep'
const props = defineProps({
/**
* @type {CommonFormOption}
*/
option: {
type: Object,
required: true
},
model: {
type: Object,
required: true
},
labelWidth: {
type: String,
default: '150px'
},
prop: {
type: String,
default: ''
},
unlimitedEnable: {
type: Boolean,
default: true
}
})
const calcFilterType = computed(() => props.option.type || 'checkbox-group')
const isUnlimited = computed(() => {
const option = props.option
const value = get(props.model, option.prop)
const filterType = calcFilterType.value
if (filterType === 'common-tab-filter') {
if (isObject(value) && option.attrs?.tabs && option.attrs?.tabs.length) {
let unlimited = true
option.attrs.tabs.forEach(tab => {
const tabVal = get(value, tab.prop)
if (unlimited) {
if (isArray(tabVal)) {
if (tabVal.length) {
unlimited = false
}
} else {
unlimited = !tabVal
}
}
})
return unlimited
}
}
return !value || !value.length
})
const setUnlimited = () => {
const option = props.option
const filterType = calcFilterType.value
let value
if (filterType.includes('checkbox')) {
value = []
} else if (filterType === 'common-tab-filter') {
value = {}
}
set(props.model, option.prop, value)
}
const initFilterModel = () => {
const filterType = calcFilterType.value
if (filterType === 'common-tab-filter') {
const value = get(props.model, props.option.prop)
if (!value) {
set(props.model, props.option.prop, {})
}
}
}
onMounted(() => {
initFilterModel()
})
const newOption = computed(() => {
const option = cloneDeep(props.option)
option.type = calcFilterType.value
return option
})
const slots = computed(() => {
const tmpSlots = cloneDeep(useSlots())
delete tmpSlots.afterLabel
return tmpSlots
})
</script>
<template>
<common-form-control
:label-width="labelWidth"
:model="model"
:option="newOption"
>
<template
v-if="unlimitedEnable"
#afterLabel
>
<slot name="afterLabel" />
<el-link
class="margin-left1 margin-top1"
type="primary"
:underline="false"
@click="setUnlimited()"
>
<el-tag
size="small"
:effect="isUnlimited?'dark':'light'"
>
{{ $t('common.label.unlimited') }}
</el-tag>
</el-link>
</template>
<template
v-for="(slot, slotKey) in slots"
:key="slotKey"
#[slotKey]
>
<slot :name="slotKey" />
</template>
</common-form-control>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,23 @@
<script setup>
defineProps({
component: {
type: Object,
default: null
}
})
</script>
<template>
<div class="common-form-label">
<slot />
<component
:is="component"
v-if="component"
v-bind="$attrs"
/>
</div>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,89 @@
<script setup>
import { useVModel } from '@vueuse/core'
import ControlChild from '@/components/common-form-control/control-child.vue'
import { computed } from 'vue'
import { useInputType } from '@/components/utils'
const props = defineProps({
modelValue: {
type: Object,
default: () => {}
},
tabs: {
type: Array,
default: () => []
},
type: {
type: String,
default: 'checkbox-group'
},
defaultIcon: {
type: String,
default: 'CaretBottom'
},
iconSize: {
type: Number,
default: undefined
}
})
const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
const childTypeMapping = { // 自动映射子元素类型配置的时候可以不写type
'checkbox-group': 'checkbox',
'radio-group': 'radio',
select: 'option'
}
const calcTabs = computed(() => {
return props.tabs.map(tab => {
tab.children = (tab.children || []).map(child => {
child.type = child.type || childTypeMapping[props.type]
return child
})
if (!tab.icon) {
tab.icon = props.defaultIcon
}
return tab
})
})
const inputType = computed(() => useInputType({ type: props.type }))
</script>
<template>
<el-tabs
type="border-card"
class="form-edit-width-100"
>
<el-tab-pane
v-for="(tab, index) in calcTabs"
:key="tab.label + index"
>
<template #label>
{{ tab.label }}&nbsp;
<common-icon
:size="tab.iconSize||iconSize"
:icon="tab.icon"
/>
</template>
<component
:is="inputType"
v-if="vModel"
v-model="vModel[tab.prop]"
@change="vModel._tabFilter=true"
>
<control-child
v-for="(childItem, childIdx) in tab.children"
:key="childIdx"
:option="childItem"
/>
</component>
</el-tab-pane>
</el-tabs>
</template>
<style scoped>
</style>

View File

@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue'
import { $i18nBundle } from '@/messages'
import { useInputType } from '@/components/utils'
import { toLabelByKey, useInputType } from '@/components/utils'
/**
* @type {{option:CommonFormOption}}
@@ -21,31 +20,17 @@ const inputType = computed(() => useInputType(props.option))
const label = computed(() => {
const option = props.option
if (option.labelKey) {
return $i18nBundle(option.labelKey)
return toLabelByKey(option.labelKey)
}
return option.label
})
/**
* element-plus的复选框和单选框没有value值只有label用于存储值因此特殊处理
* @type {string[]}
*/
const labelAsValueKeys = ['el-checkbox', 'el-radio', 'el-checkbox-button', 'el-radio-button']
const labelOrValue = computed(() => {
const option = props.option
if (labelAsValueKeys.includes(inputType.value)) {
return option.value
}
return label.value
})
</script>
<template>
<component
:is="inputType"
:value="option.value"
:label="labelOrValue"
:label="label"
v-bind="option.attrs"
>
{{ label }}

View File

@@ -1,10 +1,11 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, isVNode, ref, watch } from 'vue'
import { $i18nBundle } from '@/messages'
import ControlChild from '@/components/common-form-control/control-child.vue'
import { useInputType } from '@/components/utils'
import { toLabelByKey, useInputType } from '@/components/utils'
import cloneDeep from 'lodash/cloneDeep'
import { get, set } from 'lodash'
import { get, isFunction, set } from 'lodash'
import dayjs from 'dayjs'
/**
* @type {{option:CommonFormOption}}
@@ -24,13 +25,27 @@ const props = defineProps({
labelWidth: {
type: String,
default: null
},
addInfo: {
type: Object,
default: () => ({})
}
})
const inputType = computed(() => useInputType(props.option))
const calcOption = computed(() => {
let option = props.option
if (isFunction(option.dynamicOption)) {
option = { ...option, ...option.dynamicOption(props.model, option, props.addInfo) }
} else if (isFunction(option.dynamicAttrs)) {
option = { ...option, attrs: { ...option.attrs, ...option.dynamicAttrs(props.model, option, props.addInfo) } }
}
return option
})
const inputType = computed(() => useInputType(calcOption.value))
const modelAttrs = computed(() => {
const option = props.option
const option = calcOption.value
const attrs = { ...option.attrs }
if (attrs.clearable === undefined && ['el-input', 'el-select', 'el-select-v2', 'common-autocomplete', 'el-autocomplete', 'el-cascader', 'el-tree-select'].includes(inputType.value)) {
attrs.clearable = true
@@ -38,33 +53,67 @@ const modelAttrs = computed(() => {
if (inputType.value === 'common-autocomplete' && option.getAutocompleteLabel) {
attrs.defaultLabel = option.getAutocompleteLabel(props.model, option)
}
if (inputType.value === 'el-date-picker') {
attrs.disabledDate = (date) => {
const option = calcOption.value
let result = false
if (option.minDate) {
result = date.getTime() < dayjs(option.minDate).startOf('d').toDate().getTime()
}
if (!result && option.maxDate) {
result = date.getTime() > dayjs(option.maxDate).startOf('d').toDate().getTime()
}
return result
}
}
const defaultValue = modelValue.value || option.minDate
if (defaultValue) {
attrs.defaultValue = dayjs(defaultValue).toDate()
}
return attrs
})
watch(() => [inputType.value, calcOption.value.minDate, calcOption.value.maxDate], ([type, minDate, maxDate]) => {
const option = calcOption.value
const date = modelValue.value
if (type === 'el-date-picker' && date && !option.disabled && option.clearInvalidDate !== false) {
let invalid = false
if (minDate) {
invalid = dayjs(date).isBefore(dayjs(option.minDate).startOf('d'))
}
if (invalid && maxDate) {
invalid = dayjs(date).isAfter(dayjs(option.maxDate).startOf('d'))
}
if (invalid) {
modelValue.value = undefined
}
}
})
const label = computed(() => {
const option = props.option
const option = calcOption.value
if (option.labelKey) {
return $i18nBundle(option.labelKey)
return toLabelByKey(option.labelKey)
}
return option.label
})
const showLabel = computed(() => {
return props.option.showLabel !== false && props.labelWidth !== '0'
return calcOption.value.showLabel !== false && props.labelWidth !== '0'
})
const formModel = computed(() => props.option.model || props.model)
const formModel = computed(() => calcOption.value.model || props.model)
const modelValue = computed({
get () {
if (formModel.value && props.option.prop) {
return get(formModel.value, props.option.prop)
if (formModel.value && calcOption.value.prop) {
return get(formModel.value, calcOption.value.prop)
}
return null
},
set (val) {
if (formModel.value && props.option.prop) {
set(formModel.value, props.option.prop, val)
if (formModel.value && calcOption.value.prop) {
set(formModel.value, calcOption.value.prop, val)
}
}
})
@@ -76,7 +125,7 @@ const childTypeMapping = { // 自动映射子元素类型,配置的时候可
}
const children = computed(() => {
const option = props.option
const option = calcOption.value
const result = option.children || [] // 初始化一些默认值
result.forEach(childItem => {
if (!childItem.type) {
@@ -89,11 +138,11 @@ const children = computed(() => {
const formItemRef = ref()
const rules = computed(() => {
const option = props.option
const option = calcOption.value
let _rules = cloneDeep(option.rules || [])
if (option.prop) {
if (option.required !== undefined) {
const label = option.label || $i18nBundle(option.labelKey)
const label = option.label || toLabelByKey(option.labelKey)
_rules = [{
trigger: option.trigger,
required: option.required,
@@ -101,7 +150,7 @@ const rules = computed(() => {
}, ..._rules]
}
if (option.pattern !== undefined) {
const label = option.label || $i18nBundle(option.labelKey)
const label = option.label || toLabelByKey(option.labelKey)
_rules = [{
pattern: option.pattern,
message: option.patternMsg || $i18nBundle('common.msg.patternInvalid', [label])
@@ -114,7 +163,7 @@ const rules = computed(() => {
const initFormModel = () => {
if (formModel.value) {
const option = props.option
const option = calcOption.value
if (option.prop) {
const defaultVal = get(formModel.value, option.prop)
set(formModel.value, option.prop, defaultVal ?? option.value ?? undefined)
@@ -124,23 +173,48 @@ const initFormModel = () => {
initFormModel()
watch(() => props.option, initFormModel, { deep: true })
watch(() => calcOption.value, initFormModel, { deep: true })
const hasModelText = computed(() => {
return !!(modelAttrs.value.modelText || modelAttrs.value.modelTextFunc)
return modelAttrs.value.modelText || calcOption.value.formatter
})
const emit = defineEmits(['change'])
const controlChange = (...args) => {
const option = props.option
const option = calcOption.value
if (option.change) {
option.change(...args)
}
emit('change', ...args)
}
const formItemEnabled = computed(() => props.option.enabled !== false)
const formItemEnabled = computed(() => calcOption.value.enabled !== false)
const controlLabelWidth = computed(() => {
const option = calcOption.value
const labelWidth = props.labelWidth
return option.labelWidth || modelAttrs.value.labelWidth || labelWidth
})
const formatResult = computed(() => {
if (hasModelText.value) {
if (modelAttrs.value.modelText) {
return {
modelText: modelAttrs.value.modelText
}
}
const option = calcOption.value
if (option.formatter) {
const result = option.formatter(modelValue.value, calcOption.value)
return {
modelText: result,
vnode: isVNode(result)
}
}
}
return null
})
</script>
@@ -149,26 +223,30 @@ const formItemEnabled = computed(() => props.option.enabled !== false)
v-if="formItemEnabled"
ref="formItemRef"
:rules="rules"
:prop="option.prop"
:label-width="labelWidth"
:prop="calcOption.prop"
:style="calcOption.style"
:label-width="controlLabelWidth"
v-bind="$attrs"
>
<template
v-if="showLabel"
#label
>
<span>{{ label }}</span>
<slot name="beforeLabel" />
<span :class="calcOption.labelCls">{{ label }}</span>
<slot name="afterLabel" />
<el-tooltip
v-if="option.tooltip||option.tooltipFunc"
v-if="calcOption.tooltip||calcOption.tooltipFunc"
class="box-item"
effect="dark"
:disabled="!option.tooltip"
:content="option.tooltip"
:disabled="!calcOption.tooltip"
:content="calcOption.tooltip"
placement="top-start"
>
<span>
<el-link
:underline="false"
@click="option.tooltipFunc"
@click="calcOption.tooltipFunc"
>&nbsp;
<common-icon
icon="QuestionFilled"
@@ -181,17 +259,27 @@ const formItemEnabled = computed(() => props.option.enabled !== false)
:is="inputType"
v-model="modelValue"
v-bind="modelAttrs"
:placeholder="option.placeholder"
:disabled="option.disabled"
:readonly="option.readonly"
:placeholder="calcOption.placeholder"
:disabled="calcOption.disabled"
:readonly="calcOption.readonly"
@change="controlChange"
>
<template
v-if="hasModelText"
v-if="hasModelText&&formatResult"
#default
>
{{ modelAttrs.modelText || modelAttrs.modelTextFunc(modelValue) }}
<span
v-if="formatResult.modelText&&!formatResult.vnode"
class="common-form-label-text"
v-html="formatResult.modelText"
/>
<component
:is="formatResult.modelText"
v-if="formatResult.vnode"
class="common-form-label-text"
/>
</template>
<slot name="childBefore" />
<template v-if="children&&children.length">
<control-child
v-for="(childItem, index) in children"
@@ -199,7 +287,9 @@ const formItemEnabled = computed(() => props.option.enabled !== false)
:option="childItem"
/>
</template>
<slot name="childAfter" />
</component>
<slot name="after" />
</el-form-item>
</template>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue'
import { inject, ref, onMounted, isRef, watchEffect } from 'vue'
import { useVModel } from '@vueuse/core'
/**
@@ -23,6 +23,17 @@ const props = defineProps({
type: Object,
default: null
},
inline: {
type: Boolean
},
className: {
type: String,
default: 'common-form'
},
buttonStyle: {
type: [String, Object],
default: ''
},
validateOnRuleChange: {
type: Boolean,
default: false
@@ -35,6 +46,10 @@ const props = defineProps({
type: Boolean,
default: true
},
disableSubmitIfNotValid: {
type: Boolean,
default: false
},
submitLabel: {
type: String,
default: ''
@@ -66,22 +81,37 @@ const emit = defineEmits(['submitForm', 'update:model'])
const formModel = useVModel(props, 'model', emit)
//= ============form暴露============//
const form = ref()
defineExpose({
form
})
onMounted(() => {
const windowFormRef = inject('commonWindowForm', null)
if (isRef(windowFormRef)) {
windowFormRef.value = form.value
}
})
/**
* 表单校验不通过时禁止点击提交按钮
*/
const disableSubmit = ref(false)
watchEffect(async () => {
if (!props.disableSubmitIfNotValid) { return false }
if (!form.value) { return true }
await form.value.validate((ok) => { disableSubmit.value = !ok })
})
</script>
<template>
<el-form
ref="form"
class="common-form"
:inline="inline"
:class="className"
:model="formModel"
:label-width="labelWidth"
v-bind="$attrs"
:validate-on-rule-change="validateOnRuleChange"
>
<template
@@ -90,13 +120,13 @@ defineExpose({
>
<slot
v-if="option.slot"
name="option.slot"
:name="option.slot"
:option="option"
:form="form"
:model="formModel"
/>
<common-form-control
v-if="option.enabled!==false"
v-if="!option.slot&&option.enabled!==false"
:model="formModel"
:option="option"
/>
@@ -106,9 +136,13 @@ defineExpose({
:model="formModel"
name="default"
/>
<el-form-item v-if="showButtons">
<el-form-item
v-if="showButtons"
:style="buttonStyle"
>
<el-button
v-if="showSubmit"
:disabled="disableSubmit"
type="primary"
@click="$emit('submitForm', form)"
>

View File

@@ -1,19 +1,114 @@
import { RuleItem } from 'async-validator/dist-types/interface'
import { FormInstance, FormProps } from 'element-plus'
import {
FormInstance, FormProps, DialogProps,
InputProps, InputNumberProps, CascaderProps,
RadioGroupProps, RadioProps, RadioButtonProps,
CheckboxProps, CheckboxGroupProps, CheckboxButtonProps,
DatePickerProps, timePickerDefaultProps, SwitchProps, SliderProps, TransferProps
} from 'element-plus'
import { SelectProps as SelectV1Props } from 'element-plus/es/components/select/src/select'
import SelectV2Props from 'element-plus/es/components/select-v2/src/select-v2/defaults'
import { CommonAutocompleteProps } from '../common-autocomplete/public'
import { TreeComponentProps } from 'element-plus/es/components/tree/src/tree.type'
import { ExtractPropTypes, CSSProperties, VNode } from 'vue'
export interface CommonFormOption {
/** 表单类型 */
type: 'input' | 'input-number' | 'cascader' | 'radio'
| 'radio-group' | 'radio-button' | 'checkbox' | 'checkbox-group' | 'checkbox-button' | 'date-picker'
| 'time-picker' | 'switch' | 'select' | 'option' | 'slider' | 'transfer' | 'upload' | 'common-icon-select' | 'common-autocomplete' | 'tree-select';
export type TimePickerProps = ExtractPropTypes<typeof timePickerDefaultProps>
export type SelectProps = ExtractPropTypes<typeof SelectV1Props>
export interface OptionProps {
label: string;
value: any;
disabled: boolean;
}
export interface IconSelectProps {
dialogAttrs: DialogProps,
colSize: number,
dialogHeight: string,
dialogWidth: string,
disabled: boolean,
readonly: boolean,
placeholder: string,
clearable: boolean,
validateEvent: boolean
}
export interface CommonFormLabelProps {
/**
* 显示文本
*/
modelText: string;
}
export interface CommonTabFilterProps {
tabs: Array<{
icon: string,
label: string,
prop: string,
children: OptionProps[]
}>,
type: 'checkbox-group' | 'radio-group',
defaultIcon: string,
iconSize: number
}
/**
* 'input' | 'input-number' | 'cascader' | 'radio'
* | 'radio-group' | 'radio-button' | 'checkbox' | 'checkbox-group' | 'checkbox-button' | 'date-picker'
* | 'time-picker' | 'switch' | 'select' | 'select-v2' | 'option' | 'slider' | 'transfer' | 'upload'
* | 'common-tab-filter' | 'common-icon-select' | 'common-autocomplete' | 'common-form-label'
* | 'tree-select'
*/
export type PropsMap = {
'input': InputProps,
'input-number': InputNumberProps,
'cascader': CascaderProps,
'radio': RadioProps,
'radio-group': RadioGroupProps,
'radio-button': RadioButtonProps,
'checkbox': CheckboxProps,
'checkbox-group': CheckboxGroupProps,
'checkbox-button': CheckboxButtonProps,
'date-picker': DatePickerProps,
'time-picker': TimePickerProps,
'switch': SwitchProps,
'select': SelectProps,
'select-v2': SelectV2Props,
'option': OptionProps,
'slider': SliderProps,
'transfer': TransferProps,
'upload': TransferProps,
'tree-select': SelectProps & TreeComponentProps,
'common-tab-filter': CommonTabFilterProps,
'common-form-label': CommonFormLabelProps,
'common-icon-select': IconSelectProps,
'common-autocomplete': CommonAutocompleteProps,
[key:string]: InputProps
}
type FormControlTypeOption = {
[Type in keyof PropsMap]: {
type?: Type,
attrs?: PropsMap[Type]
}
}[keyof PropsMap]
export interface CommonFormOption extends FormControlTypeOption {
/** 数据值 */
value?: any;
/** 属性名 */
prop: string | string[];
prop?: string | string[];
/** 表单标签 */
label?: string;
/** 用于国际化的label */
labelKey?: string;
/**
* 样式自定义
*/
labelCls?: string;
/**
* item样式
*/
style: CSSProperties;
/** 是否必填,后面解析成为rules的一部分 */
required?: boolean;
/** 正则表达式验证解析成为rules的一部分 */
@@ -22,27 +117,42 @@ export interface CommonFormOption {
patternMsg?: string;
/** 是否禁用 */
disabled?: boolean;
/** 是否显示 */
enabled?: boolean;
/** 是否只读 */
readonly?: boolean;
/** 占位提示符 */
placeholder?: string;
/** 其他可用属性 */
attrs?: {
showPassword: boolean,
[key: string]: any
};
/** 有些控件柚子节点 */
children?: Array<CommonFormOption>;
/** async-validator验证器 */
rules: Array<RuleItem>;
rules?: Array<RuleItem>;
/** change事件 */
change: (val: any) => void;
change?: (val: any) => void;
/** 提示信息 */
tooltip: string;
tooltip?: string;
/** 提示函数 */
tooltipFunc: () => void;
tooltipFunc?: () => void;
/**
* common-form-label格式化
* @param modelValue 数据
* @param option 选项
*/
formatter?: (modelValue:any, option: CommonFormOption) => string|VNode;
/** 自定义slot名称 */
slot: string;
slot?: string;
/**
* 根据model数据动态计算Option值
* @param model 表单model
* @param option 原始选项
*/
dynamicOption?: (model: any, option: CommonFormOption, addInfo?: any) => CommonFormOption;
/**
* 根据model数据动态计算attrs的值
* @param model 表单model
* @param option 原始选项
*/
dynamicAttrs?: (model: any, option: CommonFormOption, addInfo?: any) => any;
}
export interface CommonFormProps extends FormProps {
@@ -64,10 +174,14 @@ export interface CommonFormProps extends FormProps {
showButtons: boolean;
/** 是否显示提交按钮 */
showSubmit: boolean;
/** 当校验不通过时提交按钮不可点击,默认为 false: 校验不通过也可直接提交 */
disableSubmitIfNotValid: boolean;
/** 是否显示重置按钮 */
showReset: boolean;
/** 提交逻辑 */
submitForm: (form: FormInstance) => void;
/** 返回地址 */
backUrl: string;
/** 行级排列 */
inline: boolean;
}

View File

@@ -1,14 +1,9 @@
<script setup>
import { computed, ref } from 'vue'
import { filterIconsByKeywords } from '@/services/icon/IconService'
import { useVModel } from '@vueuse/core'
import { UPDATE_MODEL_EVENT, CHANGE_EVENT, useFormItem } from 'element-plus'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
dialogAttrs: {
type: Object,
default () {
@@ -60,7 +55,10 @@ const filterIcons = computed(() => {
const emit = defineEmits([UPDATE_MODEL_EVENT, CHANGE_EVENT])
const vModel = useVModel(props, 'modelValue', emit)
const vModel = defineModel({
type: String,
default: ''
})
const { formItem } = useFormItem()
@@ -91,76 +89,76 @@ const selectIcon = icon => {
type="primary"
:disabled="disabled||readonly"
size="small"
@click="iconSelectVisible = true"
@click.prevent="iconSelectVisible = true"
>
{{ $t('common.label.select') }}
</el-button>
</label>
<el-button
v-if="clearable&&vModel"
type="danger"
:disabled="disabled||readonly"
size="small"
@click="selectIcon()"
>
{{ $t('common.label.clear') }}
</el-button>
<el-dialog
v-model="iconSelectVisible"
:width="dialogWidth"
v-bind="dialogAttrs"
draggable
class="icon-dialog"
:title="$t('common.msg.pleaseSelectIcon')"
>
<el-container
style="overflow: auto;"
:style="{ height: dialogHeight }"
class="icon-container"
<el-button
v-if="clearable&&vModel"
type="danger"
:disabled="disabled||readonly"
size="small"
@click.prevent="selectIcon()"
>
<el-header height="40px">
<el-form label-width="120px">
<el-form-item :label="$t('common.label.keywords')">
<el-input
v-model="keyWords"
:placeholder="$t('common.msg.inputKeywords')"
/>
</el-form-item>
</el-form>
</el-header>
<el-main class="icon-area">
<recycle-scroller
v-slot="{ item }"
class="scroller icon-list"
:items="filterIcons"
:item-size="80"
key-field="id"
>
<el-row>
<el-col
v-for="icon in item.icons"
:key="icon"
:span="24/colSize"
class="text-center"
>
<a
class="el-button el-button--large is-text icon-a"
@click="selectIcon(icon)"
{{ $t('common.label.clear') }}
</el-button>
<el-dialog
v-model="iconSelectVisible"
:width="dialogWidth"
v-bind="dialogAttrs"
draggable
class="icon-dialog"
:title="$t('common.msg.pleaseSelectIcon')"
>
<el-container
style="overflow: auto;"
:style="{ height: dialogHeight }"
class="icon-container"
>
<el-header height="40px">
<el-form label-width="120px">
<el-form-item :label="$t('common.label.keywords')">
<el-input
v-model="keyWords"
:placeholder="$t('common.msg.inputKeywords')"
/>
</el-form-item>
</el-form>
</el-header>
<el-main class="icon-area">
<recycle-scroller
v-slot="{ item }"
class="scroller icon-list"
:items="filterIcons"
:item-size="80"
key-field="id"
>
<el-row>
<el-col
v-for="icon in item.icons"
:key="icon"
:span="24/colSize"
class="text-center"
>
<div>
<common-icon
size="20"
:icon="icon"
/><br>
<span class="icon-text">{{ icon }}</span>
</div>
</a>
</el-col>
</el-row>
</recycle-scroller>
</el-main>
</el-container>
</el-dialog>
<a
class="el-button el-button--large is-text icon-a"
@click.prevent="selectIcon(icon)"
>
<div>
<common-icon
size="20"
:icon="icon"
/><br>
<span class="icon-text">{{ icon }}</span>
</div>
</a>
</el-col>
</el-row>
</recycle-scroller>
</el-main>
</el-container>
</el-dialog>
</label>
</template>
<style scoped>

View File

@@ -6,7 +6,7 @@ import kebabCase from 'lodash/kebabCase'
const props = defineProps({
icon: {
type: String,
required: false
default: ''
}
})
const calcIcon = computed(() => {

View File

@@ -1,7 +1,10 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useGlobalConfigStore } from '@/stores/GlobalConfigStore'
const router = useRouter()
const globalConfigStore = useGlobalConfigStore()
/**
* @type {CommonMenuItemProps}
*/
@@ -15,7 +18,7 @@ const props = defineProps({
},
index: {
type: [Number, String],
required: false
default: ''
}
})
const isSubMenu = computed(() => {
@@ -35,7 +38,7 @@ const menuCls = computed(() => {
})
const dropdownClick = (menuItem, $event) => {
if (menuItem.click) {
menuItem.click($event)
menuItem.click($event, menuItem)
} else {
const route = menuItem.route || menuItem.index
if (route) {
@@ -44,35 +47,39 @@ const dropdownClick = (menuItem, $event) => {
}
}
const checkShowMenuIcon = menuItem => {
return menuItem.icon && (globalConfigStore.showMenuIcon || (!menuItem.labelKey && !menuItem.label))
}
const showMenuIcon = computed(() => {
return checkShowMenuIcon(props.menuItem)
})
</script>
<template>
<div
v-if="menuItem.isSplit"
:key="menuItem.index||index"
:class="menuCls"
>
<slot name="split" />
</div>
<el-sub-menu
v-else-if="isSubMenu"
:key="menuItem.index||index"
:index="`${menuItem.index||index}`"
:class="menuCls"
:disabled="menuItem.disabled"
v-bind="menuItem.attrs"
>
<template #title>
<common-icon
v-if="showMenuIcon"
:size="menuItem.iconSize"
:icon="menuItem.icon"
/>
<span v-if="menuItem.labelKey||menuItem.label">
{{ menuItem.labelKey?$t(menuItem.labelKey):menuItem.label }}
</span>
<div
v-if="menuItem.html"
v-html="menuItem.html"
/>
</template>
<common-menu-item
v-for="(childMenu, childIdx) in menuItem.children"
@@ -83,13 +90,14 @@ const dropdownClick = (menuItem, $event) => {
</el-sub-menu>
<el-menu-item
v-else-if="isDropdown"
:key="menuItem.index||index"
:class="menuCls"
@click="menuItem.click&&menuItem.click($event)"
:disabled="menuItem.disabled"
@click="menuItem.click&&menuItem.click($event, menuItem)"
>
<el-dropdown class="common-dropdown">
<span class="el-dropdown-link">
<common-icon
v-if="showMenuIcon"
:size="menuItem.iconSize"
:icon="menuItem.icon"
/>
@@ -104,6 +112,7 @@ const dropdownClick = (menuItem, $event) => {
@click="dropdownClick(childMenu, $event)"
>
<common-icon
v-if="checkShowMenuIcon(childMenu)"
:size="childMenu.iconSize"
:icon="childMenu.icon"
/>
@@ -117,12 +126,14 @@ const dropdownClick = (menuItem, $event) => {
<el-menu-item
v-else
:class="menuCls"
:disabled="menuItem.disabled"
:route="menuItem.route"
v-bind="menuItem.attrs"
:index="menuItem.index"
@click="menuItem.click&&menuItem.click(menuItem, $event)"
@click="menuItem.click&&menuItem.click($event,menuItem)"
>
<common-icon
v-if="showMenuIcon"
:size="menuItem.iconSize"
:icon="menuItem.icon"
/>
@@ -130,10 +141,6 @@ const dropdownClick = (menuItem, $event) => {
<span v-if="menuItem.labelKey||menuItem.label">
{{ menuItem.labelKey?$t(menuItem.labelKey):menuItem.label }}
</span>
<div
v-if="menuItem.html"
v-html="menuItem.html"
/>
</template>
</el-menu-item>
</template>
@@ -142,7 +149,4 @@ const dropdownClick = (menuItem, $event) => {
.common-dropdown {
height: 100%;
}
.common-dropdown .el-icon {
margin-top: 20px;
}
</style>

View File

@@ -1,21 +1,28 @@
<script setup>
import { computed } from 'vue'
import { filterMenus, useParentRoute } from '@/components/utils'
import { computed, ref, watchEffect } from 'vue'
import { processMenus, useParentRoute } from '@/components/utils'
import { useRoute } from 'vue-router'
const props = defineProps({
menus: {
type: Array,
required: true
},
defaultActivePath: {
type: String,
default: ''
}
})
const menuItems = computed(() => {
return filterMenus(props.menus)
const route = useRoute()
const menuItems = ref([])
watchEffect(() => {
menuItems.value = processMenus(props.menus)
})
const activeRoutePath = computed(() => {
let route = useRoute()
route = useParentRoute(route)
return route && route.path !== '/' ? route.path : ''
if (props.defaultActivePath) {
return props.defaultActivePath
}
const current = useParentRoute(route)
return current && current.path !== '/' ? current.path : ''
})
</script>

View File

@@ -8,6 +8,14 @@ export interface CommonMenuItem {
isDropdown?: boolean;
/** 是否是分割元素 */
isSplit?: boolean;
/**
* 是否禁用,禁用状态仍然是显示的
*/
disabled?: boolean;
/**
* 是否启用默认true设置false不显示
*/
enabled?: boolean;
/** 自定义样式 */
menuCls?: string;
/** 路由地址 */

View File

@@ -0,0 +1,101 @@
<script setup>
import { useVModel } from '@vueuse/core'
import { onMounted } from 'vue'
import { toLabelByKey } from '@/components/utils'
/**
* 同时只能有一个字段被排序
* 排序的数据会被放到vModel中
* 如 { "deptTime": "DESC", "price": "", "duration": "" }
* @type {{modelValue:SortProps}} 可以传入默认排序值,但是最多只能有一个字段有值 如:{ "deptTime": "DESC", "price": "", "duration": "" }
* @type {{options:SortOption[]}} 所有要排序的字段和要显示的值
*/
const props = defineProps({
modelValue: {
type: Object,
default: () => { }
},
options: {
type: Array,
default () {
return []
}
}
})
const emit = defineEmits(['update:modelValue'])
const vModel = useVModel(props, 'modelValue', emit)
onMounted(() => {
// 根据传入的option和初始vmodel设置vModel
const model = {}
for (const option of props.options) {
const prop = option.prop
if (vModel.value[prop] !== undefined) {
model[prop] = vModel.value[prop]
} else {
model[option.prop] = ''
}
}
vModel.value = model
})
/**
* 计算要显示的label
* @param {SortOption} option
*/
const calLabel = (option) => {
if (option.labelKey) {
return toLabelByKey(option.labelKey)
}
if (option.label) {
return option.label
}
return 'undefined'
}
const handleClick = (key, fixedValue) => {
const newValue = { ...vModel.value }
for (const k in newValue) {
if (k === key) { continue }
newValue[k] = ''
}
if (fixedValue) {
newValue[key] = fixedValue
} else {
newValue[key] = newValue[key] === 'ASC' ? 'DESC' : 'ASC'
}
vModel.value = newValue
}
</script>
<template>
<el-button-group
class="ml-4"
>
<template
v-for="option in props.options"
:key="option.prop"
>
<el-button
:type="vModel[option.prop]===''?'':'primary'"
@click="handleClick(option.prop, option.fixedValue)"
>
{{ calLabel(option) }}
<template v-if="option.showIcon!==false">
<common-icon
v-if="vModel[option.prop]==='ASC'"
icon="SortUp"
/>
<common-icon
v-if="vModel[option.prop]==='DESC'"
icon="SortDown"
/>
</template>
</el-button>
</template>
</el-button-group>
</template>
<style scoped>
</style>

17
src/components/common-sort/public.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
export interface SortOption {
prop: string; // 排序字段如price该值会作为
labelKey?: string;// 国际化资源key首选该属性不存在才使用label
label?: string;
showIcon?: boolean; // 控制某些排序不显示图标
fixedValue?: 'ASC' | 'DESC' // 固定排序模式
}
/**
* 排序结果
* key是排序字段来自SortOption的propvalue是排序方式
* 'ASC':升序 'DESC':降序 '':不生效
* 如 { "deptTime": "DESC", "price": "", "duration": "" }
*/
export interface SortProps {
[key: string]: 'ASC' | 'DESC' | ''
}

View File

@@ -0,0 +1,136 @@
<script setup>
import { computed } from 'vue'
import TableFormControl from '@/components/common-table-form/table-form-control.vue'
import { toLabelByKey } from '@/components/utils'
const props = defineProps({
formOptions: {
type: Array,
default: () => []
},
model: {
type: Object,
required: true
},
dataListKey: {
type: String,
default: 'items'
},
showOperation: {
type: Boolean,
default: true
}
})
const dataList = computed(() => {
return props.model[props.dataListKey] || []
})
const emit = defineEmits(['delete', 'change'])
const deleteItem = (item, index) => {
emit('delete', {
item, index
})
}
const formChange = ($event, row, $index, option) => {
const args = [$event, {
model: row,
index: $index,
option
}]
if (option.formChange) { // 动态表单change事件
option.formChange(...args)
}
emit('change', ...args)
}
const options = computed(() => {
return props.formOptions.filter(option => option.enabled !== false)
})
</script>
<template>
<el-table
:data="dataList"
border
class="common-table-form"
>
<el-table-column
v-for="(option, index) in options"
:key="index"
:label="option.label||toLabelByKey(option.labelKey)"
:width="option.width"
>
<template
v-if="option.headerSlot"
#header="scope"
>
<slot
v-bind="scope"
:name="option.headerSlot"
/>
</template>
<!--用于自定义显示属性-->
<template
v-if="option.slot"
#default="scope"
>
<slot
v-bind="scope"
:item="scope.row"
:name="option.slot"
/>
</template>
<template
v-else
#default="{row, $index}"
>
<table-form-control
:model="row"
label-width="0"
:option="option"
:prop="`${dataListKey}.${$index}.${option.prop}`"
@change="formChange($event, row, $index, option)"
/>
</template>
</el-table-column>
<el-table-column
v-if="showOperation"
:label="$t('common.label.operation')"
>
<template #default="{row, $index}">
<div class="el-form-item">
<el-button
type="danger"
size="small"
:underline="false"
@click="deleteItem(row, $index)"
>
<common-icon
icon="Delete"
/>
</el-button>
</div>
</template>
</el-table-column>
<template #empty="scope">
<slot
name="empty"
v-bind="scope"
/>
</template>
<template #append="scope">
<slot
name="append"
v-bind="scope"
/>
</template>
</el-table>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
defineProps({
/**
* @type {CommonFormOption}
*/
option: {
type: Object,
required: true
},
model: {
type: Object,
required: true
},
labelWidth: {
type: String,
default: null
},
prop: {
type: String,
default: ''
}
})
</script>
<template>
<common-form-control
:model="model"
:label-width="labelWidth"
:option="option"
:prop="prop"
v-bind="$attrs"
/>
</template>
<style scoped>
</style>

View File

@@ -1,6 +1,6 @@
<script setup>
import { formatDate } from '@/components/utils'
import { computed } from 'vue'
import { formatDate, toLabelByKey } from '@/components/utils'
import { computed, useSlots } from 'vue'
import { get } from 'lodash'
/**
@@ -36,22 +36,24 @@ const formatter = computed(() => {
})
const getPropertyData = (row) => {
return get(row, props.column.property)
return get(row, props.column.prop || props.column.property)
}
const slots = useSlots()
</script>
<template>
<el-table-column
v-if="!column.isOperation"
:label="column.label || $t(column.labelKey)"
:label="column.label || toLabelByKey(column.labelKey)"
:prop="column.prop||column.property"
:width="column.width"
v-bind="column.attrs"
:formatter="formatter"
>
<template
v-if="column.slot||column.click"
v-if="column.click"
#default="scope"
>
<el-link
@@ -62,16 +64,22 @@ const getPropertyData = (row) => {
>
{{ formatter?formatter(scope.row, scope):getPropertyData(scope.row) }}
</el-link>
</template>
<template
v-for="(slot, slotKey) in slots"
:key="slotKey"
#[slotKey]="scope"
>
<slot
:name="slotKey"
v-bind="scope"
:column-conf="column"
name="default"
/>
</template>
</el-table-column>
<el-table-column
v-if="column.isOperation"
:label="column.label || $t(column.labelKey)"
:label="column.label || toLabelByKey(column.labelKey)"
:width="column.width"
v-bind="column.attrs"
>
@@ -80,7 +88,7 @@ const getPropertyData = (row) => {
>
<template v-for="(button, index) in column.buttons">
<el-button
v-if="!button.buttonIf||button.buttonIf(scope.row, scope)"
v-if="(!button.buttonIf||button.buttonIf(scope.row, scope))&&button.enabled!==false"
:key="index"
:type="button.type"
:icon="button.icon"
@@ -90,7 +98,7 @@ const getPropertyData = (row) => {
:circle="button.circle"
@click="button.click&&button.click(scope.row, scope)"
>
{{ button.label || $t(button.labelKey) }}
{{ button.label || toLabelByKey(button.labelKey) }}
</el-button>
</template>
<slot

View File

@@ -1,6 +1,12 @@
<script setup>
import CommonTableColumn from '@/components/common-table/common-table-column.vue'
import { computed, ref } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
import { getFrontendPage } from '@/components/utils'
defineOptions({
inheritAttrs: false
})
/**
* @type CommonTableProps
@@ -11,7 +17,7 @@ const props = defineProps({
*/
columns: {
type: Array,
required: true
default: () => []
},
/**
* 显示数据
@@ -36,7 +42,7 @@ const props = defineProps({
},
/**
* el-button
* @type [ButtonProps]
* @type [TableButtonProps]
*/
buttons: {
type: Array,
@@ -84,6 +90,26 @@ const props = defineProps({
loadingText: {
type: String,
default: ''
},
expandTable: {
type: Boolean,
default: false
},
hideExpandBtn: {
type: Boolean,
default: false
},
frontendPaging: {
type: Boolean,
default: false
},
frontendPageSize: {
type: Number,
default: 10
},
infinitePaging: {
type: Boolean,
default: false
}
})
/**
@@ -92,6 +118,15 @@ const props = defineProps({
*/
const calcColumns = computed(() => {
let _columns = props.columns
if (props.expandTable) {
_columns = [{
slot: 'expand',
attrs: {
type: 'expand',
width: props.hideExpandBtn ? 1 : 0
}
}, ..._columns]
}
if (props.buttons.length || props.buttonsSlot) {
const buttonColumn = {
labelKey: 'common.label.operation',
@@ -116,6 +151,74 @@ const currentPageChange = (pageNumber) => {
emit('currentPageChange', pageNumber)
}
const calcData = ref([])
const frontendPage = ref(getFrontendPage(0, props.frontendPageSize))
const infiniteRef = ref(null)
const isInfiniteEnd = ref(false)
function checkInfiniteEnd (pageVal) {
if (props.infinitePaging) {
isInfiniteEnd.value = pageVal ? pageVal.pageNumber >= pageVal.pageCount : true
}
}
function calcFrontEndPageData () {
if (props.frontendPaging) {
calcData.value = props.data?.slice((frontendPage.value.pageNumber - 1) * frontendPage.value.pageSize,
frontendPage.value.pageNumber * frontendPage.value.pageSize) // 展示数据
checkInfiniteEnd(frontendPage.value)
}
}
function calcTableDataAndPage () {
if (props.frontendPaging) { // 前端分页模式
frontendPage.value = getFrontendPage(props.data?.length, frontendPage.value.pageSize, frontendPage.value.pageNumber) // 前端分页信息
calcFrontEndPageData()
} else { // 后端分页模式
if (props.infinitePaging) { // 无限加载模式
if (!calcData.value.length && props.page && props.page.pageNumber > 1) { // 如果进来就是后面的页码,重新查询
emit('update:page', { ...props.page, pageNumber: 1 }) // 仅更新pageNumber
} else {
calcData.value = [...calcData.value, ...props.data]
}
checkInfiniteEnd(props.page)
} else {
calcData.value = props.data
}
}
}
watch(() => props.data, () => {
calcTableDataAndPage()
}, { deep: true, immediate: true })
watch(() => frontendPage, () => {
calcFrontEndPageData()
}, { deep: true, immediate: true })
onMounted(() => {
if (props.infinitePaging) {
console.info('================================mounted', infiniteRef.value)
useIntersectionObserver(infiniteRef, onInfiniteLoad, {
threshold: 1
})
}
})
const onInfiniteLoad = (args) => {
const isIntersecting = args[0].isIntersecting
console.info('===========================infinite', isIntersecting, ...args)
if (isIntersecting && calcData.value?.length) {
if (props.frontendPaging) {
frontendPage.value = getFrontendPage(props.data?.length,
frontendPage.value.pageSize + props.frontendPageSize,
frontendPage.value.pageNumber)
} else if (props.page) {
currentPageChange(props.page.pageNumber + 1)
}
}
}
const table = ref()
defineExpose({
@@ -135,7 +238,8 @@ defineExpose({
v-bind="$attrs"
:highlight-current-row="highlightCurrentRow"
:stripe="stripe"
:data="data"
:data="calcData"
:class="{'common-hide-expand': hideExpandBtn}"
:border="border"
>
<common-table-column
@@ -144,23 +248,50 @@ defineExpose({
:column="column"
:button-size="buttonSize"
>
<template
v-if="column.headerSlot"
#header="scope"
>
<slot
v-bind="scope"
:name="column.headerSlot"
/>
</template>
<!--用于自定义显示属性-->
<template
v-if="column.slot"
#default="scope"
>
<slot
v-if="column.slot"
:row="scope.row"
:column="scope.column"
v-bind="scope"
:item="scope.row"
:column-conf="scope.columnConf"
:name="column.slot"
/>
</template>
</common-table-column>
<template #append="scope">
<slot
name="append"
v-bind="scope"
/>
<el-container
v-show="infinitePaging&&!isInfiniteEnd"
ref="infiniteRef"
v-loading="true"
class="container-center"
>
Loading
</el-container>
</template>
<template #empty="scope">
<slot
name="empty"
v-bind="scope"
/>
</template>
</el-table>
<el-pagination
v-if="page&&page.pageCount"
v-if="!infinitePaging&&!frontendPaging&&page&&page.pageCount"
class="common-pagination"
v-bind="pageAttrs"
:total="page.totalCount"
@@ -169,6 +300,16 @@ defineExpose({
@size-change="pageSizeChange($event)"
@current-change="currentPageChange($event)"
/>
<el-pagination
v-if="!infinitePaging&&frontendPaging&&frontendPage&&frontendPage.pageCount"
class="common-pagination"
v-bind="pageAttrs"
:total="frontendPage.totalCount"
:page-size="frontendPage.pageSize"
:current-page="frontendPage.pageNumber"
@size-change="frontendPage.pageSize=$event"
@current-change="frontendPage.pageNumber=$event"
/>
</el-container>
</template>

View File

@@ -1,34 +1,52 @@
import { ButtonProps, LinkProps, TableProps, PaginationProps } from 'element-plus'
import tableColumnProps from 'element-plus/es/components/table/src/table-column/defaults'
import { CommonPage } from '../public'
import { ExtractPropTypes } from 'vue'
export type TableColumnProps = ExtractPropTypes<typeof tableColumnProps>
export type TableButtonProps = {
/**
* 计算是否显示按钮
* @param data 表格数据
*/
buttonIf: (data: any) => boolean
} & ButtonProps
/**
* 表格列定义
*/
export interface CommonTableColumn {
// 表格头
label: string;
// 表格头国际化key
labelKey: string;
// 属性名
property: string;
// 属性名同property
prop: string;
// 宽度
width: string;
// 是否是可操作列
isOperation: boolean;
// 自定义插槽名称,用于自定义显示数据
slot: string;
// 自定义按钮
buttons: Array<ButtonProps>
// 可选属性
attrs: any;
// 链接可选属性
linkAttrs: LinkProps;
// 点击事件
click: (data: any) => any;
// 格式化函数
formatter: (data: any, scope: any) => string;
/** 是否显示 */
enabled?: boolean;
/** 表格头 */
label?: string;
/** 表格头国际化key */
labelKey?: string;
/** 属性名 */
property?: string;
/** 属性名同property */
prop?: string;
/** 宽度 */
width?: string;
/** 是否是可操作列 */
isOperation?: boolean;
/** 自定义插槽名称,用于自定义显示数据 */
slot?: string;
/** 自定义插槽名称用于自定义显示Label */
headerSlot?: string;
/** 自定义按钮 */
buttons?: Array<TableButtonProps>
/** 可选属性 */
attrs?: TableColumnProps;
/** 链接可选属性 */
linkAttrs?: LinkProps;
/** 点击事件 */
click?: (data: any) => any;
/** 格式化函数 */
formatter?: (data: any, scope: any) => string;
/** 日期格式化 */
dateFormat?: string
}
/**
@@ -46,15 +64,13 @@ export interface CommonTableProps extends TableProps<any> {
/** 边框配置 */
border: boolean;
/** 自定义按钮配置 */
buttons?: Array<ButtonProps>;
buttons?: Array<TableButtonProps>;
/** buttons插槽 */
buttonsSlot?: string;
/** 默认的按钮大小 */
buttonSize?: string;
/** 按钮列配置 */
buttonsColumnAttrs?: {
[key: string]: any
};
buttonsColumnAttrs?: TableColumnProps;
/** 分页配置 */
page?: CommonPage;
/** 分页对齐 */
@@ -65,6 +81,16 @@ export interface CommonTableProps extends TableProps<any> {
loading?: boolean;
/** loading显示消息 */
loadingText?: string;
/** 可以展开的table */
expandTable: boolean;
/** 是否隐藏展开按钮 */
hideExpandBtn: boolean;
/** 是否是前台分页**/
frontendPaging: boolean;
/** 前端模式分页数量 **/
frontendPageSize: number;
/** 是否是无限加载分页**/
infinitePaging: boolean;
}
export interface CommonTableColumnProps {

View File

@@ -1,6 +1,6 @@
<script setup>
import { useVModel } from '@vueuse/core'
import { computed } from 'vue'
import { computed, ref, provide } from 'vue'
import { UPDATE_MODEL_EVENT } from 'element-plus'
const props = defineProps({
@@ -8,10 +8,6 @@ const props = defineProps({
type: Boolean,
default: false
},
draggable: {
type: Boolean,
default: true
},
title: {
type: String,
default: ''
@@ -24,6 +20,10 @@ const props = defineProps({
type: String,
default: '800px'
},
defaultCls: {
type: [String, Object],
default: ''
},
buttons: {
type: Array,
default: () => []
@@ -63,19 +63,44 @@ const props = defineProps({
closeClick: {
type: Function,
default: null
},
destroyOnClose: {
type: Boolean,
default: false
},
draggable: {
type: Boolean,
default: true
},
overflow: {
type: Boolean,
default: true
},
closeOnClickModal: {
type: Boolean,
default: true
},
closeOnPressEscape: {
type: Boolean,
default: false
},
appendToBody: {
type: Boolean,
default: false
}
})
const emit = defineEmits([UPDATE_MODEL_EVENT])
const showDialog = useVModel(props, 'modelValue', emit) // 自动响应v-model
const windowForm = ref(null) // 如果common-window下面有common-form注册到这里
provide('commonWindowForm', windowForm)
const okButtonClick = $event => {
if (!props.okClick || props.okClick($event) !== false) {
if (!props.okClick || props.okClick({ $event, form: windowForm.value }) !== false) {
showDialog.value = false
}
}
const cancelButtonClick = $event => {
if (!props.cancelClick || props.cancelClick($event) !== false) {
if (!props.cancelClick || props.cancelClick({ $event, form: windowForm.value }) !== false) {
showDialog.value = false
}
}
@@ -85,7 +110,7 @@ const calcBeforeClose = computed(() => {
return props.beforeClose
} else if (props.closeClick) {
return done => {
if (props.closeClick() !== false) {
if (props.closeClick({ form: windowForm.value }) !== false) {
done()
}
}
@@ -102,9 +127,14 @@ const calcBeforeClose = computed(() => {
:before-close="calcBeforeClose"
:width="width"
:draggable="draggable"
v-bind="$attrs"
:overflow="true"
:destroy-on-close="destroyOnClose"
:close-on-click-modal="closeOnClickModal"
:close-on-press-escape="closeOnPressEscape"
:append-to-body="appendToBody"
>
<el-container
:class="defaultCls"
:style="{ height:height }"
>
<slot
@@ -135,7 +165,7 @@ const calcBeforeClose = computed(() => {
:disabled="button.disabled"
:round="button.round"
:circle="button.circle"
@click="button.click&&button.click($event)"
@click="button.click&&button.click({$event, form:windowForm})"
>
{{ button.label || $t(button.labelKey) }}
</el-button>

View File

@@ -0,0 +1,52 @@
<script setup>
import { ref } from 'vue'
const triggerRef = ref()
const visible = ref(false)
defineProps({
type: {
type: String,
default: 'el-tooltip'
}
})
const propConfig = ref({
placement: 'top-start',
rawContent: true
})
const setConfig = (config) => {
Object.assign(propConfig.value, config)
}
const showOrHideTooltip = (show) => {
visible.value = show
}
defineExpose({
triggerRef,
setConfig,
showOrHideTooltip
})
</script>
<template>
<component
:is="type"
v-if="triggerRef"
:disabled="!propConfig.content"
:popper-class="`common-${type}`"
:visible="visible"
:virtual-ref="triggerRef"
virtual-triggering
v-bind="propConfig"
>
<template #content>
<div v-html="propConfig.content" />
</template>
</component>
</template>
<style scoped>
</style>

View File

@@ -0,0 +1,56 @@
import { isObject, isString } from 'lodash'
import CommonTooltip from '@/components/directives/CommonTooltip.vue'
import { DynamicHelper } from '@/components/directives/index'
const calcTooltipConfig = (binding) => {
let config = {}
if (isObject(binding.value)) {
config = { ...config, ...binding.value }
} else if (isString(binding.value)) {
config.content = binding.value
}
if (binding.arg) {
config.placement = binding.arg
}
return config
}
const initTooltipVnode = (el, binding, props) => {
const dynamicHelper = new DynamicHelper()
const tooltipVnode = dynamicHelper.createAndRender(CommonTooltip, props)
el.tooltipVnode = tooltipVnode
el.tooltipDynamicHelper = dynamicHelper
if (!el.tooltipConfig) {
el.tooltipConfig = calcTooltipConfig(binding)
}
if (tooltipVnode.component?.exposed?.triggerRef) {
tooltipVnode.component.exposed.triggerRef.value = el
}
}
const getTooltipDirective = (props) => {
return {
mounted (el, binding) {
el.addEventListener('mouseenter', () => {
if (!el.tooltipVnode) {
initTooltipVnode(el, binding, props)
}
el?.tooltipVnode?.component?.exposed?.setConfig(el.tooltipConfig)
el.tooltipVnode?.component?.exposed?.showOrHideTooltip(true)
})
el.addEventListener('mouseleave', () => {
el.tooltipVnode?.component?.exposed?.showOrHideTooltip(false)
})
},
updated (el, binding) {
el.tooltipConfig = calcTooltipConfig(binding)
},
unmounted (el) {
el.tooltipDynamicHelper?.destroy()
}
}
}
export const CommonTooltipDirective = getTooltipDirective({ type: 'el-tooltip' })
export const CommonPopoverDirective = getTooltipDirective({ type: 'el-popover' })

View File

@@ -0,0 +1,43 @@
import { CommonPopoverDirective, CommonTooltipDirective } from '@/components/directives/CommonTooltipDirective'
import { h, render } from 'vue'
export class DynamicHelper {
constructor () {
this.appDivId = 'app'
this.context = DynamicHelper.app._context
this.container = DynamicHelper.createContainer()
this.destroy = DynamicHelper.getDestroyFunc(this.container)
}
static createContainer () {
return document.createElement('div')
}
static getDestroyFunc (container) {
return () => {
if (container) {
render(null, container)
}
}
}
createAndRender (...args) {
const container = this.container
const vnode = h(...args)
vnode.appContext = this.context
render(vnode, container)
const appDiv = document.getElementById(this.appDivId)
if (appDiv && container.firstElementChild) {
appDiv.appendChild(container.firstElementChild)
}
return vnode
}
}
export default {
install (Vue) {
DynamicHelper.app = Vue
Vue.directive('common-tooltip', CommonTooltipDirective)
Vue.directive('common-popover', CommonPopoverDirective)
}
}

View File

@@ -2,6 +2,8 @@ import CommonIcon from '@/components/common-icon/index.vue'
import CommonIconSelect from '@/components/common-icon-select/index.vue'
import CommonForm from '@/components/common-form/index.vue'
import CommonFormControl from '@/components/common-form-control/index.vue'
import CommonFilterControl from '@/components/common-form-control/common-filter-control.vue'
import CommonTabFilter from '@/components/common-form-control/common-tab-filter.vue'
import CommonFormLabel from '@/components/common-form-control/common-form-label.vue'
import CommonMenu from '@/components/common-menu/index.vue'
import CommonMenuItem from '@/components/common-menu-item/index.vue'
@@ -11,6 +13,8 @@ import CommonTableForm from '@/components/common-table-form/index.vue'
import CommonBreadcrumb from '@/components/common-breadcrumb/index.vue'
import CommonWindow from '@/components/common-window/index.vue'
import CommonAutocomplete from '@/components/common-autocomplete/index.vue'
import CommonSort from '@/components/common-sort/index.vue'
import CommonDirectives from '@/components/directives'
/**
* 自定义通用组件自动注册
@@ -24,6 +28,8 @@ export default {
Vue.component('CommonIconSelect', CommonIconSelect)
Vue.component('CommonForm', CommonForm)
Vue.component('CommonFormControl', CommonFormControl)
Vue.component('CommonFilterControl', CommonFilterControl)
Vue.component('CommonTabFilter', CommonTabFilter)
Vue.component('CommonFormLabel', CommonFormLabel)
Vue.component('CommonMenu', CommonMenu)
Vue.component('CommonMenuItem', CommonMenuItem)
@@ -33,5 +39,7 @@ export default {
Vue.component('CommonBreadcrumb', CommonBreadcrumb)
Vue.component('CommonWindow', CommonWindow)
Vue.component('CommonAutocomplete', CommonAutocomplete)
Vue.component('CommonSort', CommonSort)
Vue.use(CommonDirectives)
}
}

View File

@@ -1,7 +1,21 @@
import { ref } from 'vue'
import { $i18nBundle } from '@/messages'
import { $i18nBundle, $i18nKey } from '@/messages'
import { isArray, isObject } from 'lodash'
import dayjs from 'dayjs'
export const getFrontendPage = (totalCount, pageSize, pageNumber = 1) => {
const pageCount = Math.floor((totalCount + pageSize - 1) / pageSize)
if (pageNumber > pageCount && pageCount > 0) {
pageNumber = pageCount
}
return {
pageNumber,
pageSize,
totalCount,
pageCount
}
}
const calcWithIf = menuItem => {
['icon', 'labelKey', 'label', 'html'].forEach(key => {
const keyIf = menuItem[`${key}If`]
@@ -11,6 +25,10 @@ const calcWithIf = menuItem => {
})
}
/**
* @param {CommonFormOption} option
* @return {string}
*/
export const useInputType = (option) => {
const inType = option.type || 'input'
if (inType.startsWith('common-') || inType.startsWith('el-')) {
@@ -28,6 +46,15 @@ export const useMenuInfo = item => {
}
}
export const toLabelByKey = labelKey => {
if (isArray(labelKey)) {
return $i18nKey(...labelKey)
}
if (labelKey) {
return $i18nBundle(labelKey)
}
}
export const useMenuName = item => {
const menuInfo = useMenuInfo(item)
if (menuInfo) {
@@ -35,23 +62,50 @@ export const useMenuName = item => {
return menuInfo.label
}
if (menuInfo.labelKey) {
return $i18nBundle(menuInfo.labelKey)
return toLabelByKey(menuInfo.labelKey)
}
}
if (item.meta && item.meta.labelKey) {
return $i18nBundle(item.meta.labelKey)
return toLabelByKey(item.meta.labelKey)
}
return item.name || 'No Name'
}
/**
* 外部链接判断
* @param path
* @return {boolean}
*/
export const isExternalLink = (path) => {
return /^(https?:|mailto:|tel:)/.test(path)
}
/**
* 外部菜单类型判断
* @param menuItem
* @return {boolean}
*/
export const isExternalMenu = menuItem => {
return menuItem.external || isExternalLink(menuItem.index)
}
export const filterMenus = menus => menus.filter(menu => !menu.disabled)
/**
* @param menus {[CommonMenuItem] }菜单列表
* @return {[CommonMenuItem]}
*/
export const processMenus = menus => menus.filter(menu => menu.enabled !== false)
.map(menu => {
calcWithIf(menu)
if (menu.index) { // 把菜单存储下来,后面需要使用名字
MENU_INFO_LIST.value[menu.index] = menu
}
if (menu.children && menu.children.length) {
menu.children = filterMenus(menu.children)
menu.children = processMenus(menu.children)
}
if (isExternalMenu(menu) && !menu.click) { // 跳转外部链接
const url = menu.index
menu.index = ''
menu.click = () => {
window.open(url, menu.target || '_blank')
}
}
return menu
})
@@ -62,7 +116,7 @@ export const filterMenus = menus => menus.filter(menu => !menu.disabled)
* @param route {RouteRecordMultipleViewsWithChildren} 路由信息
*/
export const useParentRoute = function (route) {
const parentName = route.meta?.replaceTabHistory
const parentName = route?.meta?.replaceTabHistory
if (parentName) {
const routes = route.matched || []
for (let i = routes.length - 1; i > 0; i--) {
@@ -75,6 +129,48 @@ export const useParentRoute = function (route) {
return route
}
export const parsePathParams = (path, params) => {
if (path && path.includes(':') && isObject(params)) {
Object.keys(params).forEach(key => {
path = path.replace(new RegExp(`:${key}`, 'g'), params[key])
})
}
return path
}
/**
* 定义表单选项带有jsdoc注解方便代码提示
* @param {CommonFormOption[]} formOptions 表单选项
* @return {CommonFormOption[]} 表单选项配置
*/
export const defineFormOptions = (formOptions) => {
return formOptions
}
/**
* 定义表格选项带有jsdoc注解方便代码提示
* @param {CommonTableColumn[]} tableColumns 表单的列
* @return {CommonTableColumn[]} 表单的列配置
*/
export const defineTableColumns = (tableColumns) => {
return tableColumns
}
/**
* 定义表格的按钮带有jsdoc注解方便代码提示
* @param {TableButtonProps[]} tableButtons 表格的按钮
* @return {TableButtonProps[]} 表格的按钮配置
*/
export const defineTableButtons = (tableButtons) => {
return tableButtons
}
/**
* @param menuItems {CommonMenuItem[]} 菜单配置项
* @return {CommonMenuItem[]} 菜单配置项
*/
export const defineMenuItems = (menuItems) => {
return menuItems
}
export const formatDate = (date, format) => {
if (date) {
return dayjs(date).format(format || 'YYYY-MM-DD HH:mm:ss')
@@ -86,3 +182,4 @@ export const formatDay = (date, format) => {
return dayjs(date).format(format || 'YYYY-MM-DD')
}
}

View File

@@ -35,11 +35,15 @@ export const changeMessages = locale => {
export const $changeLocale = locale => {
useGlobalConfigStore().changeLocale(locale)
}
export const $isLocale = locale => {
return useGlobalConfigStore().currentLocale === locale
}
/**
* @param cn
* @param en
* @param cn 中文字段
* @param en 英文字段
* @param {boolean} replaceEmpty 为空是否用不为空的数据代替
* @returns {*}
* @returns {String}
*/
export const $i18nMsg = (cn, en, replaceEmpty = true) => {
const { currentLocale } = useGlobalConfigStore()
@@ -48,16 +52,50 @@ export const $i18nMsg = (cn, en, replaceEmpty = true) => {
}
return replaceEmpty ? (en || cn) : en
}
/**
* @param {String} key 国际化资源key
* @param {String[]=} params 可选参数
* @returns {string}
*/
export const $i18nBundle = i18n.global.t
/**
* 根据key和locale返回数据<br>
* vue-i18n似乎有bug按照官方文档传locale得不到正确的消息:<br>
* <code>$t('ab.c', 'zh-CN')</code><br>
* https://vue-i18n.intlify.dev/api/injection.html#t-key-locale
* @param key
* @param locale
* @param [args]
* @return {String}
*/
export const $i18nByLocale = (key, locale, args) => {
return i18n.global.t(key, locale, {
locale,
list: args || []
})
}
/**
* 方便多个资源key解析
* @param {String} key 国际化资源key
* @param {String} args 可选参数也是资源key方便多个资源key解析
*/
export const $i18nKey = (key, ...args) => {
args = args.map(argKey => $i18nBundle(argKey))
return $i18nBundle(key, args)
}
export default {
install (app) {
app.use(i18n)
Object.assign(app.config.globalProperties, {
$changeLocale,
$i18nMsg,
$i18nBundle
$i18nKey,
$i18nBundle,
$isLocale,
$i18nByLocale
})
}
}

View File

@@ -1,10 +1,28 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
import { createPersistedState } from 'pinia-plugin-persistedstate'
/**
* 组合式api的$reset需要自己实现
*
* @param store
*/
const piniaPluginResetStore = ({ store }) => {
const initialState = JSON.parse(JSON.stringify(store.$state)) // deep clone(store.$state)
store.$reset = () => {
store.$state = JSON.parse(JSON.stringify(initialState))
}
}
export default {
install (app) {
const pinia = createPinia()
pinia.use(piniaPluginPersistedState)
pinia.use(piniaPluginResetStore)
pinia.use(createPersistedState({
key: key => {
const systemKey = import.meta.env.VITE_APP_SYSTEM_KEY
return `__${systemKey}__${key}`
}
}))
app.use(pinia)
return pinia
}

View File

@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import { useCityAutocompleteConfig, useCitySelectPageConfig } from '@/services/city/CityService'
import { $i18nMsg } from '@/messages'
import { ElMessage } from 'element-plus'
import { defineFormOptions } from '@/components/utils'
const defaultCity = ref({})
@@ -14,11 +15,53 @@ setTimeout(() => {
}
}, 1000)
const getGenderOptions = (type) => {
type = type || 'radio'
return [
{
type,
label: '男',
value: 'male'
},
{
type,
label: '女',
value: 'female'
},
{
type,
label: '保密',
value: 'unknown'
}
]
}
const getHobbyOptions = (type) => {
type = type || 'checkbox'
return [
{
type,
label: '编程',
value: 'program'
},
{
type,
label: '吃饭',
value: 'eat'
},
{
type,
label: '睡觉',
value: 'sleep'
}
]
}
/**
* @type {[CommonFormOption]}
*/
const formOptions = computed(() => {
return [{
return defineFormOptions([{
label: '用户名',
prop: 'userName',
value: '',
@@ -52,22 +95,14 @@ const formOptions = computed(() => {
label: '兴趣爱好',
type: 'checkbox-group',
prop: 'hobby',
value: '',
required: true,
children: [
{
label: '编程',
value: 'program'
},
{
label: '吃饭',
value: 'eat'
},
{
label: '睡觉',
value: 'sleep'
}
]
children: getHobbyOptions()
}, {
label: '兴趣爱好',
type: 'checkbox-group',
prop: 'hobby',
required: true,
children: getHobbyOptions('checkbox-button')
}, {
label: '职业',
type: 'select',
@@ -94,20 +129,14 @@ const formOptions = computed(() => {
prop: 'gender',
value: '',
required: true,
children: [
{
label: '',
value: 'male'
},
{
label: '女',
value: 'female'
},
{
label: '保密',
value: 'unknown'
}
]
children: getGenderOptions()
}, {
label: '性别',
type: 'radio-group',
prop: 'gender',
value: '',
required: true,
children: getGenderOptions('radio-button')
}, {
label: '图标',
prop: 'icon',
@@ -124,6 +153,9 @@ const formOptions = computed(() => {
change: (city) => {
defaultCity.value = city
},
getAutocompleteLabel: () => {
return $i18nMsg(defaultCity.value?.nameCN, defaultCity.value?.nameEN)
},
attrs: {
defaultLabel: $i18nMsg(defaultCity.value?.nameCn, defaultCity.value?.nameEn),
autocompleteConfig: useCityAutocompleteConfig(),
@@ -146,9 +178,11 @@ const formOptions = computed(() => {
prop: 'address',
value: '',
attrs: {
type: 'textarea'
type: 'textarea',
maxlength: 100,
showWordLimit: true
}
}]
}])
})
const userDto = ref({
contacts: [{
@@ -195,7 +229,7 @@ const submitForm = (form) => {
</script>
<template>
<div>
<el-container class="flex-column container-center">
<common-form
:model="userDto"
:options="formOptions"
@@ -246,7 +280,7 @@ const submitForm = (form) => {
<div>
{{ userDto }}
</div>
</div>
</el-container>
</template>
<style scoped>

View File

@@ -13,8 +13,7 @@ const tableData = ref([])
const loading = ref(true)
const loadUsers = async () => {
loading.value = true
const usersResult = await loadUsersResult({ page: page.value })
loading.value = false
const usersResult = await loadUsersResult({ page: page.value }).finally(() => (loading.value = false))
if (usersResult.success && usersResult.resultData) {
const resultData = usersResult.resultData
tableData.value = resultData.userList

View File

@@ -2,10 +2,16 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { viteMockServe } from 'vite-plugin-mock'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
viteMockServe({
mockPath: './mock'
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))