mirror of https://github.com/halo-dev/halo
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
parent
52d064381f
commit
b0aec48c7c
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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";
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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") }}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -96,5 +96,7 @@ export interface PluginModule {
|
|||
|
||||
routes?: RouteRecordRaw[] | RouteRecordAppend[];
|
||||
|
||||
ucRoutes?: RouteRecordRaw[] | RouteRecordAppend[];
|
||||
|
||||
extensionPoints?: ExtensionPoint;
|
||||
}
|
||||
|
|
|
@ -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"] });
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
|
@ -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>
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue