1
0
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:
源文雨
2023-03-10 17:18:32 +08:00
parent 30cd57ef76
commit 2a0fdeae31
469 changed files with 42028 additions and 0 deletions

View 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)

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }
}

View File

@@ -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)
}

View 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)

View 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>

View 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>

View 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>

View 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>>

View 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>

View 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>

View 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 },
}

View 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'

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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
}>

View 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)

View 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>

View File

@@ -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>

View 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 }
}

View 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)

View 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>

View 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]

View File

@@ -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]
}

View 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'

View 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>

View File

@@ -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>

View File

@@ -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>

View 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,
}

View 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
}

View 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)
},
},
]
}

View File

@@ -0,0 +1,5 @@
import { withInstall } from '/@/utils'
import dropdown from './src/Dropdown.vue'
export * from './src/typing'
export const Dropdown = withInstall(dropdown)

View 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>

View File

@@ -0,0 +1,9 @@
export interface DropMenu {
onClick?: Fn
to?: string
icon?: string
event: string | number
text: string
disabled?: boolean
divider?: boolean
}

View 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',
],
}

View 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

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,5 @@
import Loading from './src/Loading.vue'
export { Loading }
export { useLoading } from './src/useLoading'
export { createLoading } from './src/createLoading'

View 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>

View 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
},
}
}

View 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'
}

View 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]
}

View File

@@ -0,0 +1,3 @@
import BasicMenu from './src/BasicMenu.vue'
export { BasicMenu }

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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;
}
}

View 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),
}

View 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[]
}

View 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 }
}

View 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'

View 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>

View 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>
}
},
})

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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()
},
},
]
}

View File

@@ -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)
}

View 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)
})
}

View File

@@ -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 }
}

View 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;
}
}

View 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 },
})

View 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
}

View 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'

View 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>

View 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>

View File

@@ -0,0 +1,8 @@
/**
* copy from element-ui
*/
import Scrollbar from './src/Scrollbar.vue'
export { Scrollbar }
export type { ScrollbarType } from './src/types'

View 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>

View 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,
}),
}),
)
},
})

View 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
}

View 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
}

View File

@@ -0,0 +1,2 @@
export { default as SimpleMenu } from './src/SimpleMenu.vue'
export { default as SimpleMenuTag } from './src/SimpleMenuTag.vue'

View 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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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;
}
}
}
}
}

View File

@@ -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;
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View 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;
}
}
}

View File

@@ -0,0 +1,5 @@
export interface MenuState {
activeName: string
openNames: string[]
activeSubMenuNames: string[]
}

View 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 }
}

View 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