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 {
VButton,
VTabbar,
VDropdown,
VDropdownItem,
VTabbar,
} 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 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 { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
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 { useRouteQuery } from "@vueuse/router";
import { useUserStore } from "@/stores/user";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule, UserTab } from "@halo-dev/console-shared";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
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 passwordChangeModal = ref(false);
@ -56,25 +52,44 @@ const {
enabled: computed(() => !!params.name),
});
provide<Ref<DetailedUser | undefined>>("user", user);
const tabs: UserTab[] = [
const tabs = ref<UserTab[]>([
{
id: "detail",
label: t("core.user.detail.tabs.detail"),
component: markRaw(DetailTab),
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",
});
provide<Ref<string>>("activeTab", activeTab);
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() {
@ -142,7 +157,8 @@ function handleRouteToUC() {
<template v-for="tab in tabs" :key="tab.id">
<component
:is="tab.component"
v-if="activeTab === tab.id && !tab.hidden"
v-if="activeTab === tab.id"
:user="user"
/>
</template>
</div>

View File

@ -5,13 +5,13 @@ import {
VDescriptionItem,
VTag,
} from "@halo-dev/components";
import type { Ref } from "vue";
import { inject } from "vue";
import type { DetailedUser } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date";
const user = inject<Ref<DetailedUser | undefined>>("user");
withDefaults(defineProps<{ user?: DetailedUser }>(), {
user: undefined,
});
</script>
<template>
<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/theme-list-tabs";
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 { RouteRecordRaw, RouteRecordName } from "vue-router";
import type { RouteRecordName, RouteRecordRaw } from "vue-router";
import type { FunctionalPage } from "../states/pages";
import type { AttachmentSelectProvider } from "../states/attachment-selector";
import type { EditorProvider, PluginTab } from "..";
@ -17,6 +17,7 @@ import type {
Plugin,
Theme,
} from "@halo-dev/api-client";
import type { UserProfileTab, UserTab } from "@/states/user-tab";
export interface RouteRecordAppend {
parentName: RouteRecordName;
@ -76,6 +77,12 @@ export interface ExtensionPoint {
"theme:list-item:operation:create"?: (
theme: Ref<Theme>
) => OperationItem<Theme>[] | Promise<OperationItem<Theme>[]>;
"user:detail:tabs:create"?: () => UserTab[] | Promise<UserTab[]>;
"uc:user:profile:tabs:create"?: () =>
| UserProfileTab[]
| Promise<UserProfileTab[]>;
}
export interface PluginModule {

View File

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

View File

@ -8,8 +8,7 @@ import {
VDescriptionItem,
VTag,
} from "@halo-dev/components";
import type { Ref } from "vue";
import { inject, computed } from "vue";
import { computed, ref } from "vue";
import type { DetailedUser, ListedAuthProvider } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date";
@ -18,9 +17,8 @@ import { apiClient } from "@/utils/api-client";
import axios from "axios";
import { useI18n } from "vue-i18n";
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();

View File

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