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
Ryan Wang 2023-09-26 23:34:16 +08:00 committed by GitHub
parent a5a69780a3
commit 470b0de70d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 538 additions and 518 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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: "个人令牌",
},
},
],
},
],

View File

@ -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>

View File

@ -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,