feat: add extension points for user detail tab (#5763)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.15.x

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

为 Console 的用户详情页面的选项卡和个人中心的个人资料页面选项卡添加扩展点,支持通过插件扩展选项卡。

todo:

- [x] 完善 docs.halo.run 的开发文档 https://github.com/halo-dev/docs/pull/340

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/5745

#### Special notes for your reviewer:

可以使用 [plugin-starter-1.3.2-SNAPSHOT.jar.zip](https://github.com/halo-dev/halo/files/15059291/plugin-starter-1.3.2-SNAPSHOT.jar.zip) 进行测试。

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

```release-note
为 Console 的用户详情页面的选项卡和个人中心的个人资料页面选项卡添加扩展点
```
pull/5794/head
Ryan Wang 2024-04-25 08:33:09 +08:00 committed by GitHub
parent e4b2e07cc8
commit fbf2b06432
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 126 additions and 64 deletions

View File

@ -2,40 +2,36 @@
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { import {
VButton, VButton,
VTabbar,
VDropdown, VDropdown,
VDropdownItem, VDropdownItem,
VTabbar,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { computed, provide, ref, type Ref } from "vue"; import {
computed,
markRaw,
onMounted,
provide,
type Ref,
ref,
toRaw,
} from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import type { DetailedUser } from "@halo-dev/api-client";
import UserEditingModal from "./components/UserEditingModal.vue"; import UserEditingModal from "./components/UserEditingModal.vue";
import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue"; import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import UserAvatar from "@/components/user-avatar/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 DetailTab from "./tabs/Detail.vue";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule, UserTab } from "@halo-dev/console-shared";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
const { currentUser } = useUserStore(); const { currentUser } = useUserStore();
interface UserTab {
id: string;
label: string;
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
priority: number;
hidden?: boolean;
}
const editingModal = ref(false); const editingModal = ref(false);
const passwordChangeModal = ref(false); const passwordChangeModal = ref(false);
@ -56,25 +52,44 @@ const {
enabled: computed(() => !!params.name), enabled: computed(() => !!params.name),
}); });
provide<Ref<DetailedUser | undefined>>("user", user); const tabs = ref<UserTab[]>([
const tabs: UserTab[] = [
{ {
id: "detail", id: "detail",
label: t("core.user.detail.tabs.detail"), label: t("core.user.detail.tabs.detail"),
component: markRaw(DetailTab), component: markRaw(DetailTab),
priority: 10, priority: 10,
}, },
]; ]);
const activeTab = useRouteQuery<string>("tab", tabs[0].id, { // Collect user:detail:tabs:create extension points
onMounted(() => {
const { pluginModules } = usePluginModuleStore();
pluginModules.forEach((pluginModule: PluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.["user:detail:tabs:create"]) {
return;
}
const providers = extensionPoints["user:detail:tabs:create"]() as UserTab[];
tabs.value.push(...providers);
});
});
const activeTab = useRouteQuery<string>("tab", tabs.value[0].id, {
mode: "push", mode: "push",
}); });
provide<Ref<string>>("activeTab", activeTab); provide<Ref<string>>("activeTab", activeTab);
const tabbarItems = computed(() => { const tabbarItems = computed(() => {
return tabs.map((tab) => ({ id: tab.id, label: tab.label })); return toRaw(tabs)
.value.sort((a, b) => a.priority - b.priority)
.map((tab) => ({
id: tab.id,
label: tab.label,
}));
}); });
function handleRouteToUC() { function handleRouteToUC() {
@ -142,7 +157,8 @@ function handleRouteToUC() {
<template v-for="tab in tabs" :key="tab.id"> <template v-for="tab in tabs" :key="tab.id">
<component <component
:is="tab.component" :is="tab.component"
v-if="activeTab === tab.id && !tab.hidden" v-if="activeTab === tab.id"
:user="user"
/> />
</template> </template>
</div> </div>

View File

@ -5,13 +5,13 @@ import {
VDescriptionItem, VDescriptionItem,
VTag, VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import type { Ref } from "vue";
import { inject } from "vue";
import type { DetailedUser } from "@halo-dev/api-client"; import type { DetailedUser } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations"; import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
const user = inject<Ref<DetailedUser | undefined>>("user"); withDefaults(defineProps<{ user?: DetailedUser }>(), {
user: undefined,
});
</script> </script>
<template> <template>
<div class="border-t border-gray-100"> <div class="border-t border-gray-100">

View File

@ -11,3 +11,4 @@ export * from "./states/plugin-installation-tabs";
export * from "./states/entity"; export * from "./states/entity";
export * from "./states/theme-list-tabs"; export * from "./states/theme-list-tabs";
export * from "./states/operation"; export * from "./states/operation";
export * from "./states/user-tab";

View File

@ -0,0 +1,17 @@
import type { Component, Raw } from "vue";
export interface UserTab {
id: string;
label: string;
component: Raw<Component>;
permissions?: string[];
priority: number;
}
export interface UserProfileTab {
id: string;
label: string;
component: Raw<Component>;
permissions?: string[];
priority: number;
}

View File

@ -1,5 +1,5 @@
import type { Component, Ref } from "vue"; import type { Component, Ref } from "vue";
import type { RouteRecordRaw, RouteRecordName } from "vue-router"; import type { RouteRecordName, RouteRecordRaw } from "vue-router";
import type { FunctionalPage } from "../states/pages"; import type { FunctionalPage } from "../states/pages";
import type { AttachmentSelectProvider } from "../states/attachment-selector"; import type { AttachmentSelectProvider } from "../states/attachment-selector";
import type { EditorProvider, PluginTab } from ".."; import type { EditorProvider, PluginTab } from "..";
@ -17,6 +17,7 @@ import type {
Plugin, Plugin,
Theme, Theme,
} from "@halo-dev/api-client"; } from "@halo-dev/api-client";
import type { UserProfileTab, UserTab } from "@/states/user-tab";
export interface RouteRecordAppend { export interface RouteRecordAppend {
parentName: RouteRecordName; parentName: RouteRecordName;
@ -76,6 +77,12 @@ export interface ExtensionPoint {
"theme:list-item:operation:create"?: ( "theme:list-item:operation:create"?: (
theme: Ref<Theme> theme: Ref<Theme>
) => OperationItem<Theme>[] | Promise<OperationItem<Theme>[]>; ) => OperationItem<Theme>[] | Promise<OperationItem<Theme>[]>;
"user:detail:tabs:create"?: () => UserTab[] | Promise<UserTab[]>;
"uc:user:profile:tabs:create"?: () =>
| UserProfileTab[]
| Promise<UserProfileTab[]>;
} }
export interface PluginModule { export interface PluginModule {

View File

@ -2,38 +2,35 @@
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { import {
VButton, VButton,
VTabbar,
VDropdown, VDropdown,
VDropdownItem, VDropdownItem,
VTabbar,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { computed, provide, ref, type Ref } from "vue"; import {
computed,
markRaw,
onMounted,
provide,
type Ref,
ref,
toRaw,
} from "vue";
import type { DetailedUser } from "@halo-dev/api-client"; import type { DetailedUser } from "@halo-dev/api-client";
import ProfileEditingModal from "./components/ProfileEditingModal.vue"; import ProfileEditingModal from "./components/ProfileEditingModal.vue";
import PasswordChangeModal from "./components/PasswordChangeModal.vue"; import PasswordChangeModal from "./components/PasswordChangeModal.vue";
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import UserAvatar from "@/components/user-avatar/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 DetailTab from "./tabs/Detail.vue";
import PersonalAccessTokensTab from "./tabs/PersonalAccessTokens.vue"; import PersonalAccessTokensTab from "./tabs/PersonalAccessTokens.vue";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import NotificationPreferences from "./tabs/NotificationPreferences.vue"; import NotificationPreferences from "./tabs/NotificationPreferences.vue";
import TwoFactor from "./tabs/TwoFactor.vue"; import TwoFactor from "./tabs/TwoFactor.vue";
import type { PluginModule, UserProfileTab } from "@halo-dev/console-shared";
import { usePluginModuleStore } from "@/stores/plugin";
const { t } = useI18n(); 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 editingModal = ref(false);
const passwordChangeModal = ref(false); const passwordChangeModal = ref(false);
@ -51,7 +48,7 @@ const {
provide<Ref<DetailedUser | undefined>>("user", user); provide<Ref<DetailedUser | undefined>>("user", user);
const tabs: UserTab[] = [ const tabs = ref<UserProfileTab[]>([
{ {
id: "detail", id: "detail",
label: t("core.uc_profile.tabs.detail"), label: t("core.uc_profile.tabs.detail"),
@ -76,13 +73,36 @@ const tabs: UserTab[] = [
component: markRaw(TwoFactor), component: markRaw(TwoFactor),
priority: 40, priority: 40,
}, },
]; ]);
const tabbarItems = computed(() => { // Collect uc:profile:tabs:create extension points
return tabs.map((tab) => ({ id: tab.id, label: tab.label })); onMounted(() => {
const { pluginModules } = usePluginModuleStore();
pluginModules.forEach((pluginModule: PluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.["uc:user:profile:tabs:create"]) {
return;
}
const providers = extensionPoints[
"uc:user:profile:tabs:create"
]() as UserProfileTab[];
tabs.value.push(...providers);
});
}); });
const activeTab = useRouteQuery<string>("tab", tabs[0].id, { const tabbarItems = computed(() => {
return toRaw(tabs)
.value.sort((a, b) => a.priority - b.priority)
.map((tab) => ({
id: tab.id,
label: tab.label,
}));
});
const activeTab = useRouteQuery<string>("tab", tabs.value[0].id, {
mode: "push", mode: "push",
}); });
</script> </script>
@ -140,7 +160,8 @@ const activeTab = useRouteQuery<string>("tab", tabs[0].id, {
<template v-for="tab in tabs" :key="tab.id"> <template v-for="tab in tabs" :key="tab.id">
<component <component
:is="tab.component" :is="tab.component"
v-if="activeTab === tab.id && !tab.hidden" v-if="activeTab === tab.id"
:user="user"
/> />
</template> </template>
</div> </div>

View File

@ -8,8 +8,7 @@ import {
VDescriptionItem, VDescriptionItem,
VTag, VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import type { Ref } from "vue"; import { computed, ref } from "vue";
import { inject, computed } from "vue";
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client"; import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations"; import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
@ -18,9 +17,8 @@ import { apiClient } from "@/utils/api-client";
import axios from "axios"; import axios from "axios";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import EmailVerifyModal from "../components/EmailVerifyModal.vue"; import EmailVerifyModal from "../components/EmailVerifyModal.vue";
import { ref } from "vue";
const user = inject<Ref<DetailedUser | undefined>>("user"); withDefaults(defineProps<{ user?: DetailedUser }>(), { user: undefined });
const { t } = useI18n(); const { t } = useI18n();

View File

@ -1,34 +1,36 @@
<script lang="ts" setup> <script lang="ts" setup>
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import type { DetailedUser } from "@halo-dev/api-client"; import type {
DetailedUser,
ReasonTypeNotifierRequest,
} from "@halo-dev/api-client";
import { VLoading, VSwitch } from "@halo-dev/components"; import { VLoading, VSwitch } from "@halo-dev/components";
import type { Ref } from "vue";
import { computed } from "vue"; import { computed } from "vue";
import { inject } from "vue";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import type { ReasonTypeNotifierRequest } from "@halo-dev/api-client";
import HasPermission from "@/components/permission/HasPermission.vue"; import HasPermission from "@/components/permission/HasPermission.vue";
const queryClient = useQueryClient(); const props = withDefaults(defineProps<{ user?: DetailedUser }>(), {
user: undefined,
});
const user = inject<Ref<DetailedUser | undefined>>("user"); const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["notification-preferences"], queryKey: ["notification-preferences"],
queryFn: async () => { queryFn: async () => {
if (!user?.value) { if (!props.user) {
return; return null;
} }
const { data } = const { data } =
await apiClient.notification.listUserNotificationPreferences({ await apiClient.notification.listUserNotificationPreferences({
username: user?.value?.user.metadata.name, username: props.user?.user.metadata.name,
}); });
return data; return data;
}, },
enabled: computed(() => !!user?.value), enabled: computed(() => !!props.user),
}); });
const { const {
@ -48,7 +50,7 @@ const {
}) => { }) => {
const preferences = cloneDeep(data.value); const preferences = cloneDeep(data.value);
if (!user?.value || !preferences) { if (!props.user || !preferences) {
return; return;
} }
@ -78,7 +80,7 @@ const {
.filter(Boolean) as Array<ReasonTypeNotifierRequest>; .filter(Boolean) as Array<ReasonTypeNotifierRequest>;
return await apiClient.notification.saveUserNotificationPreferences({ return await apiClient.notification.saveUserNotificationPreferences({
username: user?.value?.user.metadata.name, username: props.user.user.metadata.name,
reasonTypeNotifierCollectionRequest: { reasonTypeNotifierCollectionRequest: {
reasonTypeNotifiers, reasonTypeNotifiers,
}, },