feat: refine user center feature (#4857)

Signed-off-by: Ryan Wang <i@ryanc.cc>
pull/4864/head
Ryan Wang 2023-11-16 15:51:19 +08:00 committed by GitHub
parent 96a01cccc7
commit a951a34a1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 490 additions and 601 deletions

View File

@ -1,129 +1,7 @@
<script lang="ts" setup>
import { RouterView, useRoute } from "vue-router";
import { computed, watch, reactive, onMounted, inject } from "vue";
import { useTitle } from "@vueuse/core";
import { useFavicon } from "@vueuse/core";
import { useSystemConfigMapStore } from "@console/stores/system-configmap";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import type { FormKitConfig } from "@formkit/core";
import { i18n } from "@/locales";
import { AppName } from "@/constants/app";
import { useGlobalInfoStore } from "@/stores/global-info";
const { t } = useI18n();
const { configMap } = storeToRefs(useSystemConfigMapStore());
const globalInfoStore = useGlobalInfoStore();
const route = useRoute();
const title = useTitle();
watch(
() => route.name,
() => {
const { title: routeTitle } = route.meta;
if (routeTitle) {
title.value = `${t(routeTitle)} - ${AppName}`;
return;
}
title.value = AppName;
}
);
// Favicon
const defaultFavicon = "/console/favicon.ico";
const favicon = computed(() => {
if (configMap?.value?.data?.["basic"]) {
const basic = JSON.parse(configMap.value.data["basic"]);
if (basic.favicon) {
return basic.favicon;
}
}
if (globalInfoStore.globalInfo?.favicon) {
return globalInfoStore.globalInfo.favicon;
}
return defaultFavicon;
});
useFavicon(favicon);
// body scroll
const body = document.querySelector("body");
const reactiveParams = reactive<UseOverlayScrollbarsParams>({
options: {
scrollbars: {
autoHide: "scroll",
autoHideDelay: 600,
},
},
defer: true,
});
const [initialize] = useOverlayScrollbars(reactiveParams);
onMounted(() => {
if (body) initialize({ target: body });
});
// setup formkit locale
// see https://formkit.com/essentials/internationalization
const formkitLocales = {
en: "en",
zh: "zh",
"en-US": "en",
"zh-CN": "zh",
};
const formkitConfig = inject(Symbol.for("FormKitConfig")) as FormKitConfig;
formkitConfig.locale = formkitLocales[i18n.global.locale.value] || "zh";
// Fix 100vh issue in ios devices
function setViewportProperty(doc: HTMLElement) {
let prevClientHeight: number;
const customVar = "--vh";
function handleResize() {
const clientHeight = doc.clientHeight;
if (clientHeight === prevClientHeight) return;
requestAnimationFrame(function updateViewportHeight() {
doc.style.setProperty(customVar, clientHeight * 0.01 + "px");
prevClientHeight = clientHeight;
});
}
handleResize();
return handleResize;
}
window.addEventListener(
"resize",
setViewportProperty(document.documentElement)
);
import BaseApp from "@/components/base-app/BaseApp.vue";
</script>
<template>
<RouterView />
<BaseApp />
</template>
<style lang="scss">
body {
background: #eff4f9;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
.v-popper__popper {
outline: none;
}
.v-popper--theme-tooltip {
pointer-events: none;
}
</style>

View File

@ -3,29 +3,20 @@ import {
IconMore,
IconSearch,
IconUserSettings,
IconLogoutCircleRLine,
VTag,
VAvatar,
Dialog,
VDropdown,
VDropdownItem,
IconAccountCircleLine,
} from "@halo-dev/components";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import {
RouterView,
useRoute,
useRouter,
type RouteRecordRaw,
} from "vue-router";
import { RouterView, useRoute, useRouter } from "vue-router";
import { onMounted, reactive, ref } from "vue";
import axios from "axios";
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
import LoginModal from "@/components/login/LoginModal.vue";
import { coreMenuGroups } from "@console/router/routes.config";
import sortBy from "lodash.sortby";
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
import { coreMenuGroups } from "@console/router/constant";
import { useUserStore } from "@/stores/user";
import { rbacAnnotations } from "@/constants/annotations";
import { defineStore, storeToRefs } from "pinia";
@ -36,6 +27,7 @@ import {
} from "overlayscrollbars-vue";
import { isMac } from "@/utils/device";
import { useEventListener } from "@vueuse/core";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
const route = useRoute();
const router = useRouter();
@ -79,105 +71,7 @@ useEventListener(document, "keydown", (e: KeyboardEvent) => {
}
});
// Generate menus by routes
const menus = ref<MenuGroupType[]>([] as MenuGroupType[]);
const minimenus = ref<MenuItemType[]>([] as MenuItemType[]);
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
const generateMenus = () => {
// sort by menu.priority and meta.core
const currentRoutes = sortBy(
router.getRoutes().filter((route) => {
const { meta } = route;
if (!meta?.menu) {
return false;
}
if (meta.permissions) {
return hasPermission(uiPermissions, meta.permissions as string[], true);
}
return true;
}),
[
(route: RouteRecordRaw) => !route.meta?.core,
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
]
);
// group by menu.group
menus.value = currentRoutes.reduce((acc, route) => {
const { menu } = route.meta;
if (!menu) {
return acc;
}
const group = acc.find((item) => item.id === menu.group);
const childRoute = route.children[0];
const childMetaMenu = childRoute?.meta?.menu;
// only support one level
const menuChildren = childMetaMenu
? [
{
name: childMetaMenu.name,
path: childRoute.path,
icon: childMetaMenu.icon,
},
]
: undefined;
if (group) {
group.items?.push({
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
});
} else {
const menuGroup = coreMenuGroups.find((item) => item.id === menu.group);
let name = "";
if (!menuGroup) {
name = menu.group;
} else if (menuGroup.name) {
name = menuGroup.name;
}
acc.push({
id: menuGroup?.id || menu.group,
name: name,
priority: menuGroup?.priority || 0,
items: [
{
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
},
],
});
}
return acc;
}, [] as MenuGroupType[]);
// sort by menu.priority
menus.value = sortBy(menus.value, [
(menu: MenuGroupType) => {
return coreMenuGroups.findIndex((item) => item.id === menu.id) < 0;
},
(menu: MenuGroupType) => menu.priority || 0,
]);
minimenus.value = menus.value
.reduce((acc, group) => {
if (group?.items) {
acc.push(...group.items);
}
return acc;
}, [] as MenuItemType[])
.filter((item) => item.mobile);
};
onMounted(generateMenus);
const { menus, minimenus } = useRouteMenuGenerator(coreMenuGroups);
// aside scroll
const navbarScroller = ref();
@ -215,10 +109,6 @@ onMounted(() => {
initialize({ target: navbarScroller.value });
}
});
function handleRouteToUC() {
window.location.href = "/uc";
}
</script>
<template>
@ -262,12 +152,15 @@ function handleRouteToUC() {
<VAvatar
:src="currentUser?.spec.avatar"
:alt="currentUser?.spec.displayName"
size="md"
size="sm"
circle
></VAvatar>
</div>
<div class="profile-name">
<div class="flex text-sm font-medium">
<div
class="flex text-sm font-medium"
:title="currentUser?.spec.displayName"
>
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.[0]" class="flex">
@ -283,19 +176,26 @@ function handleRouteToUC() {
</VTag>
</div>
</div>
<VDropdown
class="profile-control cursor-pointer rounded p-1 transition-all hover:bg-gray-100"
>
<IconMore />
<template #popper>
<VDropdownItem @click="handleRouteToUC">
{{ $t("core.sidebar.operations.profile.button") }}
</VDropdownItem>
<VDropdownItem @click="handleLogout">
{{ $t("core.sidebar.operations.logout.button") }}
</VDropdownItem>
</template>
</VDropdown>
<div class="flex items-center gap-1">
<a
v-tooltip="'个人中心'"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
href="/uc"
>
<IconAccountCircleLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</a>
<div
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
@click="handleLogout"
>
<IconLogoutCircleRLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</div>
</div>
</aside>
@ -418,11 +318,8 @@ function handleRouteToUC() {
.profile-name {
@apply flex-1
self-center;
}
.profile-control {
@apply self-center;
self-center
overflow-hidden;
}
}
}

View File

@ -11,7 +11,7 @@ import {
IconSearch,
IconDatabase2Line,
VCard,
IconUserLine,
IconAccountCircleLine,
Dialog,
Toast,
} from "@halo-dev/components";
@ -37,9 +37,9 @@ const { t } = useI18n();
const actions: Action[] = [
{
icon: markRaw(IconUserLine),
icon: markRaw(IconAccountCircleLine),
title: t(
"core.dashboard.widgets.presets.quicklink.actions.user_profile.title"
"core.dashboard.widgets.presets.quicklink.actions.user_center.title"
),
action: () => {
window.location.href = "/uc/profile";

View File

@ -22,9 +22,11 @@ import type { Component } from "vue";
import { markRaw } from "vue";
import DetailTab from "./tabs/Detail.vue";
import { useRouteQuery } from "@vueuse/router";
import { useUserStore } from "@/stores/user";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const { currentUser } = useUserStore();
interface UserTab {
id: string;
@ -84,6 +86,10 @@ provide<Ref<string>>("activeTab", activeTab);
const tabbarItems = computed(() => {
return tabs.map((tab) => ({ id: tab.id, label: tab.label }));
});
function handleRouteToUC() {
window.location.href = "/uc";
}
</script>
<template>
<UserEditingModal v-model:visible="editingModal" :user="user?.user" />
@ -111,8 +117,15 @@ const tabbarItems = computed(() => {
</span>
</div>
</div>
<div v-if="currentUserHasPermission(['system:users:manage'])">
<VDropdown>
<div class="inline-flex items-center gap-2">
<VButton
v-if="currentUser?.metadata.name === user?.user.metadata.name"
type="primary"
@click="handleRouteToUC"
>
个人中心
</VButton>
<VDropdown v-if="currentUserHasPermission(['system:users:manage'])">
<VButton type="default">
{{ $t("core.common.buttons.edit") }}
</VButton>

View File

@ -1,72 +1,17 @@
<script lang="ts" setup>
import {
Dialog,
IconUserSettings,
VButton,
VDescription,
VDescriptionItem,
VTag,
} from "@halo-dev/components";
import type { ComputedRef, Ref } from "vue";
import { inject, computed } from "vue";
import { useRouter } from "vue-router";
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
import type { Ref } from "vue";
import { inject } from "vue";
import type { DetailedUser } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date";
import { useQuery } from "@tanstack/vue-query";
import { apiClient } from "@/utils/api-client";
import axios from "axios";
import { useI18n } from "vue-i18n";
const user = inject<Ref<DetailedUser | undefined>>("user");
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
const router = useRouter();
const { t } = useI18n();
const { data: authProviders, isFetching } = useQuery<ListedAuthProvider[]>({
queryKey: ["user-auth-providers"],
queryFn: async () => {
const { data } = await apiClient.authProvider.listAuthProviders();
return data;
},
enabled: isCurrentUser,
});
const availableAuthProviders = computed(() => {
return authProviders.value?.filter(
(authProvider) => authProvider.enabled && authProvider.supportsBinding
);
});
const handleUnbindAuth = (authProvider: ListedAuthProvider) => {
Dialog.warning({
title: t("core.user.detail.operations.unbind.title", {
display_name: authProvider.displayName,
}),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await axios.put(
`${import.meta.env.VITE_API_URL}${authProvider.unbindingUrl}`,
{
withCredentials: true,
}
);
window.location.reload();
},
});
};
const handleBindAuth = (authProvider: ListedAuthProvider) => {
if (!authProvider.bindingUrl) {
return;
}
window.location.href = `${
authProvider.bindingUrl
}?redirect_uri=${encodeURIComponent(window.location.href)}`;
};
</script>
<template>
<div class="border-t border-gray-100">
@ -94,7 +39,7 @@ const handleBindAuth = (authProvider: ListedAuthProvider) => {
v-for="(role, index) in user?.roles"
:key="index"
@click="
router.push({
$router.push({
name: 'RoleDetail',
params: { name: role.metadata.name },
})
@ -119,50 +64,6 @@ const handleBindAuth = (authProvider: ListedAuthProvider) => {
:content="formatDatetime(user?.user.metadata?.creationTimestamp)"
class="!px-2"
/>
<VDescriptionItem
v-if="!isFetching && isCurrentUser && availableAuthProviders?.length"
:label="$t('core.user.detail.fields.identity_authentication')"
class="!px-2"
>
<ul class="space-y-2">
<template v-for="(authProvider, index) in authProviders">
<li
v-if="authProvider.supportsBinding && authProvider.enabled"
:key="index"
>
<div
class="flex w-full cursor-pointer flex-wrap justify-between gap-y-3 rounded border p-5 hover:border-primary sm:w-1/2"
>
<div class="inline-flex items-center gap-3">
<div>
<img class="h-7 w-7 rounded" :src="authProvider.logo" />
</div>
<div class="text-sm font-medium text-gray-900">
{{ authProvider.displayName }}
</div>
</div>
<div class="inline-flex items-center">
<VButton
v-if="authProvider.isBound"
size="sm"
@click="handleUnbindAuth(authProvider)"
>
{{ $t("core.user.detail.operations.unbind.button") }}
</VButton>
<VButton
v-else
size="sm"
type="secondary"
@click="handleBindAuth(authProvider)"
>
{{ $t("core.user.detail.operations.bind.button") }}
</VButton>
</div>
</div>
</li>
</template>
</ul>
</VDescriptionItem>
</VDescription>
</div>
</template>

View File

@ -0,0 +1,29 @@
import type { MenuGroupType } from "@halo-dev/console-shared";
export const coreMenuGroups: MenuGroupType[] = [
{
id: "dashboard",
name: undefined,
priority: 0,
},
{
id: "content",
name: "core.sidebar.menu.groups.content",
priority: 1,
},
{
id: "interface",
name: "core.sidebar.menu.groups.interface",
priority: 2,
},
{
id: "system",
name: "core.sidebar.menu.groups.system",
priority: 3,
},
{
id: "tool",
name: "core.sidebar.menu.groups.tool",
priority: 4,
},
];

View File

@ -4,7 +4,6 @@ import Forbidden from "@/views/exceptions/Forbidden.vue";
import BasicLayout from "@console/layouts/BasicLayout.vue";
import Setup from "@console/views/system/Setup.vue";
import Redirect from "@console/views/system/Redirect.vue";
import type { MenuGroupType } from "@halo-dev/console-shared";
import SetupInitialData from "@console/views/system/SetupInitialData.vue";
export const routes: Array<RouteRecordRaw> = [
@ -47,32 +46,4 @@ export const routes: Array<RouteRecordRaw> = [
},
];
export const coreMenuGroups: MenuGroupType[] = [
{
id: "dashboard",
name: undefined,
priority: 0,
},
{
id: "content",
name: "core.sidebar.menu.groups.content",
priority: 1,
},
{
id: "interface",
name: "core.sidebar.menu.groups.interface",
priority: 2,
},
{
id: "system",
name: "core.sidebar.menu.groups.system",
priority: 3,
},
{
id: "tool",
name: "core.sidebar.menu.groups.tool",
priority: 4,
},
];
export default routes;

View File

@ -119,6 +119,7 @@
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.isequal": "^4.5.6",
"@types/lodash.merge": "^4.6.7",
"@types/lodash.sortby": "^4.7.9",
"@types/node": "^18.11.19",
"@types/qs": "^6.9.7",
"@types/randomstring": "^1.1.8",

View File

@ -67,6 +67,9 @@ import IconArrowLeftRightLine from "~icons/ri/arrow-left-right-line";
import IconArrowUpDownLine from "~icons/ri/arrow-up-down-line";
import IconRiUpload2Fill from "~icons/ri/upload-2-fill";
import IconNotificationBadgeLine from "~icons/ri/notification-badge-line";
import IconLogoutCircleRLine from "~icons/ri/logout-circle-r-line";
import IconAccountCircleLine from "~icons/ri/account-circle-line";
import IconSettings3Line from "~icons/ri/settings-3-line";
export {
IconDashboard,
@ -138,4 +141,7 @@ export {
IconArrowUpDownLine,
IconRiUpload2Fill,
IconNotificationBadgeLine,
IconLogoutCircleRLine,
IconAccountCircleLine,
IconSettings3Line,
};

View File

@ -243,6 +243,9 @@ importers:
'@types/lodash.merge':
specifier: ^4.6.7
version: 4.6.7
'@types/lodash.sortby':
specifier: ^4.7.9
version: 4.7.9
'@types/node':
specifier: ^18.11.19
version: 18.13.0
@ -3952,6 +3955,12 @@ packages:
'@types/lodash': 4.14.186
dev: true
/@types/lodash.sortby@4.7.9:
resolution: {integrity: sha512-PDmjHnOlndLS59GofH0pnxIs+n9i4CWeXGErSB5JyNFHu2cmvW6mQOaUKjG8EDPkni14IgF8NsRW8bKvFzTm9A==}
dependencies:
'@types/lodash': 4.14.186
dev: true
/@types/lodash@4.14.186:
resolution: {integrity: sha512-eHcVlLXP0c2FlMPm56ITode2AgLMSa6aJ05JTTbYbI+7EMkCEE5qk2E41d5g2lCVTqRe0GnnRFurmlCsDODrPw==}
dev: true

View File

@ -0,0 +1,113 @@
<script lang="ts" setup>
import { RouterView, useRoute } from "vue-router";
import { computed, watch, reactive, onMounted, inject } from "vue";
import { useTitle } from "@vueuse/core";
import { useFavicon } from "@vueuse/core";
import { useI18n } from "vue-i18n";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import type { FormKitConfig } from "@formkit/core";
import { i18n } from "@/locales";
import { AppName } from "@/constants/app";
import { useGlobalInfoStore } from "@/stores/global-info";
const { t } = useI18n();
const globalInfoStore = useGlobalInfoStore();
const route = useRoute();
const title = useTitle();
watch(
() => route.name,
() => {
const { title: routeTitle } = route.meta;
if (routeTitle) {
title.value = `${t(routeTitle)} - ${AppName}`;
return;
}
title.value = AppName;
}
);
// Favicon
const defaultFavicon = "/console/favicon.ico";
const favicon = computed(() => {
return globalInfoStore.globalInfo?.favicon || defaultFavicon;
});
useFavicon(favicon);
// body scroll
const body = document.querySelector("body");
const reactiveParams = reactive<UseOverlayScrollbarsParams>({
options: {
scrollbars: {
autoHide: "scroll",
autoHideDelay: 600,
},
},
defer: true,
});
const [initialize] = useOverlayScrollbars(reactiveParams);
onMounted(() => {
if (body) initialize({ target: body });
});
// setup formkit locale
// see https://formkit.com/essentials/internationalization
const formkitLocales = {
en: "en",
zh: "zh",
"en-US": "en",
"zh-CN": "zh",
};
const formkitConfig = inject(Symbol.for("FormKitConfig")) as FormKitConfig;
formkitConfig.locale = formkitLocales[i18n.global.locale.value] || "zh";
// Fix 100vh issue in ios devices
function setViewportProperty(doc: HTMLElement) {
let prevClientHeight: number;
const customVar = "--vh";
function handleResize() {
const clientHeight = doc.clientHeight;
if (clientHeight === prevClientHeight) return;
requestAnimationFrame(function updateViewportHeight() {
doc.style.setProperty(customVar, clientHeight * 0.01 + "px");
prevClientHeight = clientHeight;
});
}
handleResize();
return handleResize;
}
window.addEventListener(
"resize",
setViewportProperty(document.documentElement)
);
</script>
<template>
<RouterView />
</template>
<style lang="scss">
body {
background: #eff4f9;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
.v-popper__popper {
outline: none;
}
.v-popper--theme-tooltip {
pointer-events: none;
}
</style>

View File

@ -0,0 +1,129 @@
import { useRoleStore } from "@/stores/role";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import { onMounted, ref, type Ref } from "vue";
import sortBy from "lodash.sortby";
import { hasPermission } from "@/utils/permission";
import {
useRouter,
type RouteRecordRaw,
type RouteRecordNormalized,
} from "vue-router";
interface useRouteMenuGeneratorReturn {
menus: Ref<MenuGroupType[]>;
minimenus: Ref<MenuItemType[]>;
}
export function useRouteMenuGenerator(
menuGroups: MenuGroupType[]
): useRouteMenuGeneratorReturn {
const router = useRouter();
const menus = ref<MenuGroupType[]>([] as MenuGroupType[]);
const minimenus = ref<MenuItemType[]>([] as MenuItemType[]);
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
const generateMenus = () => {
// sort by menu.priority and meta.core
const currentRoutes = sortBy<RouteRecordNormalized>(
router.getRoutes().filter((route) => {
const { meta } = route;
if (!meta?.menu) {
return false;
}
if (meta.permissions) {
return hasPermission(
uiPermissions,
meta.permissions as string[],
true
);
}
return true;
}),
[
(route: RouteRecordRaw) => !route.meta?.core,
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
]
);
// group by menu.group
menus.value = currentRoutes.reduce((acc, route) => {
const { menu } = route.meta;
if (!menu) {
return acc;
}
const group = acc.find((item) => item.id === menu.group);
const childRoute = route.children[0];
const childMetaMenu = childRoute?.meta?.menu;
// only support one level
const menuChildren = childMetaMenu
? [
{
name: childMetaMenu.name,
path: childRoute.path,
icon: childMetaMenu.icon,
},
]
: undefined;
if (group) {
group.items?.push({
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
});
} else {
const menuGroup = menuGroups.find((item) => item.id === menu.group);
let name = "";
if (!menuGroup) {
name = menu.group || "";
} else if (menuGroup.name) {
name = menuGroup.name;
}
acc.push({
id: menuGroup?.id || menu.group || "",
name: name,
priority: menuGroup?.priority || 0,
items: [
{
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
},
],
});
}
return acc;
}, [] as MenuGroupType[]);
// sort by menu.priority
menus.value = sortBy(menus.value, [
(menu: MenuGroupType) => {
return menuGroups.findIndex((item) => item.id === menu.id) < 0;
},
(menu: MenuGroupType) => menu.priority || 0,
]);
minimenus.value = menus.value
.reduce((acc, group) => {
if (group?.items) {
acc.push(...group.items);
}
return acc;
}, [] as MenuItemType[])
.filter((item) => item.mobile);
};
onMounted(generateMenus);
return {
menus,
minimenus,
};
}

View File

@ -106,8 +106,8 @@ core:
quicklink:
title: Quick Link
actions:
user_profile:
title: User Profile
user_center:
title: User Center
view_site:
title: View Site
new_post:

View File

@ -105,7 +105,7 @@ core:
quicklink:
title: Enlace Rápido
actions:
user_profile:
user_center:
title: Perfil de Usuario
view_site:
title: Ver Sitio

View File

@ -106,8 +106,8 @@ core:
quicklink:
title: 快捷访问
actions:
user_profile:
title: 个人资料
user_center:
title: 个人中心
view_site:
title: 查看站点
new_post:

View File

@ -106,8 +106,8 @@ core:
quicklink:
title: 快捷訪問
actions:
user_profile:
title: 個人資料
user_center:
title: 個人中心
view_site:
title: 查看站點
new_post:

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { RouterView } from "vue-router";
import BaseApp from "@/components/base-app/BaseApp.vue";
</script>
<template>
<RouterView />
<BaseApp />
</template>

View File

@ -5,25 +5,16 @@ import {
VTag,
VAvatar,
Dialog,
VDropdown,
VDropdownItem,
IconLogoutCircleRLine,
IconSettings3Line,
} from "@halo-dev/components";
import { RoutesMenu } from "@/components/menu/RoutesMenu";
import type { MenuGroupType, MenuItemType } from "@halo-dev/console-shared";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import {
RouterView,
useRoute,
useRouter,
type RouteRecordRaw,
} from "vue-router";
import { RouterView, useRoute, useRouter } from "vue-router";
import { onMounted, reactive, ref } from "vue";
import axios from "axios";
import LoginModal from "@/components/login/LoginModal.vue";
import { coreMenuGroups } from "@console/router/routes.config";
import sortBy from "lodash.sortby";
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
import { coreMenuGroups } from "@console/router/constant";
import { useUserStore } from "@/stores/user";
import { rbacAnnotations } from "@/constants/annotations";
import { defineStore, storeToRefs } from "pinia";
@ -32,6 +23,7 @@ import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import { useRouteMenuGenerator } from "@/composables/use-route-menu-generator";
const route = useRoute();
const router = useRouter();
@ -65,105 +57,7 @@ const handleLogout = () => {
});
};
// Generate menus by routes
const menus = ref<MenuGroupType[]>([] as MenuGroupType[]);
const minimenus = ref<MenuItemType[]>([] as MenuItemType[]);
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
const generateMenus = () => {
// sort by menu.priority and meta.core
const currentRoutes = sortBy(
router.getRoutes().filter((route) => {
const { meta } = route;
if (!meta?.menu) {
return false;
}
if (meta.permissions) {
return hasPermission(uiPermissions, meta.permissions as string[], true);
}
return true;
}),
[
(route: RouteRecordRaw) => !route.meta?.core,
(route: RouteRecordRaw) => route.meta?.menu?.priority || 0,
]
);
// group by menu.group
menus.value = currentRoutes.reduce((acc, route) => {
const { menu } = route.meta;
if (!menu) {
return acc;
}
const group = acc.find((item) => item.id === menu.group);
const childRoute = route.children[0];
const childMetaMenu = childRoute?.meta?.menu;
// only support one level
const menuChildren = childMetaMenu
? [
{
name: childMetaMenu.name,
path: childRoute.path,
icon: childMetaMenu.icon,
},
]
: undefined;
if (group) {
group.items?.push({
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
});
} else {
const menuGroup = coreMenuGroups.find((item) => item.id === menu.group);
let name = "";
if (!menuGroup) {
name = menu.group;
} else if (menuGroup.name) {
name = menuGroup.name;
}
acc.push({
id: menuGroup?.id || menu.group,
name: name,
priority: menuGroup?.priority || 0,
items: [
{
name: menu.name,
path: route.path,
icon: menu.icon,
mobile: menu.mobile,
children: menuChildren,
},
],
});
}
return acc;
}, [] as MenuGroupType[]);
// sort by menu.priority
menus.value = sortBy(menus.value, [
(menu: MenuGroupType) => {
return coreMenuGroups.findIndex((item) => item.id === menu.id) < 0;
},
(menu: MenuGroupType) => menu.priority || 0,
]);
minimenus.value = menus.value
.reduce((acc, group) => {
if (group?.items) {
acc.push(...group.items);
}
return acc;
}, [] as MenuItemType[])
.filter((item) => item.mobile);
};
onMounted(generateMenus);
const { menus, minimenus } = useRouteMenuGenerator(coreMenuGroups);
// aside scroll
const navbarScroller = ref();
@ -201,10 +95,6 @@ onMounted(() => {
initialize({ target: navbarScroller.value });
}
});
function handleRouteToConsole() {
window.location.href = "/console";
}
</script>
<template>
@ -253,19 +143,25 @@ function handleRouteToConsole() {
</VTag>
</div>
</div>
<VDropdown
class="profile-control cursor-pointer rounded p-1 transition-all hover:bg-gray-100"
>
<IconMore />
<template #popper>
<VDropdownItem @click="handleRouteToConsole">
管理控制台
</VDropdownItem>
<VDropdownItem @click="handleLogout">
{{ $t("core.sidebar.operations.logout.button") }}
</VDropdownItem>
</template>
</VDropdown>
<div class="flex items-center gap-1">
<a
v-tooltip="'管理控制台'"
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
href="/console"
>
<IconSettings3Line
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</a>
<div
class="group inline-block cursor-pointer rounded-full p-1.5 transition-all hover:bg-gray-100"
@click="handleLogout"
>
<IconLogoutCircleRLine
class="h-5 w-5 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</div>
</div>
</aside>
@ -387,11 +283,8 @@ function handleRouteToConsole() {
.profile-name {
@apply flex-1
self-center;
}
.profile-control {
@apply self-center;
self-center
overflow-hidden;
}
}
}

View File

@ -3,7 +3,7 @@ import { createApp, type DirectiveBinding } from "vue";
import App from "./App.vue";
import { setupVueQuery } from "@/setup/setupVueQuery";
import { setupComponents } from "@/setup/setupComponents";
import { setupI18n } from "@/locales";
import { getBrowserLanguage, i18n, setupI18n } from "@/locales";
import { createPinia } from "pinia";
import { setupCoreModules, setupPluginModules } from "@uc/setup/setupModules";
import router from "@uc/router";
@ -11,6 +11,7 @@ import { useUserStore } from "@/stores/user";
import { apiClient } from "@/utils/api-client";
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
import { useGlobalInfoStore } from "@/stores/global-info";
const app = createApp(App);
@ -55,19 +56,34 @@ async function loadUserPermissions() {
})();
async function initApp() {
setupCoreModules(app);
const userStore = useUserStore();
await userStore.fetchCurrentUser();
loadUserPermissions();
try {
await setupPluginModules(app);
} catch (e) {
console.error("Failed to load plugins", e);
}
setupCoreModules(app);
app.use(router);
app.mount("#app");
const userStore = useUserStore();
await userStore.fetchCurrentUser();
// set locale
i18n.global.locale.value =
localStorage.getItem("locale") || getBrowserLanguage();
const globalInfoStore = useGlobalInfoStore();
await globalInfoStore.fetchGlobalInfo();
if (userStore.isAnonymous) {
return;
}
await loadUserPermissions();
try {
await setupPluginModules(app);
} catch (e) {
console.error("Failed to load plugins", e);
}
} catch (error) {
console.error("Failed to init app", error);
} finally {
app.use(router);
app.mount("#app");
}
}

View File

@ -90,7 +90,7 @@ const handleUpdateUser = async () => {
onVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["profile"] });
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
Toast.success(t("core.common.toast.save_success"));
} catch (e) {

View File

@ -1,6 +1,6 @@
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@uc/layouts/BasicLayout.vue";
import { IconUserLine } from "@halo-dev/components";
import { IconAccountCircleLine } from "@halo-dev/components";
import { markRaw } from "vue";
import Profile from "./Profile.vue";
@ -21,7 +21,7 @@ export default definePlugin({
searchable: true,
menu: {
name: "我的",
icon: markRaw(IconUserLine),
icon: markRaw(IconAccountCircleLine),
priority: 0,
mobile: true,
},

View File

@ -9,7 +9,6 @@ import {
} from "@halo-dev/components";
import type { Ref } from "vue";
import { inject, computed } from "vue";
import { useRouter } from "vue-router";
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date";
@ -20,7 +19,6 @@ import { useI18n } from "vue-i18n";
const user = inject<Ref<DetailedUser | undefined>>("user");
const router = useRouter();
const { t } = useI18n();
const { data: authProviders, isFetching } = useQuery<ListedAuthProvider[]>({
@ -88,16 +86,7 @@ const handleBindAuth = (authProvider: ListedAuthProvider) => {
:label="$t('core.user.detail.fields.roles')"
class="!px-2"
>
<VTag
v-for="(role, index) in user?.roles"
:key="index"
@click="
router.push({
name: 'RoleDetail',
params: { name: role.metadata.name },
})
"
>
<VTag v-for="role in user?.roles" :key="role.metadata.name">
<template #leftIcon>
<IconUserSettings />
</template>
@ -118,7 +107,7 @@ const handleBindAuth = (authProvider: ListedAuthProvider) => {
class="!px-2"
/>
<VDescriptionItem
v-if="!isFetching && isCurrentUser && availableAuthProviders?.length"
v-if="!isFetching && availableAuthProviders?.length"
:label="$t('core.user.detail.fields.identity_authentication')"
class="!px-2"
>

View File

@ -0,0 +1,29 @@
import type { MenuGroupType } from "@halo-dev/console-shared";
export const coreMenuGroups: MenuGroupType[] = [
{
id: "dashboard",
name: undefined,
priority: 0,
},
{
id: "content",
name: "core.sidebar.menu.groups.content",
priority: 1,
},
{
id: "interface",
name: "core.sidebar.menu.groups.interface",
priority: 2,
},
{
id: "system",
name: "core.sidebar.menu.groups.system",
priority: 3,
},
{
id: "tool",
name: "core.sidebar.menu.groups.tool",
priority: 4,
},
];

View File

@ -0,0 +1,17 @@
import { useUserStore } from "@/stores/user";
import type { Router } from "vue-router";
export function setupAuthCheckGuard(router: Router) {
router.beforeEach((to, from, next) => {
const userStore = useUserStore();
if (userStore.isAnonymous) {
window.location.href = `/console/login?redirect_uri=${encodeURIComponent(
window.location.href
)}`;
return;
}
next();
});
}

View File

@ -0,0 +1,22 @@
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
import type { Router } from "vue-router";
export function setupPermissionGuard(router: Router) {
router.beforeEach((to, from, next) => {
const roleStore = useRoleStore();
const { uiPermissions } = roleStore.permissions;
const { meta } = to;
if (meta && meta.permissions) {
const flag = hasPermission(
Array.from(uiPermissions),
meta.permissions as string[],
true
);
if (!flag) {
next({ name: "Forbidden" });
}
}
next();
});
}

View File

@ -5,6 +5,8 @@ import {
type RouteLocationNormalizedLoaded,
} from "vue-router";
import routesConfig from "@uc/router/routes.config";
import { setupAuthCheckGuard } from "./guards/auth-check";
import { setupPermissionGuard } from "./guards/permission";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -19,4 +21,7 @@ const router = createRouter({
},
});
setupAuthCheckGuard(router);
setupPermissionGuard(router);
export default router;

View File

@ -2,7 +2,6 @@ import type { RouteRecordRaw } from "vue-router";
import NotFound from "@/views/exceptions/NotFound.vue";
import Forbidden from "@/views/exceptions/Forbidden.vue";
import BasicLayout from "@uc/layouts/BasicLayout.vue";
import type { MenuGroupType } from "@halo-dev/console-shared";
export const routes: Array<RouteRecordRaw> = [
{
@ -23,32 +22,4 @@ export const routes: Array<RouteRecordRaw> = [
},
];
export const coreMenuGroups: MenuGroupType[] = [
{
id: "dashboard",
name: undefined,
priority: 0,
},
{
id: "content",
name: "core.sidebar.menu.groups.content",
priority: 1,
},
{
id: "interface",
name: "core.sidebar.menu.groups.interface",
priority: 2,
},
{
id: "system",
name: "core.sidebar.menu.groups.system",
priority: 3,
},
{
id: "tool",
name: "core.sidebar.menu.groups.tool",
priority: 4,
},
];
export default routes;

View File

@ -21,7 +21,7 @@ export async function setupPluginModules(app: App) {
const { load } = useScriptTag(
`${
import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js`
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js?t=${Date.now()}`
);
await load();
@ -45,7 +45,7 @@ export async function setupPluginModules(app: App) {
await loadStyle(
`${
import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css`
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css?t=${Date.now()}`
);
} catch (e) {
const message = i18n.global.t("core.plugin.loader.toast.style_load_failed");