mirror of https://github.com/halo-dev/halo
refactor: user detail page structure (#4664)
#### What type of PR is this? /area console /kind improvement /milestone 2.10.x #### What this PR does / why we need it: 重构 Console 端用户详情页面的结构。 1. 提高代码可读性。 2. 使用问号参数来区分不同的选项卡。 3. 封装头像修改相关的代码为组件。 #### Special notes for your reviewer: 测试用户详情页面的所有功能是否正常。 #### Does this PR introduce a user-facing change? ```release-note 重构 Console 端用户详情页面的代码结构。 ```pull/4665/head
parent
a5a69780a3
commit
470b0de70d
|
@ -1,168 +1,199 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
Dialog,
|
||||
IconUserSettings,
|
||||
VButton,
|
||||
VDescription,
|
||||
VDescriptionItem,
|
||||
VTag,
|
||||
} from "@halo-dev/components";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
import { inject, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
|
||||
import { 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 {
|
||||
VButton,
|
||||
VTabbar,
|
||||
VDropdown,
|
||||
VDropdownItem,
|
||||
VLoading,
|
||||
} from "@halo-dev/components";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
type ComputedRef,
|
||||
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 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";
|
||||
|
||||
const user = inject<Ref<DetailedUser | undefined>>("user");
|
||||
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
|
||||
|
||||
const router = useRouter();
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const userStore = useUserStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: authProviders, isFetching } = useQuery<ListedAuthProvider[]>({
|
||||
queryKey: ["user-auth-providers"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.authProvider.listAuthProviders();
|
||||
return data;
|
||||
},
|
||||
enabled: isCurrentUser,
|
||||
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 { params } = useRoute();
|
||||
const name = ref();
|
||||
|
||||
onMounted(() => {
|
||||
name.value = params.name;
|
||||
});
|
||||
|
||||
const availableAuthProviders = computed(() => {
|
||||
return authProviders.value?.filter(
|
||||
(authProvider) => authProvider.enabled && authProvider.supportsBinding
|
||||
// Update name when route change
|
||||
onBeforeRouteUpdate((to, _, next) => {
|
||||
name.value = to.params.name;
|
||||
next();
|
||||
});
|
||||
|
||||
const {
|
||||
data: user,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["user-detail", 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;
|
||||
}
|
||||
},
|
||||
refetchInterval: (data) => {
|
||||
const annotations = data?.user.metadata.annotations;
|
||||
return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !==
|
||||
annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME]
|
||||
? 1000
|
||||
: false;
|
||||
},
|
||||
enabled: computed(() => !!name.value),
|
||||
});
|
||||
|
||||
const isCurrentUser = computed(() => {
|
||||
if (name.value === "-") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
user.value?.user.metadata.name === userStore.currentUser?.metadata.name
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
||||
provide<Ref<DetailedUser | undefined>>("user", user);
|
||||
provide<ComputedRef<boolean>>("isCurrentUser", isCurrentUser);
|
||||
|
||||
window.location.reload();
|
||||
const tabs = computed((): UserTab[] => {
|
||||
return [
|
||||
{
|
||||
id: "detail",
|
||||
label: t("core.user.detail.tabs.detail"),
|
||||
component: markRaw(DetailTab),
|
||||
priority: 10,
|
||||
},
|
||||
});
|
||||
};
|
||||
{
|
||||
id: "pat",
|
||||
label: t("core.user.detail.tabs.pat"),
|
||||
component: markRaw(PersonalAccessTokensTab),
|
||||
priority: 20,
|
||||
hidden: !isCurrentUser.value,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const handleBindAuth = (authProvider: ListedAuthProvider) => {
|
||||
if (!authProvider.bindingUrl) {
|
||||
return;
|
||||
}
|
||||
window.location.href = `${
|
||||
authProvider.bindingUrl
|
||||
}?redirect_uri=${encodeURIComponent(window.location.href)}`;
|
||||
};
|
||||
const activeTab = useRouteQuery<string>("tab", tabs.value[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 }));
|
||||
});
|
||||
</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 },
|
||||
})
|
||||
<UserEditingModal v-model:visible="editingModal" :user="user?.user" />
|
||||
|
||||
<UserPasswordChangeModal
|
||||
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 />
|
||||
</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
|
||||
v-if="
|
||||
currentUserHasPermission(['system:users:manage']) || isCurrentUser
|
||||
"
|
||||
>
|
||||
<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>
|
||||
<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,186 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import {
|
||||
IconRiPencilFill,
|
||||
VButton,
|
||||
VAvatar,
|
||||
VDropdown,
|
||||
VDropdownItem,
|
||||
VModal,
|
||||
VSpace,
|
||||
Toast,
|
||||
Dialog,
|
||||
} from "@halo-dev/components";
|
||||
import { ref, defineAsyncComponent, type ComputedRef, type Ref } from "vue";
|
||||
import type { DetailedUser } from "@halo-dev/api-client";
|
||||
import { usePermission } from "@/utils/permission";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFileDialog } from "@vueuse/core";
|
||||
import { inject } from "vue";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const { t } = useI18n();
|
||||
|
||||
const UserAvatarCropper = defineAsyncComponent(
|
||||
() => import("../components/UserAvatarCropper.vue")
|
||||
);
|
||||
|
||||
interface IUserAvatarCropperType
|
||||
extends Ref<InstanceType<typeof UserAvatarCropper>> {
|
||||
getCropperFile(): Promise<File>;
|
||||
}
|
||||
|
||||
const { open, reset, onChange } = useFileDialog({
|
||||
accept: ".jpg, .jpeg, .png",
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const user = inject<Ref<DetailedUser | undefined>>("user");
|
||||
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
|
||||
|
||||
const userAvatarCropper = ref<IUserAvatarCropperType>();
|
||||
const showAvatarEditor = ref(false);
|
||||
const visibleCropperModal = ref(false);
|
||||
const originalFile = ref<File>() as Ref<File>;
|
||||
|
||||
onChange((files) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
if (files.length > 0) {
|
||||
originalFile.value = files[0];
|
||||
visibleCropperModal.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const uploadSaving = ref(false);
|
||||
const handleUploadAvatar = () => {
|
||||
userAvatarCropper.value?.getCropperFile().then((file) => {
|
||||
if (!user?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
uploadSaving.value = true;
|
||||
|
||||
apiClient.user
|
||||
.uploadUserAvatar({
|
||||
name: user.value.user.metadata.name,
|
||||
file: file,
|
||||
})
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
|
||||
handleCloseCropperModal();
|
||||
})
|
||||
.catch(() => {
|
||||
Toast.error(t("core.user.detail.avatar.toast_upload_failed"));
|
||||
})
|
||||
.finally(() => {
|
||||
uploadSaving.value = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCurrentAvatar = () => {
|
||||
Dialog.warning({
|
||||
title: t("core.user.detail.avatar.remove.title"),
|
||||
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||
confirmType: "danger",
|
||||
confirmText: t("core.common.buttons.confirm"),
|
||||
cancelText: t("core.common.buttons.cancel"),
|
||||
onConfirm: async () => {
|
||||
if (!user?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
apiClient.user
|
||||
.deleteUserAvatar({
|
||||
name: user.value.user.metadata.name,
|
||||
})
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
|
||||
})
|
||||
.catch(() => {
|
||||
Toast.error(t("core.user.detail.avatar.toast_remove_failed"));
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseCropperModal = () => {
|
||||
visibleCropperModal.value = false;
|
||||
reset();
|
||||
};
|
||||
|
||||
const changeUploadAvatar = () => {
|
||||
reset();
|
||||
open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-full w-full"
|
||||
@mouseover="showAvatarEditor = true"
|
||||
@mouseout="showAvatarEditor = false"
|
||||
>
|
||||
<VAvatar
|
||||
:src="user?.user.spec.avatar"
|
||||
:alt="user?.user.spec.displayName"
|
||||
circle
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="ring-4 ring-white drop-shadow-md"
|
||||
/>
|
||||
<VDropdown
|
||||
v-if="currentUserHasPermission(['system:users:manage']) || isCurrentUser"
|
||||
>
|
||||
<div
|
||||
v-show="showAvatarEditor"
|
||||
class="absolute left-0 right-0 top-0 h-full w-full cursor-pointer rounded-full border-0 bg-black/60 text-center leading-[5rem] transition-opacity duration-300 group-hover:opacity-100"
|
||||
>
|
||||
<IconRiPencilFill
|
||||
class="inline-block w-full self-center text-2xl text-white"
|
||||
/>
|
||||
</div>
|
||||
<template #popper>
|
||||
<VDropdownItem @click="open()">
|
||||
{{ $t("core.common.buttons.upload") }}
|
||||
</VDropdownItem>
|
||||
<VDropdownItem @click="handleRemoveCurrentAvatar">
|
||||
{{ $t("core.common.buttons.delete") }}
|
||||
</VDropdownItem>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</div>
|
||||
|
||||
<VModal
|
||||
:visible="visibleCropperModal"
|
||||
:width="1200"
|
||||
:title="$t('core.user.detail.avatar.cropper_modal.title')"
|
||||
mount-to-body
|
||||
@update:visible="handleCloseCropperModal"
|
||||
>
|
||||
<UserAvatarCropper
|
||||
ref="userAvatarCropper"
|
||||
:file="originalFile"
|
||||
@change-file="changeUploadAvatar"
|
||||
/>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
v-if="visibleCropperModal"
|
||||
:loading="uploadSaving"
|
||||
type="secondary"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
{{ $t("core.common.buttons.submit") }}
|
||||
</VButton>
|
||||
<VButton @click="handleCloseCropperModal">
|
||||
{{ $t("core.common.buttons.cancel") }}
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
|
@ -1,355 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import {
|
||||
IconRiPencilFill,
|
||||
VButton,
|
||||
VTabbar,
|
||||
VAvatar,
|
||||
VDropdown,
|
||||
VDropdownItem,
|
||||
VModal,
|
||||
VSpace,
|
||||
Toast,
|
||||
VLoading,
|
||||
Dialog,
|
||||
} from "@halo-dev/components";
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
provide,
|
||||
ref,
|
||||
watch,
|
||||
defineAsyncComponent,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from "vue";
|
||||
import { useRoute, useRouter } 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 { useFileDialog } from "@vueuse/core";
|
||||
import { rbacAnnotations } from "@/constants/annotations";
|
||||
import { onBeforeRouteUpdate } from "vue-router";
|
||||
|
||||
const UserAvatarCropper = defineAsyncComponent(
|
||||
() => import("../components/UserAvatarCropper.vue")
|
||||
);
|
||||
|
||||
interface IUserAvatarCropperType
|
||||
extends Ref<InstanceType<typeof UserAvatarCropper>> {
|
||||
getCropperFile(): Promise<File>;
|
||||
}
|
||||
|
||||
const { open, reset, onChange } = useFileDialog({
|
||||
accept: ".jpg, .jpeg, .png",
|
||||
multiple: false,
|
||||
});
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const userStore = useUserStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
id: "detail",
|
||||
label: t("core.user.detail.tabs.detail"),
|
||||
routeName: "UserDetail",
|
||||
},
|
||||
]);
|
||||
|
||||
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,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["user-detail", 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;
|
||||
}
|
||||
},
|
||||
refetchInterval: (data) => {
|
||||
const annotations = data?.user.metadata.annotations;
|
||||
return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !==
|
||||
annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME]
|
||||
? 1000
|
||||
: false;
|
||||
},
|
||||
enabled: computed(() => !!name.value),
|
||||
});
|
||||
|
||||
const isCurrentUser = computed(() => {
|
||||
if (name.value === "-") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
user.value?.user.metadata.name === userStore.currentUser?.metadata.name
|
||||
);
|
||||
});
|
||||
|
||||
provide<Ref<DetailedUser | undefined>>("user", user);
|
||||
provide<ComputedRef<boolean>>("isCurrentUser", isCurrentUser);
|
||||
|
||||
// fixme: refactor this component to simplify the logic
|
||||
watch(
|
||||
() => isCurrentUser.value,
|
||||
(value) => {
|
||||
if (value) {
|
||||
tabs.value.push({
|
||||
id: "tokens",
|
||||
label: t("core.user.detail.tabs.pat"),
|
||||
routeName: "PersonalAccessTokens",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const activeTab = ref();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// set default active tab
|
||||
onMounted(() => {
|
||||
const tab = tabs.value.find((tab) => tab.routeName === route.name);
|
||||
activeTab.value = tab ? tab.id : tabs.value[0].id;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
async (newRouteName) => {
|
||||
const tab = tabs.value.find((tab) => tab.routeName === newRouteName);
|
||||
activeTab.value = tab ? tab.id : tabs.value[0].id;
|
||||
}
|
||||
);
|
||||
|
||||
const handleTabChange = (id: string) => {
|
||||
const tab = tabs.value.find((tab) => tab.id === id);
|
||||
if (tab) {
|
||||
router.push({ name: tab.routeName });
|
||||
}
|
||||
};
|
||||
|
||||
const userAvatarCropper = ref<IUserAvatarCropperType>();
|
||||
const showAvatarEditor = ref(false);
|
||||
const visibleCropperModal = ref(false);
|
||||
const originalFile = ref<File>() as Ref<File>;
|
||||
onChange((files) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
if (files.length > 0) {
|
||||
originalFile.value = files[0];
|
||||
visibleCropperModal.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const uploadSaving = ref(false);
|
||||
const handleUploadAvatar = () => {
|
||||
userAvatarCropper.value?.getCropperFile().then((file) => {
|
||||
uploadSaving.value = true;
|
||||
apiClient.user
|
||||
.uploadUserAvatar({
|
||||
name: name.value,
|
||||
file: file,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
handleCloseCropperModal();
|
||||
})
|
||||
.catch(() => {
|
||||
Toast.error(t("core.user.detail.avatar.toast_upload_failed"));
|
||||
})
|
||||
.finally(() => {
|
||||
uploadSaving.value = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCurrentAvatar = () => {
|
||||
Dialog.warning({
|
||||
title: t("core.user.detail.avatar.remove.title"),
|
||||
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||
confirmType: "danger",
|
||||
confirmText: t("core.common.buttons.confirm"),
|
||||
cancelText: t("core.common.buttons.cancel"),
|
||||
onConfirm: async () => {
|
||||
apiClient.user
|
||||
.deleteUserAvatar({
|
||||
name: name.value as string,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
Toast.error(t("core.user.detail.avatar.toast_remove_failed"));
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseCropperModal = () => {
|
||||
visibleCropperModal.value = false;
|
||||
reset();
|
||||
};
|
||||
|
||||
const changeUploadAvatar = () => {
|
||||
reset();
|
||||
open();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<UserEditingModal v-model:visible="editingModal" :user="user?.user" />
|
||||
|
||||
<UserPasswordChangeModal
|
||||
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" />
|
||||
<div
|
||||
v-else
|
||||
class="h-full w-full"
|
||||
@mouseover="showAvatarEditor = true"
|
||||
@mouseout="showAvatarEditor = false"
|
||||
>
|
||||
<VAvatar
|
||||
v-if="user"
|
||||
:src="user.user.spec.avatar"
|
||||
:alt="user.user.spec.displayName"
|
||||
circle
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="ring-4 ring-white drop-shadow-md"
|
||||
/>
|
||||
<VDropdown
|
||||
v-if="
|
||||
currentUserHasPermission(['system:users:manage']) ||
|
||||
isCurrentUser
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-show="showAvatarEditor"
|
||||
class="absolute left-0 right-0 top-0 h-full w-full cursor-pointer rounded-full border-0 bg-black/60 text-center leading-[5rem] transition-opacity duration-300 group-hover:opacity-100"
|
||||
>
|
||||
<IconRiPencilFill
|
||||
class="inline-block w-full self-center text-2xl text-white"
|
||||
/>
|
||||
</div>
|
||||
<template #popper>
|
||||
<VDropdownItem @click="open()">
|
||||
{{ $t("core.common.buttons.upload") }}
|
||||
</VDropdownItem>
|
||||
<VDropdownItem @click="handleRemoveCurrentAvatar">
|
||||
{{ $t("core.common.buttons.delete") }}
|
||||
</VDropdownItem>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</div>
|
||||
</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
|
||||
v-if="
|
||||
currentUserHasPermission(['system:users:manage']) || isCurrentUser
|
||||
"
|
||||
>
|
||||
<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="tabs"
|
||||
class="w-full"
|
||||
type="outline"
|
||||
@change="handleTabChange"
|
||||
></VTabbar>
|
||||
<div class="mt-2">
|
||||
<RouterView></RouterView>
|
||||
</div>
|
||||
</section>
|
||||
<VModal
|
||||
:visible="visibleCropperModal"
|
||||
:width="1200"
|
||||
:title="$t('core.user.detail.avatar.cropper_modal.title')"
|
||||
@update:visible="handleCloseCropperModal"
|
||||
>
|
||||
<UserAvatarCropper
|
||||
ref="userAvatarCropper"
|
||||
:file="originalFile"
|
||||
@change-file="changeUploadAvatar"
|
||||
/>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
v-if="visibleCropperModal"
|
||||
:loading="uploadSaving"
|
||||
type="secondary"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
{{ $t("core.common.buttons.submit") }}
|
||||
</VButton>
|
||||
<VButton @click="handleCloseCropperModal">
|
||||
{{ $t("core.common.buttons.cancel") }}
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</BasicLayout>
|
||||
</template>
|
|
@ -1,11 +1,9 @@
|
|||
import { definePlugin } from "@halo-dev/console-shared";
|
||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||
import BlankLayout from "@/layouts/BlankLayout.vue";
|
||||
import UserProfileLayout from "./layouts/UserProfileLayout.vue";
|
||||
import UserStatsWidget from "./widgets/UserStatsWidget.vue";
|
||||
import UserList from "./UserList.vue";
|
||||
import UserDetail from "./UserDetail.vue";
|
||||
import PersonalAccessTokens from "./PersonalAccessTokens.vue";
|
||||
import Login from "./Login.vue";
|
||||
import { IconUserSettings } from "@halo-dev/components";
|
||||
import { markRaw } from "vue";
|
||||
|
@ -61,25 +59,17 @@ export default definePlugin({
|
|||
},
|
||||
{
|
||||
path: ":name",
|
||||
component: UserProfileLayout,
|
||||
component: BasicLayout,
|
||||
name: "User",
|
||||
children: [
|
||||
{
|
||||
path: "detail",
|
||||
path: "",
|
||||
name: "UserDetail",
|
||||
component: UserDetail,
|
||||
meta: {
|
||||
title: "core.user.detail.title",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tokens",
|
||||
name: "PersonalAccessTokens",
|
||||
component: PersonalAccessTokens,
|
||||
meta: {
|
||||
title: "个人令牌",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
Dialog,
|
||||
IconUserSettings,
|
||||
VButton,
|
||||
VDescription,
|
||||
VDescriptionItem,
|
||||
VTag,
|
||||
} from "@halo-dev/components";
|
||||
import type { ComputedRef, Ref } from "vue";
|
||||
import { inject, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
|
||||
import { rbacAnnotations } from "@/constants/annotations";
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import axios from "axios";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const user = inject<Ref<DetailedUser | undefined>>("user");
|
||||
const isCurrentUser = inject<ComputedRef<boolean>>("isCurrentUser");
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { data: authProviders, isFetching } = useQuery<ListedAuthProvider[]>({
|
||||
queryKey: ["user-auth-providers"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.authProvider.listAuthProviders();
|
||||
return data;
|
||||
},
|
||||
enabled: isCurrentUser,
|
||||
});
|
||||
|
||||
const availableAuthProviders = computed(() => {
|
||||
return authProviders.value?.filter(
|
||||
(authProvider) => authProvider.enabled && authProvider.supportsBinding
|
||||
);
|
||||
});
|
||||
|
||||
const handleUnbindAuth = (authProvider: ListedAuthProvider) => {
|
||||
Dialog.warning({
|
||||
title: t("core.user.detail.operations.unbind.title", {
|
||||
display_name: authProvider.displayName,
|
||||
}),
|
||||
confirmText: t("core.common.buttons.confirm"),
|
||||
cancelText: t("core.common.buttons.cancel"),
|
||||
onConfirm: async () => {
|
||||
await axios.put(
|
||||
`${import.meta.env.VITE_API_URL}${authProvider.unbindingUrl}`,
|
||||
{
|
||||
withCredentials: true,
|
||||
}
|
||||
);
|
||||
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBindAuth = (authProvider: ListedAuthProvider) => {
|
||||
if (!authProvider.bindingUrl) {
|
||||
return;
|
||||
}
|
||||
window.location.href = `${
|
||||
authProvider.bindingUrl
|
||||
}?redirect_uri=${encodeURIComponent(window.location.href)}`;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="border-t border-gray-100">
|
||||
<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>
|
|
@ -10,9 +10,9 @@ import { ref } from "vue";
|
|||
import { apiClient } from "@/utils/api-client";
|
||||
import type { PersonalAccessToken } from "@halo-dev/api-client";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import PersonalAccessTokenCreationModal from "./components/PersonalAccessTokenCreationModal.vue";
|
||||
import PersonalAccessTokenCreationModal from "../components/PersonalAccessTokenCreationModal.vue";
|
||||
import { nextTick } from "vue";
|
||||
import PersonalAccessTokenListItem from "./components/PersonalAccessTokenListItem.vue";
|
||||
import PersonalAccessTokenListItem from "../components/PersonalAccessTokenListItem.vue";
|
||||
|
||||
const {
|
||||
data: pats,
|
Loading…
Reference in New Issue