初始版本提交
This commit is contained in:
16
src/components/Application/index.ts
Normal file
16
src/components/Application/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
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';
|
||||
import userPostChange from './src/UserPostChange.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);
|
||||
export const UserPostChange = withInstall(userPostChange);
|
||||
76
src/components/Application/src/AppDarkModeToggle.vue
Normal file
76
src/components/Application/src/AppDarkModeToggle.vue
Normal 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>
|
||||
89
src/components/Application/src/AppLocalePicker.vue
Normal file
89
src/components/Application/src/AppLocalePicker.vue
Normal file
@ -0,0 +1,89 @@
|
||||
<!--
|
||||
* @Author: Vben
|
||||
* @Description: Multi-language switching component
|
||||
-->
|
||||
<template>
|
||||
<Dropdown
|
||||
placement="bottom"
|
||||
:trigger="['click']"
|
||||
:dropMenuList="languageTypeList"
|
||||
:selectedKeys="selectedKeys"
|
||||
@menu-event="handleMenuEvent"
|
||||
overlayClassName="app-locale-picker-overlay"
|
||||
>
|
||||
<span class="cursor-pointer flex items-center">
|
||||
<Icon icon="ri:translate" size="24" />
|
||||
<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, onMounted } from 'vue';
|
||||
import { Dropdown } from '/@/components/Dropdown';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { useLocale } from '/@/locales/useLocale';
|
||||
// import { localeList } from '/@/settings/localeSetting';
|
||||
import { getLanguageTypeList } from '/@/api/system/language';
|
||||
|
||||
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 languageTypeList = ref<DropMenu[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
const typeList = await getLanguageTypeList();
|
||||
for (const item of typeList) {
|
||||
languageTypeList.value.push({
|
||||
text: item.name,
|
||||
event: item.code,
|
||||
} as DropMenu);
|
||||
}
|
||||
});
|
||||
|
||||
const { changeLocale, getLocale } = useLocale();
|
||||
|
||||
const getLocaleText = computed(() => {
|
||||
const key = selectedKeys.value[0];
|
||||
if (!key) {
|
||||
return '';
|
||||
}
|
||||
return languageTypeList.value.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>
|
||||
102
src/components/Application/src/AppLogo.vue
Normal file
102
src/components/Application/src/AppLogo.vue
Normal file
@ -0,0 +1,102 @@
|
||||
<!--
|
||||
* @Author: Vben
|
||||
* @Description: logo component
|
||||
-->
|
||||
<template>
|
||||
<div class="anticon" :class="getAppLogoClass" @click="goHome">
|
||||
<a href="https://www.learun.cn/" target="_blank">
|
||||
<img :src="logoConfig.menuLogoUrl || logo" width="24" style="height: 30px" />
|
||||
<div class="truncate md:opacity-100" :class="getTitleClass" v-show="showTitle">
|
||||
・{{ logoConfig.shortName || title }}
|
||||
</div>
|
||||
</a>
|
||||
</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';
|
||||
import { useAppStore } from '/@/store/modules/app';
|
||||
import logo from '/@/assets/images/logo.svg';
|
||||
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 appStore = useAppStore();
|
||||
const logoConfig = appStore.getLogoConfig;
|
||||
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: #0096df;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
transition: all 0.5s;
|
||||
line-height: normal;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
82
src/components/Application/src/AppProvider.vue
Normal file
82
src/components/Application/src/AppProvider.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, ref, unref } from 'vue';
|
||||
import { createAppProviderContext } from './useAppContext';
|
||||
import { createBreakpointListen } from '/@/hooks/event/useBreakpoint';
|
||||
import { prefixCls } from '/@/settings/designSetting';
|
||||
import { useAppStore } from '/@/store/modules/app';
|
||||
import { MenuModeEnum, MenuTypeEnum } from '/@/enums/menuEnum';
|
||||
|
||||
const props = {
|
||||
/**
|
||||
* class style prefix
|
||||
*/
|
||||
prefixCls: { type: String, default: prefixCls },
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AppProvider',
|
||||
inheritAttrs: false,
|
||||
props,
|
||||
setup(props, { slots }) {
|
||||
const isMobile = ref(false);
|
||||
const isSetState = ref(false);
|
||||
|
||||
const appStore = useAppStore();
|
||||
|
||||
// Monitor screen breakpoint information changes
|
||||
createBreakpointListen(({ screenMap, sizeEnum, width }) => {
|
||||
const lgWidth = screenMap.get(sizeEnum.LG);
|
||||
if (lgWidth) {
|
||||
isMobile.value = width.value - 1 < lgWidth;
|
||||
}
|
||||
handleRestoreState();
|
||||
});
|
||||
|
||||
const { prefixCls } = toRefs(props);
|
||||
|
||||
// Inject variables into the global
|
||||
createAppProviderContext({ prefixCls, isMobile });
|
||||
|
||||
/**
|
||||
* Used to maintain the state before the window changes
|
||||
*/
|
||||
function handleRestoreState() {
|
||||
if (unref(isMobile)) {
|
||||
if (!unref(isSetState)) {
|
||||
isSetState.value = true;
|
||||
const {
|
||||
menuSetting: {
|
||||
type: menuType,
|
||||
mode: menuMode,
|
||||
collapsed: menuCollapsed,
|
||||
split: menuSplit,
|
||||
},
|
||||
} = appStore.getProjectConfig;
|
||||
appStore.setProjectConfig({
|
||||
menuSetting: {
|
||||
type: MenuTypeEnum.SIDEBAR,
|
||||
mode: MenuModeEnum.INLINE,
|
||||
split: false,
|
||||
},
|
||||
});
|
||||
appStore.setBeforeMiniInfo({ menuMode, menuCollapsed, menuType, menuSplit });
|
||||
}
|
||||
} else {
|
||||
if (unref(isSetState)) {
|
||||
isSetState.value = false;
|
||||
const { menuMode, menuCollapsed, menuType, menuSplit } = appStore.getBeforeMiniInfo;
|
||||
appStore.setProjectConfig({
|
||||
menuSetting: {
|
||||
type: menuType,
|
||||
mode: menuMode,
|
||||
collapsed: menuCollapsed,
|
||||
split: menuSplit,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => slots.default?.();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
86
src/components/Application/src/UserPostChange.vue
Normal file
86
src/components/Application/src/UserPostChange.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<!--
|
||||
* @Author: Vben
|
||||
* @Description: post switching component
|
||||
-->
|
||||
<template>
|
||||
<Tooltip :title="t('岗位切换')" placement="bottom" :mouseEnterDelay="0.5">
|
||||
<Dropdown
|
||||
placement="bottom"
|
||||
:trigger="['click']"
|
||||
:dropMenuList="formInfo.posts"
|
||||
:selectedKeys="selectedKeys"
|
||||
@menu-event="handleMenuEvent"
|
||||
overlayClassName="app-locale-picker-overlay"
|
||||
>
|
||||
<span class="cursor-pointer flex items-center p-2">
|
||||
<Icon icon="ant-design:user-switch-outlined" size="24" />
|
||||
</span>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { DropMenu } from '/@/components/Dropdown';
|
||||
import { ref, watchEffect, watch } from 'vue';
|
||||
import { Dropdown } from '/@/components/Dropdown';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import { useUserStore } from '/@/store/modules/user';
|
||||
import { changePost } from '/@/api/system/post';
|
||||
import { useI18n } from '/@/hooks/web/useI18n';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { t } = useI18n();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const formInfo: any = ref({
|
||||
posts: [],
|
||||
postId: '',
|
||||
});
|
||||
getDropMenuList();
|
||||
watch(
|
||||
() => userInfo.value,
|
||||
(val) => {
|
||||
if (val) getDropMenuList();
|
||||
},
|
||||
);
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
|
||||
watchEffect(() => {
|
||||
selectedKeys.value = [formInfo.value.postId];
|
||||
});
|
||||
function getDropMenuList() {
|
||||
if (userInfo.value?.posts) {
|
||||
formInfo.value = userInfo.value;
|
||||
formInfo.value.posts.forEach((o) => {
|
||||
o.text = o.name;
|
||||
o.event = o.id;
|
||||
});
|
||||
}
|
||||
}
|
||||
async function toggleLocale(lang: string) {
|
||||
let res = await changePost(lang);
|
||||
|
||||
selectedKeys.value = [lang as string];
|
||||
formInfo.value.departmentId = res.departmentId;
|
||||
formInfo.value.departmentName = res.departmentName;
|
||||
formInfo.value.postId = res.postId;
|
||||
formInfo.value.postName = res.postName;
|
||||
|
||||
userStore.setUserInfo(formInfo.value);
|
||||
}
|
||||
|
||||
function handleMenuEvent(menu: DropMenu) {
|
||||
if (formInfo.value.postId === menu.event) {
|
||||
return;
|
||||
}
|
||||
toggleLocale(menu.event as string);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.app-locale-picker-overlay {
|
||||
.ant-dropdown-menu-item {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
src/components/Application/src/search/AppSearch.vue
Normal file
33
src/components/Application/src/search/AppSearch.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script lang="tsx">
|
||||
import { defineComponent, ref, unref } from 'vue';
|
||||
import { Tooltip } from 'ant-design-vue';
|
||||
import { Icon } from '/@/components/Icon';
|
||||
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('搜索'),
|
||||
default: () => <Icon size="20" icon="fa:search" />,
|
||||
}}
|
||||
</Tooltip>
|
||||
<AppSearchModal onClose={changeModal.bind(null, false)} visible={unref(showModal)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
56
src/components/Application/src/search/AppSearchFooter.vue
Normal file
56
src/components/Application/src/search/AppSearchFooter.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div :class="`${prefixCls}`">
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ant-design:enter-outlined" />
|
||||
<span>{{ t('确认') }}</span>
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ion:arrow-up-outline" />
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="ion:arrow-down-outline" />
|
||||
<span>{{ t('切换') }}</span>
|
||||
<AppSearchKeyItem :class="`${prefixCls}-item`" icon="mdi:keyboard-esc" />
|
||||
<span>{{ t('关闭') }}</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>
|
||||
11
src/components/Application/src/search/AppSearchKeyItem.vue
Normal file
11
src/components/Application/src/search/AppSearchKeyItem.vue
Normal 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>
|
||||
267
src/components/Application/src/search/AppSearchModal.vue
Normal file
267
src/components/Application/src/search/AppSearchModal.vue
Normal 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('搜索')"
|
||||
ref="inputRef"
|
||||
allow-clear
|
||||
@change="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
<span :class="`${prefixCls}-cancel`" @click="handleClose">
|
||||
{{ t('取消') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div :class="`${prefixCls}-not-data`" v-show="getIsNotData">
|
||||
{{ t('暂无搜索结果') }}
|
||||
</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>
|
||||
169
src/components/Application/src/search/useMenuSearch.ts
Normal file
169
src/components/Application/src/search/useMenuSearch.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import type { Menu } from '/@/router/types';
|
||||
import { ref, 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';
|
||||
import { testRegLength } from '/@/utils/event/design';
|
||||
|
||||
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('.*');
|
||||
testRegLength(str);
|
||||
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);
|
||||
|
||||
async function getCurSysMenus() {
|
||||
const list = await getMenus();
|
||||
menuList = cloneDeep(list);
|
||||
forEach(menuList, (item) => {
|
||||
item.name = t(item.name);
|
||||
});
|
||||
}
|
||||
async function search(e: ChangeEvent) {
|
||||
e?.stopPropagation();
|
||||
await getCurSysMenus();
|
||||
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 };
|
||||
}
|
||||
17
src/components/Application/src/useAppContext.ts
Normal file
17
src/components/Application/src/useAppContext.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user