feat: add plugin detail modal as global component (#6233)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.17.x

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

添加 PluginDetailModal,用于打开插件的设置界面。并在扩展设置页面适配以测试。

<img width="1643" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/4bb38ab1-ed51-4437-8202-ccaf9f79cb41">

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

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

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

```release-note
为 UI 添加通用的插件设置弹窗,以供插件主动调用
```
pull/6242/head
Ryan Wang 2024-07-01 16:59:17 +08:00 committed by GitHub
parent 8a61a39be3
commit 2aaf64aa34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 219 additions and 137 deletions

View File

@ -1,123 +1,23 @@
<script lang="ts" setup>
// core libs
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import { computed, provide, ref } from "vue";
import { useRoute } from "vue-router";
// libs
import { cloneDeep } from "lodash-es";
// components
import type { Plugin, Setting } from "@halo-dev/api-client";
import { VAvatar, VCard, VPageHeader, VTabbar } from "@halo-dev/components";
// types
import { usePluginModuleStore } from "@/stores/plugin";
import { usePermission } from "@/utils/permission";
import type { Plugin, Setting, SettingForm } from "@halo-dev/api-client";
import type { PluginTab } from "@halo-dev/console-shared";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import type { Ref } from "vue";
import { markRaw } from "vue";
import { useI18n } from "vue-i18n";
import DetailTab from "./tabs/Detail.vue";
import SettingTab from "./tabs/Setting.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const initialTabs = ref<PluginTab[]>([
{
id: "detail",
label: t("core.plugin.tabs.detail"),
component: markRaw(DetailTab),
},
]);
import { provide, toRefs } from "vue";
import { useRoute } from "vue-router";
import { usePluginDetailTabs } from "./composables/use-plugin";
const route = useRoute();
const tabs = ref<PluginTab[]>(cloneDeep(initialTabs.value));
const activeTab = useRouteQuery<string>("tab", tabs.value[0].id);
const { name } = toRefs(route.params);
const { plugin, setting, activeTab, tabs } = usePluginDetailTabs(
name as Ref<string | undefined>,
true
);
provide<Ref<string>>("activeTab", activeTab);
const { data: plugin } = useQuery({
queryKey: ["plugin", route.params.name],
queryFn: async () => {
const { data } = await coreApiClient.plugin.plugin.getPlugin({
name: route.params.name as string,
});
return data;
},
async onSuccess(data) {
if (
!data.spec.settingName ||
!currentUserHasPermission(["system:plugins:manage"])
) {
tabs.value = [...initialTabs.value, ...(await getTabsFromExtensions())];
}
},
});
provide<Ref<Plugin | undefined>>("plugin", plugin);
const { data: setting } = useQuery({
queryKey: ["plugin-setting", plugin],
queryFn: async () => {
const { data } = await consoleApiClient.plugin.plugin.fetchPluginSetting({
name: plugin.value?.metadata.name as string,
});
return data;
},
enabled: computed(() => {
return (
!!plugin.value &&
!!plugin.value.spec.settingName &&
currentUserHasPermission(["system:plugins:manage"])
);
}),
async onSuccess(data) {
if (data) {
const { forms } = data.spec;
tabs.value = [
...initialTabs.value,
...(await getTabsFromExtensions()),
...forms.map((item: SettingForm) => {
return {
id: item.group,
label: item.label || "",
component: markRaw(SettingTab),
};
}),
] as PluginTab[];
}
},
});
provide<Ref<Setting | undefined>>("setting", setting);
async function getTabsFromExtensions() {
const { pluginModuleMap } = usePluginModuleStore();
const currentPluginModule = pluginModuleMap[route.params.name as string];
if (!currentPluginModule) {
return [];
}
const callbackFunction =
currentPluginModule?.extensionPoints?.["plugin:self:tabs:create"];
if (typeof callbackFunction !== "function") {
return [];
}
const pluginTabs = await callbackFunction();
return pluginTabs.filter((tab) => {
return currentUserHasPermission(tab.permissions);
});
}
</script>
<template>
<VPageHeader :title="plugin?.spec?.displayName">

View File

