mirror of
https://github.com/fumiama/paper-manager.git
synced 2026-06-10 02:40:23 +08:00
add frontend/vben from vben-admin-thin
This commit is contained in:
15
frontend/vben/src/components/Application/index.ts
Normal file
15
frontend/vben/src/components/Application/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
|
||||
import appLogo from './src/AppLogo.vue'
|
||||
import appProvider from './src/AppProvider.vue'
|
||||
import appSearch from './src/search/AppSearch.vue'
|
||||
import appLocalePicker from './src/AppLocalePicker.vue'
|
||||
import appDarkModeToggle from './src/AppDarkModeToggle.vue'
|
||||
|
||||
export { useAppProviderContext } from './src/useAppContext'
|
||||
|
||||
export const AppLogo = withInstall(appLogo)
|
||||
export const AppProvider = withInstall(appProvider)
|
||||
export const AppSearch = withInstall(appSearch)
|
||||
export const AppLocalePicker = withInstall(appLocalePicker)
|
||||
export const AppDarkModeToggle = withInstall(appDarkModeToggle)
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div v-if="getShowDarkModeToggle" :class="getClass" @click="toggleDarkMode">
|
||||
<div :class="`${prefixCls}-inner`"></div>
|
||||
<SvgIcon size="14" name="sun" />
|
||||
<SvgIcon size="14" name="moon" />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, unref } from 'vue'
|
||||
import { SvgIcon } from '/@/components/Icon'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { useRootSetting } from '/@/hooks/setting/useRootSetting'
|
||||
import { updateHeaderBgColor, updateSidebarBgColor } from '/@/logics/theme/updateBackground'
|
||||
import { updateDarkTheme } from '/@/logics/theme/dark'
|
||||
import { ThemeEnum } from '/@/enums/appEnum'
|
||||
|
||||
const { prefixCls } = useDesign('dark-switch')
|
||||
const { getDarkMode, setDarkMode, getShowDarkModeToggle } = useRootSetting()
|
||||
|
||||
const isDark = computed(() => getDarkMode.value === ThemeEnum.DARK)
|
||||
|
||||
const getClass = computed(() => [
|
||||
prefixCls,
|
||||
{
|
||||
[`${prefixCls}--dark`]: unref(isDark),
|
||||
},
|
||||
])
|
||||
|
||||
function toggleDarkMode() {
|
||||
const darkMode = getDarkMode.value === ThemeEnum.DARK ? ThemeEnum.LIGHT : ThemeEnum.DARK
|
||||
setDarkMode(darkMode)
|
||||
updateDarkTheme(darkMode)
|
||||
updateHeaderBgColor()
|
||||
updateSidebarBgColor()
|
||||
}
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-dark-switch';
|
||||
|
||||
html[data-theme='dark'] {
|
||||
.@{prefix-cls} {
|
||||
border: 1px solid rgb(196 188 188);
|
||||
}
|
||||
}
|
||||
|
||||
.@{prefix-cls} {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
background-color: #151515;
|
||||
border-radius: 30px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
&-inner {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.5s, background-color 0.5s;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
&--dark {
|
||||
.@{prefix-cls}-inner {
|
||||
transform: translateX(calc(100% + 2px));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<!--
|
||||
* @Author: Vben
|
||||
* @Description: Multi-language switching component
|
||||
-->
|
||||
<template>
|
||||
<Dropdown
|
||||
placement="bottom"
|
||||
:trigger="['click']"
|
||||
:dropMenuList="localeList"
|
||||
:selectedKeys="selectedKeys"
|
||||
@menu-event="handleMenuEvent"
|
||||
overlayClassName="app-locale-picker-overlay"
|
||||
>
|
||||
<span class="cursor-pointer flex items-center">
|
||||
<Icon icon="ion:language" />
|
||||
<span v-if="showText" class="ml-1">{{ getLocaleText }}</span>
|
||||
</span>
|
||||
</Dropdown>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { LocaleType } from '/#/config'
|
||||
import type { DropMenu } from '/@/components/Dropdown'
|
||||
import { ref, watchEffect, unref, computed } from 'vue'
|
||||
import { Dropdown } from '/@/components/Dropdown'
|
||||
import { Icon } from '/@/components/Icon'
|
||||
import { useLocale } from '/@/locales/useLocale'
|
||||
import { localeList } from '/@/settings/localeSetting'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Whether to display text
|
||||
*/
|
||||
showText: { type: Boolean, default: true },
|
||||
/**
|
||||
* Whether to refresh the interface when changing
|
||||
*/
|
||||
reload: { type: Boolean },
|
||||
})
|
||||
|
||||
const selectedKeys = ref<string[]>([])
|
||||
|
||||
const { changeLocale, getLocale } = useLocale()
|
||||
|
||||
const getLocaleText = computed(() => {
|
||||
const key = selectedKeys.value[0]
|
||||
if (!key) {
|
||||
return ''
|
||||
}
|
||||
return localeList.find((item) => item.event === key)?.text
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
selectedKeys.value = [unref(getLocale)]
|
||||
})
|
||||
|
||||
async function toggleLocale(lang: LocaleType | string) {
|
||||
await changeLocale(lang as LocaleType)
|
||||
selectedKeys.value = [lang as string]
|
||||
props.reload && location.reload()
|
||||
}
|
||||
|
||||
function handleMenuEvent(menu: DropMenu) {
|
||||
if (unref(getLocale) === menu.event) {
|
||||
return
|
||||
}
|
||||
toggleLocale(menu.event as string)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.app-locale-picker-overlay {
|
||||
.ant-dropdown-menu-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
93
frontend/vben/src/components/Application/src/AppLogo.vue
Normal file
93
frontend/vben/src/components/Application/src/AppLogo.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<!--
|
||||
* @Author: Vben
|
||||
* @Description: logo component
|
||||
-->
|
||||
<template>
|
||||
<div class="anticon" :class="getAppLogoClass" @click="goHome">
|
||||
<img src="../../../assets/images/logo.png" />
|
||||
<div class="ml-2 truncate md:opacity-100" :class="getTitleClass" v-show="showTitle">
|
||||
{{ title }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed, unref } from 'vue'
|
||||
import { useGlobSetting } from '/@/hooks/setting'
|
||||
import { useGo } from '/@/hooks/web/usePage'
|
||||
import { useMenuSetting } from '/@/hooks/setting/useMenuSetting'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { PageEnum } from '/@/enums/pageEnum'
|
||||
import { useUserStore } from '/@/store/modules/user'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* The theme of the current parent component
|
||||
*/
|
||||
theme: { type: String, validator: (v: string) => ['light', 'dark'].includes(v) },
|
||||
/**
|
||||
* Whether to show title
|
||||
*/
|
||||
showTitle: { type: Boolean, default: true },
|
||||
/**
|
||||
* The title is also displayed when the menu is collapsed
|
||||
*/
|
||||
alwaysShowTitle: { type: Boolean },
|
||||
})
|
||||
|
||||
const { prefixCls } = useDesign('app-logo')
|
||||
const { getCollapsedShowTitle } = useMenuSetting()
|
||||
const userStore = useUserStore()
|
||||
const { title } = useGlobSetting()
|
||||
const go = useGo()
|
||||
|
||||
const getAppLogoClass = computed(() => [
|
||||
prefixCls,
|
||||
props.theme,
|
||||
{ 'collapsed-show-title': unref(getCollapsedShowTitle) },
|
||||
])
|
||||
|
||||
const getTitleClass = computed(() => [
|
||||
`${prefixCls}__title`,
|
||||
{
|
||||
'xs:opacity-0': !props.alwaysShowTitle,
|
||||
},
|
||||
])
|
||||
|
||||
function goHome() {
|
||||
go(userStore.getUserInfo.homePath || PageEnum.BASE_HOME)
|
||||
}
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-app-logo';
|
||||
|
||||
.@{prefix-cls} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 7px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.light {
|
||||
border-bottom: 1px solid @border-color-base;
|
||||
}
|
||||
|
||||
&.collapsed-show-title {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
&.light &__title {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&.dark &__title {
|
||||
color: @white;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
transition: all 0.5s;
|
||||
line-height: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
82
frontend/vben/src/components/Application/src/AppProvider.vue
Normal file
82
frontend/vben/src/components/Application/src/AppProvider.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, ref, unref } from 'vue'
|
||||
import { createAppProviderContext } from './useAppContext'
|
||||
import { createBreakpointListen } from '/@/hooks/event/useBreakpoint'
|
||||
import { prefixCls } from '/@/settings/designSetting'
|
||||
import { useAppStore } from '/@/store/modules/app'
|
||||
import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum'
|
||||
|
||||
const props = {
|
||||
/**
|
||||
* class style prefix
|
||||
*/
|
||||
prefixCls: { type: String, default: prefixCls },
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppProvider',
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
setup(props, { slots }) {
|
||||
const isMobile = ref(false)
|
||||
const isSetState = ref(false)
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// Monitor screen breakpoint information changes
|
||||
createBreakpointListen(({ screenMap, sizeEnum, width }) => {
|
||||
const lgWidth = screenMap.get(sizeEnum.LG)
|
||||
if (lgWidth) {
|
||||
isMobile.value = width.value - 1 < lgWidth
|
||||
}
|
||||
handleRestoreState()
|
||||
})
|
||||
|
||||
const { prefixCls } = toRefs(props)
|
||||
|
||||
// Inject variables into the global
|
||||
createAppProviderContext({ prefixCls, isMobile })
|
||||
|
||||
/**
|
||||
* Used to maintain the state before the window changes
|
||||
*/
|
||||
function handleRestoreState() {
|
||||
if (unref(isMobile)) {
|
||||
if (!unref(isSetState)) {
|
||||
isSetState.value = true
|
||||
const {
|
||||
menuSetting: {
|
||||
type: menuType,
|
||||
mode: menuMode,
|
||||
collapsed: menuCollapsed,
|
||||
split: menuSplit,
|
||||
},
|
||||
} = appStore.getProjectConfig
|
||||
appStore.setProjectConfig({
|
||||
menuSetting: {
|
||||
type: MenuTypeEnum.SIDEBAR,
|
||||
mode: MenuModeEnum.INLINE,
|
||||
split: false,
|
||||
},
|
||||
})
|
||||
appStore.setBeforeMiniInfo({ menuMode, menuCollapsed, menuType, menuSplit })
|
||||
}
|
||||
} else {
|
||||
if (unref(isSetState)) {
|
||||
isSetState.value = false
|
||||
const { menuMode, menuCollapsed, menuType, menuSplit } = appStore.getBeforeMiniInfo
|
||||
appStore.setProjectConfig({
|
||||
menuSetting: {
|
||||
type: menuType,
|
||||
mode: menuMode,
|
||||
collapsed: menuCollapsed,
|
||||
split: menuSplit,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => slots.default?.()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, ref, unref } from 'vue'
|
||||
import { Tooltip } from 'ant-design-vue'
|
||||
import { SearchOutlined } from '@ant-design/icons-vue'
|
||||
import AppSearchModal from './AppSearchModal.vue'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppSearch',
|
||||
setup() {
|
||||
const showModal = ref(false)
|
||||
const { t } = useI18n()
|
||||
|
||||
function changeModal(show: boolean) {
|
||||
showModal.value = show
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<div class="p-1" onClick={changeModal.bind(null, true)}>
|
||||
<Tooltip>
|
||||
{{
|
||||
title: () => t('common.searchText'),
|
||||
default: () => <SearchOutlined />,
|
||||
}}
|
||||
</Tooltip>
|
||||
<AppSearchModal onClose={changeModal.bind(null, false)} visible={unref(showModal)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div :class="`${prefixCls}`">
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ant-design:enter-outlined" />
|
||||
<span>{{ t('component.app.toSearch') }}</span>
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ion:arrow-up-outline" />
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ion:arrow-down-outline" />
|
||||
<span>{{ t('component.app.toNavigate') }}</span>
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="mdi:keyboard-esc" />
|
||||
<span>{{ t('common.closeText') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import AppSearchKeyItem from './AppSearchKeyItem.vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
const { prefixCls } = useDesign('app-search-footer')
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-app-search-footer';
|
||||
|
||||
.@{prefix-cls} {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
background-color: @component-background;
|
||||
border-top: 1px solid @border-color-base;
|
||||
border-radius: 0 0 16px 16px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
padding-bottom: 2px;
|
||||
margin-right: 0.4em;
|
||||
background-color: linear-gradient(-225deg, #d5dbe4, #f8f8f8);
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 -2px 0 0 #cdcde6, inset 0 0 1px 1px #fff,
|
||||
0 1px 2px 1px rgb(30 35 90 / 40%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:nth-child(2),
|
||||
&:nth-child(3),
|
||||
&:nth-child(6) {
|
||||
margin-left: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<span :class="$attrs.class">
|
||||
<Icon :icon="icon" />
|
||||
</span>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '/@/components/Icon'
|
||||
defineProps({
|
||||
icon: String,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="zoom-fade" mode="out-in">
|
||||
<div :class="getClass" @click.stop v-if="visible">
|
||||
<div :class="`${prefixCls}-content`" v-click-outside="handleClose">
|
||||
<div :class="`${prefixCls}-input__wrapper`">
|
||||
<a-input
|
||||
:class="`${prefixCls}-input`"
|
||||
:placeholder="t('common.searchText')"
|
||||
ref="inputRef"
|
||||
allow-clear
|
||||
@change="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<span :class="`${prefixCls}-cancel`" @click="handleClose">
|
||||
{{ t('common.cancelText') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-not-data`" v-show="getIsNotData">
|
||||
{{ t('component.app.searchNotData') }}
|
||||
</div>
|
||||
|
||||
<ul :class="`${prefixCls}-list`" v-show="!getIsNotData" ref="scrollWrap">
|
||||
<li
|
||||
:ref="setRefs(index)"
|
||||
v-for="(item, index) in searchResult"
|
||||
:key="item.path"
|
||||
:data-index="index"
|
||||
@mouseenter="handleMouseenter"
|
||||
@click="handleEnter"
|
||||
:class="[
|
||||
`${prefixCls}-list__item`,
|
||||
{
|
||||
[`${prefixCls}-list__item--active`]: activeIndex === index,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div :class="`${prefixCls}-list__item-icon`">
|
||||
<Icon :icon="item.icon || 'mdi:form-select'" :size="20" />
|
||||
</div>
|
||||
<div :class="`${prefixCls}-list__item-text`">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div :class="`${prefixCls}-list__item-enter`">
|
||||
<Icon icon="ant-design:enter-outlined" :size="20" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<AppSearchFooter />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, unref, ref, watch, nextTick } from 'vue'
|
||||
import { SearchOutlined } from '@ant-design/icons-vue'
|
||||
import AppSearchFooter from './AppSearchFooter.vue'
|
||||
import Icon from '/@/components/Icon'
|
||||
// @ts-ignore
|
||||
import vClickOutside from '/@/directives/clickOutside'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { useRefs } from '/@/hooks/core/useRefs'
|
||||
import { useMenuSearch } from './useMenuSearch'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
import { useAppInject } from '/@/hooks/web/useAppInject'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const scrollWrap = ref(null)
|
||||
const inputRef = ref<Nullable<HTMLElement>>(null)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { prefixCls } = useDesign('app-search-modal')
|
||||
const [refs, setRefs] = useRefs()
|
||||
const { getIsMobile } = useAppInject()
|
||||
|
||||
const { handleSearch, searchResult, keyword, activeIndex, handleEnter, handleMouseenter } =
|
||||
useMenuSearch(refs, scrollWrap, emit)
|
||||
|
||||
const getIsNotData = computed(() => !keyword || unref(searchResult).length === 0)
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
{
|
||||
[`${prefixCls}--mobile`]: unref(getIsMobile),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible: boolean) => {
|
||||
visible &&
|
||||
nextTick(() => {
|
||||
unref(inputRef)?.focus()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function handleClose() {
|
||||
searchResult.value = []
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-app-search-modal';
|
||||
@footer-prefix-cls: ~'@{namespace}-app-search-footer';
|
||||
.@{prefix-cls} {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 800;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-top: 50px;
|
||||
background-color: rgb(0 0 0 / 25%);
|
||||
justify-content: center;
|
||||
|
||||
&--mobile {
|
||||
padding: 0;
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.@{prefix-cls}-input {
|
||||
width: calc(100% - 38px);
|
||||
}
|
||||
|
||||
.@{prefix-cls}-cancel {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.@{prefix-cls}-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.@{footer-prefix-cls} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.@{prefix-cls}-list {
|
||||
height: calc(100% - 80px);
|
||||
max-height: unset;
|
||||
|
||||
&__item {
|
||||
&-enter {
|
||||
opacity: 0% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
position: relative;
|
||||
width: 632px;
|
||||
margin: 0 auto auto;
|
||||
background-color: @component-background;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 25%);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&-input__wrapper {
|
||||
display: flex;
|
||||
padding: 14px 14px 0;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 1.5em;
|
||||
color: #1c1e21;
|
||||
border-radius: 6px;
|
||||
|
||||
span[role='img'] {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
&-cancel {
|
||||
display: none;
|
||||
font-size: 1em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
&-not-data {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
font-size: 0.9;
|
||||
color: rgb(150 159 175);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&-list {
|
||||
max-height: 472px;
|
||||
padding: 0 14px;
|
||||
padding-bottom: 20px;
|
||||
margin: 0 auto;
|
||||
margin-top: 14px;
|
||||
overflow: auto;
|
||||
|
||||
&__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 14px;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: @text-color-base;
|
||||
cursor: pointer;
|
||||
background-color: @component-background;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px 0 #d4d9e1;
|
||||
align-items: center;
|
||||
|
||||
> div:first-child,
|
||||
> div:last-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: #fff;
|
||||
background-color: @primary-color;
|
||||
|
||||
.@{prefix-cls}-list__item-enter {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
&-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-enter {
|
||||
width: 30px;
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { Menu } from '/@/router/types'
|
||||
import { ref, onBeforeMount, unref, Ref, nextTick } from 'vue'
|
||||
import { getMenus } from '/@/router/menus'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { filter, forEach } from '/@/utils/helper/treeHelper'
|
||||
import { useGo } from '/@/hooks/web/usePage'
|
||||
import { useScrollTo } from '/@/hooks/event/useScrollTo'
|
||||
import { onKeyStroke, useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
|
||||
export interface SearchResult {
|
||||
name: string
|
||||
path: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
// Translate special characters
|
||||
function transform(c: string) {
|
||||
const code: string[] = ['$', '(', ')', '*', '+', '.', '[', ']', '?', '\\', '^', '{', '}', '|']
|
||||
return code.includes(c) ? `\\${c}` : c
|
||||
}
|
||||
|
||||
function createSearchReg(key: string) {
|
||||
const keys = [...key].map((item) => transform(item))
|
||||
const str = ['', ...keys, ''].join('.*')
|
||||
return new RegExp(str)
|
||||
}
|
||||
|
||||
export function useMenuSearch(refs: Ref<HTMLElement[]>, scrollWrap: Ref<ElRef>, emit: EmitType) {
|
||||
const searchResult = ref<SearchResult[]>([])
|
||||
const keyword = ref('')
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
let menuList: Menu[] = []
|
||||
|
||||
const { t } = useI18n()
|
||||
const go = useGo()
|
||||
const handleSearch = useDebounceFn(search, 200)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
const list = await getMenus()
|
||||
menuList = cloneDeep(list)
|
||||
forEach(menuList, (item) => {
|
||||
item.name = t(item.name)
|
||||
})
|
||||
})
|
||||
|
||||
function search(e: ChangeEvent) {
|
||||
e?.stopPropagation()
|
||||
const key = e.target.value
|
||||
keyword.value = key.trim()
|
||||
if (!key) {
|
||||
searchResult.value = []
|
||||
return
|
||||
}
|
||||
const reg = createSearchReg(unref(keyword))
|
||||
const filterMenu = filter(menuList, (item) => {
|
||||
return reg.test(item.name) && !item.hideMenu
|
||||
})
|
||||
searchResult.value = handlerSearchResult(filterMenu, reg)
|
||||
activeIndex.value = 0
|
||||
}
|
||||
|
||||
function handlerSearchResult(filterMenu: Menu[], reg: RegExp, parent?: Menu) {
|
||||
const ret: SearchResult[] = []
|
||||
filterMenu.forEach((item) => {
|
||||
const { name, path, icon, children, hideMenu, meta } = item
|
||||
if (!hideMenu && reg.test(name) && (!children?.length || meta?.hideChildrenInMenu)) {
|
||||
ret.push({
|
||||
name: parent?.name ? `${parent.name} > ${name}` : name,
|
||||
path,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
if (!meta?.hideChildrenInMenu && Array.isArray(children) && children.length) {
|
||||
ret.push(...handlerSearchResult(children, reg, item))
|
||||
}
|
||||
})
|
||||
return ret
|
||||
}
|
||||
|
||||
// Activate when the mouse moves to a certain line
|
||||
function handleMouseenter(e: any) {
|
||||
const index = e.target.dataset.index
|
||||
activeIndex.value = Number(index)
|
||||
}
|
||||
|
||||
// Arrow key up
|
||||
function handleUp() {
|
||||
if (!searchResult.value.length) return
|
||||
activeIndex.value--
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = searchResult.value.length - 1
|
||||
}
|
||||
handleScroll()
|
||||
}
|
||||
|
||||
// Arrow key down
|
||||
function handleDown() {
|
||||
if (!searchResult.value.length) return
|
||||
activeIndex.value++
|
||||
if (activeIndex.value > searchResult.value.length - 1) {
|
||||
activeIndex.value = 0
|
||||
}
|
||||
handleScroll()
|
||||
}
|
||||
|
||||
// When the keyboard up and down keys move to an invisible place
|
||||
// the scroll bar needs to scroll automatically
|
||||
function handleScroll() {
|
||||
const refList = unref(refs)
|
||||
if (!refList || !Array.isArray(refList) || refList.length === 0 || !unref(scrollWrap)) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = unref(activeIndex)
|
||||
const currentRef = refList[index]
|
||||
if (!currentRef) {
|
||||
return
|
||||
}
|
||||
const wrapEl = unref(scrollWrap)
|
||||
if (!wrapEl) {
|
||||
return
|
||||
}
|
||||
const scrollHeight = currentRef.offsetTop + currentRef.offsetHeight
|
||||
const wrapHeight = wrapEl.offsetHeight
|
||||
const { start } = useScrollTo({
|
||||
el: wrapEl,
|
||||
duration: 100,
|
||||
to: scrollHeight - wrapHeight,
|
||||
})
|
||||
start()
|
||||
}
|
||||
|
||||
// enter keyboard event
|
||||
async function handleEnter() {
|
||||
if (!searchResult.value.length) {
|
||||
return
|
||||
}
|
||||
const result = unref(searchResult)
|
||||
const index = unref(activeIndex)
|
||||
if (result.length === 0 || index < 0) {
|
||||
return
|
||||
}
|
||||
const to = result[index]
|
||||
handleClose()
|
||||
await nextTick()
|
||||
go(to.path)
|
||||
}
|
||||
|
||||
// close search modal
|
||||
function handleClose() {
|
||||
searchResult.value = []
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// enter search
|
||||
onKeyStroke('Enter', handleEnter)
|
||||
// Monitor keyboard arrow keys
|
||||
onKeyStroke('ArrowUp', handleUp)
|
||||
onKeyStroke('ArrowDown', handleDown)
|
||||
// esc close
|
||||
onKeyStroke('Escape', handleClose)
|
||||
|
||||
return { handleSearch, searchResult, keyword, activeIndex, handleMouseenter, handleEnter }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { InjectionKey, Ref } from 'vue'
|
||||
import { createContext, useContext } from '/@/hooks/core/useContext'
|
||||
|
||||
export interface AppProviderContextProps {
|
||||
prefixCls: Ref<string>
|
||||
isMobile: Ref<boolean>
|
||||
}
|
||||
|
||||
const key: InjectionKey<AppProviderContextProps> = Symbol()
|
||||
|
||||
export function createAppProviderContext(context: AppProviderContextProps) {
|
||||
return createContext<AppProviderContextProps>(context, key)
|
||||
}
|
||||
|
||||
export function useAppProviderContext() {
|
||||
return useContext<AppProviderContextProps>(key)
|
||||
}
|
||||
8
frontend/vben/src/components/Basic/index.ts
Normal file
8
frontend/vben/src/components/Basic/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import basicArrow from './src/BasicArrow.vue'
|
||||
import basicTitle from './src/BasicTitle.vue'
|
||||
import basicHelp from './src/BasicHelp.vue'
|
||||
|
||||
export const BasicArrow = withInstall(basicArrow)
|
||||
export const BasicTitle = withInstall(basicTitle)
|
||||
export const BasicHelp = withInstall(basicHelp)
|
||||
84
frontend/vben/src/components/Basic/src/BasicArrow.vue
Normal file
84
frontend/vben/src/components/Basic/src/BasicArrow.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<!--
|
||||
* @Author: Vben
|
||||
* @Description: Arrow component with animation
|
||||
-->
|
||||
<template>
|
||||
<span :class="getClass">
|
||||
<Icon icon="ion:chevron-forward" :style="$attrs.iconStyle" />
|
||||
</span>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { Icon } from '/@/components/Icon'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Arrow expand state
|
||||
*/
|
||||
expand: { type: Boolean },
|
||||
/**
|
||||
* Arrow up by default
|
||||
*/
|
||||
up: { type: Boolean },
|
||||
/**
|
||||
* Arrow down by default
|
||||
*/
|
||||
down: { type: Boolean },
|
||||
/**
|
||||
* Cancel padding/margin for inline
|
||||
*/
|
||||
inset: { type: Boolean },
|
||||
})
|
||||
|
||||
const { prefixCls } = useDesign('basic-arrow')
|
||||
|
||||
// get component class
|
||||
const getClass = computed(() => {
|
||||
const { expand, up, down, inset } = props
|
||||
return [
|
||||
prefixCls,
|
||||
{
|
||||
[`${prefixCls}--active`]: expand,
|
||||
up,
|
||||
inset,
|
||||
down,
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-basic-arrow';
|
||||
|
||||
.@{prefix-cls} {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
transform: rotate(0deg);
|
||||
transition: all 0.3s ease 0.1s;
|
||||
transform-origin: center center;
|
||||
|
||||
&--active {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.inset {
|
||||
line-height: 0px;
|
||||
}
|
||||
|
||||
&.up {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.up.@{prefix-cls}--active {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.down.@{prefix-cls}--active {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
114
frontend/vben/src/components/Basic/src/BasicHelp.vue
Normal file
114
frontend/vben/src/components/Basic/src/BasicHelp.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="tsx">
|
||||
import type { CSSProperties, PropType } from 'vue'
|
||||
import { defineComponent, computed, unref } from 'vue'
|
||||
import { Tooltip } from 'ant-design-vue'
|
||||
import { InfoCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { getPopupContainer } from '/@/utils'
|
||||
import { isString, isArray } from '/@/utils/is'
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
const props = {
|
||||
/**
|
||||
* Help text max-width
|
||||
* @default: 600px
|
||||
*/
|
||||
maxWidth: { type: String, default: '600px' },
|
||||
/**
|
||||
* Whether to display the serial number
|
||||
* @default: false
|
||||
*/
|
||||
showIndex: { type: Boolean },
|
||||
/**
|
||||
* Help text font color
|
||||
* @default: #ffffff
|
||||
*/
|
||||
color: { type: String, default: '#ffffff' },
|
||||
/**
|
||||
* Help text font size
|
||||
* @default: 14px
|
||||
*/
|
||||
fontSize: { type: String, default: '14px' },
|
||||
/**
|
||||
* Help text list
|
||||
*/
|
||||
placement: { type: String, default: 'right' },
|
||||
/**
|
||||
* Help text list
|
||||
*/
|
||||
text: { type: [Array, String] as PropType<string[] | string> },
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicHelp',
|
||||
components: { Tooltip },
|
||||
props,
|
||||
setup(props, { slots }) {
|
||||
const { prefixCls } = useDesign('basic-help')
|
||||
|
||||
const getTooltipStyle = computed(
|
||||
(): CSSProperties => ({ color: props.color, fontSize: props.fontSize }),
|
||||
)
|
||||
|
||||
const getOverlayStyle = computed((): CSSProperties => ({ maxWidth: props.maxWidth }))
|
||||
|
||||
function renderTitle() {
|
||||
const textList = props.text
|
||||
|
||||
if (isString(textList)) {
|
||||
return <p>{textList}</p>
|
||||
}
|
||||
|
||||
if (isArray(textList)) {
|
||||
return textList.map((text, index) => {
|
||||
return (
|
||||
<p key={text}>
|
||||
<>
|
||||
{props.showIndex ? `${index + 1}. ` : ''}
|
||||
{text}
|
||||
</>
|
||||
</p>
|
||||
)
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<Tooltip
|
||||
overlayClassName={`${prefixCls}__wrap`}
|
||||
title={<div style={unref(getTooltipStyle)}>{renderTitle()}</div>}
|
||||
autoAdjustOverflow={true}
|
||||
overlayStyle={unref(getOverlayStyle)}
|
||||
placement={props.placement as 'right'}
|
||||
getPopupContainer={() => getPopupContainer()}
|
||||
>
|
||||
<span class={prefixCls}>{getSlot(slots) || <InfoCircleOutlined />}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-basic-help';
|
||||
|
||||
.@{prefix-cls} {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
font-size: 14px;
|
||||
color: @text-color-help-dark;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&__wrap {
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
76
frontend/vben/src/components/Basic/src/BasicTitle.vue
Normal file
76
frontend/vben/src/components/Basic/src/BasicTitle.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<span :class="getClass">
|
||||
<slot></slot>
|
||||
<BasicHelp :class="`${prefixCls}-help`" v-if="helpMessage" :text="helpMessage" />
|
||||
</span>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { useSlots, computed } from 'vue'
|
||||
import BasicHelp from './BasicHelp.vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Help text list or string
|
||||
* @default: ''
|
||||
*/
|
||||
helpMessage: {
|
||||
type: [String, Array] as PropType<string | string[]>,
|
||||
default: '',
|
||||
},
|
||||
/**
|
||||
* Whether the color block on the left side of the title
|
||||
* @default: false
|
||||
*/
|
||||
span: { type: Boolean },
|
||||
/**
|
||||
* Whether to default the text, that is, not bold
|
||||
* @default: false
|
||||
*/
|
||||
normal: { type: Boolean },
|
||||
})
|
||||
|
||||
const { prefixCls } = useDesign('basic-title')
|
||||
const slots = useSlots()
|
||||
const getClass = computed(() => [
|
||||
prefixCls,
|
||||
{ [`${prefixCls}-show-span`]: props.span && slots.default },
|
||||
{ [`${prefixCls}-normal`]: props.normal },
|
||||
])
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-basic-title';
|
||||
|
||||
.@{prefix-cls} {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding-left: 7px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: @text-color-base;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&-normal {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&-show-span::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
background-color: @primary-color;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&-help {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
frontend/vben/src/components/Button/index.ts
Normal file
9
frontend/vben/src/components/Button/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import type { ExtractPropTypes } from 'vue'
|
||||
import button from './src/BasicButton.vue'
|
||||
import popConfirmButton from './src/PopConfirmButton.vue'
|
||||
import { buttonProps } from './src/props'
|
||||
|
||||
export const Button = withInstall(button)
|
||||
export const PopConfirmButton = withInstall(popConfirmButton)
|
||||
export declare type ButtonProps = Partial<ExtractPropTypes<typeof buttonProps>>
|
||||
40
frontend/vben/src/components/Button/src/BasicButton.vue
Normal file
40
frontend/vben/src/components/Button/src/BasicButton.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<Button v-bind="getBindValue" :class="getButtonClass" @click="onClick">
|
||||
<template #default="data">
|
||||
<Icon :icon="preIcon" v-if="preIcon" :size="iconSize" />
|
||||
<slot v-bind="data || {}"></slot>
|
||||
<Icon :icon="postIcon" v-if="postIcon" :size="iconSize" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
export default defineComponent({
|
||||
name: 'AButton',
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import { computed, unref } from 'vue'
|
||||
import { Button } from 'ant-design-vue'
|
||||
import Icon from '/@/components/Icon/src/Icon.vue'
|
||||
import { buttonProps } from './props'
|
||||
import { useAttrs } from '/@/hooks/core/useAttrs'
|
||||
|
||||
const props = defineProps(buttonProps)
|
||||
// get component class
|
||||
const attrs = useAttrs({ excludeDefaultKeys: false })
|
||||
const getButtonClass = computed(() => {
|
||||
const { color, disabled } = props
|
||||
return [
|
||||
{
|
||||
[`ant-btn-${color}`]: !!color,
|
||||
[`is-disabled`]: disabled,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
// get inherit binding value
|
||||
const getBindValue = computed(() => ({ ...unref(attrs), ...props }))
|
||||
</script>
|
||||
54
frontend/vben/src/components/Button/src/PopConfirmButton.vue
Normal file
54
frontend/vben/src/components/Button/src/PopConfirmButton.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, h, unref } from 'vue'
|
||||
import BasicButton from './BasicButton.vue'
|
||||
import { Popconfirm } from 'ant-design-vue'
|
||||
import { extendSlots } from '/@/utils/helper/tsxHelper'
|
||||
import { omit } from 'lodash-es'
|
||||
import { useAttrs } from '/@/hooks/core/useAttrs'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
|
||||
const props = {
|
||||
/**
|
||||
* Whether to enable the drop-down menu
|
||||
* @default: true
|
||||
*/
|
||||
enable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PopButton',
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
setup(props, { slots }) {
|
||||
const { t } = useI18n()
|
||||
const attrs = useAttrs()
|
||||
|
||||
// get inherit binding value
|
||||
const getBindValues = computed(() => {
|
||||
return Object.assign(
|
||||
{
|
||||
okText: t('common.okText'),
|
||||
cancelText: t('common.cancelText'),
|
||||
},
|
||||
{ ...props, ...unref(attrs) },
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
const bindValues = omit(unref(getBindValues), 'icon')
|
||||
const btnBind = omit(bindValues, 'title') as Recordable
|
||||
if (btnBind.disabled) btnBind.color = ''
|
||||
const Button = h(BasicButton, btnBind, extendSlots(slots))
|
||||
|
||||
// If it is not enabled, it is a normal button
|
||||
if (!props.enable) {
|
||||
return Button
|
||||
}
|
||||
return h(Popconfirm, bindValues, { default: () => Button })
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
19
frontend/vben/src/components/Button/src/props.ts
Normal file
19
frontend/vben/src/components/Button/src/props.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const buttonProps = {
|
||||
color: { type: String, validator: (v) => ['error', 'warning', 'success', ''].includes(v) },
|
||||
loading: { type: Boolean },
|
||||
disabled: { type: Boolean },
|
||||
/**
|
||||
* Text before icon.
|
||||
*/
|
||||
preIcon: { type: String },
|
||||
/**
|
||||
* Text after icon.
|
||||
*/
|
||||
postIcon: { type: String },
|
||||
/**
|
||||
* preIcon and postIcon icon size.
|
||||
* @default: 14
|
||||
*/
|
||||
iconSize: { type: Number, default: 14 },
|
||||
onClick: { type: Function as PropType<(...args) => any>, default: null },
|
||||
}
|
||||
10
frontend/vben/src/components/Container/index.ts
Normal file
10
frontend/vben/src/components/Container/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import collapseContainer from './src/collapse/CollapseContainer.vue'
|
||||
import scrollContainer from './src/ScrollContainer.vue'
|
||||
import lazyContainer from './src/LazyContainer.vue'
|
||||
|
||||
export const CollapseContainer = withInstall(collapseContainer)
|
||||
export const ScrollContainer = withInstall(scrollContainer)
|
||||
export const LazyContainer = withInstall(lazyContainer)
|
||||
|
||||
export * from './src/typing'
|
||||
145
frontend/vben/src/components/Container/src/LazyContainer.vue
Normal file
145
frontend/vben/src/components/Container/src/LazyContainer.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<transition-group
|
||||
class="h-full w-full"
|
||||
v-bind="$attrs"
|
||||
ref="elRef"
|
||||
:name="transitionName"
|
||||
:tag="tag"
|
||||
mode="out-in"
|
||||
>
|
||||
<div key="component" v-if="isInit">
|
||||
<slot :loading="loading"></slot>
|
||||
</div>
|
||||
<div key="skeleton" v-else>
|
||||
<slot name="skeleton" v-if="$slots.skeleton"></slot>
|
||||
<Skeleton v-else />
|
||||
</div>
|
||||
</transition-group>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import { defineComponent, reactive, onMounted, ref, toRef, toRefs } from 'vue'
|
||||
import { Skeleton } from 'ant-design-vue'
|
||||
import { useTimeoutFn } from '/@/hooks/core/useTimeout'
|
||||
import { useIntersectionObserver } from '/@/hooks/event/useIntersectionObserver'
|
||||
|
||||
interface State {
|
||||
isInit: boolean
|
||||
loading: boolean
|
||||
intersectionObserverInstance: IntersectionObserver | null
|
||||
}
|
||||
|
||||
const props = {
|
||||
/**
|
||||
* Waiting time, if the time is specified, whether visible or not, it will be automatically loaded after the specified time
|
||||
*/
|
||||
timeout: { type: Number },
|
||||
/**
|
||||
* The viewport where the component is located.
|
||||
* If the component is scrolling in the page container, the viewport is the container
|
||||
*/
|
||||
viewport: {
|
||||
type: (typeof window !== 'undefined' ? window.HTMLElement : Object) as PropType<HTMLElement>,
|
||||
default: () => null,
|
||||
},
|
||||
/**
|
||||
* Preload threshold, css unit
|
||||
*/
|
||||
threshold: { type: String, default: '0px' },
|
||||
/**
|
||||
* The scroll direction of the viewport, vertical represents the vertical direction, horizontal represents the horizontal direction
|
||||
*/
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'vertical',
|
||||
validator: (v) => ['vertical', 'horizontal'].includes(v),
|
||||
},
|
||||
/**
|
||||
* The label name of the outer container that wraps the component
|
||||
*/
|
||||
tag: { type: String, default: 'div' },
|
||||
maxWaitingTime: { type: Number, default: 80 },
|
||||
/**
|
||||
* transition name
|
||||
*/
|
||||
transitionName: { type: String, default: 'lazy-container' },
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LazyContainer',
|
||||
components: { Skeleton },
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
emits: ['init'],
|
||||
setup(props, { emit }) {
|
||||
const elRef = ref()
|
||||
const state = reactive<State>({
|
||||
isInit: false,
|
||||
loading: false,
|
||||
intersectionObserverInstance: null,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
immediateInit()
|
||||
initIntersectionObserver()
|
||||
})
|
||||
|
||||
// If there is a set delay time, it will be executed immediately
|
||||
function immediateInit() {
|
||||
const { timeout } = props
|
||||
timeout &&
|
||||
useTimeoutFn(() => {
|
||||
init()
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
function init() {
|
||||
state.loading = true
|
||||
|
||||
useTimeoutFn(() => {
|
||||
if (state.isInit) return
|
||||
state.isInit = true
|
||||
emit('init')
|
||||
}, props.maxWaitingTime || 80)
|
||||
}
|
||||
|
||||
function initIntersectionObserver() {
|
||||
const { timeout, direction, threshold } = props
|
||||
if (timeout) return
|
||||
// According to the scrolling direction to construct the viewport margin, used to load in advance
|
||||
let rootMargin = '0px'
|
||||
switch (direction) {
|
||||
case 'vertical':
|
||||
rootMargin = `${threshold} 0px`
|
||||
break
|
||||
case 'horizontal':
|
||||
rootMargin = `0px ${threshold}`
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const { stop, observer } = useIntersectionObserver({
|
||||
rootMargin,
|
||||
target: toRef(elRef.value, '$el'),
|
||||
onIntersect: (entries: any[]) => {
|
||||
const isIntersecting = entries[0].isIntersecting || entries[0].intersectionRatio
|
||||
if (isIntersecting) {
|
||||
init()
|
||||
if (observer) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
},
|
||||
root: toRef(props, 'viewport'),
|
||||
})
|
||||
} catch (e) {
|
||||
init()
|
||||
}
|
||||
}
|
||||
return {
|
||||
elRef,
|
||||
...toRefs(state),
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<Scrollbar ref="scrollbarRef" class="scroll-container" v-bind="$attrs">
|
||||
<slot></slot>
|
||||
</Scrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, unref, nextTick } from 'vue'
|
||||
import { Scrollbar, ScrollbarType } from '/@/components/Scrollbar'
|
||||
import { useScrollTo } from '/@/hooks/event/useScrollTo'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ScrollContainer',
|
||||
components: { Scrollbar },
|
||||
setup() {
|
||||
const scrollbarRef = ref<Nullable<ScrollbarType>>(null)
|
||||
|
||||
/**
|
||||
* Scroll to the specified position
|
||||
*/
|
||||
function scrollTo(to: number, duration = 500) {
|
||||
const scrollbar = unref(scrollbarRef)
|
||||
if (!scrollbar) {
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
const wrap = unref(scrollbar.wrap)
|
||||
if (!wrap) {
|
||||
return
|
||||
}
|
||||
const { start } = useScrollTo({
|
||||
el: wrap,
|
||||
to,
|
||||
duration,
|
||||
})
|
||||
start()
|
||||
})
|
||||
}
|
||||
|
||||
function getScrollWrap() {
|
||||
const scrollbar = unref(scrollbarRef)
|
||||
if (!scrollbar) {
|
||||
return null
|
||||
}
|
||||
return scrollbar.wrap
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the bottom
|
||||
*/
|
||||
function scrollBottom() {
|
||||
const scrollbar = unref(scrollbarRef)
|
||||
if (!scrollbar) {
|
||||
return
|
||||
}
|
||||
nextTick(() => {
|
||||
const wrap = unref(scrollbar.wrap) as any
|
||||
if (!wrap) {
|
||||
return
|
||||
}
|
||||
const scrollHeight = wrap.scrollHeight as number
|
||||
const { start } = useScrollTo({
|
||||
el: wrap,
|
||||
to: scrollHeight,
|
||||
})
|
||||
start()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
scrollbarRef,
|
||||
scrollTo,
|
||||
scrollBottom,
|
||||
getScrollWrap,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
.scroll-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.scrollbar__wrap {
|
||||
margin-bottom: 18px !important;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.scrollbar__view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div :class="prefixCls">
|
||||
<CollapseHeader v-bind="props" :prefixCls="prefixCls" :show="show" @expand="handleExpand">
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
<template #action>
|
||||
<slot name="action"></slot>
|
||||
</template>
|
||||
</CollapseHeader>
|
||||
|
||||
<div class="p-2">
|
||||
<CollapseTransition :enable="canExpan">
|
||||
<Skeleton v-if="loading" :active="loading" />
|
||||
<div :class="`${prefixCls}__body`" v-else v-show="show">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
<div :class="`${prefixCls}__footer`" v-if="$slots.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { isNil } from 'lodash-es'
|
||||
// component
|
||||
import { Skeleton } from 'ant-design-vue'
|
||||
import { CollapseTransition } from '/@/components/Transition'
|
||||
import CollapseHeader from './CollapseHeader.vue'
|
||||
import { triggerWindowResize } from '/@/utils/event'
|
||||
// hook
|
||||
import { useTimeoutFn } from '/@/hooks/core/useTimeout'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
loading: { type: Boolean },
|
||||
/**
|
||||
* Can it be expanded
|
||||
*/
|
||||
canExpan: { type: Boolean, default: true },
|
||||
/**
|
||||
* Warm reminder on the right side of the title
|
||||
*/
|
||||
helpMessage: {
|
||||
type: [Array, String] as PropType<string[] | string>,
|
||||
default: '',
|
||||
},
|
||||
/**
|
||||
* Whether to trigger window.resize when expanding and contracting,
|
||||
* Can adapt to tables and forms, when the form shrinks, the form triggers resize to adapt to the height
|
||||
*/
|
||||
triggerWindowResize: { type: Boolean },
|
||||
/**
|
||||
* Delayed loading time
|
||||
*/
|
||||
lazyTime: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const show = ref(true)
|
||||
|
||||
const { prefixCls } = useDesign('collapse-container')
|
||||
|
||||
/**
|
||||
* @description: Handling development events
|
||||
*/
|
||||
function handleExpand(val: boolean) {
|
||||
show.value = isNil(val) ? !show.value : val
|
||||
if (props.triggerWindowResize) {
|
||||
// 200 milliseconds here is because the expansion has animation,
|
||||
useTimeoutFn(triggerWindowResize, 200)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
handleExpand,
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-collapse-container';
|
||||
|
||||
.@{prefix-cls} {
|
||||
background-color: @component-background;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid @border-color-light;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
border-top: 1px solid @border-color-light;
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div :class="[`${prefixCls}__header px-2 py-5`, $attrs.class]">
|
||||
<BasicTitle :helpMessage="helpMessage" normal>
|
||||
<template v-if="title">
|
||||
{{ title }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</BasicTitle>
|
||||
<div :class="`${prefixCls}__action`">
|
||||
<slot name="action"></slot>
|
||||
<BasicArrow v-if="canExpan" up :expand="show" @click="$emit('expand')" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { BasicArrow, BasicTitle } from '/@/components/Basic'
|
||||
|
||||
const props = {
|
||||
prefixCls: { type: String },
|
||||
helpMessage: {
|
||||
type: [Array, String] as PropType<string[] | string>,
|
||||
default: '',
|
||||
},
|
||||
title: { type: String },
|
||||
show: { type: Boolean },
|
||||
canExpan: { type: Boolean },
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: { BasicArrow, BasicTitle },
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
emits: ['expand'],
|
||||
})
|
||||
</script>
|
||||
17
frontend/vben/src/components/Container/src/typing.ts
Normal file
17
frontend/vben/src/components/Container/src/typing.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type ScrollType = 'default' | 'main'
|
||||
|
||||
export interface CollapseContainerOptions {
|
||||
canExpand?: boolean
|
||||
title?: string
|
||||
helpMessage?: Array<any> | string
|
||||
}
|
||||
export interface ScrollContainerOptions {
|
||||
enableScroll?: boolean
|
||||
type?: ScrollType
|
||||
}
|
||||
|
||||
export type ScrollActionType = RefType<{
|
||||
scrollBottom: () => void
|
||||
getScrollWrap: () => Nullable<HTMLElement>
|
||||
scrollTo: (top: number) => void
|
||||
}>
|
||||
6
frontend/vben/src/components/CountDown/index.ts
Normal file
6
frontend/vben/src/components/CountDown/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import countButton from './src/CountButton.vue'
|
||||
import countdownInput from './src/CountdownInput.vue'
|
||||
|
||||
export const CountdownInput = withInstall(countdownInput)
|
||||
export const CountButton = withInstall(countButton)
|
||||
62
frontend/vben/src/components/CountDown/src/CountButton.vue
Normal file
62
frontend/vben/src/components/CountDown/src/CountButton.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<Button v-bind="$attrs" :disabled="isStart" @click="handleStart" :loading="loading">
|
||||
{{ getButtonText }}
|
||||
</Button>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watchEffect, computed, unref } from 'vue'
|
||||
import { Button } from 'ant-design-vue'
|
||||
import { useCountdown } from './useCountdown'
|
||||
import { isFunction } from '/@/utils/is'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
|
||||
const props = {
|
||||
value: { type: [Object, Number, String, Array] },
|
||||
count: { type: Number, default: 60 },
|
||||
beforeStartFunc: {
|
||||
type: Function as PropType<() => Promise<boolean>>,
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CountButton',
|
||||
components: { Button },
|
||||
props,
|
||||
setup(props) {
|
||||
const loading = ref(false)
|
||||
|
||||
const { currentCount, isStart, start, reset } = useCountdown(props.count)
|
||||
const { t } = useI18n()
|
||||
|
||||
const getButtonText = computed(() => {
|
||||
return !unref(isStart)
|
||||
? t('component.countdown.normalText')
|
||||
: t('component.countdown.sendText', [unref(currentCount)])
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
props.value === undefined && reset()
|
||||
})
|
||||
|
||||
/**
|
||||
* @description: Judge whether there is an external function before execution, and decide whether to start after execution
|
||||
*/
|
||||
async function handleStart() {
|
||||
const { beforeStartFunc } = props
|
||||
if (beforeStartFunc && isFunction(beforeStartFunc)) {
|
||||
loading.value = true
|
||||
try {
|
||||
const canStart = await beforeStartFunc()
|
||||
canStart && start()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
start()
|
||||
}
|
||||
}
|
||||
return { handleStart, currentCount, loading, getButtonText, isStart }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<a-input v-bind="$attrs" :class="prefixCls" :size="size" :value="state">
|
||||
<template #addonAfter>
|
||||
<CountButton :size="size" :count="count" :value="state" :beforeStartFunc="sendCodeApi" />
|
||||
</template>
|
||||
<template #[item]="data" v-for="item in Object.keys($slots).filter((k) => k !== 'addonAfter')">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
</a-input>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue'
|
||||
import CountButton from './CountButton.vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { useRuleFormItem } from '/@/hooks/component/useFormItem'
|
||||
|
||||
const props = {
|
||||
value: { type: String },
|
||||
size: { type: String, validator: (v) => ['default', 'large', 'small'].includes(v) },
|
||||
count: { type: Number, default: 60 },
|
||||
sendCodeApi: {
|
||||
type: Function as PropType<() => Promise<boolean>>,
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CountDownInput',
|
||||
components: { CountButton },
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
setup(props) {
|
||||
const { prefixCls } = useDesign('countdown-input')
|
||||
const [state] = useRuleFormItem(props)
|
||||
|
||||
return { prefixCls, state }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-countdown-input';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.ant-input-group-addon {
|
||||
padding-right: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
|
||||
button {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
frontend/vben/src/components/CountDown/src/useCountdown.ts
Normal file
51
frontend/vben/src/components/CountDown/src/useCountdown.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ref, unref } from 'vue'
|
||||
import { tryOnUnmounted } from '@vueuse/core'
|
||||
|
||||
export function useCountdown(count: number) {
|
||||
const currentCount = ref(count)
|
||||
|
||||
const isStart = ref(false)
|
||||
|
||||
let timerId: ReturnType<typeof setInterval> | null
|
||||
|
||||
function clear() {
|
||||
timerId && window.clearInterval(timerId)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
isStart.value = false
|
||||
clear()
|
||||
timerId = null
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (unref(isStart) || !!timerId) {
|
||||
return
|
||||
}
|
||||
isStart.value = true
|
||||
timerId = setInterval(() => {
|
||||
if (unref(currentCount) === 1) {
|
||||
stop()
|
||||
currentCount.value = count
|
||||
} else {
|
||||
currentCount.value -= 1
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentCount.value = count
|
||||
stop()
|
||||
}
|
||||
|
||||
function restart() {
|
||||
reset()
|
||||
start()
|
||||
}
|
||||
|
||||
tryOnUnmounted(() => {
|
||||
reset()
|
||||
})
|
||||
|
||||
return { start, reset, restart, clear, stop, currentCount, isStart }
|
||||
}
|
||||
6
frontend/vben/src/components/Description/index.ts
Normal file
6
frontend/vben/src/components/Description/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import description from './src/Description.vue'
|
||||
|
||||
export * from './src/typing'
|
||||
export { useDescription } from './src/useDescription'
|
||||
export const Description = withInstall(description)
|
||||
184
frontend/vben/src/components/Description/src/Description.vue
Normal file
184
frontend/vben/src/components/Description/src/Description.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="tsx">
|
||||
import type { DescriptionProps, DescInstance, DescItem } from './typing'
|
||||
import type { DescriptionsProps } from 'ant-design-vue/es/descriptions/index'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CollapseContainerOptions } from '/@/components/Container/index'
|
||||
import { defineComponent, computed, ref, unref, toRefs } from 'vue'
|
||||
import { get } from 'lodash-es'
|
||||
import { Descriptions } from 'ant-design-vue'
|
||||
import { CollapseContainer } from '/@/components/Container/index'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { isFunction } from '/@/utils/is'
|
||||
import { getSlot } from '/@/utils/helper/tsxHelper'
|
||||
import { useAttrs } from '/@/hooks/core/useAttrs'
|
||||
|
||||
const props = {
|
||||
useCollapse: { type: Boolean, default: true },
|
||||
title: { type: String, default: '' },
|
||||
size: {
|
||||
type: String,
|
||||
validator: (v) => ['small', 'default', 'middle', undefined].includes(v),
|
||||
default: 'small',
|
||||
},
|
||||
bordered: { type: Boolean, default: true },
|
||||
column: {
|
||||
type: [Number, Object] as PropType<number | Recordable>,
|
||||
default: () => {
|
||||
return { xxl: 4, xl: 3, lg: 3, md: 3, sm: 2, xs: 1 }
|
||||
},
|
||||
},
|
||||
collapseOptions: {
|
||||
type: Object as PropType<CollapseContainerOptions>,
|
||||
default: null,
|
||||
},
|
||||
schema: {
|
||||
type: Array as PropType<DescItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
data: { type: Object },
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Description',
|
||||
props,
|
||||
emits: ['register'],
|
||||
setup(props, { slots, emit }) {
|
||||
const propsRef = ref<Partial<DescriptionProps> | null>(null)
|
||||
|
||||
const { prefixCls } = useDesign('description')
|
||||
const attrs = useAttrs()
|
||||
|
||||
// Custom title component: get title
|
||||
const getMergeProps = computed(() => {
|
||||
return {
|
||||
...props,
|
||||
...(unref(propsRef) as Recordable),
|
||||
} as DescriptionProps
|
||||
})
|
||||
|
||||
const getProps = computed(() => {
|
||||
const opt = {
|
||||
...unref(getMergeProps),
|
||||
title: undefined,
|
||||
}
|
||||
return opt as DescriptionProps
|
||||
})
|
||||
|
||||
/**
|
||||
* @description: Whether to setting title
|
||||
*/
|
||||
const useWrapper = computed(() => !!unref(getMergeProps).title)
|
||||
|
||||
/**
|
||||
* @description: Get configuration Collapse
|
||||
*/
|
||||
const getCollapseOptions = computed((): CollapseContainerOptions => {
|
||||
return {
|
||||
// Cannot be expanded by default
|
||||
canExpand: false,
|
||||
...unref(getProps).collapseOptions,
|
||||
}
|
||||
})
|
||||
|
||||
const getDescriptionsProps = computed(() => {
|
||||
return { ...unref(attrs), ...unref(getProps) } as DescriptionsProps
|
||||
})
|
||||
|
||||
/**
|
||||
* @description:设置desc
|
||||
*/
|
||||
function setDescProps(descProps: Partial<DescriptionProps>): void {
|
||||
// Keep the last setDrawerProps
|
||||
propsRef.value = { ...(unref(propsRef) as Recordable), ...descProps } as Recordable
|
||||
}
|
||||
|
||||
// Prevent line breaks
|
||||
function renderLabel({ label, labelMinWidth, labelStyle }: DescItem) {
|
||||
if (!labelStyle && !labelMinWidth) {
|
||||
return label
|
||||
}
|
||||
|
||||
const labelStyles: CSSProperties = {
|
||||
...labelStyle,
|
||||
minWidth: `${labelMinWidth}px `,
|
||||
}
|
||||
return <div style={labelStyles}>{label}</div>
|
||||
}
|
||||
|
||||
function renderItem() {
|
||||
const { schema, data } = unref(getProps)
|
||||
return unref(schema)
|
||||
.map((item) => {
|
||||
const { render, field, span, show, contentMinWidth } = item
|
||||
|
||||
if (show && isFunction(show) && !show(data)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getContent = () => {
|
||||
const _data = unref(getProps)?.data
|
||||
if (!_data) {
|
||||
return null
|
||||
}
|
||||
const getField = get(_data, field)
|
||||
if (getField && !toRefs(_data).hasOwnProperty(field)) {
|
||||
return isFunction(render) ? render('', _data) : ''
|
||||
}
|
||||
return isFunction(render) ? render(getField, _data) : getField ?? ''
|
||||
}
|
||||
|
||||
const width = contentMinWidth
|
||||
return (
|
||||
<Descriptions.Item label={renderLabel(item)} key={field} span={span}>
|
||||
{() => {
|
||||
if (!contentMinWidth) {
|
||||
return getContent()
|
||||
}
|
||||
const style: CSSProperties = {
|
||||
minWidth: `${width}px`,
|
||||
}
|
||||
return <div style={style}>{getContent()}</div>
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
)
|
||||
})
|
||||
.filter((item) => !!item)
|
||||
}
|
||||
|
||||
const renderDesc = () => {
|
||||
return (
|
||||
<Descriptions class={`${prefixCls}`} {...(unref(getDescriptionsProps) as any)}>
|
||||
{renderItem()}
|
||||
</Descriptions>
|
||||
)
|
||||
}
|
||||
|
||||
const renderContainer = () => {
|
||||
const content = props.useCollapse ? renderDesc() : <div>{renderDesc()}</div>
|
||||
// Reduce the dom level
|
||||
if (!props.useCollapse) {
|
||||
return content
|
||||
}
|
||||
|
||||
const { canExpand, helpMessage } = unref(getCollapseOptions)
|
||||
const { title } = unref(getMergeProps)
|
||||
|
||||
return (
|
||||
<CollapseContainer title={title} canExpan={canExpand} helpMessage={helpMessage}>
|
||||
{{
|
||||
default: () => content,
|
||||
action: () => getSlot(slots, 'action'),
|
||||
}}
|
||||
</CollapseContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const methods: DescInstance = {
|
||||
setDescProps,
|
||||
}
|
||||
|
||||
emit('register', methods)
|
||||
return () => (unref(useWrapper) ? renderContainer() : renderDesc())
|
||||
},
|
||||
})
|
||||
</script>
|
||||
50
frontend/vben/src/components/Description/src/typing.ts
Normal file
50
frontend/vben/src/components/Description/src/typing.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { VNode, CSSProperties } from 'vue'
|
||||
import type { CollapseContainerOptions } from '/@/components/Container/index'
|
||||
import type { DescriptionsProps } from 'ant-design-vue/es/descriptions/index'
|
||||
|
||||
export interface DescItem {
|
||||
labelMinWidth?: number
|
||||
contentMinWidth?: number
|
||||
labelStyle?: CSSProperties
|
||||
field: string
|
||||
label: string | VNode | JSX.Element
|
||||
// Merge column
|
||||
span?: number
|
||||
show?: (...arg: any) => boolean
|
||||
// render
|
||||
render?: (
|
||||
val: any,
|
||||
data: Recordable,
|
||||
) => VNode | undefined | JSX.Element | Element | string | number
|
||||
}
|
||||
|
||||
export interface DescriptionProps extends DescriptionsProps {
|
||||
// Whether to include the collapse component
|
||||
useCollapse?: boolean
|
||||
/**
|
||||
* item configuration
|
||||
* @type DescItem
|
||||
*/
|
||||
schema: DescItem[]
|
||||
/**
|
||||
* 数据
|
||||
* @type object
|
||||
*/
|
||||
data: Recordable
|
||||
/**
|
||||
* Built-in CollapseContainer component configuration
|
||||
* @type CollapseContainerOptions
|
||||
*/
|
||||
collapseOptions?: CollapseContainerOptions
|
||||
}
|
||||
|
||||
export interface DescInstance {
|
||||
setDescProps(descProps: Partial<DescriptionProps>): void
|
||||
}
|
||||
|
||||
export type Register = (descInstance: DescInstance) => void
|
||||
|
||||
/**
|
||||
* @description:
|
||||
*/
|
||||
export type UseDescReturnType = [Register, DescInstance]
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DescriptionProps, DescInstance, UseDescReturnType } from './typing'
|
||||
import { ref, getCurrentInstance, unref } from 'vue'
|
||||
import { isProdMode } from '/@/utils/env'
|
||||
|
||||
export function useDescription(props?: Partial<DescriptionProps>): UseDescReturnType {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('useDescription() can only be used inside setup() or functional components!')
|
||||
}
|
||||
const desc = ref<Nullable<DescInstance>>(null)
|
||||
const loaded = ref(false)
|
||||
|
||||
function register(instance: DescInstance) {
|
||||
if (unref(loaded) && isProdMode()) {
|
||||
return
|
||||
}
|
||||
desc.value = instance
|
||||
props && instance.setDescProps(props)
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
const methods: DescInstance = {
|
||||
setDescProps: (descProps: Partial<DescriptionProps>): void => {
|
||||
unref(desc)?.setDescProps(descProps)
|
||||
},
|
||||
}
|
||||
|
||||
return [register, methods]
|
||||
}
|
||||
6
frontend/vben/src/components/Drawer/index.ts
Normal file
6
frontend/vben/src/components/Drawer/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import basicDrawer from './src/BasicDrawer.vue'
|
||||
|
||||
export const BasicDrawer = withInstall(basicDrawer)
|
||||
export * from './src/typing'
|
||||
export { useDrawer, useDrawerInner } from './src/useDrawer'
|
||||
256
frontend/vben/src/components/Drawer/src/BasicDrawer.vue
Normal file
256
frontend/vben/src/components/Drawer/src/BasicDrawer.vue
Normal file
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<Drawer :class="prefixCls" @close="onClose" v-bind="getBindValues">
|
||||
<template #title v-if="!$slots.title">
|
||||
<DrawerHeader
|
||||
:title="getMergeProps.title"
|
||||
:isDetail="isDetail"
|
||||
:showDetailBack="showDetailBack"
|
||||
@close="onClose"
|
||||
>
|
||||
<template #titleToolbar>
|
||||
<slot name="titleToolbar"></slot>
|
||||
</template>
|
||||
</DrawerHeader>
|
||||
</template>
|
||||
<template v-else #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
|
||||
<ScrollContainer
|
||||
:style="getScrollContentStyle"
|
||||
v-loading="getLoading"
|
||||
:loading-tip="loadingText || t('common.loadingText')"
|
||||
>
|
||||
<slot></slot>
|
||||
</ScrollContainer>
|
||||
<DrawerFooter v-bind="getProps" @close="onClose" @ok="handleOk" :height="getFooterHeight">
|
||||
<template #[item]="data" v-for="item in Object.keys($slots)">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
</DrawerFooter>
|
||||
</Drawer>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { DrawerInstance, DrawerProps } from './typing'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
computed,
|
||||
watch,
|
||||
unref,
|
||||
nextTick,
|
||||
toRaw,
|
||||
getCurrentInstance,
|
||||
} from 'vue'
|
||||
import { Drawer } from 'ant-design-vue'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
import { isFunction, isNumber } from '/@/utils/is'
|
||||
import { deepMerge } from '/@/utils'
|
||||
import DrawerFooter from './components/DrawerFooter.vue'
|
||||
import DrawerHeader from './components/DrawerHeader.vue'
|
||||
import { ScrollContainer } from '/@/components/Container'
|
||||
import { basicProps } from './props'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { useAttrs } from '/@/hooks/core/useAttrs'
|
||||
|
||||
export default defineComponent({
|
||||
components: { Drawer, ScrollContainer, DrawerFooter, DrawerHeader },
|
||||
inheritAttrs: false,
|
||||
props: basicProps,
|
||||
emits: ['visible-change', 'ok', 'close', 'register'],
|
||||
setup(props, { emit }) {
|
||||
const visibleRef = ref(false)
|
||||
const attrs = useAttrs()
|
||||
const propsRef = ref<Partial<Nullable<DrawerProps>>>(null)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { prefixVar, prefixCls } = useDesign('basic-drawer')
|
||||
|
||||
const drawerInstance: DrawerInstance = {
|
||||
setDrawerProps: setDrawerProps,
|
||||
emitVisible: undefined,
|
||||
}
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
instance && emit('register', drawerInstance, instance.uid)
|
||||
|
||||
const getMergeProps = computed((): DrawerProps => {
|
||||
return deepMerge(toRaw(props), unref(propsRef))
|
||||
})
|
||||
|
||||
const getProps = computed((): DrawerProps => {
|
||||
const opt = {
|
||||
placement: 'right',
|
||||
...unref(attrs),
|
||||
...unref(getMergeProps),
|
||||
visible: unref(visibleRef),
|
||||
}
|
||||
opt.title = undefined
|
||||
const { isDetail, width, wrapClassName, getContainer } = opt
|
||||
if (isDetail) {
|
||||
if (!width) {
|
||||
opt.width = '100%'
|
||||
}
|
||||
const detailCls = `${prefixCls}__detail`
|
||||
opt.class = wrapClassName ? `${wrapClassName} ${detailCls}` : detailCls
|
||||
|
||||
if (!getContainer) {
|
||||
// TODO type error?
|
||||
opt.getContainer = `.${prefixVar}-layout-content` as any
|
||||
}
|
||||
}
|
||||
return opt as DrawerProps
|
||||
})
|
||||
|
||||
const getBindValues = computed((): DrawerProps => {
|
||||
return {
|
||||
...attrs,
|
||||
...unref(getProps),
|
||||
}
|
||||
})
|
||||
|
||||
// Custom implementation of the bottom button,
|
||||
const getFooterHeight = computed(() => {
|
||||
const { footerHeight, showFooter } = unref(getProps)
|
||||
if (showFooter && footerHeight) {
|
||||
return isNumber(footerHeight)
|
||||
? `${footerHeight}px`
|
||||
: `${footerHeight.replace('px', '')}px`
|
||||
}
|
||||
return `0px`
|
||||
})
|
||||
|
||||
const getScrollContentStyle = computed((): CSSProperties => {
|
||||
const footerHeight = unref(getFooterHeight)
|
||||
return {
|
||||
position: 'relative',
|
||||
height: `calc(100% - ${footerHeight})`,
|
||||
}
|
||||
})
|
||||
|
||||
const getLoading = computed(() => {
|
||||
return !!unref(getProps)?.loading
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) visibleRef.value = newVal
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => visibleRef.value,
|
||||
(visible) => {
|
||||
nextTick(() => {
|
||||
emit('visible-change', visible)
|
||||
instance && drawerInstance.emitVisible?.(visible, instance.uid)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// Cancel event
|
||||
async function onClose(e: Recordable) {
|
||||
const { closeFunc } = unref(getProps)
|
||||
emit('close', e)
|
||||
if (closeFunc && isFunction(closeFunc)) {
|
||||
const res = await closeFunc()
|
||||
visibleRef.value = !res
|
||||
return
|
||||
}
|
||||
visibleRef.value = false
|
||||
}
|
||||
|
||||
function setDrawerProps(props: Partial<DrawerProps>): void {
|
||||
// Keep the last setDrawerProps
|
||||
propsRef.value = deepMerge(unref(propsRef) || ({} as any), props)
|
||||
|
||||
if (Reflect.has(props, 'visible')) {
|
||||
visibleRef.value = !!props.visible
|
||||
}
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
emit('ok')
|
||||
}
|
||||
|
||||
return {
|
||||
onClose,
|
||||
t,
|
||||
prefixCls,
|
||||
getMergeProps: getMergeProps as any,
|
||||
getScrollContentStyle,
|
||||
getProps: getProps as any,
|
||||
getLoading,
|
||||
getBindValues,
|
||||
getFooterHeight,
|
||||
handleOk,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@header-height: 60px;
|
||||
@detail-header-height: 40px;
|
||||
@prefix-cls: ~'@{namespace}-basic-drawer';
|
||||
@prefix-cls-detail: ~'@{namespace}-basic-drawer__detail';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.ant-drawer-wrapper-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
&:hover {
|
||||
color: @error-color;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
height: calc(100% - @header-height);
|
||||
padding: 0;
|
||||
background-color: @component-background;
|
||||
|
||||
.scrollbar__wrap {
|
||||
padding: 16px !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
> .scrollbar > .scrollbar__bar.is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.@{prefix-cls-detail} {
|
||||
position: absolute;
|
||||
|
||||
.ant-drawer-header {
|
||||
width: 100%;
|
||||
height: @detail-header-height;
|
||||
padding: 0;
|
||||
border-top: 1px solid @border-color-base;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ant-drawer-title {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: @detail-header-height;
|
||||
line-height: @detail-header-height;
|
||||
}
|
||||
|
||||
.scrollbar__wrap {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
height: calc(100% - @detail-header-height);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div :class="prefixCls" :style="getStyle" v-if="showFooter || $slots.footer">
|
||||
<template v-if="!$slots.footer">
|
||||
<slot name="insertFooter"></slot>
|
||||
<a-button v-bind="cancelButtonProps" @click="handleClose" class="mr-2" v-if="showCancelBtn">
|
||||
{{ cancelText }}
|
||||
</a-button>
|
||||
<slot name="centerFooter"></slot>
|
||||
<a-button
|
||||
:type="okType"
|
||||
@click="handleOk"
|
||||
v-bind="okButtonProps"
|
||||
class="mr-2"
|
||||
:loading="confirmLoading"
|
||||
v-if="showOkBtn"
|
||||
>
|
||||
{{ okText }}
|
||||
</a-button>
|
||||
<slot name="appendFooter"></slot>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<slot name="footer"></slot>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
import { footerProps } from '../props'
|
||||
export default defineComponent({
|
||||
name: 'BasicDrawerFooter',
|
||||
props: {
|
||||
...footerProps,
|
||||
height: {
|
||||
type: String,
|
||||
default: '60px',
|
||||
},
|
||||
},
|
||||
emits: ['ok', 'close'],
|
||||
setup(props, { emit }) {
|
||||
const { prefixCls } = useDesign('basic-drawer-footer')
|
||||
|
||||
const getStyle = computed((): CSSProperties => {
|
||||
const heightStr = `${props.height}`
|
||||
return {
|
||||
height: heightStr,
|
||||
lineHeight: `calc(${heightStr} - 1px)`,
|
||||
}
|
||||
})
|
||||
|
||||
function handleOk() {
|
||||
emit('ok')
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
return { handleOk, prefixCls, handleClose, getStyle }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-basic-drawer-footer';
|
||||
@footer-height: 60px;
|
||||
.@{prefix-cls} {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 0 12px 0 20px;
|
||||
text-align: right;
|
||||
background-color: @component-background;
|
||||
border-top: 1px solid @border-color-base;
|
||||
|
||||
> * {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<BasicTitle v-if="!isDetail" :class="prefixCls">
|
||||
<slot name="title"></slot>
|
||||
{{ !$slots.title ? title : '' }}
|
||||
</BasicTitle>
|
||||
|
||||
<div :class="[prefixCls, `${prefixCls}--detail`]" v-else>
|
||||
<span :class="`${prefixCls}__twrap`">
|
||||
<span @click="handleClose" v-if="showDetailBack">
|
||||
<ArrowLeftOutlined :class="`${prefixCls}__back`" />
|
||||
</span>
|
||||
<span v-if="title">{{ title }}</span>
|
||||
</span>
|
||||
|
||||
<span :class="`${prefixCls}__toolbar`">
|
||||
<slot name="titleToolbar"></slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { BasicTitle } from '/@/components/Basic'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
export default defineComponent({
|
||||
name: 'BasicDrawerHeader',
|
||||
components: { BasicTitle, ArrowLeftOutlined },
|
||||
props: {
|
||||
isDetail: propTypes.bool,
|
||||
showDetailBack: propTypes.bool,
|
||||
title: propTypes.string,
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(_, { emit }) {
|
||||
const { prefixCls } = useDesign('basic-drawer-header')
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
return { prefixCls, handleClose }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-basic-drawer-header';
|
||||
@footer-height: 60px;
|
||||
.@{prefix-cls} {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
|
||||
&__back {
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__twrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
padding-right: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
44
frontend/vben/src/components/Drawer/src/props.ts
Normal file
44
frontend/vben/src/components/Drawer/src/props.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
const { t } = useI18n()
|
||||
|
||||
export const footerProps = {
|
||||
confirmLoading: { type: Boolean },
|
||||
/**
|
||||
* @description: Show close button
|
||||
*/
|
||||
showCancelBtn: { type: Boolean, default: true },
|
||||
cancelButtonProps: Object as PropType<Recordable>,
|
||||
cancelText: { type: String, default: t('common.cancelText') },
|
||||
/**
|
||||
* @description: Show confirmation button
|
||||
*/
|
||||
showOkBtn: { type: Boolean, default: true },
|
||||
okButtonProps: Object as PropType<Recordable>,
|
||||
okText: { type: String, default: t('common.okText') },
|
||||
okType: { type: String, default: 'primary' },
|
||||
showFooter: { type: Boolean },
|
||||
footerHeight: {
|
||||
type: [String, Number] as PropType<string | number>,
|
||||
default: 60,
|
||||
},
|
||||
}
|
||||
export const basicProps = {
|
||||
isDetail: { type: Boolean },
|
||||
title: { type: String, default: '' },
|
||||
loadingText: { type: String },
|
||||
showDetailBack: { type: Boolean, default: true },
|
||||
visible: { type: Boolean },
|
||||
loading: { type: Boolean },
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
getContainer: {
|
||||
type: [Object, String] as PropType<any>,
|
||||
},
|
||||
closeFunc: {
|
||||
type: [Function, Object] as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
destroyOnClose: { type: Boolean },
|
||||
...footerProps,
|
||||
}
|
||||
193
frontend/vben/src/components/Drawer/src/typing.ts
Normal file
193
frontend/vben/src/components/Drawer/src/typing.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import type { ButtonProps } from 'ant-design-vue/lib/button/buttonTypes'
|
||||
import type { CSSProperties, VNodeChild, ComputedRef } from 'vue'
|
||||
import type { ScrollContainerOptions } from '/@/components/Container/index'
|
||||
|
||||
export interface DrawerInstance {
|
||||
setDrawerProps: (props: Partial<DrawerProps> | boolean) => void
|
||||
emitVisible?: (visible: boolean, uid: number) => void
|
||||
}
|
||||
|
||||
export interface ReturnMethods extends DrawerInstance {
|
||||
openDrawer: <T = any>(visible?: boolean, data?: T, openOnSet?: boolean) => void
|
||||
closeDrawer: () => void
|
||||
getVisible?: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export type RegisterFn = (drawerInstance: DrawerInstance, uuid?: string) => void
|
||||
|
||||
export interface ReturnInnerMethods extends DrawerInstance {
|
||||
closeDrawer: () => void
|
||||
changeLoading: (loading: boolean) => void
|
||||
changeOkLoading: (loading: boolean) => void
|
||||
getVisible?: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export type UseDrawerReturnType = [RegisterFn, ReturnMethods]
|
||||
|
||||
export type UseDrawerInnerReturnType = [RegisterFn, ReturnInnerMethods]
|
||||
|
||||
export interface DrawerFooterProps {
|
||||
showOkBtn: boolean
|
||||
showCancelBtn: boolean
|
||||
/**
|
||||
* Text of the Cancel button
|
||||
* @default 'cancel'
|
||||
* @type string
|
||||
*/
|
||||
cancelText: string
|
||||
/**
|
||||
* Text of the OK button
|
||||
* @default 'OK'
|
||||
* @type string
|
||||
*/
|
||||
okText: string
|
||||
|
||||
/**
|
||||
* Button type of the OK button
|
||||
* @default 'primary'
|
||||
* @type string
|
||||
*/
|
||||
okType: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default'
|
||||
/**
|
||||
* The ok button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
okButtonProps: { props: ButtonProps; on: {} }
|
||||
|
||||
/**
|
||||
* The cancel button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
cancelButtonProps: { props: ButtonProps; on: {} }
|
||||
/**
|
||||
* Whether to apply loading visual effect for OK button or not
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
confirmLoading: boolean
|
||||
|
||||
showFooter: boolean
|
||||
footerHeight: string | number
|
||||
}
|
||||
export interface DrawerProps extends DrawerFooterProps {
|
||||
isDetail?: boolean
|
||||
loading?: boolean
|
||||
showDetailBack?: boolean
|
||||
visible?: boolean
|
||||
/**
|
||||
* Built-in ScrollContainer component configuration
|
||||
* @type ScrollContainerOptions
|
||||
*/
|
||||
scrollOptions?: ScrollContainerOptions
|
||||
closeFunc?: () => Promise<any>
|
||||
triggerWindowResize?: boolean
|
||||
/**
|
||||
* Whether a close (x) button is visible on top right of the Drawer dialog or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
closable?: boolean
|
||||
|
||||
/**
|
||||
* Whether to unmount child components on closing drawer or not.
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
destroyOnClose?: boolean
|
||||
|
||||
/**
|
||||
* Return the mounted node for Drawer.
|
||||
* @default 'body'
|
||||
* @type any ( HTMLElement| () => HTMLElement | string)
|
||||
*/
|
||||
getContainer?: () => HTMLElement | string
|
||||
|
||||
/**
|
||||
* Whether to show mask or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
mask?: boolean
|
||||
|
||||
/**
|
||||
* Clicking on the mask (area outside the Drawer) to close the Drawer or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
maskClosable?: boolean
|
||||
|
||||
/**
|
||||
* Style for Drawer's mask element.
|
||||
* @default {}
|
||||
* @type object
|
||||
*/
|
||||
maskStyle?: CSSProperties
|
||||
|
||||
/**
|
||||
* The title for Drawer.
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
title?: VNodeChild | JSX.Element
|
||||
/**
|
||||
* The class name of the container of the Drawer dialog.
|
||||
* @type string
|
||||
*/
|
||||
wrapClassName?: string
|
||||
class?: string
|
||||
/**
|
||||
* Style of wrapper element which **contains mask** compare to `drawerStyle`
|
||||
* @type object
|
||||
*/
|
||||
wrapStyle?: CSSProperties
|
||||
|
||||
/**
|
||||
* Style of the popup layer element
|
||||
* @type object
|
||||
*/
|
||||
drawerStyle?: CSSProperties
|
||||
|
||||
/**
|
||||
* Style of floating layer, typically used for adjusting its position.
|
||||
* @type object
|
||||
*/
|
||||
bodyStyle?: CSSProperties
|
||||
headerStyle?: CSSProperties
|
||||
|
||||
/**
|
||||
* Width of the Drawer dialog.
|
||||
* @default 256
|
||||
* @type string | number
|
||||
*/
|
||||
width?: string | number
|
||||
|
||||
/**
|
||||
* placement is top or bottom, height of the Drawer dialog.
|
||||
* @type string | number
|
||||
*/
|
||||
height?: string | number
|
||||
|
||||
/**
|
||||
* The z-index of the Drawer.
|
||||
* @default 1000
|
||||
* @type number
|
||||
*/
|
||||
zIndex?: number
|
||||
|
||||
/**
|
||||
* The placement of the Drawer.
|
||||
* @default 'right'
|
||||
* @type string
|
||||
*/
|
||||
placement?: 'top' | 'right' | 'bottom' | 'left'
|
||||
afterVisibleChange?: (visible?: boolean) => void
|
||||
keyboard?: boolean
|
||||
/**
|
||||
* Specify a callback that will be called when a user clicks mask, close button or Cancel button.
|
||||
*/
|
||||
onClose?: (e?: Event) => void
|
||||
}
|
||||
export interface DrawerActionType {
|
||||
scrollBottom: () => void
|
||||
scrollTo: (to: number) => void
|
||||
getScrollWrap: () => Element | null
|
||||
}
|
||||
161
frontend/vben/src/components/Drawer/src/useDrawer.ts
Normal file
161
frontend/vben/src/components/Drawer/src/useDrawer.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type {
|
||||
UseDrawerReturnType,
|
||||
DrawerInstance,
|
||||
ReturnMethods,
|
||||
DrawerProps,
|
||||
UseDrawerInnerReturnType,
|
||||
} from './typing'
|
||||
import {
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
unref,
|
||||
reactive,
|
||||
watchEffect,
|
||||
nextTick,
|
||||
toRaw,
|
||||
computed,
|
||||
} from 'vue'
|
||||
import { isProdMode } from '/@/utils/env'
|
||||
import { isFunction } from '/@/utils/is'
|
||||
import { tryOnUnmounted } from '@vueuse/core'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { error } from '/@/utils/log'
|
||||
|
||||
const dataTransferRef = reactive<any>({})
|
||||
|
||||
const visibleData = reactive<{ [key: number]: boolean }>({})
|
||||
|
||||
/**
|
||||
* @description: Applicable to separate drawer and call outside
|
||||
*/
|
||||
export function useDrawer(): UseDrawerReturnType {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('useDrawer() can only be used inside setup() or functional components!')
|
||||
}
|
||||
const drawer = ref<DrawerInstance | null>(null)
|
||||
const loaded = ref<Nullable<boolean>>(false)
|
||||
const uid = ref<string>('')
|
||||
|
||||
function register(drawerInstance: DrawerInstance, uuid: string) {
|
||||
isProdMode() &&
|
||||
tryOnUnmounted(() => {
|
||||
drawer.value = null
|
||||
loaded.value = null
|
||||
dataTransferRef[unref(uid)] = null
|
||||
})
|
||||
|
||||
if (unref(loaded) && isProdMode() && drawerInstance === unref(drawer)) {
|
||||
return
|
||||
}
|
||||
uid.value = uuid
|
||||
drawer.value = drawerInstance
|
||||
loaded.value = true
|
||||
|
||||
drawerInstance.emitVisible = (visible: boolean, uid: number) => {
|
||||
visibleData[uid] = visible
|
||||
}
|
||||
}
|
||||
|
||||
const getInstance = () => {
|
||||
const instance = unref(drawer)
|
||||
if (!instance) {
|
||||
error('useDrawer instance is undefined!')
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
const methods: ReturnMethods = {
|
||||
setDrawerProps: (props: Partial<DrawerProps>): void => {
|
||||
getInstance()?.setDrawerProps(props)
|
||||
},
|
||||
|
||||
getVisible: computed((): boolean => {
|
||||
return visibleData[~~unref(uid)]
|
||||
}),
|
||||
|
||||
openDrawer: <T = any>(visible = true, data?: T, openOnSet = true): void => {
|
||||
getInstance()?.setDrawerProps({
|
||||
visible: visible,
|
||||
})
|
||||
if (!data) return
|
||||
|
||||
if (openOnSet) {
|
||||
dataTransferRef[unref(uid)] = null
|
||||
dataTransferRef[unref(uid)] = toRaw(data)
|
||||
return
|
||||
}
|
||||
const equal = isEqual(toRaw(dataTransferRef[unref(uid)]), toRaw(data))
|
||||
if (!equal) {
|
||||
dataTransferRef[unref(uid)] = toRaw(data)
|
||||
}
|
||||
},
|
||||
closeDrawer: () => {
|
||||
getInstance()?.setDrawerProps({ visible: false })
|
||||
},
|
||||
}
|
||||
|
||||
return [register, methods]
|
||||
}
|
||||
|
||||
export const useDrawerInner = (callbackFn?: Fn): UseDrawerInnerReturnType => {
|
||||
const drawerInstanceRef = ref<Nullable<DrawerInstance>>(null)
|
||||
const currentInstance = getCurrentInstance()
|
||||
const uidRef = ref<string>('')
|
||||
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('useDrawerInner() can only be used inside setup() or functional components!')
|
||||
}
|
||||
|
||||
const getInstance = () => {
|
||||
const instance = unref(drawerInstanceRef)
|
||||
if (!instance) {
|
||||
error('useDrawerInner instance is undefined!')
|
||||
return
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
const register = (modalInstance: DrawerInstance, uuid: string) => {
|
||||
isProdMode() &&
|
||||
tryOnUnmounted(() => {
|
||||
drawerInstanceRef.value = null
|
||||
})
|
||||
|
||||
uidRef.value = uuid
|
||||
drawerInstanceRef.value = modalInstance
|
||||
currentInstance?.emit('register', modalInstance, uuid)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const data = dataTransferRef[unref(uidRef)]
|
||||
if (!data) return
|
||||
if (!callbackFn || !isFunction(callbackFn)) return
|
||||
nextTick(() => {
|
||||
callbackFn(data)
|
||||
})
|
||||
})
|
||||
|
||||
return [
|
||||
register,
|
||||
{
|
||||
changeLoading: (loading = true) => {
|
||||
getInstance()?.setDrawerProps({ loading })
|
||||
},
|
||||
|
||||
changeOkLoading: (loading = true) => {
|
||||
getInstance()?.setDrawerProps({ confirmLoading: loading })
|
||||
},
|
||||
getVisible: computed((): boolean => {
|
||||
return visibleData[~~unref(uidRef)]
|
||||
}),
|
||||
|
||||
closeDrawer: () => {
|
||||
getInstance()?.setDrawerProps({ visible: false })
|
||||
},
|
||||
|
||||
setDrawerProps: (props: Partial<DrawerProps>) => {
|
||||
getInstance()?.setDrawerProps(props)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
5
frontend/vben/src/components/Dropdown/index.ts
Normal file
5
frontend/vben/src/components/Dropdown/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import dropdown from './src/Dropdown.vue'
|
||||
|
||||
export * from './src/typing'
|
||||
export const Dropdown = withInstall(dropdown)
|
||||
96
frontend/vben/src/components/Dropdown/src/Dropdown.vue
Normal file
96
frontend/vben/src/components/Dropdown/src/Dropdown.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<a-dropdown :trigger="trigger" v-bind="$attrs">
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
<template #overlay>
|
||||
<a-menu :selectedKeys="selectedKeys">
|
||||
<template v-for="item in dropMenuList" :key="`${item.event}`">
|
||||
<a-menu-item
|
||||
v-bind="getAttr(item.event)"
|
||||
@click="handleClickMenu(item)"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
<a-popconfirm
|
||||
v-if="popconfirm && item.popConfirm"
|
||||
v-bind="getPopConfirmAttrs(item.popConfirm)"
|
||||
>
|
||||
<template #icon v-if="item.popConfirm.icon">
|
||||
<Icon :icon="item.popConfirm.icon" />
|
||||
</template>
|
||||
<div>
|
||||
<Icon :icon="item.icon" v-if="item.icon" />
|
||||
<span class="ml-1">{{ item.text }}</span>
|
||||
</div>
|
||||
</a-popconfirm>
|
||||
<template v-else>
|
||||
<Icon :icon="item.icon" v-if="item.icon" />
|
||||
<span class="ml-1">{{ item.text }}</span>
|
||||
</template>
|
||||
</a-menu-item>
|
||||
<a-menu-divider v-if="item.divider" :key="`d-${item.event}`" />
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, PropType } from 'vue'
|
||||
import type { DropMenu } from './typing'
|
||||
import { Dropdown, Menu, Popconfirm } from 'ant-design-vue'
|
||||
import { Icon } from '/@/components/Icon'
|
||||
import { omit } from 'lodash-es'
|
||||
import { isFunction } from '/@/utils/is'
|
||||
|
||||
const ADropdown = Dropdown
|
||||
const AMenu = Menu
|
||||
const AMenuItem = Menu.Item
|
||||
const AMenuDivider = Menu.Divider
|
||||
const APopconfirm = Popconfirm
|
||||
|
||||
const props = defineProps({
|
||||
popconfirm: Boolean,
|
||||
/**
|
||||
* the trigger mode which executes the drop-down action
|
||||
* @default ['hover']
|
||||
* @type string[]
|
||||
*/
|
||||
trigger: {
|
||||
type: [Array] as PropType<('contextmenu' | 'click' | 'hover')[]>,
|
||||
default: () => {
|
||||
return ['contextmenu']
|
||||
},
|
||||
},
|
||||
dropMenuList: {
|
||||
type: Array as PropType<(DropMenu & Recordable)[]>,
|
||||
default: () => [],
|
||||
},
|
||||
selectedKeys: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['menuEvent'])
|
||||
|
||||
function handleClickMenu(item: DropMenu) {
|
||||
const { event } = item
|
||||
const menu = props.dropMenuList.find((item) => `${item.event}` === `${event}`)
|
||||
emit('menuEvent', menu)
|
||||
item.onClick?.()
|
||||
}
|
||||
|
||||
const getPopConfirmAttrs = computed(() => {
|
||||
return (attrs) => {
|
||||
const originAttrs = omit(attrs, ['confirm', 'cancel', 'icon'])
|
||||
if (!attrs.onConfirm && attrs.confirm && isFunction(attrs.confirm))
|
||||
originAttrs['onConfirm'] = attrs.confirm
|
||||
if (!attrs.onCancel && attrs.cancel && isFunction(attrs.cancel))
|
||||
originAttrs['onCancel'] = attrs.cancel
|
||||
return originAttrs
|
||||
}
|
||||
})
|
||||
|
||||
const getAttr = (key: string | number) => ({ key })
|
||||
</script>
|
||||
9
frontend/vben/src/components/Dropdown/src/typing.ts
Normal file
9
frontend/vben/src/components/Dropdown/src/typing.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface DropMenu {
|
||||
onClick?: Fn
|
||||
to?: string
|
||||
icon?: string
|
||||
event: string | number
|
||||
text: string
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
786
frontend/vben/src/components/Icon/data/icons.data.ts
Normal file
786
frontend/vben/src/components/Icon/data/icons.data.ts
Normal file
@@ -0,0 +1,786 @@
|
||||
export default {
|
||||
prefix: 'ant-design',
|
||||
icons: [
|
||||
'account-book-filled',
|
||||
'account-book-outlined',
|
||||
'account-book-twotone',
|
||||
'aim-outlined',
|
||||
'alert-filled',
|
||||
'alert-outlined',
|
||||
'alert-twotone',
|
||||
'alibaba-outlined',
|
||||
'align-center-outlined',
|
||||
'align-left-outlined',
|
||||
'align-right-outlined',
|
||||
'alipay-circle-filled',
|
||||
'alipay-circle-outlined',
|
||||
'alipay-outlined',
|
||||
'alipay-square-filled',
|
||||
'aliwangwang-filled',
|
||||
'aliwangwang-outlined',
|
||||
'aliyun-outlined',
|
||||
'amazon-circle-filled',
|
||||
'amazon-outlined',
|
||||
'amazon-square-filled',
|
||||
'android-filled',
|
||||
'android-outlined',
|
||||
'ant-cloud-outlined',
|
||||
'ant-design-outlined',
|
||||
'apartment-outlined',
|
||||
'api-filled',
|
||||
'api-outlined',
|
||||
'api-twotone',
|
||||
'apple-filled',
|
||||
'apple-outlined',
|
||||
'appstore-add-outlined',
|
||||
'appstore-filled',
|
||||
'appstore-outlined',
|
||||
'appstore-twotone',
|
||||
'area-chart-outlined',
|
||||
'arrow-down-outlined',
|
||||
'arrow-left-outlined',
|
||||
'arrow-right-outlined',
|
||||
'arrow-up-outlined',
|
||||
'arrows-alt-outlined',
|
||||
'audio-filled',
|
||||
'audio-muted-outlined',
|
||||
'audio-outlined',
|
||||
'audio-twotone',
|
||||
'audit-outlined',
|
||||
'backward-filled',
|
||||
'backward-outlined',
|
||||
'bank-filled',
|
||||
'bank-outlined',
|
||||
'bank-twotone',
|
||||
'bar-chart-outlined',
|
||||
'barcode-outlined',
|
||||
'bars-outlined',
|
||||
'behance-circle-filled',
|
||||
'behance-outlined',
|
||||
'behance-square-filled',
|
||||
'behance-square-outlined',
|
||||
'bell-filled',
|
||||
'bell-outlined',
|
||||
'bell-twotone',
|
||||
'bg-colors-outlined',
|
||||
'block-outlined',
|
||||
'bold-outlined',
|
||||
'book-filled',
|
||||
'book-outlined',
|
||||
'book-twotone',
|
||||
'border-bottom-outlined',
|
||||
'border-horizontal-outlined',
|
||||
'border-inner-outlined',
|
||||
'border-left-outlined',
|
||||
'border-outer-outlined',
|
||||
'border-outlined',
|
||||
'border-right-outlined',
|
||||
'border-top-outlined',
|
||||
'border-verticle-outlined',
|
||||
'borderless-table-outlined',
|
||||
'box-plot-filled',
|
||||
'box-plot-outlined',
|
||||
'box-plot-twotone',
|
||||
'branches-outlined',
|
||||
'bug-filled',
|
||||
'bug-outlined',
|
||||
'bug-twotone',
|
||||
'build-filled',
|
||||
'build-outlined',
|
||||
'build-twotone',
|
||||
'bulb-filled',
|
||||
'bulb-outlined',
|
||||
'bulb-twotone',
|
||||
'calculator-filled',
|
||||
'calculator-outlined',
|
||||
'calculator-twotone',
|
||||
'calendar-filled',
|
||||
'calendar-outlined',
|
||||
'calendar-twotone',
|
||||
'camera-filled',
|
||||
'camera-outlined',
|
||||
'camera-twotone',
|
||||
'car-filled',
|
||||
'car-outlined',
|
||||
'car-twotone',
|
||||
'caret-down-filled',
|
||||
'caret-down-outlined',
|
||||
'caret-left-filled',
|
||||
'caret-left-outlined',
|
||||
'caret-right-filled',
|
||||
'caret-right-outlined',
|
||||
'caret-up-filled',
|
||||
'caret-up-outlined',
|
||||
'carry-out-filled',
|
||||
'carry-out-outlined',
|
||||
'carry-out-twotone',
|
||||
'check-circle-filled',
|
||||
'check-circle-outlined',
|
||||
'check-circle-twotone',
|
||||
'check-outlined',
|
||||
'check-square-filled',
|
||||
'check-square-outlined',
|
||||
'check-square-twotone',
|
||||
'chrome-filled',
|
||||
'chrome-outlined',
|
||||
'ci-circle-filled',
|
||||
'ci-circle-outlined',
|
||||
'ci-circle-twotone',
|
||||
'ci-outlined',
|
||||
'ci-twotone',
|
||||
'clear-outlined',
|
||||
'clock-circle-filled',
|
||||
'clock-circle-outlined',
|
||||
'clock-circle-twotone',
|
||||
'close-circle-filled',
|
||||
'close-circle-outlined',
|
||||
'close-circle-twotone',
|
||||
'close-outlined',
|
||||
'close-square-filled',
|
||||
'close-square-outlined',
|
||||
'close-square-twotone',
|
||||
'cloud-download-outlined',
|
||||
'cloud-filled',
|
||||
'cloud-outlined',
|
||||
'cloud-server-outlined',
|
||||
'cloud-sync-outlined',
|
||||
'cloud-twotone',
|
||||
'cloud-upload-outlined',
|
||||
'cluster-outlined',
|
||||
'code-filled',
|
||||
'code-outlined',
|
||||
'code-sandbox-circle-filled',
|
||||
'code-sandbox-outlined',
|
||||
'code-sandbox-square-filled',
|
||||
'code-twotone',
|
||||
'codepen-circle-filled',
|
||||
'codepen-circle-outlined',
|
||||
'codepen-outlined',
|
||||
'codepen-square-filled',
|
||||
'coffee-outlined',
|
||||
'column-height-outlined',
|
||||
'column-width-outlined',
|
||||
'comment-outlined',
|
||||
'compass-filled',
|
||||
'compass-outlined',
|
||||
'compass-twotone',
|
||||
'compress-outlined',
|
||||
'console-sql-outlined',
|
||||
'contacts-filled',
|
||||
'contacts-outlined',
|
||||
'contacts-twotone',
|
||||
'container-filled',
|
||||
'container-outlined',
|
||||
'container-twotone',
|
||||
'control-filled',
|
||||
'control-outlined',
|
||||
'control-twotone',
|
||||
'copy-filled',
|
||||
'copy-outlined',
|
||||
'copy-twotone',
|
||||
'copyright-circle-filled',
|
||||
'copyright-circle-outlined',
|
||||
'copyright-circle-twotone',
|
||||
'copyright-outlined',
|
||||
'copyright-twotone',
|
||||
'credit-card-filled',
|
||||
'credit-card-outlined',
|
||||
'credit-card-twotone',
|
||||
'crown-filled',
|
||||
'crown-outlined',
|
||||
'crown-twotone',
|
||||
'customer-service-filled',
|
||||
'customer-service-outlined',
|
||||
'customer-service-twotone',
|
||||
'dash-outlined',
|
||||
'dashboard-filled',
|
||||
'dashboard-outlined',
|
||||
'dashboard-twotone',
|
||||
'database-filled',
|
||||
'database-outlined',
|
||||
'database-twotone',
|
||||
'delete-column-outlined',
|
||||
'delete-filled',
|
||||
'delete-outlined',
|
||||
'delete-row-outlined',
|
||||
'delete-twotone',
|
||||
'delivered-procedure-outlined',
|
||||
'deployment-unit-outlined',
|
||||
'desktop-outlined',
|
||||
'diff-filled',
|
||||
'diff-outlined',
|
||||
'diff-twotone',
|
||||
'dingding-outlined',
|
||||
'dingtalk-circle-filled',
|
||||
'dingtalk-outlined',
|
||||
'dingtalk-square-filled',
|
||||
'disconnect-outlined',
|
||||
'dislike-filled',
|
||||
'dislike-outlined',
|
||||
'dislike-twotone',
|
||||
'dollar-circle-filled',
|
||||
'dollar-circle-outlined',
|
||||
'dollar-circle-twotone',
|
||||
'dollar-outlined',
|
||||
'dollar-twotone',
|
||||
'dot-chart-outlined',
|
||||
'double-left-outlined',
|
||||
'double-right-outlined',
|
||||
'down-circle-filled',
|
||||
'down-circle-outlined',
|
||||
'down-circle-twotone',
|
||||
'down-outlined',
|
||||
'down-square-filled',
|
||||
'down-square-outlined',
|
||||
'down-square-twotone',
|
||||
'download-outlined',
|
||||
'drag-outlined',
|
||||
'dribbble-circle-filled',
|
||||
'dribbble-outlined',
|
||||
'dribbble-square-filled',
|
||||
'dribbble-square-outlined',
|
||||
'dropbox-circle-filled',
|
||||
'dropbox-outlined',
|
||||
'dropbox-square-filled',
|
||||
'edit-filled',
|
||||
'edit-outlined',
|
||||
'edit-twotone',
|
||||
'ellipsis-outlined',
|
||||
'enter-outlined',
|
||||
'environment-filled',
|
||||
'environment-outlined',
|
||||
'environment-twotone',
|
||||
'euro-circle-filled',
|
||||
'euro-circle-outlined',
|
||||
'euro-circle-twotone',
|
||||
'euro-outlined',
|
||||
'euro-twotone',
|
||||
'exception-outlined',
|
||||
'exclamation-circle-filled',
|
||||
'exclamation-circle-outlined',
|
||||
'exclamation-circle-twotone',
|
||||
'exclamation-outlined',
|
||||
'expand-alt-outlined',
|
||||
'expand-outlined',
|
||||
'experiment-filled',
|
||||
'experiment-outlined',
|
||||
'experiment-twotone',
|
||||
'export-outlined',
|
||||
'eye-filled',
|
||||
'eye-invisible-filled',
|
||||
'eye-invisible-outlined',
|
||||
'eye-invisible-twotone',
|
||||
'eye-outlined',
|
||||
'eye-twotone',
|
||||
'facebook-filled',
|
||||
'facebook-outlined',
|
||||
'fall-outlined',
|
||||
'fast-backward-filled',
|
||||
'fast-backward-outlined',
|
||||
'fast-forward-filled',
|
||||
'fast-forward-outlined',
|
||||
'field-binary-outlined',
|
||||
'field-number-outlined',
|
||||
'field-string-outlined',
|
||||
'field-time-outlined',
|
||||
'file-add-filled',
|
||||
'file-add-outlined',
|
||||
'file-add-twotone',
|
||||
'file-done-outlined',
|
||||
'file-excel-filled',
|
||||
'file-excel-outlined',
|
||||
'file-excel-twotone',
|
||||
'file-exclamation-filled',
|
||||
'file-exclamation-outlined',
|
||||
'file-exclamation-twotone',
|
||||
'file-filled',
|
||||
'file-gif-outlined',
|
||||
'file-image-filled',
|
||||
'file-image-outlined',
|
||||
'file-image-twotone',
|
||||
'file-jpg-outlined',
|
||||
'file-markdown-filled',
|
||||
'file-markdown-outlined',
|
||||
'file-markdown-twotone',
|
||||
'file-outlined',
|
||||
'file-pdf-filled',
|
||||
'file-pdf-outlined',
|
||||
'file-pdf-twotone',
|
||||
'file-ppt-filled',
|
||||
'file-ppt-outlined',
|
||||
'file-ppt-twotone',
|
||||
'file-protect-outlined',
|
||||
'file-search-outlined',
|
||||
'file-sync-outlined',
|
||||
'file-text-filled',
|
||||
'file-text-outlined',
|
||||
'file-text-twotone',
|
||||
'file-twotone',
|
||||
'file-unknown-filled',
|
||||
'file-unknown-outlined',
|
||||
'file-unknown-twotone',
|
||||
'file-word-filled',
|
||||
'file-word-outlined',
|
||||
'file-word-twotone',
|
||||
'file-zip-filled',
|
||||
'file-zip-outlined',
|
||||
'file-zip-twotone',
|
||||
'filter-filled',
|
||||
'filter-outlined',
|
||||
'filter-twotone',
|
||||
'fire-filled',
|
||||
'fire-outlined',
|
||||
'fire-twotone',
|
||||
'flag-filled',
|
||||
'flag-outlined',
|
||||
'flag-twotone',
|
||||
'folder-add-filled',
|
||||
'folder-add-outlined',
|
||||
'folder-add-twotone',
|
||||
'folder-filled',
|
||||
'folder-open-filled',
|
||||
'folder-open-outlined',
|
||||
'folder-open-twotone',
|
||||
'folder-outlined',
|
||||
'folder-twotone',
|
||||
'folder-view-outlined',
|
||||
'font-colors-outlined',
|
||||
'font-size-outlined',
|
||||
'fork-outlined',
|
||||
'form-outlined',
|
||||
'format-painter-filled',
|
||||
'format-painter-outlined',
|
||||
'forward-filled',
|
||||
'forward-outlined',
|
||||
'frown-filled',
|
||||
'frown-outlined',
|
||||
'frown-twotone',
|
||||
'fullscreen-exit-outlined',
|
||||
'fullscreen-outlined',
|
||||
'function-outlined',
|
||||
'fund-filled',
|
||||
'fund-outlined',
|
||||
'fund-projection-screen-outlined',
|
||||
'fund-twotone',
|
||||
'fund-view-outlined',
|
||||
'funnel-plot-filled',
|
||||
'funnel-plot-outlined',
|
||||
'funnel-plot-twotone',
|
||||
'gateway-outlined',
|
||||
'gif-outlined',
|
||||
'gift-filled',
|
||||
'gift-outlined',
|
||||
'gift-twotone',
|
||||
'github-filled',
|
||||
'github-outlined',
|
||||
'gitlab-filled',
|
||||
'gitlab-outlined',
|
||||
'global-outlined',
|
||||
'gold-filled',
|
||||
'gold-outlined',
|
||||
'gold-twotone',
|
||||
'golden-filled',
|
||||
'google-circle-filled',
|
||||
'google-outlined',
|
||||
'google-plus-circle-filled',
|
||||
'google-plus-outlined',
|
||||
'google-plus-square-filled',
|
||||
'google-square-filled',
|
||||
'group-outlined',
|
||||
'hdd-filled',
|
||||
'hdd-outlined',
|
||||
'hdd-twotone',
|
||||
'heart-filled',
|
||||
'heart-outlined',
|
||||
'heart-twotone',
|
||||
'heat-map-outlined',
|
||||
'highlight-filled',
|
||||
'highlight-outlined',
|
||||
'highlight-twotone',
|
||||
'history-outlined',
|
||||
'home-filled',
|
||||
'home-outlined',
|
||||
'home-twotone',
|
||||
'hourglass-filled',
|
||||
'hourglass-outlined',
|
||||
'hourglass-twotone',
|
||||
'html5-filled',
|
||||
'html5-outlined',
|
||||
'html5-twotone',
|
||||
'idcard-filled',
|
||||
'idcard-outlined',
|
||||
'idcard-twotone',
|
||||
'ie-circle-filled',
|
||||
'ie-outlined',
|
||||
'ie-square-filled',
|
||||
'import-outlined',
|
||||
'inbox-outlined',
|
||||
'info-circle-filled',
|
||||
'info-circle-outlined',
|
||||
'info-circle-twotone',
|
||||
'info-outlined',
|
||||
'insert-row-above-outlined',
|
||||
'insert-row-below-outlined',
|
||||
'insert-row-left-outlined',
|
||||
'insert-row-right-outlined',
|
||||
'instagram-filled',
|
||||
'instagram-outlined',
|
||||
'insurance-filled',
|
||||
'insurance-outlined',
|
||||
'insurance-twotone',
|
||||
'interaction-filled',
|
||||
'interaction-outlined',
|
||||
'interaction-twotone',
|
||||
'issues-close-outlined',
|
||||
'italic-outlined',
|
||||
'key-outlined',
|
||||
'laptop-outlined',
|
||||
'layout-filled',
|
||||
'layout-outlined',
|
||||
'layout-twotone',
|
||||
'left-circle-filled',
|
||||
'left-circle-outlined',
|
||||
'left-circle-twotone',
|
||||
'left-outlined',
|
||||
'left-square-filled',
|
||||
'left-square-outlined',
|
||||
'left-square-twotone',
|
||||
'like-filled',
|
||||
'like-outlined',
|
||||
'like-twotone',
|
||||
'line-chart-outlined',
|
||||
'line-height-outlined',
|
||||
'line-outlined',
|
||||
'link-outlined',
|
||||
'linkedin-filled',
|
||||
'linkedin-outlined',
|
||||
'loading-3-quarters-outlined',
|
||||
'loading-outlined',
|
||||
'login-outlined',
|
||||
'logout-outlined',
|
||||
'mac-command-filled',
|
||||
'mac-command-outlined',
|
||||
'mail-filled',
|
||||
'mail-outlined',
|
||||
'mail-twotone',
|
||||
'man-outlined',
|
||||
'medicine-box-filled',
|
||||
'medicine-box-outlined',
|
||||
'medicine-box-twotone',
|
||||
'medium-circle-filled',
|
||||
'medium-outlined',
|
||||
'medium-square-filled',
|
||||
'medium-workmark-outlined',
|
||||
'meh-filled',
|
||||
'meh-outlined',
|
||||
'meh-twotone',
|
||||
'menu-fold-outlined',
|
||||
'menu-outlined',
|
||||
'menu-unfold-outlined',
|
||||
'merge-cells-outlined',
|
||||
'message-filled',
|
||||
'message-outlined',
|
||||
'message-twotone',
|
||||
'minus-circle-filled',
|
||||
'minus-circle-outlined',
|
||||
'minus-circle-twotone',
|
||||
'minus-outlined',
|
||||
'minus-square-filled',
|
||||
'minus-square-outlined',
|
||||
'minus-square-twotone',
|
||||
'mobile-filled',
|
||||
'mobile-outlined',
|
||||
'mobile-twotone',
|
||||
'money-collect-filled',
|
||||
'money-collect-outlined',
|
||||
'money-collect-twotone',
|
||||
'monitor-outlined',
|
||||
'more-outlined',
|
||||
'node-collapse-outlined',
|
||||
'node-expand-outlined',
|
||||
'node-index-outlined',
|
||||
'notification-filled',
|
||||
'notification-outlined',
|
||||
'notification-twotone',
|
||||
'number-outlined',
|
||||
'one-to-one-outlined',
|
||||
'ordered-list-outlined',
|
||||
'paper-clip-outlined',
|
||||
'partition-outlined',
|
||||
'pause-circle-filled',
|
||||
'pause-circle-outlined',
|
||||
'pause-circle-twotone',
|
||||
'pause-outlined',
|
||||
'pay-circle-filled',
|
||||
'pay-circle-outlined',
|
||||
'percentage-outlined',
|
||||
'phone-filled',
|
||||
'phone-outlined',
|
||||
'phone-twotone',
|
||||
'pic-center-outlined',
|
||||
'pic-left-outlined',
|
||||
'pic-right-outlined',
|
||||
'picture-filled',
|
||||
'picture-outlined',
|
||||
'picture-twotone',
|
||||
'pie-chart-filled',
|
||||
'pie-chart-outlined',
|
||||
'pie-chart-twotone',
|
||||
'play-circle-filled',
|
||||
'play-circle-outlined',
|
||||
'play-circle-twotone',
|
||||
'play-square-filled',
|
||||
'play-square-outlined',
|
||||
'play-square-twotone',
|
||||
'plus-circle-filled',
|
||||
'plus-circle-outlined',
|
||||
'plus-circle-twotone',
|
||||
'plus-outlined',
|
||||
'plus-square-filled',
|
||||
'plus-square-outlined',
|
||||
'plus-square-twotone',
|
||||
'pound-circle-filled',
|
||||
'pound-circle-outlined',
|
||||
'pound-circle-twotone',
|
||||
'pound-outlined',
|
||||
'poweroff-outlined',
|
||||
'printer-filled',
|
||||
'printer-outlined',
|
||||
'printer-twotone',
|
||||
'profile-filled',
|
||||
'profile-outlined',
|
||||
'profile-twotone',
|
||||
'project-filled',
|
||||
'project-outlined',
|
||||
'project-twotone',
|
||||
'property-safety-filled',
|
||||
'property-safety-outlined',
|
||||
'property-safety-twotone',
|
||||
'pull-request-outlined',
|
||||
'pushpin-filled',
|
||||
'pushpin-outlined',
|
||||
'pushpin-twotone',
|
||||
'qq-circle-filled',
|
||||
'qq-outlined',
|
||||
'qq-square-filled',
|
||||
'question-circle-filled',
|
||||
'question-circle-outlined',
|
||||
'question-circle-twotone',
|
||||
'question-outlined',
|
||||
'radar-chart-outlined',
|
||||
'radius-bottomleft-outlined',
|
||||
'radius-bottomright-outlined',
|
||||
'radius-setting-outlined',
|
||||
'radius-upleft-outlined',
|
||||
'radius-upright-outlined',
|
||||
'read-filled',
|
||||
'read-outlined',
|
||||
'reconciliation-filled',
|
||||
'reconciliation-outlined',
|
||||
'reconciliation-twotone',
|
||||
'red-envelope-filled',
|
||||
'red-envelope-outlined',
|
||||
'red-envelope-twotone',
|
||||
'reddit-circle-filled',
|
||||
'reddit-outlined',
|
||||
'reddit-square-filled',
|
||||
'redo-outlined',
|
||||
'reload-outlined',
|
||||
'rest-filled',
|
||||
'rest-outlined',
|
||||
'rest-twotone',
|
||||
'retweet-outlined',
|
||||
'right-circle-filled',
|
||||
'right-circle-outlined',
|
||||
'right-circle-twotone',
|
||||
'right-outlined',
|
||||
'right-square-filled',
|
||||
'right-square-outlined',
|
||||
'right-square-twotone',
|
||||
'rise-outlined',
|
||||
'robot-filled',
|
||||
'robot-outlined',
|
||||
'rocket-filled',
|
||||
'rocket-outlined',
|
||||
'rocket-twotone',
|
||||
'rollback-outlined',
|
||||
'rotate-left-outlined',
|
||||
'rotate-right-outlined',
|
||||
'safety-certificate-filled',
|
||||
'safety-certificate-outlined',
|
||||
'safety-certificate-twotone',
|
||||
'safety-outlined',
|
||||
'save-filled',
|
||||
'save-outlined',
|
||||
'save-twotone',
|
||||
'scan-outlined',
|
||||
'schedule-filled',
|
||||
'schedule-outlined',
|
||||
'schedule-twotone',
|
||||
'scissor-outlined',
|
||||
'search-outlined',
|
||||
'security-scan-filled',
|
||||
'security-scan-outlined',
|
||||
'security-scan-twotone',
|
||||
'select-outlined',
|
||||
'send-outlined',
|
||||
'setting-filled',
|
||||
'setting-outlined',
|
||||
'setting-twotone',
|
||||
'shake-outlined',
|
||||
'share-alt-outlined',
|
||||
'shop-filled',
|
||||
'shop-outlined',
|
||||
'shop-twotone',
|
||||
'shopping-cart-outlined',
|
||||
'shopping-filled',
|
||||
'shopping-outlined',
|
||||
'shopping-twotone',
|
||||
'shrink-outlined',
|
||||
'signal-filled',
|
||||
'sisternode-outlined',
|
||||
'sketch-circle-filled',
|
||||
'sketch-outlined',
|
||||
'sketch-square-filled',
|
||||
'skin-filled',
|
||||
'skin-outlined',
|
||||
'skin-twotone',
|
||||
'skype-filled',
|
||||
'skype-outlined',
|
||||
'slack-circle-filled',
|
||||
'slack-outlined',
|
||||
'slack-square-filled',
|
||||
'slack-square-outlined',
|
||||
'sliders-filled',
|
||||
'sliders-outlined',
|
||||
'sliders-twotone',
|
||||
'small-dash-outlined',
|
||||
'smile-filled',
|
||||
'smile-outlined',
|
||||
'smile-twotone',
|
||||
'snippets-filled',
|
||||
'snippets-outlined',
|
||||
'snippets-twotone',
|
||||
'solution-outlined',
|
||||
'sort-ascending-outlined',
|
||||
'sort-descending-outlined',
|
||||
'sound-filled',
|
||||
'sound-outlined',
|
||||
'sound-twotone',
|
||||
'split-cells-outlined',
|
||||
'star-filled',
|
||||
'star-outlined',
|
||||
'star-twotone',
|
||||
'step-backward-filled',
|
||||
'step-backward-outlined',
|
||||
'step-forward-filled',
|
||||
'step-forward-outlined',
|
||||
'stock-outlined',
|
||||
'stop-filled',
|
||||
'stop-outlined',
|
||||
'stop-twotone',
|
||||
'strikethrough-outlined',
|
||||
'subnode-outlined',
|
||||
'swap-left-outlined',
|
||||
'swap-outlined',
|
||||
'swap-right-outlined',
|
||||
'switcher-filled',
|
||||
'switcher-outlined',
|
||||
'switcher-twotone',
|
||||
'sync-outlined',
|
||||
'table-outlined',
|
||||
'tablet-filled',
|
||||
'tablet-outlined',
|
||||
'tablet-twotone',
|
||||
'tag-filled',
|
||||
'tag-outlined',
|
||||
'tag-twotone',
|
||||
'tags-filled',
|
||||
'tags-outlined',
|
||||
'tags-twotone',
|
||||
'taobao-circle-filled',
|
||||
'taobao-circle-outlined',
|
||||
'taobao-outlined',
|
||||
'taobao-square-filled',
|
||||
'team-outlined',
|
||||
'thunderbolt-filled',
|
||||
'thunderbolt-outlined',
|
||||
'thunderbolt-twotone',
|
||||
'to-top-outlined',
|
||||
'tool-filled',
|
||||
'tool-outlined',
|
||||
'tool-twotone',
|
||||
'trademark-circle-filled',
|
||||
'trademark-circle-outlined',
|
||||
'trademark-circle-twotone',
|
||||
'trademark-outlined',
|
||||
'transaction-outlined',
|
||||
'translation-outlined',
|
||||
'trophy-filled',
|
||||
'trophy-outlined',
|
||||
'trophy-twotone',
|
||||
'twitter-circle-filled',
|
||||
'twitter-outlined',
|
||||
'twitter-square-filled',
|
||||
'underline-outlined',
|
||||
'undo-outlined',
|
||||
'ungroup-outlined',
|
||||
'unordered-list-outlined',
|
||||
'up-circle-filled',
|
||||
'up-circle-outlined',
|
||||
'up-circle-twotone',
|
||||
'up-outlined',
|
||||
'up-square-filled',
|
||||
'up-square-outlined',
|
||||
'up-square-twotone',
|
||||
'upload-outlined',
|
||||
'usb-filled',
|
||||
'usb-outlined',
|
||||
'usb-twotone',
|
||||
'user-add-outlined',
|
||||
'user-delete-outlined',
|
||||
'user-outlined',
|
||||
'user-switch-outlined',
|
||||
'usergroup-add-outlined',
|
||||
'usergroup-delete-outlined',
|
||||
'verified-outlined',
|
||||
'vertical-align-bottom-outlined',
|
||||
'vertical-align-middle-outlined',
|
||||
'vertical-align-top-outlined',
|
||||
'vertical-left-outlined',
|
||||
'vertical-right-outlined',
|
||||
'video-camera-add-outlined',
|
||||
'video-camera-filled',
|
||||
'video-camera-outlined',
|
||||
'video-camera-twotone',
|
||||
'wallet-filled',
|
||||
'wallet-outlined',
|
||||
'wallet-twotone',
|
||||
'warning-filled',
|
||||
'warning-outlined',
|
||||
'warning-twotone',
|
||||
'wechat-filled',
|
||||
'wechat-outlined',
|
||||
'weibo-circle-filled',
|
||||
'weibo-circle-outlined',
|
||||
'weibo-outlined',
|
||||
'weibo-square-filled',
|
||||
'weibo-square-outlined',
|
||||
'whats-app-outlined',
|
||||
'wifi-outlined',
|
||||
'windows-filled',
|
||||
'windows-outlined',
|
||||
'woman-outlined',
|
||||
'yahoo-filled',
|
||||
'yahoo-outlined',
|
||||
'youtube-filled',
|
||||
'youtube-outlined',
|
||||
'yuque-filled',
|
||||
'yuque-outlined',
|
||||
'zhihu-circle-filled',
|
||||
'zhihu-outlined',
|
||||
'zhihu-square-filled',
|
||||
'zoom-in-outlined',
|
||||
'zoom-out-outlined',
|
||||
],
|
||||
}
|
||||
7
frontend/vben/src/components/Icon/index.ts
Normal file
7
frontend/vben/src/components/Icon/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Icon from './src/Icon.vue'
|
||||
import SvgIcon from './src/SvgIcon.vue'
|
||||
import IconPicker from './src/IconPicker.vue'
|
||||
|
||||
export { Icon, IconPicker, SvgIcon }
|
||||
|
||||
export default Icon
|
||||
121
frontend/vben/src/components/Icon/src/Icon.vue
Normal file
121
frontend/vben/src/components/Icon/src/Icon.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<SvgIcon
|
||||
:size="size"
|
||||
:name="getSvgIcon"
|
||||
v-if="isSvgIcon"
|
||||
:class="[$attrs.class, 'anticon']"
|
||||
:spin="spin"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
ref="elRef"
|
||||
:class="[$attrs.class, 'app-iconify anticon', spin && 'app-iconify-spin']"
|
||||
:style="getWrapStyle"
|
||||
></span>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
watch,
|
||||
onMounted,
|
||||
nextTick,
|
||||
unref,
|
||||
computed,
|
||||
CSSProperties,
|
||||
} from 'vue'
|
||||
import SvgIcon from './SvgIcon.vue'
|
||||
import Iconify from '@purge-icons/generated'
|
||||
import { isString } from '/@/utils/is'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
|
||||
const SVG_END_WITH_FLAG = '|svg'
|
||||
export default defineComponent({
|
||||
name: 'Icon',
|
||||
components: { SvgIcon },
|
||||
props: {
|
||||
// icon name
|
||||
icon: propTypes.string,
|
||||
// icon color
|
||||
color: propTypes.string,
|
||||
// icon size
|
||||
size: {
|
||||
type: [String, Number] as PropType<string | number>,
|
||||
default: 16,
|
||||
},
|
||||
spin: propTypes.bool.def(false),
|
||||
prefix: propTypes.string.def(''),
|
||||
},
|
||||
setup(props) {
|
||||
const elRef = ref<ElRef>(null)
|
||||
|
||||
const isSvgIcon = computed(() => props.icon?.endsWith(SVG_END_WITH_FLAG))
|
||||
const getSvgIcon = computed(() => props.icon.replace(SVG_END_WITH_FLAG, ''))
|
||||
const getIconRef = computed(() => `${props.prefix ? props.prefix + ':' : ''}${props.icon}`)
|
||||
|
||||
const update = async () => {
|
||||
if (unref(isSvgIcon)) return
|
||||
|
||||
const el = unref(elRef)
|
||||
if (!el) return
|
||||
|
||||
await nextTick()
|
||||
const icon = unref(getIconRef)
|
||||
if (!icon) return
|
||||
|
||||
const svg = Iconify.renderSVG(icon, {})
|
||||
if (svg) {
|
||||
el.textContent = ''
|
||||
el.appendChild(svg)
|
||||
} else {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'iconify'
|
||||
span.dataset.icon = icon
|
||||
el.textContent = ''
|
||||
el.appendChild(span)
|
||||
}
|
||||
}
|
||||
|
||||
const getWrapStyle = computed((): CSSProperties => {
|
||||
const { size, color } = props
|
||||
let fs = size
|
||||
if (isString(size)) {
|
||||
fs = parseInt(size, 10)
|
||||
}
|
||||
|
||||
return {
|
||||
fontSize: `${fs}px`,
|
||||
color: color,
|
||||
display: 'inline-flex',
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.icon, update, { flush: 'post' })
|
||||
|
||||
onMounted(update)
|
||||
|
||||
return { elRef, getWrapStyle, isSvgIcon, getSvgIcon }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
.app-iconify {
|
||||
display: inline-block;
|
||||
// vertical-align: middle;
|
||||
|
||||
&-spin {
|
||||
svg {
|
||||
animation: loadingCircle 1s infinite linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.iconify {
|
||||
display: block;
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
background-color: @iconify-bg-color;
|
||||
border-radius: 100%;
|
||||
}
|
||||
</style>
|
||||
188
frontend/vben/src/components/Icon/src/IconPicker.vue
Normal file
188
frontend/vben/src/components/Icon/src/IconPicker.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<a-input
|
||||
disabled
|
||||
:style="{ width }"
|
||||
:placeholder="t('component.icon.placeholder')"
|
||||
:class="prefixCls"
|
||||
v-model:value="currentSelect"
|
||||
>
|
||||
<template #addonAfter>
|
||||
<a-popover
|
||||
placement="bottomLeft"
|
||||
trigger="click"
|
||||
v-model="visible"
|
||||
:overlayClassName="`${prefixCls}-popover`"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex justify-between">
|
||||
<a-input
|
||||
:placeholder="t('component.icon.search')"
|
||||
@change="debounceHandleSearchChange"
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="getPaginationList.length">
|
||||
<ScrollContainer class="border border-solid border-t-0">
|
||||
<ul class="flex flex-wrap px-2">
|
||||
<li
|
||||
v-for="icon in getPaginationList"
|
||||
:key="icon"
|
||||
:class="currentSelect === icon ? 'border border-primary' : ''"
|
||||
class="p-2 w-1/8 cursor-pointer mr-1 mt-1 flex justify-center items-center border border-solid hover:border-primary"
|
||||
@click="handleClick(icon)"
|
||||
:title="icon"
|
||||
>
|
||||
<!-- <Icon :icon="icon" :prefix="prefix" /> -->
|
||||
<SvgIcon v-if="isSvgMode" :name="icon" />
|
||||
<Icon :icon="icon" v-else />
|
||||
</li>
|
||||
</ul>
|
||||
</ScrollContainer>
|
||||
<div class="flex py-2 items-center justify-center" v-if="getTotal >= pageSize">
|
||||
<a-pagination
|
||||
showLessItems
|
||||
size="small"
|
||||
:pageSize="pageSize"
|
||||
:total="getTotal"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else
|
||||
><div class="p-5"><a-empty /></div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<span class="cursor-pointer px-2 py-1 flex items-center" v-if="isSvgMode && currentSelect">
|
||||
<SvgIcon :name="currentSelect" />
|
||||
</span>
|
||||
<Icon :icon="currentSelect || 'ion:apps-outline'" class="cursor-pointer px-2 py-1" v-else />
|
||||
</a-popover>
|
||||
</template>
|
||||
</a-input>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, watchEffect, watch, unref } from 'vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { ScrollContainer } from '/@/components/Container'
|
||||
import { Input, Popover, Pagination, Empty } from 'ant-design-vue'
|
||||
import Icon from './Icon.vue'
|
||||
import SvgIcon from './SvgIcon.vue'
|
||||
|
||||
import iconsData from '../data/icons.data'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import { usePagination } from '/@/hooks/web/usePagination'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
import { useCopyToClipboard } from '/@/hooks/web/useCopyToClipboard'
|
||||
import { useMessage } from '/@/hooks/web/useMessage'
|
||||
import svgIcons from 'virtual:svg-icons-names'
|
||||
|
||||
// 没有使用别名引入,是因为WebStorm当前版本还不能正确识别,会报unused警告
|
||||
const AInput = Input
|
||||
const APopover = Popover
|
||||
const APagination = Pagination
|
||||
const AEmpty = Empty
|
||||
|
||||
function getIcons() {
|
||||
const data = iconsData as any
|
||||
const prefix: string = data?.prefix ?? ''
|
||||
let result: string[] = []
|
||||
if (prefix) {
|
||||
result = (data?.icons ?? []).map((item) => `${prefix}:${item}`)
|
||||
} else if (Array.isArray(iconsData)) {
|
||||
result = iconsData as string[]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getSvgIcons() {
|
||||
return svgIcons.map((icon) => icon.replace('icon-', ''))
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
value: propTypes.string,
|
||||
width: propTypes.string.def('100%'),
|
||||
pageSize: propTypes.number.def(140),
|
||||
copy: propTypes.bool.def(false),
|
||||
mode: propTypes.oneOf<('svg' | 'iconify')[]>(['svg', 'iconify']).def('iconify'),
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'update:value'])
|
||||
|
||||
const isSvgMode = props.mode === 'svg'
|
||||
const icons = isSvgMode ? getSvgIcons() : getIcons()
|
||||
|
||||
const currentSelect = ref('')
|
||||
const visible = ref(false)
|
||||
const currentList = ref(icons)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { prefixCls } = useDesign('icon-picker')
|
||||
|
||||
const debounceHandleSearchChange = useDebounceFn(handleSearchChange, 100)
|
||||
const { clipboardRef, isSuccessRef } = useCopyToClipboard(props.value)
|
||||
const { createMessage } = useMessage()
|
||||
|
||||
const { getPaginationList, getTotal, setCurrentPage } = usePagination(currentList, props.pageSize)
|
||||
|
||||
watchEffect(() => {
|
||||
currentSelect.value = props.value
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentSelect.value,
|
||||
(v) => {
|
||||
emit('update:value', v)
|
||||
return emit('change', v)
|
||||
},
|
||||
)
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
|
||||
function handleClick(icon: string) {
|
||||
currentSelect.value = icon
|
||||
if (props.copy) {
|
||||
clipboardRef.value = icon
|
||||
if (unref(isSuccessRef)) {
|
||||
createMessage.success(t('component.icon.copy'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchChange(e: ChangeEvent) {
|
||||
const value = e.target.value
|
||||
if (!value) {
|
||||
setCurrentPage(1)
|
||||
currentList.value = icons
|
||||
return
|
||||
}
|
||||
currentList.value = icons.filter((item) => item.includes(value))
|
||||
}
|
||||
</script>
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-icon-picker';
|
||||
|
||||
.@{prefix-cls} {
|
||||
.ant-input-group-addon {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-popover {
|
||||
width: 300px;
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
65
frontend/vben/src/components/Icon/src/SvgIcon.vue
Normal file
65
frontend/vben/src/components/Icon/src/SvgIcon.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<svg
|
||||
:class="[prefixCls, $attrs.class, spin && 'svg-icon-spin']"
|
||||
:style="getStyle"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use :xlink:href="symbolId" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SvgIcon',
|
||||
props: {
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'icon',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 16,
|
||||
},
|
||||
spin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { prefixCls } = useDesign('svg-icon')
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.name}`)
|
||||
|
||||
const getStyle = computed((): CSSProperties => {
|
||||
const { size } = props
|
||||
let s = `${size}`
|
||||
s = `${s.replace('px', '')}px`
|
||||
return {
|
||||
width: s,
|
||||
height: s,
|
||||
}
|
||||
})
|
||||
return { symbolId, prefixCls, getStyle }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-svg-icon';
|
||||
|
||||
.@{prefix-cls} {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.svg-icon-spin {
|
||||
animation: loadingCircle 1s infinite linear;
|
||||
}
|
||||
</style>
|
||||
5
frontend/vben/src/components/Loading/index.ts
Normal file
5
frontend/vben/src/components/Loading/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import Loading from './src/Loading.vue'
|
||||
|
||||
export { Loading }
|
||||
export { useLoading } from './src/useLoading'
|
||||
export { createLoading } from './src/createLoading'
|
||||
79
frontend/vben/src/components/Loading/src/Loading.vue
Normal file
79
frontend/vben/src/components/Loading/src/Loading.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<section
|
||||
class="full-loading"
|
||||
:class="{ absolute, [theme]: !!theme }"
|
||||
:style="[background ? `background-color: ${background}` : '']"
|
||||
v-show="loading"
|
||||
>
|
||||
<Spin v-bind="$attrs" :tip="tip" :size="size" :spinning="loading" />
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { PropType } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { Spin } from 'ant-design-vue'
|
||||
import { SizeEnum } from '/@/enums/sizeEnum'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Loading',
|
||||
components: { Spin },
|
||||
props: {
|
||||
tip: {
|
||||
type: String as PropType<string>,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String as PropType<SizeEnum>,
|
||||
default: SizeEnum.LARGE,
|
||||
validator: (v: SizeEnum): boolean => {
|
||||
return [SizeEnum.DEFAULT, SizeEnum.SMALL, SizeEnum.LARGE].includes(v)
|
||||
},
|
||||
},
|
||||
absolute: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean as PropType<boolean>,
|
||||
default: false,
|
||||
},
|
||||
background: {
|
||||
type: String as PropType<string>,
|
||||
},
|
||||
theme: {
|
||||
type: String as PropType<any>,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.full-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgb(240 242 245 / 40%);
|
||||
|
||||
&.absolute {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 300;
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
.full-loading:not(.light) {
|
||||
background-color: @modal-mask-bg;
|
||||
}
|
||||
}
|
||||
|
||||
.full-loading.dark {
|
||||
background-color: @modal-mask-bg;
|
||||
}
|
||||
</style>
|
||||
65
frontend/vben/src/components/Loading/src/createLoading.ts
Normal file
65
frontend/vben/src/components/Loading/src/createLoading.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { VNode, defineComponent } from 'vue'
|
||||
import type { LoadingProps } from './typing'
|
||||
|
||||
import { createVNode, render, reactive, h } from 'vue'
|
||||
import Loading from './Loading.vue'
|
||||
|
||||
export function createLoading(props?: Partial<LoadingProps>, target?: HTMLElement, wait = false) {
|
||||
let vm: Nullable<VNode> = null
|
||||
const data = reactive({
|
||||
tip: '',
|
||||
loading: true,
|
||||
...props,
|
||||
})
|
||||
|
||||
const LoadingWrap = defineComponent({
|
||||
render() {
|
||||
return h(Loading, { ...data })
|
||||
},
|
||||
})
|
||||
|
||||
vm = createVNode(LoadingWrap)
|
||||
|
||||
if (wait) {
|
||||
// TODO fix https://github.com/anncwb/vue-vben-admin/issues/438
|
||||
setTimeout(() => {
|
||||
render(vm, document.createElement('div'))
|
||||
}, 0)
|
||||
} else {
|
||||
render(vm, document.createElement('div'))
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (vm?.el && vm.el.parentNode) {
|
||||
vm.el.parentNode.removeChild(vm.el)
|
||||
}
|
||||
}
|
||||
|
||||
function open(target: HTMLElement = document.body) {
|
||||
if (!vm || !vm.el) {
|
||||
return
|
||||
}
|
||||
target.appendChild(vm.el as HTMLElement)
|
||||
}
|
||||
|
||||
if (target) {
|
||||
open(target)
|
||||
}
|
||||
return {
|
||||
vm,
|
||||
close,
|
||||
open,
|
||||
setTip: (tip: string) => {
|
||||
data.tip = tip
|
||||
},
|
||||
setLoading: (loading: boolean) => {
|
||||
data.loading = loading
|
||||
},
|
||||
get loading() {
|
||||
return data.loading
|
||||
},
|
||||
get $el() {
|
||||
return vm?.el as HTMLElement
|
||||
},
|
||||
}
|
||||
}
|
||||
10
frontend/vben/src/components/Loading/src/typing.ts
Normal file
10
frontend/vben/src/components/Loading/src/typing.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SizeEnum } from '/@/enums/sizeEnum'
|
||||
|
||||
export interface LoadingProps {
|
||||
tip: string
|
||||
size: SizeEnum
|
||||
absolute: boolean
|
||||
loading: boolean
|
||||
background: string
|
||||
theme: 'dark' | 'light'
|
||||
}
|
||||
49
frontend/vben/src/components/Loading/src/useLoading.ts
Normal file
49
frontend/vben/src/components/Loading/src/useLoading.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { unref } from 'vue'
|
||||
import { createLoading } from './createLoading'
|
||||
import type { LoadingProps } from './typing'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export interface UseLoadingOptions {
|
||||
target?: any
|
||||
props?: Partial<LoadingProps>
|
||||
}
|
||||
|
||||
interface Fn {
|
||||
(): void
|
||||
}
|
||||
|
||||
export function useLoading(props: Partial<LoadingProps>): [Fn, Fn, (string) => void]
|
||||
export function useLoading(opt: Partial<UseLoadingOptions>): [Fn, Fn, (string) => void]
|
||||
|
||||
export function useLoading(
|
||||
opt: Partial<LoadingProps> | Partial<UseLoadingOptions>,
|
||||
): [Fn, Fn, (string) => void] {
|
||||
let props: Partial<LoadingProps>
|
||||
let target: HTMLElement | Ref<ElRef> = document.body
|
||||
|
||||
if (Reflect.has(opt, 'target') || Reflect.has(opt, 'props')) {
|
||||
const options = opt as Partial<UseLoadingOptions>
|
||||
props = options.props || {}
|
||||
target = options.target || document.body
|
||||
} else {
|
||||
props = opt as Partial<LoadingProps>
|
||||
}
|
||||
|
||||
const instance = createLoading(props, undefined, true)
|
||||
|
||||
const open = (): void => {
|
||||
const t = unref(target as Ref<ElRef>)
|
||||
if (!t) return
|
||||
instance.open(t)
|
||||
}
|
||||
|
||||
const close = (): void => {
|
||||
instance.close()
|
||||
}
|
||||
|
||||
const setTip = (tip: string) => {
|
||||
instance.setTip(tip)
|
||||
}
|
||||
|
||||
return [open, close, setTip]
|
||||
}
|
||||
3
frontend/vben/src/components/Menu/index.ts
Normal file
3
frontend/vben/src/components/Menu/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import BasicMenu from './src/BasicMenu.vue'
|
||||
|
||||
export { BasicMenu }
|
||||
164
frontend/vben/src/components/Menu/src/BasicMenu.vue
Normal file
164
frontend/vben/src/components/Menu/src/BasicMenu.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<Menu
|
||||
:selectedKeys="selectedKeys"
|
||||
:defaultSelectedKeys="defaultSelectedKeys"
|
||||
:mode="mode"
|
||||
:openKeys="getOpenKeys"
|
||||
:inlineIndent="inlineIndent"
|
||||
:theme="theme"
|
||||
@open-change="handleOpenChange"
|
||||
:class="getMenuClass"
|
||||
@click="handleMenuClick"
|
||||
:subMenuOpenDelay="0.2"
|
||||
v-bind="getInlineCollapseOptions"
|
||||
>
|
||||
<template v-for="item in items" :key="item.path">
|
||||
<BasicSubMenuItem :item="item" :theme="theme" :isHorizontal="isHorizontal" />
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { MenuState } from './types'
|
||||
import { computed, defineComponent, unref, reactive, watch, toRefs, ref } from 'vue'
|
||||
import { Menu } from 'ant-design-vue'
|
||||
import BasicSubMenuItem from './components/BasicSubMenuItem.vue'
|
||||
import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum'
|
||||
import { useOpenKeys } from './useOpenKeys'
|
||||
import { RouteLocationNormalizedLoaded, useRouter } from 'vue-router'
|
||||
import { isFunction } from '/@/utils/is'
|
||||
import { basicProps } from './props'
|
||||
import { useMenuSetting } from '/@/hooks/setting/useMenuSetting'
|
||||
import { REDIRECT_NAME } from '/@/router/constant'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { getCurrentParentPath } from '/@/router/menus'
|
||||
import { listenerRouteChange } from '/@/logics/mitt/routeChange'
|
||||
import { getAllParentPath } from '/@/router/helper/menuHelper'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicMenu',
|
||||
components: {
|
||||
Menu,
|
||||
BasicSubMenuItem,
|
||||
},
|
||||
props: basicProps,
|
||||
emits: ['menuClick'],
|
||||
setup(props, { emit }) {
|
||||
const isClickGo = ref(false)
|
||||
const currentActiveMenu = ref('')
|
||||
|
||||
const menuState = reactive<MenuState>({
|
||||
defaultSelectedKeys: [],
|
||||
openKeys: [],
|
||||
selectedKeys: [],
|
||||
collapsedOpenKeys: [],
|
||||
})
|
||||
|
||||
const { prefixCls } = useDesign('basic-menu')
|
||||
const { items, mode, accordion } = toRefs(props)
|
||||
|
||||
const { getCollapsed, getTopMenuAlign, getSplit } = useMenuSetting()
|
||||
|
||||
const { currentRoute } = useRouter()
|
||||
|
||||
const { handleOpenChange, setOpenKeys, getOpenKeys } = useOpenKeys(
|
||||
menuState,
|
||||
items,
|
||||
mode as any,
|
||||
accordion,
|
||||
)
|
||||
|
||||
const getIsTopMenu = computed(() => {
|
||||
const { type, mode } = props
|
||||
|
||||
return (
|
||||
(type === MenuTypeEnum.TOP_MENU && mode === MenuModeEnum.HORIZONTAL) ||
|
||||
(props.isHorizontal && unref(getSplit))
|
||||
)
|
||||
})
|
||||
|
||||
const getMenuClass = computed(() => {
|
||||
const align = props.isHorizontal && unref(getSplit) ? 'start' : unref(getTopMenuAlign)
|
||||
return [
|
||||
prefixCls,
|
||||
`justify-${align}`,
|
||||
{
|
||||
[`${prefixCls}__second`]: !props.isHorizontal && unref(getSplit),
|
||||
[`${prefixCls}__sidebar-hor`]: unref(getIsTopMenu),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const getInlineCollapseOptions = computed(() => {
|
||||
const isInline = props.mode === MenuModeEnum.INLINE
|
||||
|
||||
const inlineCollapseOptions: { inlineCollapsed?: boolean } = {}
|
||||
if (isInline) {
|
||||
inlineCollapseOptions.inlineCollapsed = props.mixSider ? false : unref(getCollapsed)
|
||||
}
|
||||
return inlineCollapseOptions
|
||||
})
|
||||
|
||||
listenerRouteChange((route) => {
|
||||
if (route.name === REDIRECT_NAME) return
|
||||
handleMenuChange(route)
|
||||
currentActiveMenu.value = route.meta?.currentActiveMenu as string
|
||||
|
||||
if (unref(currentActiveMenu)) {
|
||||
menuState.selectedKeys = [unref(currentActiveMenu)]
|
||||
setOpenKeys(unref(currentActiveMenu))
|
||||
}
|
||||
})
|
||||
|
||||
!props.mixSider &&
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
handleMenuChange()
|
||||
},
|
||||
)
|
||||
|
||||
async function handleMenuClick(key) {
|
||||
const { beforeClickFn } = props
|
||||
if (beforeClickFn && isFunction(beforeClickFn)) {
|
||||
const flag = await beforeClickFn(key)
|
||||
if (!flag) return
|
||||
}
|
||||
emit('menuClick', key)
|
||||
|
||||
isClickGo.value = true
|
||||
menuState.selectedKeys = [key]
|
||||
}
|
||||
|
||||
async function handleMenuChange(route?: RouteLocationNormalizedLoaded) {
|
||||
if (unref(isClickGo)) {
|
||||
isClickGo.value = false
|
||||
return
|
||||
}
|
||||
const path =
|
||||
(route || unref(currentRoute)).meta?.currentActiveMenu ||
|
||||
(route || unref(currentRoute)).path
|
||||
setOpenKeys(path)
|
||||
if (unref(currentActiveMenu)) return
|
||||
if (props.isHorizontal && unref(getSplit)) {
|
||||
const parentPath = await getCurrentParentPath(path)
|
||||
menuState.selectedKeys = [parentPath]
|
||||
} else {
|
||||
const parentPaths = await getAllParentPath(props.items, path)
|
||||
menuState.selectedKeys = parentPaths
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleMenuClick,
|
||||
getInlineCollapseOptions,
|
||||
getMenuClass,
|
||||
handleOpenChange,
|
||||
getOpenKeys,
|
||||
...toRefs(menuState),
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@import './index.less';
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<MenuItem :key="item.path">
|
||||
<MenuItemContent v-bind="$props" :item="item" />
|
||||
</MenuItem>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { Menu } from 'ant-design-vue'
|
||||
import { itemProps } from '../props'
|
||||
|
||||
import MenuItemContent from './MenuItemContent.vue'
|
||||
export default defineComponent({
|
||||
name: 'BasicMenuItem',
|
||||
components: { MenuItem: Menu.Item, MenuItemContent },
|
||||
props: itemProps,
|
||||
setup() {
|
||||
return {}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<BasicMenuItem v-if="!menuHasChildren(item) && getShowMenu" v-bind="$props" />
|
||||
<SubMenu
|
||||
v-if="menuHasChildren(item) && getShowMenu"
|
||||
:class="[theme]"
|
||||
:key="`submenu-${item.path}`"
|
||||
popupClassName="app-top-menu-popup"
|
||||
>
|
||||
<template #title>
|
||||
<MenuItemContent v-bind="$props" :item="item" />
|
||||
</template>
|
||||
|
||||
<template v-for="childrenItem in item.children || []" :key="childrenItem.path">
|
||||
<BasicSubMenuItem v-bind="$props" :item="childrenItem" />
|
||||
</template>
|
||||
</SubMenu>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { Menu as MenuType } from '/@/router/types'
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { Menu } from 'ant-design-vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { itemProps } from '../props'
|
||||
import BasicMenuItem from './BasicMenuItem.vue'
|
||||
import MenuItemContent from './MenuItemContent.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicSubMenuItem',
|
||||
isSubMenu: true,
|
||||
components: {
|
||||
BasicMenuItem,
|
||||
SubMenu: Menu.SubMenu,
|
||||
MenuItemContent,
|
||||
},
|
||||
props: itemProps,
|
||||
setup(props) {
|
||||
const { prefixCls } = useDesign('basic-menu-item')
|
||||
|
||||
const getShowMenu = computed(() => !props.item.meta?.hideMenu)
|
||||
function menuHasChildren(menuTreeItem: MenuType): boolean {
|
||||
return (
|
||||
!menuTreeItem.meta?.hideChildrenInMenu &&
|
||||
Reflect.has(menuTreeItem, 'children') &&
|
||||
!!menuTreeItem.children &&
|
||||
menuTreeItem.children.length > 0
|
||||
)
|
||||
}
|
||||
return {
|
||||
prefixCls,
|
||||
menuHasChildren,
|
||||
getShowMenu,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<span :class="`${prefixCls}- flex items-center `">
|
||||
<Icon v-if="getIcon" :icon="getIcon" :size="18" :class="`${prefixCls}-wrapper__icon mr-2`" />
|
||||
{{ getI18nName }}
|
||||
</span>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from 'vue'
|
||||
|
||||
import Icon from '/@/components/Icon/index'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { contentProps } from '../props'
|
||||
const { t } = useI18n()
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuItemContent',
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
props: contentProps,
|
||||
setup(props) {
|
||||
const { prefixCls } = useDesign('basic-menu-item-content')
|
||||
const getI18nName = computed(() => t(props.item?.name))
|
||||
const getIcon = computed(() => props.item?.icon)
|
||||
|
||||
return {
|
||||
prefixCls,
|
||||
getI18nName,
|
||||
getIcon,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
74
frontend/vben/src/components/Menu/src/index.less
Normal file
74
frontend/vben/src/components/Menu/src/index.less
Normal file
@@ -0,0 +1,74 @@
|
||||
@basic-menu-prefix-cls: ~'@{namespace}-basic-menu';
|
||||
|
||||
.app-top-menu-popup {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.@{basic-menu-prefix-cls} {
|
||||
width: 100%;
|
||||
|
||||
.ant-menu-item {
|
||||
transition: unset;
|
||||
}
|
||||
|
||||
&__sidebar-hor {
|
||||
&.ant-menu-horizontal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.ant-menu-dark {
|
||||
background-color: transparent;
|
||||
|
||||
.ant-menu-submenu:hover,
|
||||
.ant-menu-item-open,
|
||||
.ant-menu-submenu-open,
|
||||
.ant-menu-item-selected,
|
||||
.ant-menu-submenu-selected,
|
||||
.ant-menu-item:hover,
|
||||
.ant-menu-item-active,
|
||||
.ant-menu:not(.ant-menu-inline) .ant-menu-submenu-open,
|
||||
.ant-menu-submenu-active,
|
||||
.ant-menu-submenu-title:hover {
|
||||
color: #fff;
|
||||
background-color: @top-menu-active-bg-color !important;
|
||||
}
|
||||
|
||||
.ant-menu-item:hover,
|
||||
.ant-menu-item-active,
|
||||
.ant-menu:not(.ant-menu-inline) .ant-menu-submenu-open,
|
||||
.ant-menu-submenu-active,
|
||||
.ant-menu-submenu-title:hover {
|
||||
background-color: @top-menu-active-bg-color;
|
||||
}
|
||||
|
||||
.@{basic-menu-prefix-cls}-item__level1 {
|
||||
background-color: transparent;
|
||||
|
||||
&.ant-menu-item-selected,
|
||||
&.ant-menu-submenu-selected {
|
||||
background-color: @top-menu-active-bg-color !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-item,
|
||||
.ant-menu-submenu {
|
||||
&.@{basic-menu-prefix-cls}-item__level1,
|
||||
.ant-menu-submenu-title {
|
||||
height: @header-height;
|
||||
line-height: @header-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu,
|
||||
.ant-menu-submenu-inline {
|
||||
transition: unset;
|
||||
}
|
||||
|
||||
.ant-menu-inline.ant-menu-sub {
|
||||
box-shadow: unset !important;
|
||||
transition: unset;
|
||||
}
|
||||
}
|
||||
60
frontend/vben/src/components/Menu/src/props.ts
Normal file
60
frontend/vben/src/components/Menu/src/props.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Menu } from '/@/router/types'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum'
|
||||
import { ThemeEnum } from '/@/enums/appEnum'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import type { MenuTheme } from 'ant-design-vue'
|
||||
import type { MenuMode } from 'ant-design-vue/lib/menu/src/interface'
|
||||
export const basicProps = {
|
||||
items: {
|
||||
type: Array as PropType<Menu[]>,
|
||||
default: () => [],
|
||||
},
|
||||
collapsedShowTitle: propTypes.bool,
|
||||
// 最好是4 倍数
|
||||
inlineIndent: propTypes.number.def(20),
|
||||
// 菜单组件的mode属性
|
||||
mode: {
|
||||
type: String as PropType<MenuMode>,
|
||||
default: MenuModeEnum.INLINE,
|
||||
},
|
||||
|
||||
type: {
|
||||
type: String as PropType<MenuTypeEnum>,
|
||||
default: MenuTypeEnum.MIX,
|
||||
},
|
||||
theme: {
|
||||
type: String as PropType<MenuTheme>,
|
||||
default: ThemeEnum.DARK,
|
||||
},
|
||||
inlineCollapsed: propTypes.bool,
|
||||
mixSider: propTypes.bool,
|
||||
|
||||
isHorizontal: propTypes.bool,
|
||||
accordion: propTypes.bool.def(true),
|
||||
beforeClickFn: {
|
||||
type: Function as PropType<(key: string) => Promise<boolean>>,
|
||||
},
|
||||
}
|
||||
|
||||
export const itemProps = {
|
||||
item: {
|
||||
type: Object as PropType<Menu>,
|
||||
default: {},
|
||||
},
|
||||
level: propTypes.number,
|
||||
theme: propTypes.oneOf(['dark', 'light']),
|
||||
showTitle: propTypes.bool,
|
||||
isHorizontal: propTypes.bool,
|
||||
}
|
||||
|
||||
export const contentProps = {
|
||||
item: {
|
||||
type: Object as PropType<Menu>,
|
||||
default: null,
|
||||
},
|
||||
showTitle: propTypes.bool.def(true),
|
||||
level: propTypes.number.def(0),
|
||||
isHorizontal: propTypes.bool.def(true),
|
||||
}
|
||||
25
frontend/vben/src/components/Menu/src/types.ts
Normal file
25
frontend/vben/src/components/Menu/src/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// import { ComputedRef } from 'vue';
|
||||
// import { ThemeEnum } from '/@/enums/appEnum';
|
||||
// import { MenuModeEnum } from '/@/enums/menuEnum';
|
||||
export interface MenuState {
|
||||
// 默认选中的列表
|
||||
defaultSelectedKeys: string[]
|
||||
|
||||
// 模式
|
||||
// mode: MenuModeEnum;
|
||||
|
||||
// // 主题
|
||||
// theme: ComputedRef<ThemeEnum> | ThemeEnum;
|
||||
|
||||
// 缩进
|
||||
inlineIndent?: number
|
||||
|
||||
// 展开数组
|
||||
openKeys: string[]
|
||||
|
||||
// 当前选中的菜单项 key 数组
|
||||
selectedKeys: string[]
|
||||
|
||||
// 收缩状态下展开的数组
|
||||
collapsedOpenKeys: string[]
|
||||
}
|
||||
83
frontend/vben/src/components/Menu/src/useOpenKeys.ts
Normal file
83
frontend/vben/src/components/Menu/src/useOpenKeys.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { MenuModeEnum } from '/@/enums/menuEnum'
|
||||
import type { Menu as MenuType } from '/@/router/types'
|
||||
import type { MenuState } from './types'
|
||||
|
||||
import { computed, Ref, toRaw } from 'vue'
|
||||
|
||||
import { unref } from 'vue'
|
||||
import { uniq } from 'lodash-es'
|
||||
import { useMenuSetting } from '/@/hooks/setting/useMenuSetting'
|
||||
import { getAllParentPath } from '/@/router/helper/menuHelper'
|
||||
import { useTimeoutFn } from '/@/hooks/core/useTimeout'
|
||||
|
||||
export function useOpenKeys(
|
||||
menuState: MenuState,
|
||||
menus: Ref<MenuType[]>,
|
||||
mode: Ref<MenuModeEnum>,
|
||||
accordion: Ref<boolean>,
|
||||
) {
|
||||
const { getCollapsed, getIsMixSidebar } = useMenuSetting()
|
||||
|
||||
async function setOpenKeys(path: string) {
|
||||
if (mode.value === MenuModeEnum.HORIZONTAL) {
|
||||
return
|
||||
}
|
||||
const native = unref(getIsMixSidebar)
|
||||
useTimeoutFn(
|
||||
() => {
|
||||
const menuList = toRaw(menus.value)
|
||||
if (menuList?.length === 0) {
|
||||
menuState.openKeys = []
|
||||
return
|
||||
}
|
||||
if (!unref(accordion)) {
|
||||
menuState.openKeys = uniq([...menuState.openKeys, ...getAllParentPath(menuList, path)])
|
||||
} else {
|
||||
menuState.openKeys = getAllParentPath(menuList, path)
|
||||
}
|
||||
},
|
||||
16,
|
||||
!native,
|
||||
)
|
||||
}
|
||||
|
||||
const getOpenKeys = computed(() => {
|
||||
const collapse = unref(getIsMixSidebar) ? false : unref(getCollapsed)
|
||||
|
||||
return collapse ? menuState.collapsedOpenKeys : menuState.openKeys
|
||||
})
|
||||
|
||||
/**
|
||||
* @description: 重置值
|
||||
*/
|
||||
function resetKeys() {
|
||||
menuState.selectedKeys = []
|
||||
menuState.openKeys = []
|
||||
}
|
||||
|
||||
function handleOpenChange(openKeys: string[]) {
|
||||
if (unref(mode) === MenuModeEnum.HORIZONTAL || !unref(accordion) || unref(getIsMixSidebar)) {
|
||||
menuState.openKeys = openKeys
|
||||
} else {
|
||||
// const menuList = toRaw(menus.value);
|
||||
// getAllParentPath(menuList, path);
|
||||
const rootSubMenuKeys: string[] = []
|
||||
for (const { children, path } of unref(menus)) {
|
||||
if (children && children.length > 0) {
|
||||
rootSubMenuKeys.push(path)
|
||||
}
|
||||
}
|
||||
if (!unref(getCollapsed)) {
|
||||
const latestOpenKey = openKeys.find((key) => menuState.openKeys.indexOf(key) === -1)
|
||||
if (rootSubMenuKeys.indexOf(latestOpenKey as string) === -1) {
|
||||
menuState.openKeys = openKeys
|
||||
} else {
|
||||
menuState.openKeys = latestOpenKey ? [latestOpenKey] : []
|
||||
}
|
||||
} else {
|
||||
menuState.collapsedOpenKeys = openKeys
|
||||
}
|
||||
}
|
||||
}
|
||||
return { setOpenKeys, resetKeys, getOpenKeys, handleOpenChange }
|
||||
}
|
||||
8
frontend/vben/src/components/Modal/index.ts
Normal file
8
frontend/vben/src/components/Modal/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import './src/index.less'
|
||||
import basicModal from './src/BasicModal.vue'
|
||||
|
||||
export const BasicModal = withInstall(basicModal)
|
||||
export { useModalContext } from './src/hooks/useModalContext'
|
||||
export { useModal, useModalInner } from './src/hooks/useModal'
|
||||
export * from './src/typing'
|
||||
242
frontend/vben/src/components/Modal/src/BasicModal.vue
Normal file
242
frontend/vben/src/components/Modal/src/BasicModal.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<Modal v-bind="getBindValue" @cancel="handleCancel">
|
||||
<template #closeIcon v-if="!$slots.closeIcon">
|
||||
<ModalClose
|
||||
:canFullscreen="getProps.canFullscreen"
|
||||
:fullScreen="fullScreenRef"
|
||||
@cancel="handleCancel"
|
||||
@fullscreen="handleFullScreen"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #title v-if="!$slots.title">
|
||||
<ModalHeader
|
||||
:helpMessage="getProps.helpMessage"
|
||||
:title="getMergeProps.title"
|
||||
@dblclick="handleTitleDbClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer v-if="!$slots.footer">
|
||||
<ModalFooter v-bind="getBindValue" @ok="handleOk" @cancel="handleCancel">
|
||||
<template #[item]="data" v-for="item in Object.keys($slots)">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
</ModalFooter>
|
||||
</template>
|
||||
|
||||
<ModalWrapper
|
||||
:useWrapper="getProps.useWrapper"
|
||||
:footerOffset="wrapperFooterOffset"
|
||||
:fullScreen="fullScreenRef"
|
||||
ref="modalWrapperRef"
|
||||
:loading="getProps.loading"
|
||||
:loading-tip="getProps.loadingTip"
|
||||
:minHeight="getProps.minHeight"
|
||||
:height="getWrapperHeight"
|
||||
:visible="visibleRef"
|
||||
:modalFooterHeight="footer !== undefined && !footer ? 0 : undefined"
|
||||
v-bind="omit(getProps.wrapperProps, 'visible', 'height', 'modalFooterHeight')"
|
||||
@ext-height="handleExtHeight"
|
||||
@height-change="handleHeightChange"
|
||||
>
|
||||
<slot></slot>
|
||||
</ModalWrapper>
|
||||
|
||||
<template #[item]="data" v-for="item in Object.keys(omit($slots, 'default'))">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { ModalProps, ModalMethods } from './typing'
|
||||
|
||||
import {
|
||||
defineComponent,
|
||||
computed,
|
||||
ref,
|
||||
watch,
|
||||
unref,
|
||||
watchEffect,
|
||||
toRef,
|
||||
getCurrentInstance,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import Modal from './components/Modal'
|
||||
import ModalWrapper from './components/ModalWrapper.vue'
|
||||
import ModalClose from './components/ModalClose.vue'
|
||||
import ModalFooter from './components/ModalFooter.vue'
|
||||
import ModalHeader from './components/ModalHeader.vue'
|
||||
import { isFunction } from '/@/utils/is'
|
||||
import { deepMerge } from '/@/utils'
|
||||
import { basicProps } from './props'
|
||||
import { useFullScreen } from './hooks/useModalFullScreen'
|
||||
import { omit } from 'lodash-es'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicModal',
|
||||
components: { Modal, ModalWrapper, ModalClose, ModalFooter, ModalHeader },
|
||||
inheritAttrs: false,
|
||||
props: basicProps,
|
||||
emits: ['visible-change', 'height-change', 'cancel', 'ok', 'register', 'update:visible'],
|
||||
setup(props, { emit, attrs }) {
|
||||
const visibleRef = ref(false)
|
||||
const propsRef = ref<Partial<ModalProps> | null>(null)
|
||||
const modalWrapperRef = ref<any>(null)
|
||||
const { prefixCls } = useDesign('basic-modal')
|
||||
|
||||
// modal Bottom and top height
|
||||
const extHeightRef = ref(0)
|
||||
const modalMethods: ModalMethods = {
|
||||
setModalProps,
|
||||
emitVisible: undefined,
|
||||
redoModalHeight: () => {
|
||||
nextTick(() => {
|
||||
if (unref(modalWrapperRef)) {
|
||||
;(unref(modalWrapperRef) as any).setModalHeight()
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
if (instance) {
|
||||
emit('register', modalMethods, instance.uid)
|
||||
}
|
||||
|
||||
// Custom title component: get title
|
||||
const getMergeProps = computed((): Recordable => {
|
||||
return {
|
||||
...props,
|
||||
...(unref(propsRef) as any),
|
||||
}
|
||||
})
|
||||
|
||||
const { handleFullScreen, getWrapClassName, fullScreenRef } = useFullScreen({
|
||||
modalWrapperRef,
|
||||
extHeightRef,
|
||||
wrapClassName: toRef(getMergeProps.value, 'wrapClassName'),
|
||||
})
|
||||
|
||||
// modal component does not need title and origin buttons
|
||||
const getProps = computed((): Recordable => {
|
||||
const opt = {
|
||||
...unref(getMergeProps),
|
||||
visible: unref(visibleRef),
|
||||
okButtonProps: undefined,
|
||||
cancelButtonProps: undefined,
|
||||
title: undefined,
|
||||
}
|
||||
return {
|
||||
...opt,
|
||||
wrapClassName: unref(getWrapClassName),
|
||||
}
|
||||
})
|
||||
|
||||
const getBindValue = computed((): Recordable => {
|
||||
const attr = {
|
||||
...attrs,
|
||||
...unref(getMergeProps),
|
||||
visible: unref(visibleRef),
|
||||
wrapClassName: unref(getWrapClassName),
|
||||
}
|
||||
if (unref(fullScreenRef)) {
|
||||
return omit(attr, ['height', 'title'])
|
||||
}
|
||||
return omit(attr, 'title')
|
||||
})
|
||||
|
||||
const getWrapperHeight = computed(() => {
|
||||
if (unref(fullScreenRef)) return undefined
|
||||
return unref(getProps).height
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
visibleRef.value = !!props.visible
|
||||
fullScreenRef.value = !!props.defaultFullscreen
|
||||
})
|
||||
|
||||
watch(
|
||||
() => unref(visibleRef),
|
||||
(v) => {
|
||||
emit('visible-change', v)
|
||||
emit('update:visible', v)
|
||||
instance && modalMethods.emitVisible?.(v, instance.uid)
|
||||
nextTick(() => {
|
||||
if (props.scrollTop && v && unref(modalWrapperRef)) {
|
||||
;(unref(modalWrapperRef) as any).scrollTop()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
immediate: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 取消事件
|
||||
async function handleCancel(e: Event) {
|
||||
e?.stopPropagation()
|
||||
// 过滤自定义关闭按钮的空白区域
|
||||
if ((e.target as HTMLElement)?.classList?.contains(prefixCls + '-close--custom')) return
|
||||
if (props.closeFunc && isFunction(props.closeFunc)) {
|
||||
const isClose: boolean = await props.closeFunc()
|
||||
visibleRef.value = !isClose
|
||||
return
|
||||
}
|
||||
|
||||
visibleRef.value = false
|
||||
emit('cancel', e)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description: 设置modal参数
|
||||
*/
|
||||
function setModalProps(props: Partial<ModalProps>): void {
|
||||
// Keep the last setModalProps
|
||||
propsRef.value = deepMerge(unref(propsRef) || ({} as any), props)
|
||||
if (Reflect.has(props, 'visible')) {
|
||||
visibleRef.value = !!props.visible
|
||||
}
|
||||
if (Reflect.has(props, 'defaultFullscreen')) {
|
||||
fullScreenRef.value = !!props.defaultFullscreen
|
||||
}
|
||||
}
|
||||
|
||||
function handleOk(e: Event) {
|
||||
emit('ok', e)
|
||||
}
|
||||
|
||||
function handleHeightChange(height: string) {
|
||||
emit('height-change', height)
|
||||
}
|
||||
|
||||
function handleExtHeight(height: number) {
|
||||
extHeightRef.value = height
|
||||
}
|
||||
|
||||
function handleTitleDbClick(e) {
|
||||
if (!props.canFullscreen) return
|
||||
e.stopPropagation()
|
||||
handleFullScreen(e)
|
||||
}
|
||||
|
||||
return {
|
||||
handleCancel,
|
||||
getBindValue,
|
||||
getProps,
|
||||
handleFullScreen,
|
||||
fullScreenRef,
|
||||
getMergeProps,
|
||||
handleOk,
|
||||
visibleRef,
|
||||
omit,
|
||||
modalWrapperRef,
|
||||
handleExtHeight,
|
||||
handleHeightChange,
|
||||
handleTitleDbClick,
|
||||
getWrapperHeight,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
31
frontend/vben/src/components/Modal/src/components/Modal.tsx
Normal file
31
frontend/vben/src/components/Modal/src/components/Modal.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import { defineComponent, toRefs, unref } from 'vue'
|
||||
import { basicProps } from '../props'
|
||||
import { useModalDragMove } from '../hooks/useModalDrag'
|
||||
import { useAttrs } from '/@/hooks/core/useAttrs'
|
||||
import { extendSlots } from '/@/utils/helper/tsxHelper'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Modal',
|
||||
inheritAttrs: false,
|
||||
props: basicProps,
|
||||
emits: ['cancel'],
|
||||
setup(props, { slots, emit }) {
|
||||
const { visible, draggable, destroyOnClose } = toRefs(props)
|
||||
const attrs = useAttrs()
|
||||
useModalDragMove({
|
||||
visible,
|
||||
destroyOnClose,
|
||||
draggable,
|
||||
})
|
||||
|
||||
const onCancel = (e: Event) => {
|
||||
emit('cancel', e)
|
||||
}
|
||||
|
||||
return () => {
|
||||
const propsData = { ...unref(attrs), ...props, onCancel } as Recordable
|
||||
return <Modal {...propsData}>{extendSlots(slots)}</Modal>
|
||||
}
|
||||
},
|
||||
})
|
||||
106
frontend/vben/src/components/Modal/src/components/ModalClose.vue
Normal file
106
frontend/vben/src/components/Modal/src/components/ModalClose.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div :class="getClass">
|
||||
<template v-if="canFullscreen">
|
||||
<Tooltip :title="t('component.modal.restore')" placement="bottom" v-if="fullScreen">
|
||||
<FullscreenExitOutlined role="full" @click="handleFullScreen" />
|
||||
</Tooltip>
|
||||
<Tooltip :title="t('component.modal.maximize')" placement="bottom" v-else>
|
||||
<FullscreenOutlined role="close" @click="handleFullScreen" />
|
||||
</Tooltip>
|
||||
</template>
|
||||
<Tooltip :title="t('component.modal.close')" placement="bottom">
|
||||
<CloseOutlined @click="handleCancel" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons-vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { Tooltip } from 'ant-design-vue'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ModalClose',
|
||||
components: { Tooltip, FullscreenExitOutlined, FullscreenOutlined, CloseOutlined },
|
||||
props: {
|
||||
canFullscreen: { type: Boolean, default: true },
|
||||
fullScreen: { type: Boolean },
|
||||
},
|
||||
emits: ['cancel', 'fullscreen'],
|
||||
setup(props, { emit }) {
|
||||
const { prefixCls } = useDesign('basic-modal-close')
|
||||
const { t } = useI18n()
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
`${prefixCls}--custom`,
|
||||
{
|
||||
[`${prefixCls}--can-full`]: props.canFullscreen,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function handleCancel(e: Event) {
|
||||
emit('cancel', e)
|
||||
}
|
||||
|
||||
function handleFullScreen(e: Event) {
|
||||
e?.stopPropagation()
|
||||
e?.preventDefault()
|
||||
emit('fullscreen')
|
||||
}
|
||||
|
||||
return {
|
||||
t,
|
||||
getClass,
|
||||
prefixCls,
|
||||
handleCancel,
|
||||
handleFullScreen,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-basic-modal-close';
|
||||
.@{prefix-cls} {
|
||||
display: flex;
|
||||
height: 95%;
|
||||
align-items: center;
|
||||
|
||||
> span {
|
||||
margin-left: 48px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&--can-full {
|
||||
> span {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(&--can-full) {
|
||||
> span:nth-child(1) {
|
||||
&:hover {
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& span:nth-child(1) {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
& span:last-child {
|
||||
&:hover {
|
||||
color: @error-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot name="insertFooter"></slot>
|
||||
<a-button v-bind="cancelButtonProps" @click="handleCancel" v-if="showCancelBtn">
|
||||
{{ cancelText }}
|
||||
</a-button>
|
||||
<slot name="centerFooter"></slot>
|
||||
<a-button
|
||||
:type="okType"
|
||||
@click="handleOk"
|
||||
:loading="confirmLoading"
|
||||
v-bind="okButtonProps"
|
||||
v-if="showOkBtn"
|
||||
>
|
||||
{{ okText }}
|
||||
</a-button>
|
||||
<slot name="appendFooter"></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
import { basicProps } from '../props'
|
||||
export default defineComponent({
|
||||
name: 'BasicModalFooter',
|
||||
props: basicProps,
|
||||
emits: ['ok', 'cancel'],
|
||||
setup(_, { emit }) {
|
||||
function handleOk(e: Event) {
|
||||
emit('ok', e)
|
||||
}
|
||||
|
||||
function handleCancel(e: Event) {
|
||||
emit('cancel', e)
|
||||
}
|
||||
|
||||
return { handleOk, handleCancel }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<BasicTitle :helpMessage="helpMessage">
|
||||
{{ title }}
|
||||
</BasicTitle>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { BasicTitle } from '/@/components/Basic'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BasicModalHeader',
|
||||
components: { BasicTitle },
|
||||
props: {
|
||||
helpMessage: {
|
||||
type: [String, Array] as PropType<string | string[]>,
|
||||
},
|
||||
title: { type: String },
|
||||
},
|
||||
emits: ['dblclick'],
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<ScrollContainer ref="wrapperRef">
|
||||
<div ref="spinRef" :style="spinStyle" v-loading="loading" :loading-tip="loadingTip">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import {
|
||||
defineComponent,
|
||||
computed,
|
||||
ref,
|
||||
watchEffect,
|
||||
unref,
|
||||
watch,
|
||||
onMounted,
|
||||
nextTick,
|
||||
onUnmounted,
|
||||
} from 'vue'
|
||||
import { useWindowSizeFn } from '/@/hooks/event/useWindowSizeFn'
|
||||
import { ScrollContainer } from '/@/components/Container'
|
||||
import { createModalContext } from '../hooks/useModalContext'
|
||||
import { useMutationObserver } from '@vueuse/core'
|
||||
|
||||
const props = {
|
||||
loading: { type: Boolean },
|
||||
useWrapper: { type: Boolean, default: true },
|
||||
modalHeaderHeight: { type: Number, default: 57 },
|
||||
modalFooterHeight: { type: Number, default: 74 },
|
||||
minHeight: { type: Number, default: 200 },
|
||||
height: { type: Number },
|
||||
footerOffset: { type: Number, default: 0 },
|
||||
visible: { type: Boolean },
|
||||
fullScreen: { type: Boolean },
|
||||
loadingTip: { type: String },
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ModalWrapper',
|
||||
components: { ScrollContainer },
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
emits: ['height-change', 'ext-height'],
|
||||
setup(props, { emit }) {
|
||||
const wrapperRef = ref<ComponentRef>(null)
|
||||
const spinRef = ref<ElRef>(null)
|
||||
const realHeightRef = ref(0)
|
||||
const minRealHeightRef = ref(0)
|
||||
|
||||
let realHeight = 0
|
||||
|
||||
let stopElResizeFn: Fn = () => {}
|
||||
|
||||
useWindowSizeFn(setModalHeight.bind(null, false))
|
||||
|
||||
useMutationObserver(
|
||||
spinRef,
|
||||
() => {
|
||||
setModalHeight()
|
||||
},
|
||||
{
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
},
|
||||
)
|
||||
|
||||
createModalContext({
|
||||
redoModalHeight: setModalHeight,
|
||||
})
|
||||
|
||||
const spinStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
minHeight: `${props.minHeight}px`,
|
||||
[props.fullScreen ? 'height' : 'maxHeight']: `${unref(realHeightRef)}px`,
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
props.useWrapper && setModalHeight()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.fullScreen,
|
||||
(v) => {
|
||||
setModalHeight()
|
||||
if (!v) {
|
||||
realHeightRef.value = minRealHeightRef.value
|
||||
} else {
|
||||
minRealHeightRef.value = realHeightRef.value
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const { modalHeaderHeight, modalFooterHeight } = props
|
||||
emit('ext-height', modalHeaderHeight + modalFooterHeight)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopElResizeFn && stopElResizeFn()
|
||||
})
|
||||
|
||||
async function scrollTop() {
|
||||
nextTick(() => {
|
||||
const wrapperRefDom = unref(wrapperRef)
|
||||
if (!wrapperRefDom) return
|
||||
;(wrapperRefDom as any)?.scrollTo?.(0)
|
||||
})
|
||||
}
|
||||
|
||||
async function setModalHeight() {
|
||||
// 解决在弹窗关闭的时候监听还存在,导致再次打开弹窗没有高度
|
||||
// 加上这个,就必须在使用的时候传递父级的visible
|
||||
if (!props.visible) return
|
||||
const wrapperRefDom = unref(wrapperRef)
|
||||
if (!wrapperRefDom) return
|
||||
|
||||
const bodyDom = wrapperRefDom.$el.parentElement
|
||||
if (!bodyDom) return
|
||||
bodyDom.style.padding = '0'
|
||||
await nextTick()
|
||||
|
||||
try {
|
||||
const modalDom = bodyDom.parentElement && bodyDom.parentElement.parentElement
|
||||
if (!modalDom) return
|
||||
|
||||
const modalRect = getComputedStyle(modalDom as Element).top
|
||||
const modalTop = Number.parseInt(modalRect)
|
||||
let maxHeight =
|
||||
window.innerHeight -
|
||||
modalTop * 2 +
|
||||
(props.footerOffset! || 0) -
|
||||
props.modalFooterHeight -
|
||||
props.modalHeaderHeight
|
||||
|
||||
// 距离顶部过进会出现滚动条
|
||||
if (modalTop < 40) {
|
||||
maxHeight -= 26
|
||||
}
|
||||
await nextTick()
|
||||
const spinEl = unref(spinRef)
|
||||
|
||||
if (!spinEl) return
|
||||
await nextTick()
|
||||
// if (!realHeight) {
|
||||
realHeight = spinEl.scrollHeight
|
||||
// }
|
||||
|
||||
if (props.fullScreen) {
|
||||
realHeightRef.value =
|
||||
window.innerHeight - props.modalFooterHeight - props.modalHeaderHeight - 28
|
||||
} else {
|
||||
realHeightRef.value = props.height
|
||||
? props.height
|
||||
: realHeight > maxHeight
|
||||
? maxHeight
|
||||
: realHeight
|
||||
}
|
||||
emit('height-change', unref(realHeightRef))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
return { wrapperRef, spinRef, spinStyle, scrollTop, setModalHeight }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
163
frontend/vben/src/components/Modal/src/hooks/useModal.ts
Normal file
163
frontend/vben/src/components/Modal/src/hooks/useModal.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type {
|
||||
UseModalReturnType,
|
||||
ModalMethods,
|
||||
ModalProps,
|
||||
ReturnMethods,
|
||||
UseModalInnerReturnType,
|
||||
} from '../typing'
|
||||
import {
|
||||
ref,
|
||||
onUnmounted,
|
||||
unref,
|
||||
getCurrentInstance,
|
||||
reactive,
|
||||
watchEffect,
|
||||
nextTick,
|
||||
toRaw,
|
||||
} from 'vue'
|
||||
import { isProdMode } from '/@/utils/env'
|
||||
import { isFunction } from '/@/utils/is'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { tryOnUnmounted } from '@vueuse/core'
|
||||
import { error } from '/@/utils/log'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const dataTransfer = reactive<any>({})
|
||||
|
||||
const visibleData = reactive<{ [key: number]: boolean }>({})
|
||||
|
||||
/**
|
||||
* @description: Applicable to independent modal and call outside
|
||||
*/
|
||||
export function useModal(): UseModalReturnType {
|
||||
const modal = ref<Nullable<ModalMethods>>(null)
|
||||
const loaded = ref<Nullable<boolean>>(false)
|
||||
const uid = ref<string>('')
|
||||
|
||||
function register(modalMethod: ModalMethods, uuid: string) {
|
||||
if (!getCurrentInstance()) {
|
||||
throw new Error('useModal() can only be used inside setup() or functional components!')
|
||||
}
|
||||
uid.value = uuid
|
||||
isProdMode() &&
|
||||
onUnmounted(() => {
|
||||
modal.value = null
|
||||
loaded.value = false
|
||||
dataTransfer[unref(uid)] = null
|
||||
})
|
||||
if (unref(loaded) && isProdMode() && modalMethod === unref(modal)) return
|
||||
|
||||
modal.value = modalMethod
|
||||
loaded.value = true
|
||||
modalMethod.emitVisible = (visible: boolean, uid: number) => {
|
||||
visibleData[uid] = visible
|
||||
}
|
||||
}
|
||||
|
||||
const getInstance = () => {
|
||||
const instance = unref(modal)
|
||||
if (!instance) {
|
||||
error('useModal instance is undefined!')
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
const methods: ReturnMethods = {
|
||||
setModalProps: (props: Partial<ModalProps>): void => {
|
||||
getInstance()?.setModalProps(props)
|
||||
},
|
||||
|
||||
getVisible: computed((): boolean => {
|
||||
return visibleData[~~unref(uid)]
|
||||
}),
|
||||
|
||||
redoModalHeight: () => {
|
||||
getInstance()?.redoModalHeight?.()
|
||||
},
|
||||
|
||||
openModal: <T = any>(visible = true, data?: T, openOnSet = true): void => {
|
||||
getInstance()?.setModalProps({
|
||||
visible: visible,
|
||||
})
|
||||
|
||||
if (!data) return
|
||||
const id = unref(uid)
|
||||
if (openOnSet) {
|
||||
dataTransfer[id] = null
|
||||
dataTransfer[id] = toRaw(data)
|
||||
return
|
||||
}
|
||||
const equal = isEqual(toRaw(dataTransfer[id]), toRaw(data))
|
||||
if (!equal) {
|
||||
dataTransfer[id] = toRaw(data)
|
||||
}
|
||||
},
|
||||
|
||||
closeModal: () => {
|
||||
getInstance()?.setModalProps({ visible: false })
|
||||
},
|
||||
}
|
||||
return [register, methods]
|
||||
}
|
||||
|
||||
export const useModalInner = (callbackFn?: Fn): UseModalInnerReturnType => {
|
||||
const modalInstanceRef = ref<Nullable<ModalMethods>>(null)
|
||||
const currentInstance = getCurrentInstance()
|
||||
const uidRef = ref<string>('')
|
||||
|
||||
const getInstance = () => {
|
||||
const instance = unref(modalInstanceRef)
|
||||
if (!instance) {
|
||||
error('useModalInner instance is undefined!')
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
const register = (modalInstance: ModalMethods, uuid: string) => {
|
||||
isProdMode() &&
|
||||
tryOnUnmounted(() => {
|
||||
modalInstanceRef.value = null
|
||||
})
|
||||
uidRef.value = uuid
|
||||
modalInstanceRef.value = modalInstance
|
||||
currentInstance?.emit('register', modalInstance, uuid)
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const data = dataTransfer[unref(uidRef)]
|
||||
if (!data) return
|
||||
if (!callbackFn || !isFunction(callbackFn)) return
|
||||
nextTick(() => {
|
||||
callbackFn(data)
|
||||
})
|
||||
})
|
||||
|
||||
return [
|
||||
register,
|
||||
{
|
||||
changeLoading: (loading = true) => {
|
||||
getInstance()?.setModalProps({ loading })
|
||||
},
|
||||
getVisible: computed((): boolean => {
|
||||
return visibleData[~~unref(uidRef)]
|
||||
}),
|
||||
|
||||
changeOkLoading: (loading = true) => {
|
||||
getInstance()?.setModalProps({ confirmLoading: loading })
|
||||
},
|
||||
|
||||
closeModal: () => {
|
||||
getInstance()?.setModalProps({ visible: false })
|
||||
},
|
||||
|
||||
setModalProps: (props: Partial<ModalProps>) => {
|
||||
getInstance()?.setModalProps(props)
|
||||
},
|
||||
|
||||
redoModalHeight: () => {
|
||||
const callRedo = getInstance()?.redoModalHeight
|
||||
callRedo && callRedo()
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { InjectionKey } from 'vue'
|
||||
import { createContext, useContext } from '/@/hooks/core/useContext'
|
||||
|
||||
export interface ModalContextProps {
|
||||
redoModalHeight: () => void
|
||||
}
|
||||
|
||||
const key: InjectionKey<ModalContextProps> = Symbol()
|
||||
|
||||
export function createModalContext(context: ModalContextProps) {
|
||||
return createContext<ModalContextProps>(context, key)
|
||||
}
|
||||
|
||||
export function useModalContext() {
|
||||
return useContext<ModalContextProps>(key)
|
||||
}
|
||||
107
frontend/vben/src/components/Modal/src/hooks/useModalDrag.ts
Normal file
107
frontend/vben/src/components/Modal/src/hooks/useModalDrag.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Ref, unref, watchEffect } from 'vue'
|
||||
import { useTimeoutFn } from '/@/hooks/core/useTimeout'
|
||||
|
||||
export interface UseModalDragMoveContext {
|
||||
draggable: Ref<boolean>
|
||||
destroyOnClose: Ref<boolean | undefined> | undefined
|
||||
visible: Ref<boolean>
|
||||
}
|
||||
|
||||
export function useModalDragMove(context: UseModalDragMoveContext) {
|
||||
const getStyle = (dom: any, attr: any) => {
|
||||
return getComputedStyle(dom)[attr]
|
||||
}
|
||||
const drag = (wrap: any) => {
|
||||
if (!wrap) return
|
||||
wrap.setAttribute('data-drag', unref(context.draggable))
|
||||
const dialogHeaderEl = wrap.querySelector('.ant-modal-header')
|
||||
const dragDom = wrap.querySelector('.ant-modal')
|
||||
|
||||
if (!dialogHeaderEl || !dragDom || !unref(context.draggable)) return
|
||||
|
||||
dialogHeaderEl.style.cursor = 'move'
|
||||
|
||||
dialogHeaderEl.onmousedown = (e: any) => {
|
||||
if (!e) return
|
||||
// 鼠标按下,计算当前元素距离可视区的距离
|
||||
const disX = e.clientX
|
||||
const disY = e.clientY
|
||||
const screenWidth = document.body.clientWidth // body当前宽度
|
||||
const screenHeight = document.documentElement.clientHeight // 可见区域高度(应为body高度,可某些环境下无法获取)
|
||||
|
||||
const dragDomWidth = dragDom.offsetWidth // 对话框宽度
|
||||
const dragDomheight = dragDom.offsetHeight // 对话框高度
|
||||
|
||||
const minDragDomLeft = dragDom.offsetLeft
|
||||
|
||||
const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth
|
||||
const minDragDomTop = dragDom.offsetTop
|
||||
const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomheight
|
||||
// 获取到的值带px 正则匹配替换
|
||||
const domLeft = getStyle(dragDom, 'left')
|
||||
const domTop = getStyle(dragDom, 'top')
|
||||
let styL = +domLeft
|
||||
let styT = +domTop
|
||||
|
||||
// 注意在ie中 第一次获取到的值为组件自带50% 移动之后赋值为px
|
||||
if (domLeft.includes('%')) {
|
||||
styL = +document.body.clientWidth * (+domLeft.replace(/%/g, '') / 100)
|
||||
styT = +document.body.clientHeight * (+domTop.replace(/%/g, '') / 100)
|
||||
} else {
|
||||
styL = +domLeft.replace(/px/g, '')
|
||||
styT = +domTop.replace(/px/g, '')
|
||||
}
|
||||
|
||||
document.onmousemove = function (e) {
|
||||
// 通过事件委托,计算移动的距离
|
||||
let left = e.clientX - disX
|
||||
let top = e.clientY - disY
|
||||
|
||||
// 边界处理
|
||||
if (-left > minDragDomLeft) {
|
||||
left = -minDragDomLeft
|
||||
} else if (left > maxDragDomLeft) {
|
||||
left = maxDragDomLeft
|
||||
}
|
||||
|
||||
if (-top > minDragDomTop) {
|
||||
top = -minDragDomTop
|
||||
} else if (top > maxDragDomTop) {
|
||||
top = maxDragDomTop
|
||||
}
|
||||
|
||||
// 移动当前元素
|
||||
dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`
|
||||
}
|
||||
|
||||
document.onmouseup = () => {
|
||||
document.onmousemove = null
|
||||
document.onmouseup = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrag = () => {
|
||||
const dragWraps = document.querySelectorAll('.ant-modal-wrap')
|
||||
for (const wrap of Array.from(dragWraps)) {
|
||||
if (!wrap) continue
|
||||
const display = getStyle(wrap, 'display')
|
||||
const draggable = wrap.getAttribute('data-drag')
|
||||
if (display !== 'none') {
|
||||
// 拖拽位置
|
||||
if (draggable === null || unref(context.destroyOnClose)) {
|
||||
drag(wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!unref(context.visible) || !unref(context.draggable)) {
|
||||
return
|
||||
}
|
||||
useTimeoutFn(() => {
|
||||
handleDrag()
|
||||
}, 30)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { computed, Ref, ref, unref } from 'vue'
|
||||
|
||||
export interface UseFullScreenContext {
|
||||
wrapClassName: Ref<string | undefined>
|
||||
modalWrapperRef: Ref<ComponentRef>
|
||||
extHeightRef: Ref<number>
|
||||
}
|
||||
|
||||
export function useFullScreen(context: UseFullScreenContext) {
|
||||
// const formerHeightRef = ref(0);
|
||||
const fullScreenRef = ref(false)
|
||||
|
||||
const getWrapClassName = computed(() => {
|
||||
const clsName = unref(context.wrapClassName) || ''
|
||||
return unref(fullScreenRef) ? `fullscreen-modal ${clsName} ` : unref(clsName)
|
||||
})
|
||||
|
||||
function handleFullScreen(e: Event) {
|
||||
e && e.stopPropagation()
|
||||
fullScreenRef.value = !unref(fullScreenRef)
|
||||
|
||||
// const modalWrapper = unref(context.modalWrapperRef);
|
||||
|
||||
// if (!modalWrapper) return;
|
||||
|
||||
// const wrapperEl = modalWrapper.$el as HTMLElement;
|
||||
// if (!wrapperEl) return;
|
||||
// const modalWrapSpinEl = wrapperEl.querySelector('.ant-spin-nested-loading') as HTMLElement;
|
||||
|
||||
// if (!modalWrapSpinEl) return;
|
||||
|
||||
// if (!unref(formerHeightRef) && unref(fullScreenRef)) {
|
||||
// formerHeightRef.value = modalWrapSpinEl.offsetHeight;
|
||||
// }
|
||||
|
||||
// if (unref(fullScreenRef)) {
|
||||
// modalWrapSpinEl.style.height = `${window.innerHeight - unref(context.extHeightRef)}px`;
|
||||
// } else {
|
||||
// modalWrapSpinEl.style.height = `${unref(formerHeightRef)}px`;
|
||||
// }
|
||||
}
|
||||
return { getWrapClassName, handleFullScreen, fullScreenRef }
|
||||
}
|
||||
127
frontend/vben/src/components/Modal/src/index.less
Normal file
127
frontend/vben/src/components/Modal/src/index.less
Normal file
@@ -0,0 +1,127 @@
|
||||
.fullscreen-modal {
|
||||
overflow: hidden;
|
||||
|
||||
.ant-modal {
|
||||
top: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
|
||||
&-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
width: 520px;
|
||||
padding-bottom: 0;
|
||||
|
||||
.ant-modal-body > .scrollbar {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
|
||||
.base-title {
|
||||
cursor: move !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0;
|
||||
|
||||
> .scrollbar > .scrollbar__bar.is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-large {
|
||||
top: 60px;
|
||||
|
||||
&--mini {
|
||||
top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&-content {
|
||||
box-shadow: 0 4px 8px 0 rgb(0 0 0 / 20%), 0 6px 20px 0 rgb(0 0 0 / 19%);
|
||||
}
|
||||
|
||||
&-footer {
|
||||
button + button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&-close {
|
||||
font-weight: normal;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&-close-x {
|
||||
display: inline-block;
|
||||
width: 96px;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
}
|
||||
|
||||
&-confirm-body {
|
||||
.ant-modal-confirm-content {
|
||||
// color: #fff;
|
||||
|
||||
> * {
|
||||
color: @text-color-help-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-confirm-confirm.error .ant-modal-confirm-body > .anticon {
|
||||
color: @error-color;
|
||||
}
|
||||
|
||||
&-confirm-btns {
|
||||
.ant-btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-confirm-info {
|
||||
.ant-modal-confirm-body > .anticon {
|
||||
color: @warning-color;
|
||||
}
|
||||
}
|
||||
|
||||
&-confirm-confirm.success {
|
||||
.ant-modal-confirm-body > .anticon {
|
||||
color: @success-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
@media screen and (max-height: 600px) {
|
||||
.ant-modal {
|
||||
top: 60px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 540px) {
|
||||
.ant-modal {
|
||||
top: 30px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-height: 480px) {
|
||||
.ant-modal {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
83
frontend/vben/src/components/Modal/src/props.ts
Normal file
83
frontend/vben/src/components/Modal/src/props.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { PropType, CSSProperties } from 'vue'
|
||||
import type { ModalWrapperProps } from './typing'
|
||||
import { ButtonProps } from 'ant-design-vue/es/button/buttonTypes'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export const modalProps = {
|
||||
visible: { type: Boolean },
|
||||
scrollTop: { type: Boolean, default: true },
|
||||
height: { type: Number },
|
||||
minHeight: { type: Number },
|
||||
// open drag
|
||||
draggable: { type: Boolean, default: true },
|
||||
centered: { type: Boolean },
|
||||
cancelText: { type: String, default: t('common.cancelText') },
|
||||
okText: { type: String, default: t('common.okText') },
|
||||
|
||||
closeFunc: Function as PropType<() => Promise<boolean>>,
|
||||
}
|
||||
|
||||
export const basicProps = Object.assign({}, modalProps, {
|
||||
defaultFullscreen: { type: Boolean },
|
||||
// Can it be full screen
|
||||
canFullscreen: { type: Boolean, default: true },
|
||||
// After enabling the wrapper, the bottom can be increased in height
|
||||
wrapperFooterOffset: { type: Number, default: 0 },
|
||||
// Warm reminder message
|
||||
helpMessage: [String, Array] as PropType<string | string[]>,
|
||||
// Whether to setting wrapper
|
||||
useWrapper: { type: Boolean, default: true },
|
||||
loading: { type: Boolean },
|
||||
loadingTip: { type: String },
|
||||
/**
|
||||
* @description: Show close button
|
||||
*/
|
||||
showCancelBtn: { type: Boolean, default: true },
|
||||
/**
|
||||
* @description: Show confirmation button
|
||||
*/
|
||||
showOkBtn: { type: Boolean, default: true },
|
||||
|
||||
wrapperProps: Object as PropType<Partial<ModalWrapperProps>>,
|
||||
|
||||
afterClose: Function as PropType<() => Promise<VueNode>>,
|
||||
|
||||
bodyStyle: Object as PropType<CSSProperties>,
|
||||
|
||||
closable: { type: Boolean, default: true },
|
||||
|
||||
closeIcon: Object as PropType<VueNode>,
|
||||
|
||||
confirmLoading: { type: Boolean },
|
||||
|
||||
destroyOnClose: { type: Boolean },
|
||||
|
||||
footer: Object as PropType<VueNode>,
|
||||
|
||||
getContainer: Function as PropType<() => any>,
|
||||
|
||||
mask: { type: Boolean, default: true },
|
||||
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
keyboard: { type: Boolean, default: true },
|
||||
|
||||
maskStyle: Object as PropType<CSSProperties>,
|
||||
|
||||
okType: { type: String, default: 'primary' },
|
||||
|
||||
okButtonProps: Object as PropType<ButtonProps>,
|
||||
|
||||
cancelButtonProps: Object as PropType<ButtonProps>,
|
||||
|
||||
title: { type: String },
|
||||
|
||||
visible: { type: Boolean },
|
||||
|
||||
width: [String, Number] as PropType<string | number>,
|
||||
|
||||
wrapClassName: { type: String },
|
||||
|
||||
zIndex: { type: Number },
|
||||
})
|
||||
209
frontend/vben/src/components/Modal/src/typing.ts
Normal file
209
frontend/vben/src/components/Modal/src/typing.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import type { ButtonProps } from 'ant-design-vue/lib/button/buttonTypes'
|
||||
import type { CSSProperties, VNodeChild, ComputedRef } from 'vue'
|
||||
/**
|
||||
* @description: 弹窗对外暴露的方法
|
||||
*/
|
||||
export interface ModalMethods {
|
||||
setModalProps: (props: Partial<ModalProps>) => void
|
||||
emitVisible?: (visible: boolean, uid: number) => void
|
||||
redoModalHeight?: () => void
|
||||
}
|
||||
|
||||
export type RegisterFn = (modalMethods: ModalMethods, uuid?: string) => void
|
||||
|
||||
export interface ReturnMethods extends ModalMethods {
|
||||
openModal: <T = any>(props?: boolean, data?: T, openOnSet?: boolean) => void
|
||||
closeModal: () => void
|
||||
getVisible?: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export type UseModalReturnType = [RegisterFn, ReturnMethods]
|
||||
|
||||
export interface ReturnInnerMethods extends ModalMethods {
|
||||
closeModal: () => void
|
||||
changeLoading: (loading: boolean) => void
|
||||
changeOkLoading: (loading: boolean) => void
|
||||
getVisible?: ComputedRef<boolean>
|
||||
redoModalHeight: () => void
|
||||
}
|
||||
|
||||
export type UseModalInnerReturnType = [RegisterFn, ReturnInnerMethods]
|
||||
|
||||
export interface ModalProps {
|
||||
minHeight?: number
|
||||
height?: number
|
||||
// 启用wrapper后 底部可以适当增加高度
|
||||
wrapperFooterOffset?: number
|
||||
draggable?: boolean
|
||||
scrollTop?: boolean
|
||||
|
||||
// 是否可以进行全屏
|
||||
canFullscreen?: boolean
|
||||
defaultFullscreen?: boolean
|
||||
visible?: boolean
|
||||
// 温馨提醒信息
|
||||
helpMessage: string | string[]
|
||||
|
||||
// 是否使用modalWrapper
|
||||
useWrapper: boolean
|
||||
|
||||
loading: boolean
|
||||
loadingTip?: string
|
||||
|
||||
wrapperProps: Omit<ModalWrapperProps, 'loading'>
|
||||
|
||||
showOkBtn: boolean
|
||||
showCancelBtn: boolean
|
||||
closeFunc: () => Promise<any>
|
||||
|
||||
/**
|
||||
* Specify a function that will be called when modal is closed completely.
|
||||
* @type Function
|
||||
*/
|
||||
afterClose?: () => any
|
||||
|
||||
/**
|
||||
* Body style for modal body element. Such as height, padding etc.
|
||||
* @default {}
|
||||
* @type object
|
||||
*/
|
||||
bodyStyle?: CSSProperties
|
||||
|
||||
/**
|
||||
* Text of the Cancel button
|
||||
* @default 'cancel'
|
||||
* @type string
|
||||
*/
|
||||
cancelText?: string
|
||||
|
||||
/**
|
||||
* Centered Modal
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
centered?: boolean
|
||||
|
||||
/**
|
||||
* Whether a close (x) button is visible on top right of the modal dialog or not
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
closable?: boolean
|
||||
/**
|
||||
* Whether a close (x) button is visible on top right of the modal dialog or not
|
||||
*/
|
||||
closeIcon?: VNodeChild | JSX.Element
|
||||
|
||||
/**
|
||||
* Whether to apply loading visual effect for OK button or not
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
confirmLoading?: boolean
|
||||
|
||||
/**
|
||||
* Whether to unmount child components on onClose
|
||||
* @default false
|
||||
* @type boolean
|
||||
*/
|
||||
destroyOnClose?: boolean
|
||||
|
||||
/**
|
||||
* Footer content, set as :footer="null" when you don't need default buttons
|
||||
* @default OK and Cancel buttons
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
footer?: VNodeChild | JSX.Element
|
||||
|
||||
/**
|
||||
* Return the mount node for Modal
|
||||
* @default () => document.body
|
||||
* @type Function
|
||||
*/
|
||||
getContainer?: (instance: any) => HTMLElement
|
||||
|
||||
/**
|
||||
* Whether show mask or not.
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
mask?: boolean
|
||||
|
||||
/**
|
||||
* Whether to close the modal dialog when the mask (area outside the modal) is clicked
|
||||
* @default true
|
||||
* @type boolean
|
||||
*/
|
||||
maskClosable?: boolean
|
||||
|
||||
/**
|
||||
* Style for modal's mask element.
|
||||
* @default {}
|
||||
* @type object
|
||||
*/
|
||||
maskStyle?: CSSProperties
|
||||
|
||||
/**
|
||||
* Text of the OK button
|
||||
* @default 'OK'
|
||||
* @type string
|
||||
*/
|
||||
okText?: string
|
||||
|
||||
/**
|
||||
* Button type of the OK button
|
||||
* @default 'primary'
|
||||
* @type string
|
||||
*/
|
||||
okType?: 'primary' | 'danger' | 'dashed' | 'ghost' | 'default'
|
||||
|
||||
/**
|
||||
* The ok button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
okButtonProps?: ButtonProps
|
||||
|
||||
/**
|
||||
* The cancel button props, follow jsx rules
|
||||
* @type object
|
||||
*/
|
||||
cancelButtonProps?: ButtonProps
|
||||
|
||||
/**
|
||||
* The modal dialog's title
|
||||
* @type any (string | slot)
|
||||
*/
|
||||
title?: VNodeChild | JSX.Element
|
||||
|
||||
/**
|
||||
* Width of the modal dialog
|
||||
* @default 520
|
||||
* @type string | number
|
||||
*/
|
||||
width?: string | number
|
||||
|
||||
/**
|
||||
* The class name of the container of the modal dialog
|
||||
* @type string
|
||||
*/
|
||||
wrapClassName?: string
|
||||
|
||||
/**
|
||||
* The z-index of the Modal
|
||||
* @default 1000
|
||||
* @type number
|
||||
*/
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
export interface ModalWrapperProps {
|
||||
footerOffset?: number
|
||||
loading: boolean
|
||||
modalHeaderHeight: number
|
||||
modalFooterHeight: number
|
||||
minHeight: number
|
||||
height: number
|
||||
visible: boolean
|
||||
fullScreen: boolean
|
||||
useWrapper: boolean
|
||||
}
|
||||
9
frontend/vben/src/components/Page/index.ts
Normal file
9
frontend/vben/src/components/Page/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
|
||||
import pageFooter from './src/PageFooter.vue'
|
||||
import pageWrapper from './src/PageWrapper.vue'
|
||||
|
||||
export const PageFooter = withInstall(pageFooter)
|
||||
export const PageWrapper = withInstall(pageWrapper)
|
||||
|
||||
export const PageWrapperFixedHeightKey = 'PageWrapperFixedHeight'
|
||||
50
frontend/vben/src/components/Page/src/PageFooter.vue
Normal file
50
frontend/vben/src/components/Page/src/PageFooter.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div :class="prefixCls" :style="{ width: getCalcContentWidth }">
|
||||
<div :class="`${prefixCls}__left`">
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
<slot></slot>
|
||||
<div :class="`${prefixCls}__right`">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { useMenuSetting } from '/@/hooks/setting/useMenuSetting'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageFooter',
|
||||
inheritAttrs: false,
|
||||
setup() {
|
||||
const { prefixCls } = useDesign('page-footer')
|
||||
const { getCalcContentWidth } = useMenuSetting()
|
||||
return { prefixCls, getCalcContentWidth }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
@prefix-cls: ~'@{namespace}-page-footer';
|
||||
|
||||
.@{prefix-cls} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: @page-footer-z-index;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
line-height: 44px;
|
||||
background-color: @component-background;
|
||||
border-top: 1px solid @border-color-base;
|
||||
box-shadow: 0 -6px 16px -8px rgb(0 0 0 / 8%), 0 -9px 28px 0 rgb(0 0 0 / 5%),
|
||||
0 -12px 48px 16px rgb(0 0 0 / 3%);
|
||||
transition: width 0.2s;
|
||||
|
||||
&__left {
|
||||
flex: 1 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
191
frontend/vben/src/components/Page/src/PageWrapper.vue
Normal file
191
frontend/vben/src/components/Page/src/PageWrapper.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div :class="getClass" ref="wrapperRef">
|
||||
<PageHeader
|
||||
:ghost="ghost"
|
||||
:title="title"
|
||||
v-bind="omit($attrs, 'class')"
|
||||
ref="headerRef"
|
||||
v-if="getShowHeader"
|
||||
>
|
||||
<template #default>
|
||||
<template v-if="content">
|
||||
{{ content }}
|
||||
</template>
|
||||
<slot name="headerContent" v-else></slot>
|
||||
</template>
|
||||
<template #[item]="data" v-for="item in getHeaderSlots">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="overflow-hidden" :class="getContentClass" :style="getContentStyle" ref="contentRef">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<PageFooter v-if="getShowFooter" ref="footerRef">
|
||||
<template #left>
|
||||
<slot name="leftFooter"></slot>
|
||||
</template>
|
||||
<template #right>
|
||||
<slot name="rightFooter"></slot>
|
||||
</template>
|
||||
</PageFooter>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { CSSProperties, PropType, provide } from 'vue'
|
||||
|
||||
import { defineComponent, computed, watch, ref, unref } from 'vue'
|
||||
import PageFooter from './PageFooter.vue'
|
||||
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import { omit } from 'lodash-es'
|
||||
import { PageHeader } from 'ant-design-vue'
|
||||
import { useContentHeight } from '/@/hooks/web/useContentHeight'
|
||||
import { PageWrapperFixedHeightKey } from '..'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageWrapper',
|
||||
components: { PageFooter, PageHeader },
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
title: propTypes.string,
|
||||
dense: propTypes.bool,
|
||||
ghost: propTypes.bool,
|
||||
content: propTypes.string,
|
||||
contentStyle: {
|
||||
type: Object as PropType<CSSProperties>,
|
||||
},
|
||||
contentBackground: propTypes.bool,
|
||||
contentFullHeight: propTypes.bool,
|
||||
contentClass: propTypes.string,
|
||||
fixedHeight: propTypes.bool,
|
||||
upwardSpace: propTypes.oneOfType([propTypes.number, propTypes.string]).def(0),
|
||||
},
|
||||
setup(props, { slots, attrs }) {
|
||||
const wrapperRef = ref(null)
|
||||
const headerRef = ref(null)
|
||||
const contentRef = ref(null)
|
||||
const footerRef = ref(null)
|
||||
const { prefixCls } = useDesign('page-wrapper')
|
||||
|
||||
provide(
|
||||
PageWrapperFixedHeightKey,
|
||||
computed(() => props.fixedHeight),
|
||||
)
|
||||
|
||||
const getIsContentFullHeight = computed(() => {
|
||||
return props.contentFullHeight
|
||||
})
|
||||
|
||||
const getUpwardSpace = computed(() => props.upwardSpace)
|
||||
const { redoHeight, setCompensation, contentHeight } = useContentHeight(
|
||||
getIsContentFullHeight,
|
||||
wrapperRef,
|
||||
[headerRef, footerRef],
|
||||
[contentRef],
|
||||
getUpwardSpace,
|
||||
)
|
||||
setCompensation({ useLayoutFooter: true, elements: [footerRef] })
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
prefixCls,
|
||||
{
|
||||
[`${prefixCls}--dense`]: props.dense,
|
||||
},
|
||||
attrs.class ?? {},
|
||||
]
|
||||
})
|
||||
|
||||
const getShowHeader = computed(
|
||||
() => props.content || slots?.headerContent || props.title || getHeaderSlots.value.length,
|
||||
)
|
||||
|
||||
const getShowFooter = computed(() => slots?.leftFooter || slots?.rightFooter)
|
||||
|
||||
const getHeaderSlots = computed(() => {
|
||||
return Object.keys(omit(slots, 'default', 'leftFooter', 'rightFooter', 'headerContent'))
|
||||
})
|
||||
|
||||
const getContentStyle = computed((): CSSProperties => {
|
||||
const { contentFullHeight, contentStyle, fixedHeight } = props
|
||||
if (!contentFullHeight) {
|
||||
return { ...contentStyle }
|
||||
}
|
||||
|
||||
const height = `${unref(contentHeight)}px`
|
||||
return {
|
||||
...contentStyle,
|
||||
minHeight: height,
|
||||
...(fixedHeight ? { height } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const getContentClass = computed(() => {
|
||||
const { contentBackground, contentClass } = props
|
||||
return [
|
||||
`${prefixCls}-content`,
|
||||
contentClass,
|
||||
{
|
||||
[`${prefixCls}-content-bg`]: contentBackground,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [getShowFooter.value],
|
||||
() => {
|
||||
redoHeight()
|
||||
},
|
||||
{
|
||||
flush: 'post',
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
getContentStyle,
|
||||
wrapperRef,
|
||||
headerRef,
|
||||
contentRef,
|
||||
footerRef,
|
||||
getClass,
|
||||
getHeaderSlots,
|
||||
prefixCls,
|
||||
getShowHeader,
|
||||
getShowFooter,
|
||||
omit,
|
||||
getContentClass,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@prefix-cls: ~'@{namespace}-page-wrapper';
|
||||
|
||||
.@{prefix-cls} {
|
||||
position: relative;
|
||||
|
||||
.@{prefix-cls}-content {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
&:empty {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-content-bg {
|
||||
background-color: @component-background;
|
||||
}
|
||||
|
||||
&--dense {
|
||||
.@{prefix-cls}-content {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
8
frontend/vben/src/components/Scrollbar/index.ts
Normal file
8
frontend/vben/src/components/Scrollbar/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* copy from element-ui
|
||||
*/
|
||||
|
||||
import Scrollbar from './src/Scrollbar.vue'
|
||||
|
||||
export { Scrollbar }
|
||||
export type { ScrollbarType } from './src/types'
|
||||
206
frontend/vben/src/components/Scrollbar/src/Scrollbar.vue
Normal file
206
frontend/vben/src/components/Scrollbar/src/Scrollbar.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="scrollbar">
|
||||
<div
|
||||
ref="wrap"
|
||||
:class="[wrapClass, 'scrollbar__wrap', native ? '' : 'scrollbar__wrap--hidden-default']"
|
||||
:style="style"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<component :is="tag" ref="resize" :class="['scrollbar__view', viewClass]" :style="viewStyle">
|
||||
<slot></slot>
|
||||
</component>
|
||||
</div>
|
||||
<template v-if="!native">
|
||||
<bar :move="moveX" :size="sizeWidth" />
|
||||
<bar vertical :move="moveY" :size="sizeHeight" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { addResizeListener, removeResizeListener } from '/@/utils/event'
|
||||
import componentSetting from '/@/settings/componentSetting'
|
||||
const { scrollbar } = componentSetting
|
||||
import { toObject } from './util'
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
provide,
|
||||
computed,
|
||||
unref,
|
||||
} from 'vue'
|
||||
import Bar from './bar'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Scrollbar',
|
||||
// inheritAttrs: false,
|
||||
components: { Bar },
|
||||
props: {
|
||||
native: {
|
||||
type: Boolean,
|
||||
default: scrollbar?.native ?? false,
|
||||
},
|
||||
wrapStyle: {
|
||||
type: [String, Array],
|
||||
default: '',
|
||||
},
|
||||
wrapClass: {
|
||||
type: [String, Array],
|
||||
default: '',
|
||||
},
|
||||
viewClass: {
|
||||
type: [String, Array],
|
||||
default: '',
|
||||
},
|
||||
viewStyle: {
|
||||
type: [String, Array],
|
||||
default: '',
|
||||
},
|
||||
noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
|
||||
tag: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const sizeWidth = ref('0')
|
||||
const sizeHeight = ref('0')
|
||||
const moveX = ref(0)
|
||||
const moveY = ref(0)
|
||||
const wrap = ref()
|
||||
const resize = ref()
|
||||
|
||||
provide('scroll-bar-wrap', wrap)
|
||||
|
||||
const style = computed(() => {
|
||||
if (Array.isArray(props.wrapStyle)) {
|
||||
return toObject(props.wrapStyle)
|
||||
}
|
||||
return props.wrapStyle
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!props.native) {
|
||||
moveY.value = (unref(wrap).scrollTop * 100) / unref(wrap).clientHeight
|
||||
moveX.value = (unref(wrap).scrollLeft * 100) / unref(wrap).clientWidth
|
||||
}
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
if (!unref(wrap)) return
|
||||
|
||||
const heightPercentage = (unref(wrap).clientHeight * 100) / unref(wrap).scrollHeight
|
||||
const widthPercentage = (unref(wrap).clientWidth * 100) / unref(wrap).scrollWidth
|
||||
|
||||
sizeHeight.value = heightPercentage < 100 ? heightPercentage + '%' : ''
|
||||
sizeWidth.value = widthPercentage < 100 ? widthPercentage + '%' : ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.native) return
|
||||
nextTick(update)
|
||||
if (!props.noresize) {
|
||||
addResizeListener(unref(resize), update)
|
||||
addResizeListener(unref(wrap), update)
|
||||
addEventListener('resize', update)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.native) return
|
||||
if (!props.noresize) {
|
||||
removeResizeListener(unref(resize), update)
|
||||
removeResizeListener(unref(wrap), update)
|
||||
removeEventListener('resize', update)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
moveX,
|
||||
moveY,
|
||||
sizeWidth,
|
||||
sizeHeight,
|
||||
style,
|
||||
wrap,
|
||||
resize,
|
||||
update,
|
||||
handleScroll,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
.scrollbar {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&__wrap {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
&--hidden-default {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
cursor: pointer;
|
||||
background-color: rgb(144 147 153 / 30%);
|
||||
border-radius: inherit;
|
||||
transition: 0.3s background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(144 147 153 / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
&__bar {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
opacity: 0%;
|
||||
transition: opacity 80ms ease;
|
||||
|
||||
&.is-vertical {
|
||||
top: 2px;
|
||||
width: 6px;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-horizontal {
|
||||
left: 2px;
|
||||
height: 6px;
|
||||
|
||||
& > div {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar:active > .scrollbar__bar,
|
||||
.scrollbar:focus > .scrollbar__bar,
|
||||
.scrollbar:hover > .scrollbar__bar {
|
||||
opacity: 100%;
|
||||
transition: opacity 340ms ease-out;
|
||||
}
|
||||
</style>
|
||||
110
frontend/vben/src/components/Scrollbar/src/bar.ts
Normal file
110
frontend/vben/src/components/Scrollbar/src/bar.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
defineComponent,
|
||||
h,
|
||||
computed,
|
||||
ref,
|
||||
getCurrentInstance,
|
||||
onUnmounted,
|
||||
inject,
|
||||
Ref,
|
||||
} from 'vue'
|
||||
import { on, off } from '/@/utils/domUtils'
|
||||
|
||||
import { renderThumbStyle, BAR_MAP } from './util'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Bar',
|
||||
|
||||
props: {
|
||||
vertical: Boolean,
|
||||
size: String,
|
||||
move: Number,
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const instance = getCurrentInstance()
|
||||
const thumb = ref()
|
||||
const wrap = inject('scroll-bar-wrap', {} as Ref<Nullable<HTMLElement>>) as any
|
||||
const bar = computed(() => {
|
||||
return BAR_MAP[props.vertical ? 'vertical' : 'horizontal']
|
||||
})
|
||||
const barStore = ref<Recordable>({})
|
||||
const cursorDown = ref()
|
||||
const clickThumbHandler = (e: any) => {
|
||||
// prevent click event of right button
|
||||
if (e.ctrlKey || e.button === 2) {
|
||||
return
|
||||
}
|
||||
window.getSelection()?.removeAllRanges()
|
||||
startDrag(e)
|
||||
barStore.value[bar.value.axis] =
|
||||
e.currentTarget[bar.value.offset] -
|
||||
(e[bar.value.client] - e.currentTarget.getBoundingClientRect()[bar.value.direction])
|
||||
}
|
||||
|
||||
const clickTrackHandler = (e: any) => {
|
||||
const offset = Math.abs(
|
||||
e.target.getBoundingClientRect()[bar.value.direction] - e[bar.value.client],
|
||||
)
|
||||
const thumbHalf = thumb.value[bar.value.offset] / 2
|
||||
const thumbPositionPercentage =
|
||||
((offset - thumbHalf) * 100) / instance?.vnode.el?.[bar.value.offset]
|
||||
|
||||
wrap.value[bar.value.scroll] =
|
||||
(thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100
|
||||
}
|
||||
const startDrag = (e: any) => {
|
||||
e.stopImmediatePropagation()
|
||||
cursorDown.value = true
|
||||
on(document, 'mousemove', mouseMoveDocumentHandler)
|
||||
on(document, 'mouseup', mouseUpDocumentHandler)
|
||||
document.onselectstart = () => false
|
||||
}
|
||||
|
||||
const mouseMoveDocumentHandler = (e: any) => {
|
||||
if (cursorDown.value === false) return
|
||||
const prevPage = barStore.value[bar.value.axis]
|
||||
|
||||
if (!prevPage) return
|
||||
|
||||
const offset =
|
||||
(instance?.vnode.el?.getBoundingClientRect()[bar.value.direction] - e[bar.value.client]) *
|
||||
-1
|
||||
const thumbClickPosition = thumb.value[bar.value.offset] - prevPage
|
||||
const thumbPositionPercentage =
|
||||
((offset - thumbClickPosition) * 100) / instance?.vnode.el?.[bar.value.offset]
|
||||
wrap.value[bar.value.scroll] =
|
||||
(thumbPositionPercentage * wrap.value[bar.value.scrollSize]) / 100
|
||||
}
|
||||
|
||||
function mouseUpDocumentHandler() {
|
||||
cursorDown.value = false
|
||||
barStore.value[bar.value.axis] = 0
|
||||
off(document, 'mousemove', mouseMoveDocumentHandler)
|
||||
document.onselectstart = null
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
off(document, 'mouseup', mouseUpDocumentHandler)
|
||||
})
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: ['scrollbar__bar', 'is-' + bar.value.key],
|
||||
onMousedown: clickTrackHandler,
|
||||
},
|
||||
h('div', {
|
||||
ref: thumb,
|
||||
class: 'scrollbar__thumb',
|
||||
onMousedown: clickThumbHandler,
|
||||
style: renderThumbStyle({
|
||||
size: props.size,
|
||||
move: props.move,
|
||||
bar: bar.value,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
18
frontend/vben/src/components/Scrollbar/src/types.d.ts
vendored
Normal file
18
frontend/vben/src/components/Scrollbar/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface BarMapItem {
|
||||
offset: string
|
||||
scroll: string
|
||||
scrollSize: string
|
||||
size: string
|
||||
key: string
|
||||
axis: string
|
||||
client: string
|
||||
direction: string
|
||||
}
|
||||
export interface BarMap {
|
||||
vertical: BarMapItem
|
||||
horizontal: BarMapItem
|
||||
}
|
||||
|
||||
export interface ScrollbarType {
|
||||
wrap: ElRef
|
||||
}
|
||||
50
frontend/vben/src/components/Scrollbar/src/util.ts
Normal file
50
frontend/vben/src/components/Scrollbar/src/util.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { BarMap } from './types'
|
||||
export const BAR_MAP: BarMap = {
|
||||
vertical: {
|
||||
offset: 'offsetHeight',
|
||||
scroll: 'scrollTop',
|
||||
scrollSize: 'scrollHeight',
|
||||
size: 'height',
|
||||
key: 'vertical',
|
||||
axis: 'Y',
|
||||
client: 'clientY',
|
||||
direction: 'top',
|
||||
},
|
||||
horizontal: {
|
||||
offset: 'offsetWidth',
|
||||
scroll: 'scrollLeft',
|
||||
scrollSize: 'scrollWidth',
|
||||
size: 'width',
|
||||
key: 'horizontal',
|
||||
axis: 'X',
|
||||
client: 'clientX',
|
||||
direction: 'left',
|
||||
},
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export function renderThumbStyle({ move, size, bar }) {
|
||||
const style = {} as any
|
||||
const translate = `translate${bar.axis}(${move}%)`
|
||||
|
||||
style[bar.size] = size
|
||||
style.transform = translate
|
||||
style.msTransform = translate
|
||||
style.webkitTransform = translate
|
||||
|
||||
return style
|
||||
}
|
||||
|
||||
function extend<T, K>(to: T, _from: K): T & K {
|
||||
return Object.assign(to, _from)
|
||||
}
|
||||
|
||||
export function toObject<T>(arr: Array<T>): Recordable<T> {
|
||||
const res = {}
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i]) {
|
||||
extend(res, arr[i])
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
2
frontend/vben/src/components/SimpleMenu/index.ts
Normal file
2
frontend/vben/src/components/SimpleMenu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as SimpleMenu } from './src/SimpleMenu.vue'
|
||||
export { default as SimpleMenuTag } from './src/SimpleMenuTag.vue'
|
||||
160
frontend/vben/src/components/SimpleMenu/src/SimpleMenu.vue
Normal file
160
frontend/vben/src/components/SimpleMenu/src/SimpleMenu.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<Menu
|
||||
v-bind="getBindValues"
|
||||
:activeName="activeName"
|
||||
:openNames="getOpenKeys"
|
||||
:class="prefixCls"
|
||||
:activeSubMenuNames="activeSubMenuNames"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<template v-for="item in items" :key="item.path">
|
||||
<SimpleSubMenu
|
||||
:item="item"
|
||||
:parent="true"
|
||||
:collapsedShowTitle="collapsedShowTitle"
|
||||
:collapse="collapse"
|
||||
/>
|
||||
</template>
|
||||
</Menu>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { MenuState } from './types'
|
||||
import type { Menu as MenuType } from '/@/router/types'
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||
import { defineComponent, computed, ref, unref, reactive, toRefs, watch } from 'vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import Menu from './components/Menu.vue'
|
||||
import SimpleSubMenu from './SimpleSubMenu.vue'
|
||||
import { listenerRouteChange } from '/@/logics/mitt/routeChange'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import { REDIRECT_NAME } from '/@/router/constant'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isFunction, isUrl } from '/@/utils/is'
|
||||
import { openWindow } from '/@/utils'
|
||||
|
||||
import { useOpenKeys } from './useOpenKeys'
|
||||
export default defineComponent({
|
||||
name: 'SimpleMenu',
|
||||
components: {
|
||||
Menu,
|
||||
SimpleSubMenu,
|
||||
},
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<MenuType[]>,
|
||||
default: () => [],
|
||||
},
|
||||
collapse: propTypes.bool,
|
||||
mixSider: propTypes.bool,
|
||||
theme: propTypes.string,
|
||||
accordion: propTypes.bool.def(true),
|
||||
collapsedShowTitle: propTypes.bool,
|
||||
beforeClickFn: {
|
||||
type: Function as PropType<(key: string) => Promise<boolean>>,
|
||||
},
|
||||
isSplitMenu: propTypes.bool,
|
||||
},
|
||||
emits: ['menuClick'],
|
||||
setup(props, { attrs, emit }) {
|
||||
const currentActiveMenu = ref('')
|
||||
const isClickGo = ref(false)
|
||||
|
||||
const menuState = reactive<MenuState>({
|
||||
activeName: '',
|
||||
openNames: [],
|
||||
activeSubMenuNames: [],
|
||||
})
|
||||
|
||||
const { currentRoute } = useRouter()
|
||||
const { prefixCls } = useDesign('simple-menu')
|
||||
const { items, accordion, mixSider, collapse } = toRefs(props)
|
||||
|
||||
const { setOpenKeys, getOpenKeys } = useOpenKeys(
|
||||
menuState,
|
||||
items,
|
||||
accordion,
|
||||
mixSider,
|
||||
collapse,
|
||||
)
|
||||
|
||||
const getBindValues = computed(() => ({ ...attrs, ...props }))
|
||||
|
||||
watch(
|
||||
() => props.collapse,
|
||||
(collapse) => {
|
||||
if (collapse) {
|
||||
menuState.openNames = []
|
||||
} else {
|
||||
setOpenKeys(currentRoute.value.path)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
if (!props.isSplitMenu) {
|
||||
return
|
||||
}
|
||||
setOpenKeys(currentRoute.value.path)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
listenerRouteChange((route) => {
|
||||
if (route.name === REDIRECT_NAME) return
|
||||
|
||||
currentActiveMenu.value = route.meta?.currentActiveMenu as string
|
||||
handleMenuChange(route)
|
||||
|
||||
if (unref(currentActiveMenu)) {
|
||||
menuState.activeName = unref(currentActiveMenu)
|
||||
setOpenKeys(unref(currentActiveMenu))
|
||||
}
|
||||
})
|
||||
|
||||
async function handleMenuChange(route?: RouteLocationNormalizedLoaded) {
|
||||
if (unref(isClickGo)) {
|
||||
isClickGo.value = false
|
||||
return
|
||||
}
|
||||
const path = (route || unref(currentRoute)).path
|
||||
|
||||
menuState.activeName = path
|
||||
|
||||
setOpenKeys(path)
|
||||
}
|
||||
|
||||
async function handleSelect(key: string) {
|
||||
if (isUrl(key)) {
|
||||
openWindow(key)
|
||||
return
|
||||
}
|
||||
const { beforeClickFn } = props
|
||||
if (beforeClickFn && isFunction(beforeClickFn)) {
|
||||
const flag = await beforeClickFn(key)
|
||||
if (!flag) return
|
||||
}
|
||||
|
||||
emit('menuClick', key)
|
||||
|
||||
isClickGo.value = true
|
||||
setOpenKeys(key)
|
||||
menuState.activeName = key
|
||||
}
|
||||
|
||||
return {
|
||||
prefixCls,
|
||||
getBindValues,
|
||||
handleSelect,
|
||||
getOpenKeys,
|
||||
...toRefs(menuState),
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@import './index.less';
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<span :class="getTagClass" v-if="getShowTag">{{ getContent }}</span>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { Menu } from '/@/router/types'
|
||||
|
||||
import { defineComponent, computed } from 'vue'
|
||||
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SimpleMenuTag',
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<Menu>,
|
||||
default: () => ({}),
|
||||
},
|
||||
dot: propTypes.bool,
|
||||
collapseParent: propTypes.bool,
|
||||
},
|
||||
setup(props) {
|
||||
const { prefixCls } = useDesign('simple-menu')
|
||||
|
||||
const getShowTag = computed(() => {
|
||||
const { item } = props
|
||||
|
||||
if (!item) return false
|
||||
|
||||
const { tag } = item
|
||||
if (!tag) return false
|
||||
|
||||
const { dot, content } = tag
|
||||
if (!dot && !content) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const getContent = computed(() => {
|
||||
if (!getShowTag.value) return ''
|
||||
const { item, collapseParent } = props
|
||||
const { tag } = item
|
||||
const { dot, content } = tag!
|
||||
return dot || collapseParent ? '' : content
|
||||
})
|
||||
|
||||
const getTagClass = computed(() => {
|
||||
const { item, collapseParent } = props
|
||||
const { tag = {} } = item || {}
|
||||
const { dot, type = 'error' } = tag
|
||||
const tagCls = `${prefixCls}-tag`
|
||||
return [
|
||||
tagCls,
|
||||
|
||||
[`${tagCls}--${type}`],
|
||||
{
|
||||
[`${tagCls}--collapse`]: collapseParent,
|
||||
[`${tagCls}--dot`]: dot || props.dot,
|
||||
},
|
||||
]
|
||||
})
|
||||
return {
|
||||
getTagClass,
|
||||
getShowTag,
|
||||
getContent,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
116
frontend/vben/src/components/SimpleMenu/src/SimpleSubMenu.vue
Normal file
116
frontend/vben/src/components/SimpleMenu/src/SimpleSubMenu.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<MenuItem
|
||||
:name="item.path"
|
||||
v-if="!menuHasChildren(item) && getShowMenu"
|
||||
v-bind="$props"
|
||||
:class="getLevelClass"
|
||||
>
|
||||
<Icon v-if="getIcon" :icon="getIcon" :size="16" />
|
||||
<div v-if="collapsedShowTitle && getIsCollapseParent" class="mt-1 collapse-title">
|
||||
{{ getI18nName }}
|
||||
</div>
|
||||
<template #title>
|
||||
<span :class="['ml-2', `${prefixCls}-sub-title`]">
|
||||
{{ getI18nName }}
|
||||
</span>
|
||||
<SimpleMenuTag :item="item" :collapseParent="getIsCollapseParent" />
|
||||
</template>
|
||||
</MenuItem>
|
||||
<SubMenu
|
||||
:name="item.path"
|
||||
v-if="menuHasChildren(item) && getShowMenu"
|
||||
:class="[getLevelClass, theme]"
|
||||
:collapsedShowTitle="collapsedShowTitle"
|
||||
>
|
||||
<template #title>
|
||||
<Icon v-if="getIcon" :icon="getIcon" :size="16" />
|
||||
|
||||
<div v-if="collapsedShowTitle && getIsCollapseParent" class="mt-2 collapse-title">
|
||||
{{ getI18nName }}
|
||||
</div>
|
||||
|
||||
<span v-show="getShowSubTitle" :class="['ml-2', `${prefixCls}-sub-title`]">
|
||||
{{ getI18nName }}
|
||||
</span>
|
||||
<SimpleMenuTag :item="item" :collapseParent="!!collapse && !!parent" />
|
||||
</template>
|
||||
<template
|
||||
v-for="childrenItem in item.children || []"
|
||||
:key="childrenItem.paramPath || childrenItem.path"
|
||||
>
|
||||
<SimpleSubMenu v-bind="$props" :item="childrenItem" :parent="false" />
|
||||
</template>
|
||||
</SubMenu>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { Menu } from '/@/router/types'
|
||||
|
||||
import { defineComponent, computed } from 'vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import Icon from '/@/components/Icon/index'
|
||||
|
||||
import MenuItem from './components/MenuItem.vue'
|
||||
import SubMenu from './components/SubMenuItem.vue'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import { useI18n } from '/@/hooks/web/useI18n'
|
||||
import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SimpleSubMenu',
|
||||
components: {
|
||||
SubMenu,
|
||||
MenuItem,
|
||||
SimpleMenuTag: createAsyncComponent(() => import('./SimpleMenuTag.vue')),
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<Menu>,
|
||||
default: () => ({}),
|
||||
},
|
||||
parent: propTypes.bool,
|
||||
collapsedShowTitle: propTypes.bool,
|
||||
collapse: propTypes.bool,
|
||||
theme: propTypes.oneOf(['dark', 'light']),
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n()
|
||||
const { prefixCls } = useDesign('simple-menu')
|
||||
|
||||
const getShowMenu = computed(() => !props.item?.meta?.hideMenu)
|
||||
const getIcon = computed(() => props.item?.icon)
|
||||
const getI18nName = computed(() => t(props.item?.name))
|
||||
const getShowSubTitle = computed(() => !props.collapse || !props.parent)
|
||||
const getIsCollapseParent = computed(() => !!props.collapse && !!props.parent)
|
||||
const getLevelClass = computed(() => {
|
||||
return [
|
||||
{
|
||||
[`${prefixCls}__parent`]: props.parent,
|
||||
[`${prefixCls}__children`]: !props.parent,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function menuHasChildren(menuTreeItem: Menu): boolean {
|
||||
return (
|
||||
!menuTreeItem.meta?.hideChildrenInMenu &&
|
||||
Reflect.has(menuTreeItem, 'children') &&
|
||||
!!menuTreeItem.children &&
|
||||
menuTreeItem.children.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
prefixCls,
|
||||
menuHasChildren,
|
||||
getShowMenu,
|
||||
getIcon,
|
||||
getI18nName,
|
||||
getShowSubTitle,
|
||||
getLevelClass,
|
||||
getIsCollapseParent,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
158
frontend/vben/src/components/SimpleMenu/src/components/Menu.vue
Normal file
158
frontend/vben/src/components/SimpleMenu/src/components/Menu.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<ul :class="getClass">
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { SubMenuProvider } from './types'
|
||||
import {
|
||||
defineComponent,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
watchEffect,
|
||||
watch,
|
||||
nextTick,
|
||||
getCurrentInstance,
|
||||
provide,
|
||||
} from 'vue'
|
||||
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import { createSimpleRootMenuContext } from './useSimpleMenuContext'
|
||||
import mitt from '/@/utils/mitt'
|
||||
export default defineComponent({
|
||||
name: 'Menu',
|
||||
props: {
|
||||
theme: propTypes.oneOf(['light', 'dark']).def('light'),
|
||||
activeName: propTypes.oneOfType([propTypes.string, propTypes.number]),
|
||||
openNames: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
accordion: propTypes.bool.def(true),
|
||||
width: propTypes.string.def('100%'),
|
||||
collapsedWidth: propTypes.string.def('48px'),
|
||||
indentSize: propTypes.number.def(16),
|
||||
collapse: propTypes.bool.def(true),
|
||||
activeSubMenuNames: {
|
||||
type: Array as PropType<(string | number)[]>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['select', 'open-change'],
|
||||
setup(props, { emit }) {
|
||||
const rootMenuEmitter = mitt()
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const currentActiveName = ref<string | number>('')
|
||||
const openedNames = ref<string[]>([])
|
||||
|
||||
const { prefixCls } = useDesign('menu')
|
||||
|
||||
const isRemoveAllPopup = ref(false)
|
||||
|
||||
createSimpleRootMenuContext({
|
||||
rootMenuEmitter: rootMenuEmitter,
|
||||
activeName: currentActiveName,
|
||||
})
|
||||
|
||||
const getClass = computed(() => {
|
||||
const { theme } = props
|
||||
return [
|
||||
prefixCls,
|
||||
`${prefixCls}-${theme}`,
|
||||
`${prefixCls}-vertical`,
|
||||
{
|
||||
[`${prefixCls}-collapse`]: props.collapse,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
openedNames.value = props.openNames
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.activeName) {
|
||||
currentActiveName.value = props.activeName
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.openNames,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
updateOpened()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
function updateOpened() {
|
||||
rootMenuEmitter.emit('on-update-opened', openedNames.value)
|
||||
}
|
||||
|
||||
function addSubMenu(name: string) {
|
||||
if (openedNames.value.includes(name)) return
|
||||
openedNames.value.push(name)
|
||||
updateOpened()
|
||||
}
|
||||
|
||||
function removeSubMenu(name: string) {
|
||||
openedNames.value = openedNames.value.filter((item) => item !== name)
|
||||
updateOpened()
|
||||
}
|
||||
|
||||
function removeAll() {
|
||||
openedNames.value = []
|
||||
updateOpened()
|
||||
}
|
||||
|
||||
function sliceIndex(index: number) {
|
||||
if (index === -1) return
|
||||
openedNames.value = openedNames.value.slice(0, index + 1)
|
||||
updateOpened()
|
||||
}
|
||||
|
||||
provide<SubMenuProvider>(`subMenu:${instance?.uid}`, {
|
||||
addSubMenu,
|
||||
removeSubMenu,
|
||||
getOpenNames: () => openedNames.value,
|
||||
removeAll,
|
||||
isRemoveAllPopup,
|
||||
sliceIndex,
|
||||
level: 0,
|
||||
props: props as any,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
openedNames.value = !props.collapse ? [...props.openNames] : []
|
||||
updateOpened()
|
||||
rootMenuEmitter.on('on-menu-item-select', (name: string) => {
|
||||
currentActiveName.value = name
|
||||
|
||||
nextTick(() => {
|
||||
props.collapse && removeAll()
|
||||
})
|
||||
emit('select', name)
|
||||
})
|
||||
|
||||
rootMenuEmitter.on('open-name-change', ({ name, opened }) => {
|
||||
if (opened && !openedNames.value.includes(name)) {
|
||||
openedNames.value.push(name)
|
||||
} else if (!opened) {
|
||||
const index = openedNames.value.findIndex((item) => item === name)
|
||||
index !== -1 && openedNames.value.splice(index, 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return { getClass, openedNames }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style lang="less">
|
||||
@import './menu.less';
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<transition mode="out-in" v-on="on">
|
||||
<slot></slot>
|
||||
</transition>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { addClass, removeClass } from '/@/utils/domUtils'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuCollapseTransition',
|
||||
setup() {
|
||||
return {
|
||||
on: {
|
||||
beforeEnter(el) {
|
||||
addClass(el, 'collapse-transition')
|
||||
if (!el.dataset) el.dataset = {}
|
||||
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom
|
||||
|
||||
el.style.height = '0'
|
||||
el.style.paddingTop = 0
|
||||
el.style.paddingBottom = 0
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
el.dataset.oldOverflow = el.style.overflow
|
||||
if (el.scrollHeight !== 0) {
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom
|
||||
} else {
|
||||
el.style.height = ''
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom
|
||||
}
|
||||
|
||||
el.style.overflow = 'hidden'
|
||||
},
|
||||
|
||||
afterEnter(el) {
|
||||
removeClass(el, 'collapse-transition')
|
||||
el.style.height = ''
|
||||
el.style.overflow = el.dataset.oldOverflow
|
||||
},
|
||||
|
||||
beforeLeave(el) {
|
||||
if (!el.dataset) el.dataset = {}
|
||||
el.dataset.oldPaddingTop = el.style.paddingTop
|
||||
el.dataset.oldPaddingBottom = el.style.paddingBottom
|
||||
el.dataset.oldOverflow = el.style.overflow
|
||||
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
el.style.overflow = 'hidden'
|
||||
},
|
||||
|
||||
leave(el) {
|
||||
if (el.scrollHeight !== 0) {
|
||||
addClass(el, 'collapse-transition')
|
||||
el.style.height = 0
|
||||
el.style.paddingTop = 0
|
||||
el.style.paddingBottom = 0
|
||||
}
|
||||
},
|
||||
|
||||
afterLeave(el) {
|
||||
removeClass(el, 'collapse-transition')
|
||||
el.style.height = ''
|
||||
el.style.overflow = el.dataset.oldOverflow
|
||||
el.style.paddingTop = el.dataset.oldPaddingTop
|
||||
el.style.paddingBottom = el.dataset.oldPaddingBottom
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<li :class="getClass" @click.stop="handleClickItem" :style="getCollapse ? {} : getItemStyle">
|
||||
<Tooltip placement="right" v-if="showTooltip">
|
||||
<template #title>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
<div :class="`${prefixCls}-tooltip`">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<template v-else>
|
||||
<slot></slot>
|
||||
<slot name="title"></slot>
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { PropType } from 'vue'
|
||||
import { defineComponent, ref, computed, unref, getCurrentInstance, watch } from 'vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import { useMenuItem } from './useMenu'
|
||||
import { Tooltip } from 'ant-design-vue'
|
||||
import { useSimpleRootMenuContext } from './useSimpleMenuContext'
|
||||
export default defineComponent({
|
||||
name: 'MenuItem',
|
||||
components: { Tooltip },
|
||||
props: {
|
||||
name: {
|
||||
type: [String, Number] as PropType<string | number>,
|
||||
required: true,
|
||||
},
|
||||
disabled: propTypes.bool,
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const active = ref(false)
|
||||
|
||||
const { getItemStyle, getParentList, getParentMenu, getParentRootMenu } =
|
||||
useMenuItem(instance)
|
||||
|
||||
const { prefixCls } = useDesign('menu')
|
||||
|
||||
const { rootMenuEmitter, activeName } = useSimpleRootMenuContext()
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
`${prefixCls}-item`,
|
||||
{
|
||||
[`${prefixCls}-item-active`]: unref(active),
|
||||
[`${prefixCls}-item-selected`]: unref(active),
|
||||
[`${prefixCls}-item-disabled`]: !!props.disabled,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const getCollapse = computed(() => unref(getParentRootMenu)?.props.collapse)
|
||||
|
||||
const showTooltip = computed(() => {
|
||||
return unref(getParentMenu)?.type.name === 'Menu' && unref(getCollapse) && slots.title
|
||||
})
|
||||
|
||||
function handleClickItem() {
|
||||
const { disabled } = props
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
rootMenuEmitter.emit('on-menu-item-select', props.name)
|
||||
if (unref(getCollapse)) {
|
||||
return
|
||||
}
|
||||
const { uidList } = getParentList()
|
||||
|
||||
rootMenuEmitter.emit('on-update-opened', {
|
||||
opend: false,
|
||||
parent: instance?.parent,
|
||||
uidList: uidList,
|
||||
})
|
||||
}
|
||||
watch(
|
||||
() => activeName.value,
|
||||
(name: string) => {
|
||||
if (name === props.name) {
|
||||
const { list, uidList } = getParentList()
|
||||
active.value = true
|
||||
list.forEach((item) => {
|
||||
if (item.proxy) {
|
||||
;(item.proxy as any).active = true
|
||||
}
|
||||
})
|
||||
|
||||
rootMenuEmitter.emit('on-update-active-name:submenu', uidList)
|
||||
} else {
|
||||
active.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return { getClass, prefixCls, getItemStyle, getCollapse, handleClickItem, showTooltip }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,333 @@
|
||||
<template>
|
||||
<li :class="getClass">
|
||||
<template v-if="!getCollapse">
|
||||
<div :class="`${prefixCls}-submenu-title`" @click.stop="handleClick" :style="getItemStyle">
|
||||
<slot name="title"></slot>
|
||||
<Icon
|
||||
icon="eva:arrow-ios-downward-outline"
|
||||
:size="14"
|
||||
:class="`${prefixCls}-submenu-title-icon`"
|
||||
/>
|
||||
</div>
|
||||
<CollapseTransition>
|
||||
<ul :class="prefixCls" v-show="opened">
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</CollapseTransition>
|
||||
</template>
|
||||
|
||||
<Popover
|
||||
placement="right"
|
||||
:overlayClassName="`${prefixCls}-menu-popover`"
|
||||
v-else
|
||||
:visible="getIsOpend"
|
||||
@visible-change="handleVisibleChange"
|
||||
:overlayStyle="getOverlayStyle"
|
||||
:align="{ offset: [0, 0] }"
|
||||
>
|
||||
<div :class="getSubClass" v-bind="getEvents(false)">
|
||||
<div
|
||||
:class="[
|
||||
{
|
||||
[`${prefixCls}-submenu-popup`]: !getParentSubMenu,
|
||||
[`${prefixCls}-submenu-collapsed-show-tit`]: collapsedShowTitle,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<slot name="title"></slot>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="getParentSubMenu"
|
||||
icon="eva:arrow-ios-downward-outline"
|
||||
:size="14"
|
||||
:class="`${prefixCls}-submenu-title-icon`"
|
||||
/>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line -->
|
||||
<template #content v-show="opened">
|
||||
<div v-bind="getEvents(true)">
|
||||
<ul :class="[prefixCls, `${prefixCls}-${getTheme}`, `${prefixCls}-popup`]">
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { CSSProperties, PropType } from 'vue'
|
||||
import type { SubMenuProvider } from './types'
|
||||
import {
|
||||
defineComponent,
|
||||
computed,
|
||||
unref,
|
||||
getCurrentInstance,
|
||||
toRefs,
|
||||
reactive,
|
||||
provide,
|
||||
onBeforeMount,
|
||||
inject,
|
||||
} from 'vue'
|
||||
import { useDesign } from '/@/hooks/web/useDesign'
|
||||
import { propTypes } from '/@/utils/propTypes'
|
||||
import { useMenuItem } from './useMenu'
|
||||
import { useSimpleRootMenuContext } from './useSimpleMenuContext'
|
||||
import { CollapseTransition } from '/@/components/Transition'
|
||||
import Icon from '/@/components/Icon'
|
||||
import { Popover } from 'ant-design-vue'
|
||||
import { isBoolean, isObject } from '/@/utils/is'
|
||||
import mitt from '/@/utils/mitt'
|
||||
|
||||
const DELAY = 200
|
||||
export default defineComponent({
|
||||
name: 'SubMenu',
|
||||
components: {
|
||||
Icon,
|
||||
CollapseTransition,
|
||||
Popover,
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: [String, Number] as PropType<string | number>,
|
||||
required: true,
|
||||
},
|
||||
disabled: propTypes.bool,
|
||||
collapsedShowTitle: propTypes.bool,
|
||||
},
|
||||
setup(props) {
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const state = reactive({
|
||||
active: false,
|
||||
opened: false,
|
||||
})
|
||||
|
||||
const data = reactive({
|
||||
timeout: null as TimeoutHandle | null,
|
||||
mouseInChild: false,
|
||||
isChild: false,
|
||||
})
|
||||
|
||||
const { getParentSubMenu, getItemStyle, getParentMenu, getParentList } = useMenuItem(instance)
|
||||
|
||||
const { prefixCls } = useDesign('menu')
|
||||
|
||||
const subMenuEmitter = mitt()
|
||||
|
||||
const { rootMenuEmitter } = useSimpleRootMenuContext()
|
||||
|
||||
const {
|
||||
addSubMenu: parentAddSubmenu,
|
||||
removeSubMenu: parentRemoveSubmenu,
|
||||
removeAll: parentRemoveAll,
|
||||
getOpenNames: parentGetOpenNames,
|
||||
isRemoveAllPopup,
|
||||
sliceIndex,
|
||||
level,
|
||||
props: rootProps,
|
||||
handleMouseleave: parentHandleMouseleave,
|
||||
} = inject<SubMenuProvider>(`subMenu:${getParentMenu.value?.uid}`)!
|
||||
|
||||
const getClass = computed(() => {
|
||||
return [
|
||||
`${prefixCls}-submenu`,
|
||||
{
|
||||
[`${prefixCls}-item-active`]: state.active,
|
||||
[`${prefixCls}-opened`]: state.opened,
|
||||
[`${prefixCls}-submenu-disabled`]: props.disabled,
|
||||
[`${prefixCls}-submenu-has-parent-submenu`]: unref(getParentSubMenu),
|
||||
[`${prefixCls}-child-item-active`]: state.active,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const getAccordion = computed(() => rootProps.accordion)
|
||||
const getCollapse = computed(() => rootProps.collapse)
|
||||
const getTheme = computed(() => rootProps.theme)
|
||||
|
||||
const getOverlayStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
minWidth: '200px',
|
||||
}
|
||||
})
|
||||
|
||||
const getIsOpend = computed(() => {
|
||||
const name = props.name
|
||||
if (unref(getCollapse)) {
|
||||
return parentGetOpenNames().includes(name)
|
||||
}
|
||||
return state.opened
|
||||
})
|
||||
|
||||
const getSubClass = computed(() => {
|
||||
const isActive = rootProps.activeSubMenuNames.includes(props.name)
|
||||
return [
|
||||
`${prefixCls}-submenu-title`,
|
||||
{
|
||||
[`${prefixCls}-submenu-active`]: isActive,
|
||||
[`${prefixCls}-submenu-active-border`]: isActive && level === 0,
|
||||
[`${prefixCls}-submenu-collapse`]: unref(getCollapse) && level === 0,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function getEvents(deep: boolean) {
|
||||
if (!unref(getCollapse)) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
onMouseenter: handleMouseenter,
|
||||
onMouseleave: () => handleMouseleave(deep),
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
const { disabled } = props
|
||||
if (disabled || unref(getCollapse)) return
|
||||
const opened = state.opened
|
||||
|
||||
if (unref(getAccordion)) {
|
||||
const { uidList } = getParentList()
|
||||
rootMenuEmitter.emit('on-update-opened', {
|
||||
opend: false,
|
||||
parent: instance?.parent,
|
||||
uidList: uidList,
|
||||
})
|
||||
} else {
|
||||
rootMenuEmitter.emit('open-name-change', {
|
||||
name: props.name,
|
||||
opened: !opened,
|
||||
})
|
||||
}
|
||||
state.opened = !opened
|
||||
}
|
||||
|
||||
function handleMouseenter() {
|
||||
const disabled = props.disabled
|
||||
if (disabled) return
|
||||
|
||||
subMenuEmitter.emit('submenu:mouse-enter-child')
|
||||
|
||||
const index = parentGetOpenNames().findIndex((item) => item === props.name)
|
||||
|
||||
sliceIndex(index)
|
||||
|
||||
const isRoot = level === 0 && parentGetOpenNames().length === 2
|
||||
if (isRoot) {
|
||||
parentRemoveAll()
|
||||
}
|
||||
data.isChild = parentGetOpenNames().includes(props.name)
|
||||
clearTimeout(data.timeout!)
|
||||
data.timeout = setTimeout(() => {
|
||||
parentAddSubmenu(props.name)
|
||||
}, DELAY)
|
||||
}
|
||||
|
||||
function handleMouseleave(deepDispatch = false) {
|
||||
const parentName = getParentMenu.value?.props.name
|
||||
if (!parentName) {
|
||||
isRemoveAllPopup.value = true
|
||||
}
|
||||
|
||||
if (parentGetOpenNames().slice(-1)[0] === props.name) {
|
||||
data.isChild = false
|
||||
}
|
||||
|
||||
subMenuEmitter.emit('submenu:mouse-leave-child')
|
||||
if (data.timeout) {
|
||||
clearTimeout(data.timeout!)
|
||||
data.timeout = setTimeout(() => {
|
||||
if (isRemoveAllPopup.value) {
|
||||
parentRemoveAll()
|
||||
} else if (!data.mouseInChild) {
|
||||
parentRemoveSubmenu(props.name)
|
||||
}
|
||||
}, DELAY)
|
||||
}
|
||||
if (deepDispatch) {
|
||||
if (getParentSubMenu.value) {
|
||||
parentHandleMouseleave?.(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
subMenuEmitter.on('submenu:mouse-enter-child', () => {
|
||||
data.mouseInChild = true
|
||||
isRemoveAllPopup.value = false
|
||||
clearTimeout(data.timeout!)
|
||||
})
|
||||
subMenuEmitter.on('submenu:mouse-leave-child', () => {
|
||||
if (data.isChild) return
|
||||
data.mouseInChild = false
|
||||
clearTimeout(data.timeout!)
|
||||
})
|
||||
|
||||
rootMenuEmitter.on(
|
||||
'on-update-opened',
|
||||
(data: boolean | (string | number)[] | Recordable) => {
|
||||
if (unref(getCollapse)) return
|
||||
if (isBoolean(data)) {
|
||||
state.opened = data
|
||||
return
|
||||
}
|
||||
if (isObject(data) && rootProps.accordion) {
|
||||
const { opend, parent, uidList } = data as Recordable
|
||||
if (parent === instance?.parent) {
|
||||
state.opened = opend
|
||||
} else if (!uidList.includes(instance?.uid)) {
|
||||
state.opened = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (props.name && Array.isArray(data)) {
|
||||
state.opened = (data as (string | number)[]).includes(props.name)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
rootMenuEmitter.on('on-update-active-name:submenu', (data: number[]) => {
|
||||
if (instance?.uid) {
|
||||
state.active = data.includes(instance?.uid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function handleVisibleChange(visible: boolean) {
|
||||
state.opened = visible
|
||||
}
|
||||
|
||||
// provide
|
||||
provide<SubMenuProvider>(`subMenu:${instance?.uid}`, {
|
||||
addSubMenu: parentAddSubmenu,
|
||||
removeSubMenu: parentRemoveSubmenu,
|
||||
getOpenNames: parentGetOpenNames,
|
||||
removeAll: parentRemoveAll,
|
||||
isRemoveAllPopup,
|
||||
sliceIndex,
|
||||
level: level + 1,
|
||||
handleMouseleave,
|
||||
props: rootProps,
|
||||
})
|
||||
|
||||
return {
|
||||
getClass,
|
||||
prefixCls,
|
||||
getCollapse,
|
||||
getItemStyle,
|
||||
handleClick,
|
||||
handleVisibleChange,
|
||||
getParentSubMenu,
|
||||
getOverlayStyle,
|
||||
getTheme,
|
||||
getIsOpend,
|
||||
getEvents,
|
||||
getSubClass,
|
||||
...toRefs(state),
|
||||
...toRefs(data),
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
309
frontend/vben/src/components/SimpleMenu/src/components/menu.less
Normal file
309
frontend/vben/src/components/SimpleMenu/src/components/menu.less
Normal file
@@ -0,0 +1,309 @@
|
||||
@menu-prefix-cls: ~'@{namespace}-menu';
|
||||
@menu-popup-prefix-cls: ~'@{namespace}-menu-popup';
|
||||
@submenu-popup-prefix-cls: ~'@{namespace}-menu-submenu-popup';
|
||||
|
||||
@transition-time: 0.2s;
|
||||
@menu-dark-subsidiary-color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
.light-border {
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
width: 2px;
|
||||
content: '';
|
||||
background-color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.@{menu-prefix-cls}-menu-popover {
|
||||
.ant-popover-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.@{menu-prefix-cls} {
|
||||
&-opened > * > &-submenu-title-icon {
|
||||
transform: translateY(-50%) rotate(90deg) !important;
|
||||
}
|
||||
|
||||
&-item,
|
||||
&-submenu-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 12px 20px;
|
||||
color: @menu-dark-subsidiary-color;
|
||||
cursor: pointer;
|
||||
transition: all @transition-time @ease-in-out;
|
||||
|
||||
&-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 18px;
|
||||
transition: transform @transition-time @ease-in-out;
|
||||
transform: translateY(-50%) rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&-dark {
|
||||
.@{menu-prefix-cls}-item,
|
||||
.@{menu-prefix-cls}-submenu-title {
|
||||
color: @menu-dark-subsidiary-color;
|
||||
// background: @menu-dark-active-bg;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
color: #fff;
|
||||
background-color: @primary-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-light {
|
||||
.@{menu-prefix-cls}-item,
|
||||
.@{menu-prefix-cls}-submenu-title {
|
||||
color: @text-color-base;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
z-index: 2;
|
||||
color: @primary-color;
|
||||
background-color: fade(@primary-color, 10);
|
||||
|
||||
.light-border();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content();
|
||||
.content() {
|
||||
.@{menu-prefix-cls} {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: @font-size-base;
|
||||
color: @text-color-base;
|
||||
list-style: none;
|
||||
outline: none;
|
||||
|
||||
// .collapse-transition {
|
||||
// transition: @transition-time height ease-in-out, @transition-time padding-top ease-in-out,
|
||||
// @transition-time padding-bottom ease-in-out;
|
||||
// }
|
||||
|
||||
&-light {
|
||||
background-color: #fff;
|
||||
|
||||
.@{menu-prefix-cls}-submenu-active {
|
||||
color: @primary-color !important;
|
||||
|
||||
&-border {
|
||||
.light-border();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-dark {
|
||||
.@{menu-prefix-cls}-submenu-active {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: @font-size-base;
|
||||
color: inherit;
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
&-item > i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&-submenu-title > i,
|
||||
&-submenu-title span > i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
// vertical
|
||||
&-vertical &-item,
|
||||
&-vertical &-submenu-title {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 14px 24px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: @primary-color;
|
||||
}
|
||||
|
||||
.@{menu-prefix-cls}-tooltip {
|
||||
width: calc(100% - 0px);
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.@{menu-prefix-cls}-submenu-popup {
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&-vertical &-submenu-collapse {
|
||||
.@{submenu-popup-prefix-cls} {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.@{menu-prefix-cls}-submenu-collapsed-show-tit {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
&-vertical&-collapse &-item,
|
||||
&-vertical&-collapse &-submenu-title {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-vertical &-submenu-title-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 18px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&-submenu-title-icon {
|
||||
transition: transform @transition-time @ease-in-out;
|
||||
}
|
||||
|
||||
&-vertical &-opened > * > &-submenu-title-icon {
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
&-vertical &-submenu {
|
||||
&-nested {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.@{menu-prefix-cls}-item {
|
||||
padding-left: 43px;
|
||||
}
|
||||
}
|
||||
|
||||
&-light&-vertical &-item {
|
||||
&-active:not(.@{menu-prefix-cls}-submenu) {
|
||||
z-index: 2;
|
||||
color: @primary-color;
|
||||
background-color: fade(@primary-color, 10);
|
||||
|
||||
.light-border();
|
||||
}
|
||||
&-active.@{menu-prefix-cls}-submenu {
|
||||
color: @primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
&-light&-vertical&-collapse {
|
||||
> li.@{menu-prefix-cls}-item-active,
|
||||
.@{menu-prefix-cls}-submenu-active {
|
||||
position: relative;
|
||||
background-color: fade(@primary-color, 5);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: @primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-dark&-vertical &-item,
|
||||
&-dark&-vertical &-submenu-title {
|
||||
color: @menu-dark-subsidiary-color;
|
||||
&-active:not(.@{menu-prefix-cls}-submenu) {
|
||||
color: #fff !important;
|
||||
background-color: @primary-color !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&-dark&-vertical&-collapse {
|
||||
> li.@{menu-prefix-cls}-item-active,
|
||||
.@{menu-prefix-cls}-submenu-active {
|
||||
position: relative;
|
||||
color: #fff !important;
|
||||
background-color: @sider-dark-darken-bg-color !important;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: @primary-color;
|
||||
}
|
||||
|
||||
.@{menu-prefix-cls}-submenu-collapse {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-dark&-vertical &-submenu &-item {
|
||||
&-active,
|
||||
&-active:hover {
|
||||
color: #fff;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-dark&-vertical &-child-item-active > &-submenu-title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&-dark&-vertical &-opened {
|
||||
.@{menu-prefix-cls}-submenu-has-parent-submenu {
|
||||
.@{menu-prefix-cls}-submenu-title {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Ref } from 'vue';
|
||||
|
||||
export interface Props {
|
||||
theme: string;
|
||||
activeName?: string | number | undefined;
|
||||
openNames: string[];
|
||||
accordion: boolean;
|
||||
width: string;
|
||||
collapsedWidth: string;
|
||||
indentSize: number;
|
||||
collapse: boolean;
|
||||
activeSubMenuNames: (string | number)[];
|
||||
}
|
||||
|
||||
export interface SubMenuProvider {
|
||||
addSubMenu: (name: string | number, update?: boolean) => void;
|
||||
removeSubMenu: (name: string | number, update?: boolean) => void;
|
||||
removeAll: () => void;
|
||||
sliceIndex: (index: number) => void;
|
||||
isRemoveAllPopup: Ref<boolean>;
|
||||
getOpenNames: () => (string | number)[];
|
||||
handleMouseleave?: Fn;
|
||||
level: number;
|
||||
props: Props;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { computed, ComponentInternalInstance, unref } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
export function useMenuItem(instance: ComponentInternalInstance | null) {
|
||||
const getParentMenu = computed(() => {
|
||||
return findParentMenu(['Menu', 'SubMenu'])
|
||||
})
|
||||
|
||||
const getParentRootMenu = computed(() => {
|
||||
return findParentMenu(['Menu'])
|
||||
})
|
||||
|
||||
const getParentSubMenu = computed(() => {
|
||||
return findParentMenu(['SubMenu'])
|
||||
})
|
||||
|
||||
const getItemStyle = computed((): CSSProperties => {
|
||||
let parent = instance?.parent
|
||||
if (!parent) return {}
|
||||
const indentSize = (unref(getParentRootMenu)?.props.indentSize as number) ?? 20
|
||||
let padding = indentSize
|
||||
|
||||
if (unref(getParentRootMenu)?.props.collapse) {
|
||||
padding = indentSize
|
||||
} else {
|
||||
while (parent && parent.type.name !== 'Menu') {
|
||||
if (parent.type.name === 'SubMenu') {
|
||||
padding += indentSize
|
||||
}
|
||||
parent = parent.parent
|
||||
}
|
||||
}
|
||||
return { paddingLeft: padding + 'px' }
|
||||
})
|
||||
|
||||
function findParentMenu(name: string[]) {
|
||||
let parent = instance?.parent
|
||||
if (!parent) return null
|
||||
while (parent && name.indexOf(parent.type.name!) === -1) {
|
||||
parent = parent.parent
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
function getParentList() {
|
||||
let parent = instance
|
||||
if (!parent)
|
||||
return {
|
||||
uidList: [],
|
||||
list: [],
|
||||
}
|
||||
const ret: any[] = []
|
||||
while (parent && parent.type.name !== 'Menu') {
|
||||
if (parent.type.name === 'SubMenu') {
|
||||
ret.push(parent)
|
||||
}
|
||||
parent = parent.parent
|
||||
}
|
||||
return {
|
||||
uidList: ret.map((item) => item.uid),
|
||||
list: ret,
|
||||
}
|
||||
}
|
||||
|
||||
function getParentInstance(instance: ComponentInternalInstance, name = 'SubMenu') {
|
||||
let parent = instance.parent
|
||||
while (parent) {
|
||||
if (parent.type.name !== name) {
|
||||
return parent
|
||||
}
|
||||
parent = parent.parent
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
return {
|
||||
getParentMenu,
|
||||
getParentInstance,
|
||||
getParentRootMenu,
|
||||
getParentList,
|
||||
getParentSubMenu,
|
||||
getItemStyle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import type { Emitter } from '/@/utils/mitt'
|
||||
import { createContext, useContext } from '/@/hooks/core/useContext'
|
||||
|
||||
export interface SimpleRootMenuContextProps {
|
||||
rootMenuEmitter: Emitter
|
||||
activeName: Ref<string | number>
|
||||
}
|
||||
|
||||
const key: InjectionKey<SimpleRootMenuContextProps> = Symbol()
|
||||
|
||||
export function createSimpleRootMenuContext(context: SimpleRootMenuContextProps) {
|
||||
return createContext<SimpleRootMenuContextProps>(context, key, { readonly: false, native: true })
|
||||
}
|
||||
|
||||
export function useSimpleRootMenuContext() {
|
||||
return useContext<SimpleRootMenuContextProps>(key)
|
||||
}
|
||||
77
frontend/vben/src/components/SimpleMenu/src/index.less
Normal file
77
frontend/vben/src/components/SimpleMenu/src/index.less
Normal file
@@ -0,0 +1,77 @@
|
||||
@simple-prefix-cls: ~'@{namespace}-simple-menu';
|
||||
@prefix-cls: ~'@{namespace}-menu';
|
||||
|
||||
.@{prefix-cls} {
|
||||
&-dark&-vertical .@{simple-prefix-cls}__parent {
|
||||
background-color: @sider-dark-bg-color;
|
||||
> .@{prefix-cls}-submenu-title {
|
||||
background-color: @sider-dark-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
&-dark&-vertical .@{simple-prefix-cls}__children,
|
||||
&-dark&-popup .@{simple-prefix-cls}__children {
|
||||
background-color: @sider-dark-lighten-bg-color;
|
||||
> .@{prefix-cls}-submenu-title {
|
||||
background-color: @sider-dark-lighten-bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-title {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.@{simple-prefix-cls} {
|
||||
&-sub-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
&-tag {
|
||||
position: absolute;
|
||||
top: calc(50% - 8px);
|
||||
right: 30px;
|
||||
display: inline-block;
|
||||
padding: 2px 3px;
|
||||
margin-right: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
|
||||
&--collapse {
|
||||
top: 6px !important;
|
||||
right: 2px;
|
||||
}
|
||||
|
||||
&--dot {
|
||||
top: calc(50% - 2px);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background-color: @primary-color;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: @error-color;
|
||||
}
|
||||
|
||||
&--success {
|
||||
background-color: @success-color;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background-color: @warning-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
frontend/vben/src/components/SimpleMenu/src/types.ts
Normal file
5
frontend/vben/src/components/SimpleMenu/src/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface MenuState {
|
||||
activeName: string
|
||||
openNames: string[]
|
||||
activeSubMenuNames: string[]
|
||||
}
|
||||
50
frontend/vben/src/components/SimpleMenu/src/useOpenKeys.ts
Normal file
50
frontend/vben/src/components/SimpleMenu/src/useOpenKeys.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Menu as MenuType } from '/@/router/types'
|
||||
import type { MenuState } from './types'
|
||||
|
||||
import { computed, Ref, toRaw } from 'vue'
|
||||
|
||||
import { unref } from 'vue'
|
||||
import { uniq } from 'lodash-es'
|
||||
import { getAllParentPath } from '/@/router/helper/menuHelper'
|
||||
|
||||
import { useTimeoutFn } from '/@/hooks/core/useTimeout'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
export function useOpenKeys(
|
||||
menuState: MenuState,
|
||||
menus: Ref<MenuType[]>,
|
||||
accordion: Ref<boolean>,
|
||||
mixSider: Ref<boolean>,
|
||||
collapse: Ref<boolean>,
|
||||
) {
|
||||
const debounceSetOpenKeys = useDebounceFn(setOpenKeys, 50)
|
||||
async function setOpenKeys(path: string) {
|
||||
const native = !mixSider.value
|
||||
const menuList = toRaw(menus.value)
|
||||
useTimeoutFn(
|
||||
() => {
|
||||
if (menuList?.length === 0) {
|
||||
menuState.activeSubMenuNames = []
|
||||
menuState.openNames = []
|
||||
return
|
||||
}
|
||||
const keys = getAllParentPath(menuList, path)
|
||||
|
||||
if (!unref(accordion)) {
|
||||
menuState.openNames = uniq([...menuState.openNames, ...keys])
|
||||
} else {
|
||||
menuState.openNames = keys
|
||||
}
|
||||
menuState.activeSubMenuNames = menuState.openNames
|
||||
},
|
||||
30,
|
||||
native,
|
||||
)
|
||||
}
|
||||
|
||||
const getOpenKeys = computed(() => {
|
||||
return unref(collapse) ? [] : menuState.openNames
|
||||
})
|
||||
|
||||
return { setOpenKeys: debounceSetOpenKeys, getOpenKeys }
|
||||
}
|
||||
4
frontend/vben/src/components/StrengthMeter/index.ts
Normal file
4
frontend/vben/src/components/StrengthMeter/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { withInstall } from '/@/utils'
|
||||
import strengthMeter from './src/StrengthMeter.vue'
|
||||
|
||||
export const StrengthMeter = withInstall(strengthMeter)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user