feat: basic implementation of personal center (#4851)

#### What type of PR is this?

/area console
/milestone 2.11.x
/kind feature

#### What this PR does / why we need it:

个人中心的基础实现,此 PR 已完成:

1. 个人中心基础布局。
2. 将个人相关的功能移动到个人中心,包括个人资料修改、密码修改、PAT、通知配置、通知中心等。
3. 个人中心和管理控制台的切换入口。

<img width="1920" alt="图片" src="https://github.com/halo-dev/halo/assets/21301288/2db810dc-c467-4b6d-86ad-dd7473fa8ef6">

注意:此 PR 仅包含基础实现,其他的 UI 更改和 i18n 完善会放在后面的 PR。

#### Special notes for your reviewer:

测试方式:

1. 使用开发模式启动 Halo 后端。
2. 在 Console 目录运行 `pnpm dev`。
3. 测试 /console 的功能。
4. 测试 /uc 的功能。

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/4857/head
Ryan Wang 2023-11-13 16:56:08 +08:00 committed by GitHub
parent 52d064381f
commit b0aec48c7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1487 additions and 245 deletions

View File

@ -18,7 +18,7 @@ import {
useRouter,
type RouteRecordRaw,
} from "vue-router";
import { onMounted, onUnmounted, reactive, ref } from "vue";
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";
@ -35,6 +35,7 @@ import {
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
import { isMac } from "@/utils/device";
import { useEventListener } from "@vueuse/core";
const route = useRoute();
const router = useRouter();
@ -70,21 +71,12 @@ const handleLogout = () => {
// Global Search
const globalSearchVisible = ref(false);
const handleGlobalSearchKeybinding = (e: KeyboardEvent) => {
useEventListener(document, "keydown", (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = e;
if (key === "k" && ((ctrlKey && !isMac) || metaKey)) {
globalSearchVisible.value = true;
e.preventDefault();
}
};
onMounted(() => {
document.addEventListener("keydown", handleGlobalSearchKeybinding);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleGlobalSearchKeybinding);
});
// Generate menus by routes
@ -223,6 +215,10 @@ onMounted(() => {
initialize({ target: navbarScroller.value });
}
});
function handleRouteToUC() {
window.location.href = "/uc";
}
</script>
<template>
@ -230,7 +226,7 @@ onMounted(() => {
<aside
class="navbar fixed hidden h-full overflow-y-auto md:flex md:flex-col"
>
<div class="logo flex justify-center pb-7 pt-5">
<div class="logo flex justify-center pb-5 pt-5">
<a
href="/"
target="_blank"
@ -292,25 +288,9 @@ onMounted(() => {
>
<IconMore />
<template #popper>
<VDropdownItem
@click="
$router.push({
name: 'UserDetail',
params: { name: '-' },
})
"
>
<VDropdownItem @click="handleRouteToUC">
{{ $t("core.sidebar.operations.profile.button") }}
</VDropdownItem>
<VDropdownItem
@click="
$router.push({
name: 'UserNotifications',
})
"
>
{{ $t("core.sidebar.operations.notifications.button") }}
</VDropdownItem>
<VDropdownItem @click="handleLogout">
{{ $t("core.sidebar.operations.logout.button") }}
</VDropdownItem>

View File

@ -16,7 +16,10 @@ import { useUserStore } from "@/stores/user";
import { useSystemConfigMapStore } from "@console/stores/system-configmap";
import { setupVueQuery } from "@/setup/setupVueQuery";
import { useGlobalInfoStore } from "@/stores/global-info";
import { setupCoreModules, setupPluginModules } from "@/setup/setupModules";
import {
setupCoreModules,
setupPluginModules,
} from "@console/setup/setupModules";
const app = createApp(App);
@ -66,12 +69,6 @@ async function loadActivatedTheme() {
})();
async function initApp() {
// TODO 实验性
const theme = localStorage.getItem("theme");
if (theme) {
document.body.classList.add(theme);
}
try {
setupCoreModules(app);

View File

@ -42,10 +42,7 @@ const actions: Action[] = [
"core.dashboard.widgets.presets.quicklink.actions.user_profile.title"
),
action: () => {
router.push({
name: "UserDetail",
params: { name: "-" },
});
window.location.href = "/uc/profile";
},
},
{

View File

@ -15,10 +15,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { apiClient } from "@/utils/api-client";
import { pluginLabels, roleLabels } from "@/constants/labels";
import { rbacAnnotations } from "@/constants/annotations";
import {
useRoleForm,
useRoleTemplateSelection,
} from "@console/modules/system/roles/composables/use-role";
import { useRoleForm, useRoleTemplateSelection } from "@/composables/use-role";
import { SUPER_ROLE_NAME } from "@/constants/constants";
import { useI18n } from "vue-i18n";
import { formatDatetime } from "@/utils/date";

View File

@ -26,7 +26,7 @@ import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date";
// hooks
import { useFetchRole } from "@console/modules/system/roles/composables/use-role";
import { useFetchRole } from "@/composables/use-role";
// libs
import { apiClient } from "@/utils/api-client";

View File

@ -4,10 +4,7 @@ import SubmitButton from "@/components/button/SubmitButton.vue";
import { computed, watch } from "vue";
import { rbacAnnotations } from "@/constants/annotations";
import type { Role } from "@halo-dev/api-client";
import {
useRoleForm,
useRoleTemplateSelection,
} from "@console/modules/system/roles/composables/use-role";
import { useRoleForm, useRoleTemplateSelection } from "@/composables/use-role";
import cloneDeep from "lodash.clonedeep";
import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";

View File

@ -7,35 +7,23 @@ import {
VDropdownItem,
VLoading,
} from "@halo-dev/components";
import {
computed,
onMounted,
provide,
ref,
type ComputedRef,
type Ref,
} from "vue";
import { computed, provide, ref, type Ref } from "vue";
import { useRoute } from "vue-router";
import type { DetailedUser } from "@halo-dev/api-client";
import UserEditingModal from "./components/UserEditingModal.vue";
import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue";
import { usePermission } from "@/utils/permission";
import { useUserStore } from "@/stores/user";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { rbacAnnotations } from "@/constants/annotations";
import { onBeforeRouteUpdate } from "vue-router";
import UserAvatar from "./components/UserAvatar.vue";
import UserAvatar from "@/components/user-avatar/UserAvatar.vue";
import type { Raw } from "vue";
import type { Component } from "vue";
import { markRaw } from "vue";
import DetailTab from "./tabs/Detail.vue";
import PersonalAccessTokensTab from "./tabs/PersonalAccessTokens.vue";
import { useRouteQuery } from "@vueuse/router";
import NotificationPreferences from "./tabs/NotificationPreferences.vue";
const { currentUserHasPermission } = usePermission();
const userStore = useUserStore();
const { t } = useI18n();
interface UserTab {
@ -52,17 +40,6 @@ const editingModal = ref(false);
const passwordChangeModal = ref(false);
const { params } = useRoute();
const name = ref();
onMounted(() => {
name.value = params.name;
});
// Update name when route change
onBeforeRouteUpdate((to, _, next) => {
name.value = to.params.name;
next();
});
const {
data: user,
@ -70,17 +47,12 @@ const {
isLoading,
refetch,
} = useQuery({
queryKey: ["user-detail", name],
queryKey: ["user-detail", params.name],
queryFn: async () => {
if (name.value === "-") {
const { data } = await apiClient.user.getCurrentUserDetail();
return data;
} else {
const { data } = await apiClient.user.getUserDetail({
name: name.value,
});
return data;
}
const { data } = await apiClient.user.getUserDetail({
name: params.name as string,
});
return data;
},
refetchInterval: (data) => {
const annotations = data?.user.metadata.annotations;
@ -89,55 +61,28 @@ const {
? 1000
: false;
},
enabled: computed(() => !!name.value),
});
const isCurrentUser = computed(() => {
if (name.value === "-") {
return true;
}
return (
user.value?.user.metadata.name === userStore.currentUser?.metadata.name
);
enabled: computed(() => !!params.name),
});
provide<Ref<DetailedUser | undefined>>("user", user);
provide<ComputedRef<boolean>>("isCurrentUser", isCurrentUser);
const tabs = computed((): UserTab[] => {
return [
{
id: "detail",
label: t("core.user.detail.tabs.detail"),
component: markRaw(DetailTab),
priority: 10,
},
{
id: "notification-preferences",
label: t("core.user.detail.tabs.notification-preferences"),
component: markRaw(NotificationPreferences),
priority: 20,
hidden: !isCurrentUser.value,
},
{
id: "pat",
label: t("core.user.detail.tabs.pat"),
component: markRaw(PersonalAccessTokensTab),
priority: 30,
hidden: !isCurrentUser.value,
},
];
});
const tabs: UserTab[] = [
{
id: "detail",
label: t("core.user.detail.tabs.detail"),
component: markRaw(DetailTab),
priority: 10,
},
];
const activeTab = useRouteQuery<string>("tab", tabs.value[0].id, {
const activeTab = useRouteQuery<string>("tab", tabs[0].id, {
mode: "push",
});
provide<Ref<string>>("activeTab", activeTab);
const tabbarItems = computed(() => {
return tabs.value
.filter((tab) => !tab.hidden)
.map((tab) => ({ id: tab.id, label: tab.label }));
return tabs.map((tab) => ({ id: tab.id, label: tab.label }));
});
</script>
<template>
@ -166,11 +111,7 @@ const tabbarItems = computed(() => {
</span>
</div>
</div>
<div
v-if="
currentUserHasPermission(['system:users:manage']) || isCurrentUser
"
>
<div v-if="currentUserHasPermission(['system:users:manage'])">
<VDropdown>
<VButton type="default">
{{ $t("core.common.buttons.edit") }}

View File

@ -32,7 +32,7 @@ import { formatDatetime } from "@/utils/date";
import { useRouteQuery } from "@vueuse/router";
import { usePermission } from "@/utils/permission";
import { useUserStore } from "@/stores/user";
import { useFetchRole } from "../roles/composables/use-role";
import { useFetchRole } from "@/composables/use-role";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import UserCreationModal from "./components/UserCreationModal.vue";

View File

@ -15,11 +15,9 @@ import { reset } from "@formkit/core";
// hooks
import { setFocus } from "@/formkit/utils/focus";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { useUserStore } from "@/stores/user";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
const userStore = useUserStore();
const { t } = useI18n();
const queryClient = useQueryClient();
@ -103,16 +101,10 @@ const handleUpdateUser = async () => {
try {
saving.value = true;
if (props.user?.metadata.name === userStore.currentUser?.metadata.name) {
await apiClient.user.updateCurrentUser({
user: formState.value,
});
} else {
await apiClient.extension.user.updatev1alpha1User({
name: formState.value.metadata.name,
user: formState.value,
});
}
await apiClient.extension.user.updatev1alpha1User({
name: formState.value.metadata.name,
user: formState.value,
});
onVisibleChange(false);

View File

@ -7,7 +7,6 @@ import { apiClient } from "@/utils/api-client";
import cloneDeep from "lodash.clonedeep";
import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import { useUserStore } from "@/stores/user";
const props = withDefaults(
defineProps<{
@ -25,8 +24,6 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const userStore = useUserStore();
interface PasswordChangeFormState {
password: string;
password_confirm?: string;
@ -70,17 +67,10 @@ const handleChangePassword = async () => {
const changePasswordRequest = cloneDeep(formState.value);
delete changePasswordRequest.password_confirm;
if (props.user?.metadata.name === userStore.currentUser?.metadata.name) {
await apiClient.user.changePassword({
name: "-",
changePasswordRequest,
});
} else {
await apiClient.user.changePassword({
name: props.user?.metadata.name || "",
changePasswordRequest,
});
}
await apiClient.user.changePassword({
name: props.user?.metadata.name || "",
changePasswordRequest,
});
onVisibleChange(false);
} catch (e) {

View File

@ -8,7 +8,6 @@ import Login from "./Login.vue";
import { IconUserSettings } from "@halo-dev/components";
import { markRaw } from "vue";
import Binding from "./Binding.vue";
import Notifications from "./Notifications.vue";
import NotificationWidget from "./widgets/NotificationWidget.vue";
export default definePlugin({
@ -75,20 +74,6 @@ export default definePlugin({
},
],
},
{
path: "-/notifications",
component: BasicLayout,
children: [
{
path: "",
name: "UserNotifications",
component: Notifications,
meta: {
title: "core.notification.title",
},
},
],
},
],
},
],

View File

@ -12,6 +12,7 @@ import {
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import type { Notification } from "@halo-dev/api-client";
const { currentUser } = useUserStore();
@ -33,6 +34,10 @@ const {
return data.items;
},
});
function handleRouteToNotification(notification: Notification) {
window.location.href = `/uc/notifications?name=${notification.metadata.name}`;
}
</script>
<template>
@ -43,12 +48,12 @@ const {
>
<template #actions>
<div style="padding: 12px 16px">
<RouterLink
<a
class="text-sm text-gray-600 hover:text-gray-900"
:to="{ name: 'UserNotifications' }"
href="/uc/notifications"
>
{{ $t("core.common.buttons.view_all") }}
</RouterLink>
</a>
</div>
</template>
<VLoading v-if="isLoading" />
@ -78,11 +83,8 @@ const {
<template #start>
<VEntityField
:title="notification.spec?.title"
:route="{
name: 'UserNotifications',
query: { name: notification.metadata.name },
}"
:description="notification.spec?.rawContent"
@click="handleRouteToNotification(notification)"
/>
</template>
<template #end>

View File

@ -1,6 +1,6 @@
import type { RouteRecordRaw } from "vue-router";
import NotFound from "@console/views/exceptions/NotFound.vue";
import Forbidden from "@console/views/exceptions/Forbidden.vue";
import NotFound from "@/views/exceptions/NotFound.vue";
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";

View File

@ -96,5 +96,7 @@ export interface PluginModule {
routes?: RouteRecordRaw[] | RouteRecordAppend[];
ucRoutes?: RouteRecordRaw[] | RouteRecordAppend[];
extensionPoints?: ExtensionPoint;
}

View File

@ -12,7 +12,7 @@ import {
Toast,
Dialog,
} from "@halo-dev/components";
import { ref, defineAsyncComponent, type ComputedRef, type Ref } from "vue";
import { ref, defineAsyncComponent, type Ref } from "vue";
import type { DetailedUser } from "@halo-dev/api-client";
import { usePermission } from "@/utils/permission";
import { useQueryClient } from "@tanstack/vue-query";
@ -21,12 +21,21 @@ import { useFileDialog } from "@vueuse/core";
import { inject } from "vue";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
isCurrentUser?: boolean;
}>(),
{
isCurrentUser: false,
}
);
const queryClient = useQueryClient();
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const UserAvatarCropper = defineAsyncComponent(
() => import("../components/UserAvatarCropper.vue")
() => import("./UserAvatarCropper.vue")
);
interface IUserAvatarCropperType
@ -40,7 +49,6 @@ const { open, reset, onChange } = useFileDialog({
});
const user = inject<Ref<DetailedUser | undefined>>("user");
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
const userAvatarCropper = ref<IUserAvatarCropperType>();
const visibleCropperModal = ref(false);
@ -67,7 +75,7 @@ const handleUploadAvatar = () => {
apiClient.user
.uploadUserAvatar({
name: isCurrentUser?.value ? "-" : user.value.user.metadata.name,
name: props.isCurrentUser ? "-" : user.value.user.metadata.name,
file: file,
})
.then(() => {
@ -97,7 +105,7 @@ const handleRemoveCurrentAvatar = () => {
apiClient.user
.deleteUserAvatar({
name: isCurrentUser?.value ? "-" : user.value.user.metadata.name,
name: props.isCurrentUser ? "-" : user.value.user.metadata.name,
})
.then(() => {
queryClient.invalidateQueries({ queryKey: ["user-detail"] });

View File

@ -79,8 +79,6 @@ core:
button: Profile
visit_homepage:
title: Visit homepage
notifications:
button: Notifications
dashboard:
title: Dashboard
actions:
@ -1095,7 +1093,7 @@ core:
forbidden:
message: Unauthorized access to this page
actions:
dashboard: Dashboard
home: Back to home
setup:
title: Setup
operations:

View File

@ -1027,7 +1027,7 @@ core:
forbidden:
message: Acceso no autorizado a esta página
actions:
dashboard: Tablero
home: Ir a la página de inicio
setup:
title: Configuración
operations:

View File

@ -79,8 +79,6 @@ core:
button: 个人资料
visit_homepage:
title: 访问首页
notifications:
button: 我的消息
dashboard:
title: 仪表板
actions:
@ -1095,7 +1093,7 @@ core:
forbidden:
message: 没有权限访问此页面
actions:
dashboard: 仪表盘
home: 返回首页
setup:
title: 系统初始化
operations:

View File

@ -79,8 +79,6 @@ core:
button: 個人資料
visit_homepage:
title: 訪問首頁
notifications:
button: 我的訊息
dashboard:
title: 儀表板
actions:
@ -1095,7 +1093,7 @@ core:
forbidden:
message: 沒有權限訪問此頁面
actions:
dashboard: 儀表盤
home: 返回首頁
setup:
title: 系統初始化
operations:

View File

@ -1,5 +1,5 @@
import "@halo-dev/richtext-editor/dist/style.css";
import "@halo-dev/components/dist/style.css";
import "@console/styles/tailwind.css";
import "@console/styles/index.css";
import "@/styles/tailwind.css";
import "@/styles/index.css";
import "overlayscrollbars/overlayscrollbars.css";

View File

@ -34,8 +34,8 @@ const router = useRouter();
<VButton @click="router.back()">
{{ $t("core.common.buttons.back") }}
</VButton>
<VButton type="secondary" :route="{ name: 'Dashboard' }">
{{ $t("core.exception.actions.dashboard") }}
<VButton type="secondary" :route="{ path: '/' }">
{{ $t("core.exception.actions.home") }}
</VButton>
</VSpace>
</div>

View File

@ -1,19 +1,7 @@
<script lang="ts" setup>
import { useQuery } from "@tanstack/vue-query";
const { data, isLoading } = useQuery({
queryKey: ["user"],
queryFn: async () => {
const response = await fetch(`/apis/api.console.halo.run/v1alpha1/users/-`);
const data = await response.json();
return data;
},
});
import { RouterView } from "vue-router";
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else>
Hi {{ data.user.spec.displayName }}, here is user center page.
</div>
<RouterView />
</template>

View File

@ -0,0 +1,408 @@
<script lang="ts" setup>
import {
IconMore,
IconUserSettings,
VTag,
VAvatar,
Dialog,
VDropdown,
VDropdownItem,
} 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 { 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 { useUserStore } from "@/stores/user";
import { rbacAnnotations } from "@/constants/annotations";
import { defineStore, storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import {
useOverlayScrollbars,
type UseOverlayScrollbarsParams,
} from "overlayscrollbars-vue";
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const moreMenuVisible = ref(false);
const moreMenuRootVisible = ref(false);
const userStore = useUserStore();
const { currentRoles, currentUser } = storeToRefs(userStore);
const handleLogout = () => {
Dialog.warning({
title: t("core.sidebar.operations.logout.title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await axios.post(`${import.meta.env.VITE_API_URL}/logout`, undefined, {
withCredentials: true,
});
await userStore.fetchCurrentUser();
window.location.href = "/console/login";
} catch (error) {
console.error("Failed to logout", error);
}
},
});
};
// 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);
// aside scroll
const navbarScroller = ref();
const useNavbarScrollStore = defineStore("navbar", {
state: () => ({
y: 0,
}),
});
const navbarScrollStore = useNavbarScrollStore();
const reactiveParams = reactive<UseOverlayScrollbarsParams>({
options: {
scrollbars: {
autoHide: "scroll",
autoHideDelay: 600,
},
},
events: {
scroll: (_, onScrollArgs) => {
const target = onScrollArgs.target as HTMLElement;
navbarScrollStore.y = target.scrollTop;
},
updated: (instance) => {
const { viewport } = instance.elements();
if (!viewport) return;
viewport.scrollTo({ top: navbarScrollStore.y });
},
},
});
const [initialize] = useOverlayScrollbars(reactiveParams);
onMounted(() => {
if (navbarScroller.value) {
initialize({ target: navbarScroller.value });
}
});
function handleRouteToConsole() {
window.location.href = "/console";
}
</script>
<template>
<div class="flex h-full">
<aside
class="navbar fixed hidden h-full overflow-y-auto md:flex md:flex-col"
>
<div class="logo flex justify-center pb-5 pt-5">
<a
href="/"
target="_blank"
:title="$t('core.sidebar.operations.visit_homepage.title')"
>
<IconLogo
class="cursor-pointer select-none transition-all hover:brightness-125"
/>
</a>
</div>
<div ref="navbarScroller" class="flex-1 overflow-y-hidden">
<RoutesMenu :menus="menus" />
</div>
<div class="profile-placeholder">
<div class="current-profile">
<div v-if="currentUser?.spec.avatar" class="profile-avatar">
<VAvatar
:src="currentUser?.spec.avatar"
:alt="currentUser?.spec.displayName"
size="md"
circle
></VAvatar>
</div>
<div class="profile-name">
<div class="flex text-sm font-medium">
{{ currentUser?.spec.displayName }}
</div>
<div v-if="currentRoles?.[0]" class="flex">
<VTag>
<template #leftIcon>
<IconUserSettings />
</template>
{{
currentRoles[0].metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || currentRoles[0].metadata.name
}}
</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>
</div>
</aside>
<main class="content w-full pb-12 mb-safe md:pb-0">
<slot v-if="$slots.default" />
<RouterView v-else />
</main>
<!--bottom nav bar-->
<div
v-if="minimenus"
class="bottom-nav-bar fixed bottom-0 left-0 right-0 grid grid-cols-6 border-t-2 border-black bg-secondary drop-shadow-2xl mt-safe pb-safe md:hidden"
>
<div
v-for="(menu, index) in minimenus"
:key="index"
:class="{ 'bg-black': route.path === menu?.path }"
class="nav-item"
@click="router.push(menu?.path)"
>
<div
class="flex w-full cursor-pointer items-center justify-center p-1 text-white"
>
<div class="flex h-10 w-10 flex-col items-center justify-center">
<div class="text-base">
<Component :is="menu?.icon" />
</div>
</div>
</div>
</div>
<div class="nav-item" @click="moreMenuVisible = true">
<div
class="flex w-full cursor-pointer items-center justify-center p-1 text-white"
>
<div class="flex h-10 w-10 flex-col items-center justify-center">
<div class="text-base">
<IconMore />
</div>
</div>
</div>
</div>
<Teleport to="body">
<div
v-show="moreMenuRootVisible"
class="drawer-wrapper fixed left-0 top-0 z-[99999] flex h-full w-full flex-row items-end justify-center"
>
<transition
enter-active-class="ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
@before-enter="moreMenuRootVisible = true"
@after-leave="moreMenuRootVisible = false"
>
<div
v-show="moreMenuVisible"
class="drawer-layer absolute left-0 top-0 h-full w-full flex-none bg-gray-500 bg-opacity-75 transition-opacity"
@click="moreMenuVisible = false"
></div>
</transition>
<transition
enter-active-class="transform transition ease-in-out duration-500 sm:duration-700"
enter-from-class="translate-y-full"
enter-to-class="translate-y-0"
leave-active-class="transform transition ease-in-out duration-500 sm:duration-700"
leave-from-class="translate-y-0"
leave-to-class="translate-y-full"
>
<div
v-show="moreMenuVisible"
class="drawer-content relative flex h-3/4 w-screen flex-col items-stretch overflow-y-auto rounded-t-md bg-white shadow-xl"
>
<div class="drawer-body">
<RoutesMenu
:menus="menus"
class="p-0"
@select="moreMenuVisible = false"
/>
</div>
</div>
</transition>
</div>
</Teleport>
</div>
</div>
<LoginModal />
</template>
<style lang="scss">
.navbar {
@apply w-64;
@apply bg-white;
@apply shadow;
z-index: 999;
.profile-placeholder {
height: 70px;
.current-profile {
height: 70px;
@apply fixed
bottom-0
left-0
flex
w-64
gap-3
bg-white
p-3;
.profile-avatar {
@apply flex
items-center
self-center;
}
.profile-name {
@apply flex-1
self-center;
}
.profile-control {
@apply self-center;
}
}
}
}
.content {
@apply ml-0
flex
flex-auto
flex-col
overflow-x-hidden
md:ml-64;
}
</style>

View File

@ -1,9 +1,16 @@
import { createApp } from "vue";
import "@/setup/setupStyles";
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 { createPinia } from "pinia";
import { setupCoreModules, setupPluginModules } from "@uc/setup/setupModules";
import router from "@uc/router";
import { useUserStore } from "@/stores/user";
import { apiClient } from "@/utils/api-client";
import { useRoleStore } from "@/stores/role";
import { hasPermission } from "@/utils/permission";
const app = createApp(App);
@ -13,10 +20,54 @@ setupVueQuery(app);
app.use(createPinia());
async function loadUserPermissions() {
const { data: currentPermissions } = await apiClient.user.getPermissions({
name: "-",
});
const roleStore = useRoleStore();
roleStore.$patch({
permissions: currentPermissions,
});
app.directive(
"permission",
(el: HTMLElement, binding: DirectiveBinding<string[]>) => {
const uiPermissions = Array.from<string>(
currentPermissions.uiPermissions
);
const { value } = binding;
const { any, enable } = binding.modifiers;
if (hasPermission(uiPermissions, value, any)) {
return;
}
if (enable) {
//TODO
return;
}
el?.remove?.();
}
);
}
(async function () {
await initApp();
})();
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);
}
app.use(router);
app.mount("#app");
}

View File

@ -0,0 +1,10 @@
import type { PluginModule } from "@halo-dev/console-shared";
const modules = Object.values(
import.meta.glob("./**/module.ts", {
eager: true,
import: "default",
})
) as PluginModule[];
export default modules;

View File

@ -0,0 +1,31 @@
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@uc/layouts/BasicLayout.vue";
import { IconNotificationBadgeLine } from "@halo-dev/components";
import { markRaw } from "vue";
import Notifications from "./Notifications.vue";
export default definePlugin({
ucRoutes: [
{
path: "/notifications",
component: BasicLayout,
children: [
{
path: "",
name: "Notifications",
component: Notifications,
meta: {
title: "消息",
searchable: true,
menu: {
name: "消息",
icon: markRaw(IconNotificationBadgeLine),
priority: 1,
mobile: true,
},
},
},
],
},
],
});

View File

@ -0,0 +1,152 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import {
VButton,
VTabbar,
VDropdown,
VDropdownItem,
VLoading,
} from "@halo-dev/components";
import { computed, provide, ref, type Ref } from "vue";
import type { DetailedUser } from "@halo-dev/api-client";
import ProfileEditingModal from "./components/ProfileEditingModal.vue";
import PasswordChangeModal from "./components/PasswordChangeModal.vue";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { rbacAnnotations } from "@/constants/annotations";
import UserAvatar from "@/components/user-avatar/UserAvatar.vue";
import type { Raw } from "vue";
import type { Component } from "vue";
import { markRaw } from "vue";
import DetailTab from "./tabs/Detail.vue";
import PersonalAccessTokensTab from "./tabs/PersonalAccessTokens.vue";
import { useRouteQuery } from "@vueuse/router";
import NotificationPreferences from "./tabs/NotificationPreferences.vue";
const { t } = useI18n();
interface UserTab {
id: string;
label: string;
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
priority: number;
hidden?: boolean;
}
const editingModal = ref(false);
const passwordChangeModal = ref(false);
const {
data: user,
isFetching,
isLoading,
refetch,
} = useQuery({
queryKey: ["user-detail"],
queryFn: async () => {
const { data } = await apiClient.user.getCurrentUserDetail();
return data;
},
refetchInterval: (data) => {
const annotations = data?.user.metadata.annotations;
return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !==
annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME]
? 1000
: false;
},
});
provide<Ref<DetailedUser | undefined>>("user", user);
const tabs: UserTab[] = [
{
id: "detail",
label: t("core.user.detail.tabs.detail"),
component: markRaw(DetailTab),
priority: 10,
},
{
id: "notification-preferences",
label: t("core.user.detail.tabs.notification-preferences"),
component: markRaw(NotificationPreferences),
priority: 20,
},
{
id: "pat",
label: t("core.user.detail.tabs.pat"),
component: markRaw(PersonalAccessTokensTab),
priority: 30,
},
];
const tabbarItems = computed(() => {
return tabs.map((tab) => ({ id: tab.id, label: tab.label }));
});
const activeTab = useRouteQuery<string>("tab", tabs[0].id, {
mode: "push",
});
</script>
<template>
<ProfileEditingModal v-model:visible="editingModal" :user="user?.user" />
<PasswordChangeModal
v-model:visible="passwordChangeModal"
:user="user?.user"
@close="refetch"
/>
<header class="bg-white">
<div class="p-4">
<div class="flex items-center justify-between">
<div class="flex flex-row items-center gap-5">
<div class="group relative h-20 w-20">
<VLoading v-if="isFetching" class="h-full w-full" />
<UserAvatar v-else is-current-user />
</div>
<div class="block">
<h1 class="truncate text-lg font-bold text-gray-900">
{{ user?.user.spec.displayName }}
</h1>
<span v-if="!isLoading" class="text-sm text-gray-600">
@{{ user?.user.metadata.name }}
</span>
</div>
</div>
<div>
<VDropdown>
<VButton type="default">
{{ $t("core.common.buttons.edit") }}
</VButton>
<template #popper>
<VDropdownItem @click="editingModal = true">
{{ $t("core.user.detail.actions.update_profile.title") }}
</VDropdownItem>
<VDropdownItem @click="passwordChangeModal = true">
{{ $t("core.user.detail.actions.change_password.title") }}
</VDropdownItem>
</template>
</VDropdown>
</div>
</div>
</div>
</header>
<section class="bg-white p-4">
<VTabbar
v-model:active-id="activeTab"
:items="tabbarItems"
class="w-full"
type="outline"
></VTabbar>
<div class="mt-2">
<template v-for="tab in tabs" :key="tab.id">
<component
:is="tab.component"
v-if="activeTab === tab.id && !tab.hidden"
/>
</template>
</div>
</section>
</template>

View File

@ -0,0 +1,138 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace } from "@halo-dev/components";
import SubmitButton from "@/components/button/SubmitButton.vue";
import { ref, watch } from "vue";
import type { User } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import cloneDeep from "lodash.clonedeep";
import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
const props = withDefaults(
defineProps<{
visible: boolean;
user?: User;
}>(),
{
visible: false,
user: undefined,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
interface PasswordChangeFormState {
password: string;
password_confirm?: string;
}
const initialFormState: PasswordChangeFormState = {
password: "",
password_confirm: "",
};
const formState = ref<PasswordChangeFormState>(cloneDeep(initialFormState));
const saving = ref(false);
watch(
() => props.visible,
(visible) => {
if (visible) {
setFocus("passwordInput");
} else {
handleResetForm();
}
}
);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
reset("password-form");
};
const handleChangePassword = async () => {
try {
saving.value = true;
const changePasswordRequest = cloneDeep(formState.value);
delete changePasswordRequest.password_confirm;
await apiClient.user.changePassword({
name: "-",
changePasswordRequest,
});
onVisibleChange(false);
} catch (e) {
console.error(e);
} finally {
saving.value = false;
}
};
</script>
<template>
<VModal
:visible="visible"
:width="500"
:title="$t('core.user.change_password_modal.title')"
@update:visible="onVisibleChange"
>
<FormKit
id="password-form"
v-model="formState"
name="password-form"
:actions="false"
type="form"
:config="{ validationVisibility: 'submit' }"
@submit="handleChangePassword"
>
<FormKit
id="passwordInput"
:label="$t('core.user.change_password_modal.fields.new_password.label')"
name="password"
type="password"
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
:validation-messages="{
matches: $t('core.formkit.validation.trim'),
}"
></FormKit>
<FormKit
:label="
$t('core.user.change_password_modal.fields.confirm_password.label')
"
name="password_confirm"
type="password"
validation="confirm|required:trim|length:5,100|matches:/^\S.*\S$/"
:validation-messages="{
matches: $t('core.formkit.validation.trim'),
}"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<SubmitButton
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('password-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -1,7 +1,6 @@
<script lang="ts" setup>
import SubmitButton from "@/components/button/SubmitButton.vue";
import { patAnnotations, rbacAnnotations } from "@/constants/annotations";
import { pluginLabels } from "@/constants/labels";
import { apiClient } from "@/utils/api-client";
import { toISOString } from "@/utils/date";
import { Dialog, Toast, VButton, VModal, VSpace } from "@halo-dev/components";
@ -10,7 +9,7 @@ import { useClipboard } from "@vueuse/core";
import type { PatSpec, PersonalAccessToken } from "@halo-dev/api-client";
import { computed } from "vue";
import { ref } from "vue";
import { useRoleTemplateSelection } from "../../roles/composables/use-role";
import { useRoleTemplateSelection } from "@/composables/use-role";
import { useRoleStore } from "@/stores/role";
import { toRefs } from "vue";
import { useI18n } from "vue-i18n";
@ -170,36 +169,6 @@ const { copy } = useClipboard({
<div>
{{ $t(`core.rbac.${group.module}`, group.module as string) }}
</div>
<div
v-if="
group.roles.length &&
group.roles[0].metadata.labels?.[pluginLabels.NAME]
"
class="mt-3 text-xs text-gray-500"
>
<i18n-t
keypath="core.role.common.text.provided_by_plugin"
tag="div"
>
<template #plugin>
<RouterLink
:to="{
name: 'PluginDetail',
params: {
name: group.roles[0].metadata.labels?.[
pluginLabels.NAME
],
},
}"
class="hover:text-blue-600"
>
{{
group.roles[0].metadata.labels?.[pluginLabels.NAME]
}}
</RouterLink>
</template>
</i18n-t>
</div>
</dt>
<dd class="text-sm text-gray-900">
<ul class="space-y-2">

View File

@ -0,0 +1,185 @@
<script lang="ts" setup>
// core libs
import { ref, watch } from "vue";
import { apiClient } from "@/utils/api-client";
import type { User } from "@halo-dev/api-client";
// components
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import SubmitButton from "@/components/button/SubmitButton.vue";
// libs
import cloneDeep from "lodash.clonedeep";
import { reset } from "@formkit/core";
// hooks
import { setFocus } from "@/formkit/utils/focus";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
visible: boolean;
user?: User;
}>(),
{
visible: false,
user: undefined,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const initialFormState: User = {
spec: {
displayName: "",
email: "",
phone: "",
password: "",
bio: "",
disabled: false,
loginHistoryLimit: 0,
},
apiVersion: "v1alpha1",
kind: "User",
metadata: {
name: "",
},
};
const formState = ref<User>(cloneDeep(initialFormState));
const saving = ref(false);
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
reset("user-form");
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.user) formState.value = cloneDeep(props.user);
setFocus("displayNameInput");
} else {
handleResetForm();
}
}
);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleUpdateUser = async () => {
try {
saving.value = true;
await apiClient.user.updateCurrentUser({
user: formState.value,
});
onVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["profile"] });
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to update profile", e);
} finally {
saving.value = false;
}
};
</script>
<template>
<VModal
:title="$t('core.user.editing_modal.titles.update')"
:visible="visible"
:width="700"
@update:visible="onVisibleChange"
>
<FormKit
id="user-form"
name="user-form"
:config="{ validationVisibility: 'submit' }"
type="form"
@submit="handleUpdateUser"
>
<div>
<div class="md:grid md:grid-cols-4 md:gap-6">
<div class="md:col-span-1">
<div class="sticky top-0">
<span class="text-base font-medium text-gray-900">
{{ $t("core.user.editing_modal.groups.general") }}
</span>
</div>
</div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
id="userNameInput"
v-model="formState.metadata.name"
:disabled="true"
:label="$t('core.user.editing_modal.fields.username.label')"
type="text"
name="name"
></FormKit>
<FormKit
id="displayNameInput"
v-model="formState.spec.displayName"
:label="$t('core.user.editing_modal.fields.display_name.label')"
type="text"
name="displayName"
validation="required|length:0,50"
></FormKit>
<FormKit
v-model="formState.spec.email"
:label="$t('core.user.editing_modal.fields.email.label')"
type="email"
name="email"
validation="required|email|length:0,100"
></FormKit>
<FormKit
v-model="formState.spec.phone"
:label="$t('core.user.editing_modal.fields.phone.label')"
type="text"
name="phone"
validation="length:0,20"
></FormKit>
<FormKit
v-model="formState.spec.bio"
:label="$t('core.user.editing_modal.fields.bio.label')"
type="textarea"
name="bio"
validation="length:0,2048"
></FormKit>
</div>
</div>
</div>
</FormKit>
<template #footer>
<VSpace>
<SubmitButton
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('user-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,45 @@
import type { Ref } from "vue";
import { onMounted, ref } from "vue";
import type { User } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
interface useUserFetchReturn {
users: Ref<User[]>;
loading: Ref<boolean>;
handleFetchUsers: () => void;
}
export function useUserFetch(options?: {
fetchOnMounted: boolean;
}): useUserFetchReturn {
const { fetchOnMounted } = options || {};
const users = ref<User[]>([] as User[]);
const loading = ref(false);
const ANONYMOUSUSER_NAME = "anonymousUser";
const handleFetchUsers = async () => {
try {
loading.value = true;
const { data } = await apiClient.extension.user.listv1alpha1User({
fieldSelector: [`name!=${ANONYMOUSUSER_NAME}`],
});
users.value = data.items;
} catch (e) {
console.error("Failed to fetch users", e);
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchOnMounted && handleFetchUsers();
});
return {
users,
loading,
handleFetchUsers,
};
}

View File

@ -0,0 +1,33 @@
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@uc/layouts/BasicLayout.vue";
import { IconUserLine } from "@halo-dev/components";
import { markRaw } from "vue";
import Profile from "./Profile.vue";
export default definePlugin({
ucRoutes: [
{
path: "/",
component: BasicLayout,
name: "Root",
redirect: "/profile",
children: [
{
path: "profile",
name: "Profile",
component: Profile,
meta: {
title: "个人资料",
searchable: true,
menu: {
name: "我的",
icon: markRaw(IconUserLine),
priority: 0,
mobile: true,
},
},
},
],
},
],
});

View File

@ -0,0 +1,166 @@
<script lang="ts" setup>
import {
Dialog,
IconUserSettings,
VButton,
VDescription,
VDescriptionItem,
VTag,
} 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";
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 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;
},
});
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">
<VDescription>
<VDescriptionItem
:label="$t('core.user.detail.fields.display_name')"
:content="user?.user.spec.displayName"
class="!px-2"
/>
<VDescriptionItem
:label="$t('core.user.detail.fields.username')"
:content="user?.user.metadata.name"
class="!px-2"
/>
<VDescriptionItem
:label="$t('core.user.detail.fields.email')"
:content="user?.user.spec.email || $t('core.common.text.none')"
class="!px-2"
/>
<VDescriptionItem
: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 },
})
"
>
<template #leftIcon>
<IconUserSettings />
</template>
{{
role.metadata.annotations?.[rbacAnnotations.DISPLAY_NAME] ||
role.metadata.name
}}
</VTag>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.user.detail.fields.bio')"
:content="user?.user.spec?.bio || $t('core.common.text.none')"
class="!px-2"
/>
<VDescriptionItem
:label="$t('core.user.detail.fields.creation_time')"
: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,22 @@
import {
createRouter,
createWebHistory,
type RouteLocationNormalized,
type RouteLocationNormalizedLoaded,
} from "vue-router";
import routesConfig from "@uc/router/routes.config";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routesConfig,
scrollBehavior: (
to: RouteLocationNormalized,
from: RouteLocationNormalizedLoaded
) => {
if (to.name !== from.name) {
return { left: 0, top: 0 };
}
},
});
export default router;

View File

@ -0,0 +1,54 @@
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> = [
{
path: "/:pathMatch(.*)*",
component: BasicLayout,
children: [{ path: "", name: "NotFound", component: NotFound }],
},
{
path: "/403",
component: BasicLayout,
children: [
{
path: "",
name: "Forbidden",
component: Forbidden,
},
],
},
];
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

@ -0,0 +1,108 @@
import { i18n } from "@/locales";
import modules from "@uc/modules";
import router from "@uc/router";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule, RouteRecordAppend } from "@halo-dev/console-shared";
import { useScriptTag } from "@vueuse/core";
import { Toast } from "@halo-dev/components";
import type { App } from "vue";
import type { RouteRecordRaw } from "vue-router";
import { loadStyle } from "@/utils/load-style";
export function setupCoreModules(app: App) {
modules.forEach((module) => {
registerModule(app, module, true);
});
}
export async function setupPluginModules(app: App) {
const pluginModuleStore = usePluginModuleStore();
try {
const { load } = useScriptTag(
`${
import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js`
);
await load();
const enabledPluginNames = window["enabledPluginNames"] as string[];
enabledPluginNames.forEach((name) => {
const module = window[name];
if (module) {
registerModule(app, module, false);
pluginModuleStore.registerPluginModule(name, module);
}
});
} catch (e) {
const message = i18n.global.t("core.plugin.loader.toast.entry_load_failed");
console.error(message, e);
Toast.error(message);
}
try {
await loadStyle(
`${
import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css`
);
} catch (e) {
const message = i18n.global.t("core.plugin.loader.toast.style_load_failed");
console.error(message, e);
Toast.error(message);
}
}
function registerModule(app: App, pluginModule: PluginModule, core: boolean) {
if (pluginModule.components) {
Object.keys(pluginModule.components).forEach((key) => {
const component = pluginModule.components?.[key];
if (component) {
app.component(key, component);
}
});
}
if (pluginModule.ucRoutes) {
if (!Array.isArray(pluginModule.ucRoutes)) {
return;
}
resetRouteMeta(pluginModule.ucRoutes);
for (const route of pluginModule.ucRoutes) {
if ("parentName" in route) {
router.addRoute(route.parentName, route.route);
} else {
router.addRoute(route);
}
}
}
function resetRouteMeta(routes: RouteRecordRaw[] | RouteRecordAppend[]) {
for (const route of routes) {
if ("parentName" in route) {
if (route.route.meta?.menu) {
route.route.meta = {
...route.route.meta,
core,
};
}
if (route.route.children) {
resetRouteMeta(route.route.children);
}
} else {
if (route.meta?.menu) {
route.meta = {
...route.meta,
core,
};
}
if (route.children) {
resetRouteMeta(route.children);
}
}
}
}
}