@ -0,0 +1,66 @@
<script lang="ts" setup>
import type { Plugin, Setting } from "@halo-dev/api-client";
import { IconLink, VButton, VModal, VTabbar } from "@halo-dev/components";
import { provide, ref, toRefs, type Ref } from "vue";
import { usePluginDetailTabs } from "../composables/use-plugin";
const props = withDefaults(defineProps<{ name: string }>(), {});
const emit = defineEmits<{
(event: "close"): void;
}>();
const modal = ref<InstanceType<typeof VModal> | null>(null);
const { name } = toRefs(props);
const { plugin, setting, tabs, activeTab } = usePluginDetailTabs(name, false);
provide<Ref<string>>("activeTab", activeTab);
provide<Ref<Plugin | undefined>>("plugin", plugin);
provide<Ref<Setting | undefined>>("setting", setting);
</script>
<template>
<VModal
ref="modal"
:title="plugin?.spec.displayName"
:centered="true"
:width="920"
height="calc(100vh - 20px)"
mount-to-body
@close="emit('close')"
>
<template #actions>
<span>
<RouterLink
:to="{
name: 'PluginDetail',
params: { name },
}"
>
<IconLink />
</RouterLink>
</span>
</template>
<VTabbar
v-model:active-id="activeTab"
:items="
tabs.map((tab) => {
return { label: tab.label, id: tab.id };
})
"
type="outline"
/>
<div class="-m-4 mt-2">
<template v-for="tab in tabs" :key="tab.id">
<component :is="tab.component" v-if="activeTab === tab.id" />
</template>
</div>
<template #footer>
<VButton @click="modal?.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</template>
</VModal>
</template>

View File

@ -11,7 +11,8 @@ import {
VEntityField,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
import { computed, ref } from "vue";
import PluginDetailModal from "../PluginDetailModal.vue";
const props = withDefaults(
defineProps<{ extensionDefinition: ExtensionDefinition }>(),
@ -36,9 +37,16 @@ const matchedPlugin = computed(() => {
props.extensionDefinition.metadata.labels?.["plugin.halo.run/plugin-name"]
);
});
const pluginDetailModalVisible = ref(false);
</script>
<template>
<PluginDetailModal
v-if="pluginDetailModalVisible && matchedPlugin"
:name="matchedPlugin.metadata.name"
@close="pluginDetailModalVisible = false"
/>
<VEntity>
<template v-if="$slots['selection-indicator']" #checkbox>
<slot name="selection-indicator" />
@ -61,15 +69,12 @@ const matchedPlugin = computed(() => {
<template v-if="matchedPlugin" #end>
<VEntityField>
<template #description>
<RouterLink
<div
class="cursor-pointer rounded p-1 text-gray-600 transition-all hover:text-blue-600 group-hover:bg-gray-200/60"
:to="{
name: 'PluginDetail',
params: { name: matchedPlugin?.metadata.name },
}"
@click.prevent="pluginDetailModalVisible = true"
>
<IconSettings />
</RouterLink>
</div>
</template>
</VEntityField>
</template>

View File

@ -131,6 +131,7 @@ async function onExtensionChange(e: Event) {
<label
class="cursor-pointer transition-all"
:class="{ 'pointer-events-none opacity-50': isSubmitting }"
@click.stop
>
<ExtensionDefinitionListItem :extension-definition="item">
<template #selection-indicator>

View File

@ -2,6 +2,7 @@
import { rbacAnnotations } from "@/constants/annotations";
import { pluginLabels, roleLabels } from "@/constants/labels";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import {
PluginStatusPhaseEnum,
coreApiClient,
@ -18,8 +19,10 @@ import {
import { useQuery } from "@tanstack/vue-query";
import type { Ref } from "vue";
import { computed, inject, ref } from "vue";
import PluginConditionsModal from "../components/PluginConditionsModal.vue";
import { usePluginLifeCycle } from "../composables/use-plugin";
import { usePluginLifeCycle } from "../../composables/use-plugin";
import PluginConditionsModal from "../PluginConditionsModal.vue";
const { currentUserHasPermission } = usePermission();
const plugin = inject<Ref<Plugin | undefined>>("plugin");
const { changeStatus, changingStatus } = usePluginLifeCycle(plugin);
@ -45,7 +48,11 @@ const { data: pluginRoleTemplates } = useQuery({
return data.items;
},
cacheTime: 0,
enabled: computed(() => !!plugin?.value?.metadata.name),
enabled: computed(
() =>
!!plugin?.value?.metadata.name &&
currentUserHasPermission(["system:roles:view"])
),
});
const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {

View File

@ -1,19 +1,11 @@
<script lang="ts" setup>
// core libs
import { computed, inject, ref, type Ref } from "vue";
// hooks
import { useSettingFormConvert } from "@console/composables/use-setting-form";
import { consoleApiClient } from "@halo-dev/api-client";
// components
import { Toast, VButton } from "@halo-dev/components";
// types
import StickyBlock from "@/components/sticky-block/StickyBlock.vue";
import { useSettingFormConvert } from "@console/composables/use-setting-form";
import type { ConfigMap, Plugin, Setting } from "@halo-dev/api-client";
import { consoleApiClient } from "@halo-dev/api-client";
import { Toast, VButton } from "@halo-dev/components";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { toRaw } from "vue";
import { computed, inject, ref, toRaw, type Ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();

View File

@ -1,15 +1,21 @@
import { usePluginModuleStore } from "@/stores/plugin";
import { usePermission } from "@/utils/permission";
import {
PluginStatusPhaseEnum,
consoleApiClient,
coreApiClient,
type Plugin,
type SettingForm,
} from "@halo-dev/api-client";
import type { ComputedRef, Ref } from "vue";
import { computed } from "vue";
import { Dialog, Toast } from "@halo-dev/components";
import { useMutation } from "@tanstack/vue-query";
import type { PluginTab } from "@halo-dev/console-shared";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import type { ComputedRef, Ref } from "vue";
import { computed, markRaw, ref } from "vue";
import { useI18n } from "vue-i18n";
import DetailTab from "../components/tabs/Detail.vue";
import SettingTab from "../components/tabs/Setting.vue";
interface usePluginLifeCycleReturn {
isStarted: ComputedRef<boolean | undefined>;
@ -270,3 +276,105 @@ export function usePluginBatchOperations(names: Ref<string[]>) {
return { handleUninstallInBatch, handleChangeStatusInBatch };
}
export function usePluginDetailTabs(
pluginName: Ref<string | undefined>,
recordsActiveTab: boolean
) {
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const initialTabs = [
{
id: "detail",
label: t("core.plugin.tabs.detail"),
component: markRaw(DetailTab),
},
];
const tabs = ref<PluginTab[]>(initialTabs);
const activeTab = recordsActiveTab
? useRouteQuery<string>("tab", tabs.value[0].id)
: ref(tabs.value[0].id);
const { data: plugin } = useQuery({
queryKey: ["plugin", pluginName],
queryFn: async () => {
const { data } = await coreApiClient.plugin.plugin.getPlugin({
name: pluginName.value as string,
});
return data;
},
async onSuccess(data) {
if (
!data.spec.settingName ||
!currentUserHasPermission(["system:plugins:manage"])
) {
tabs.value = [...initialTabs, ...(await getTabsFromExtensions())];
}
},
});
const { data: setting } = useQuery({
queryKey: ["plugin-setting", plugin],
queryFn: async () => {
const { data } = await consoleApiClient.plugin.plugin.fetchPluginSetting({
name: plugin.value?.metadata.name as string,
});
return data;
},
enabled: computed(() => {
return (
!!plugin.value &&
!!plugin.value.spec.settingName &&
currentUserHasPermission(["system:plugins:manage"])
);
}),
async onSuccess(data) {
if (data) {
const { forms } = data.spec;
tabs.value = [
...initialTabs,
...(await getTabsFromExtensions()),
...forms.map((item: SettingForm) => {
return {
id: item.group,
label: item.label || "",
component: markRaw(SettingTab),
};
}),
] as PluginTab[];
}
},
});
async function getTabsFromExtensions() {
const { pluginModuleMap } = usePluginModuleStore();
const currentPluginModule = pluginModuleMap[pluginName.value as string];
if (!currentPluginModule) {
return [];
}
const callbackFunction =
currentPluginModule?.extensionPoints?.["plugin:self:tabs:create"];
if (typeof callbackFunction !== "function") {
return [];
}
const pluginTabs = await callbackFunction();
return pluginTabs.filter((tab) => {
return currentUserHasPermission(tab.permissions);
});
}
return {
plugin,
setting,
tabs,
activeTab,
};
}

View File

@ -6,9 +6,12 @@ import type { RouteRecordRaw } from "vue-router";
import PluginDetail from "./PluginDetail.vue";
import PluginExtensionPointSettings from "./PluginExtensionPointSettings.vue";
import PluginList from "./PluginList.vue";
import PluginDetailModal from "./components/PluginDetailModal.vue";
export default definePlugin({
components: {},
components: {
PluginDetailModal,
},
routes: [
{
path: "/plugins",