feat: add management and view pages for extension points (#6137)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.17.x

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

添加扩展点的查看和设置页面。

<img width="1414" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/4dd4660f-540f-46b5-8250-b4f011ebaae6">

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

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

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

```release-note
添加系统扩展点的查看和设置页面。
```
pull/6082/head
Ryan Wang 2024-06-26 21:58:51 +08:00 committed by GitHub
parent 49c4a917e1
commit 5eabce7544
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 432 additions and 3 deletions

View File

@ -81,7 +81,13 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
definition -> definition.getSpec().getClassName())
));
});
schemeManager.register(ExtensionDefinition.class);
schemeManager.register(ExtensionDefinition.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.extensionPointName")
.setIndexFunc(simpleAttribute(ExtensionDefinition.class,
definition -> definition.getSpec().getExtensionPointName())
));
});
schemeManager.register(RoleBinding.class);
schemeManager.register(User.class, indexSpecs -> {

View File

@ -0,0 +1,122 @@
<script lang="ts" setup>
import { coreApiClient } from "@halo-dev/api-client";
import { IconSettings, VCard, VPageHeader } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { computed, watch } from "vue";
import ExtensionDefinitionMultiInstanceView from "./components/extension-points/ExtensionDefinitionMultiInstanceView.vue";
import ExtensionDefinitionSingletonView from "./components/extension-points/ExtensionDefinitionSingletonView.vue";
const { data: extensionPointDefinitions } = useQuery({
queryKey: ["extension-point-definitions"],
queryFn: async () => {
const { data } =
await coreApiClient.plugin.extensionPointDefinition.listExtensionPointDefinition();
return data;
},
});
const selectedExtensionPointDefinitionName = useRouteQuery<string | undefined>(
"extension-point-definition-name"
);
const selectedExtensionPointDefinition = computed(() => {
return extensionPointDefinitions.value?.items.find(
(item) => item.metadata.name === selectedExtensionPointDefinitionName.value
);
});
watch(
() => extensionPointDefinitions.value,
(value) => {
if (value?.items.length && !selectedExtensionPointDefinitionName.value) {
selectedExtensionPointDefinitionName.value =
value?.items[0].metadata.name;
}
},
{
immediate: true,
}
);
</script>
<template>
<VPageHeader title="扩展点设置">
<template #icon>
<IconSettings class="mr-2 self-center" />
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard
style="height: calc(100vh - 5.5rem)"
:body-class="['h-full', '!p-0']"
>
<div class="flex h-full divide-x">
<div class="w-72 flex-none">
<div
class="sticky top-0 z-10 flex h-12 items-center border-b bg-white px-4"
>
<h2 class="font-semibold text-green-900">扩展点定义</h2>
</div>
<ul
class="box-border h-full w-full divide-y divide-gray-100 overflow-auto"
role="list"
>
<li
v-for="extensionPointDefinition in extensionPointDefinitions?.items"
:key="extensionPointDefinition.metadata.name"
class="relative cursor-pointer"
@click="
selectedExtensionPointDefinitionName =
extensionPointDefinition.metadata.name
"
>
<div
v-show="
selectedExtensionPointDefinitionName ===
extensionPointDefinition.metadata.name
"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div
class="flex flex-col space-y-1.5 px-4 py-2.5 hover:bg-gray-50"
>
<h3
class="line-clamp-1 break-words text-sm font-medium text-gray-900"
>
{{ extensionPointDefinition.spec.displayName }}
</h3>
<p class="line-clamp-2 text-xs text-gray-600">
{{ extensionPointDefinition.spec.description }}
</p>
</div>
</li>
</ul>
</div>
<div class="flex min-w-0 flex-1 shrink flex-col overflow-auto">
<div
class="sticky top-0 z-10 flex h-12 items-center space-x-3 border-b bg-white px-4"
>
<h2 class="font-semibold text-green-900">
{{ selectedExtensionPointDefinition?.spec.displayName }}
</h2>
<small class="line-clamp-1 text-gray-600">
{{ selectedExtensionPointDefinition?.spec.description }}
</small>
</div>
<ExtensionDefinitionSingletonView
v-if="selectedExtensionPointDefinition?.spec.type === 'SINGLETON'"
:extension-point-definition="selectedExtensionPointDefinition"
/>
<ExtensionDefinitionMultiInstanceView
v-else-if="
selectedExtensionPointDefinition?.spec.type === 'MULTI_INSTANCE'
"
:extension-point-definition="selectedExtensionPointDefinition"
/>
</div>
</div>
</VCard>
</div>
</template>

View File

@ -0,0 +1,77 @@
<script lang="ts" setup>
import {
coreApiClient,
type ExtensionDefinition,
type Plugin,
} from "@halo-dev/api-client";
import {
IconSettings,
VAvatar,
VEntity,
VEntityField,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { computed } from "vue";
const props = withDefaults(
defineProps<{ extensionDefinition: ExtensionDefinition }>(),
{}
);
const { data: plugins } = useQuery<Plugin[]>({
queryKey: ["extension-definition-related-plugins"],
queryFn: async () => {
const { data } = await coreApiClient.plugin.plugin.listPlugin({
page: 0,
size: 0,
});
return data.items;
},
});
const matchedPlugin = computed(() => {
return plugins.value?.find(
(plugin) =>
plugin.metadata.name ===
props.extensionDefinition.metadata.labels?.["plugin.halo.run/plugin-name"]
);
});
</script>
<template>
<VEntity>
<template v-if="$slots['selection-indicator']" #checkbox>
<slot name="selection-indicator" />
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar
:alt="extensionDefinition.spec.displayName"
:src="extensionDefinition.spec.icon || matchedPlugin?.status?.logo"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
:title="extensionDefinition.spec.displayName"
:description="extensionDefinition.spec.description"
></VEntityField>
</template>
<template v-if="matchedPlugin" #end>
<VEntityField>
<template #description>
<RouterLink
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 },
}"
>
<IconSettings />
</RouterLink>
</template>
</VEntityField>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,45 @@
<script lang="ts" setup>
import type { ExtensionPointDefinition } from "@halo-dev/api-client";
import { VEmpty } from "@halo-dev/components";
import { toRefs } from "vue";
import { useExtensionDefinitionFetch } from "../../composables/use-extension-definition-fetch";
import ExtensionDefinitionListItem from "./ExtensionDefinitionListItem.vue";
const props = withDefaults(
defineProps<{ extensionPointDefinition?: ExtensionPointDefinition }>(),
{ extensionPointDefinition: undefined }
);
const { extensionPointDefinition } = toRefs(props);
const { data: extensionDefinitions, isLoading } = useExtensionDefinitionFetch(
extensionPointDefinition
);
</script>
<template>
<div class="p-4">
<VLoading v-if="isLoading"></VLoading>
<Transition
v-else-if="!extensionDefinitions?.items.length"
appear
name="fade"
>
<VEmpty title="当前没有扩展点实现"></VEmpty>
</Transition>
<Transition v-else name="fade" appear>
<ul
class="box-border h-full w-full divide-y divide-gray-100 overflow-hidden rounded-base border"
role="list"
>
<li
v-for="item in extensionDefinitions?.items"
:key="item.metadata.name"
class="cursor-pointer"
>
<ExtensionDefinitionListItem :extension-definition="item" />
</li>
</ul>
</Transition>
</div>
</template>

View File

@ -0,0 +1,141 @@
<script lang="ts" setup>
import {
coreApiClient,
type ExtensionPointDefinition,
} from "@halo-dev/api-client";
import { Toast, VEmpty, VLoading } from "@halo-dev/components";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { computed, ref, toRefs } from "vue";
import { useI18n } from "vue-i18n";
import { useExtensionDefinitionFetch } from "../../composables/use-extension-definition-fetch";
import ExtensionDefinitionListItem from "./ExtensionDefinitionListItem.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
const Q_KEY = (name?: string) => ["extension-point-value", name];
const props = withDefaults(
defineProps<{ extensionPointDefinition?: ExtensionPointDefinition }>(),
{ extensionPointDefinition: undefined }
);
const { extensionPointDefinition } = toRefs(props);
const { data: extensionDefinitions, isLoading } = useExtensionDefinitionFetch(
extensionPointDefinition
);
const { data: value } = useQuery({
queryKey: Q_KEY(extensionPointDefinition.value?.metadata.name),
queryFn: async () => {
if (!extensionPointDefinition.value) return null;
const { data } = await coreApiClient.configMap.getConfigMap({
name: "system",
});
const extensionPointEnabled = JSON.parse(
data.data?.["extensionPointEnabled"] || "{}"
);
const extensionPointValue =
extensionPointEnabled[extensionPointDefinition.value?.metadata.name];
// check is array
if (Array.isArray(extensionPointValue)) {
return extensionPointValue[0];
}
return null;
},
enabled: computed(() => !!extensionPointDefinition.value),
});
const isSubmitting = ref(false);
async function onExtensionChange(e: Event) {
const value = (e.target as HTMLInputElement).value;
if (!extensionPointDefinition.value) return;
isSubmitting.value = true;
try {
const { data: configMap } = await coreApiClient.configMap.getConfigMap({
name: "system",
});
const extensionPointEnabled = JSON.parse(
configMap.data?.["extensionPointEnabled"] || "{}"
);
extensionPointEnabled[extensionPointDefinition.value?.metadata.name] = [
value,
];
await coreApiClient.configMap.patchConfigMap({
name: "system",
jsonPatchInner: [
{
op: "add",
path: "/data/extensionPointEnabled",
value: JSON.stringify(extensionPointEnabled),
},
],
});
Toast.success(t("core.common.toast.save_success"));
queryClient.invalidateQueries({
queryKey: Q_KEY(extensionPointDefinition.value?.metadata.name),
});
} catch (error) {
Toast.error(t("core.common.toast.save_failed_and_retry"));
} finally {
isSubmitting.value = false;
}
}
</script>
<template>
<div class="p-4">
<VLoading v-if="isLoading"></VLoading>
<Transition
v-else-if="!extensionDefinitions?.items.length"
appear
name="fade"
>
<VEmpty title="当前没有扩展点实现"></VEmpty>
</Transition>
<Transition v-else name="fade" appear>
<ul
class="box-border h-full w-full divide-y divide-gray-100 overflow-hidden rounded-base border"
role="list"
>
<li
v-for="item in extensionDefinitions?.items"
:key="item.metadata.name"
>
<label
class="cursor-pointer transition-all"
:class="{ 'pointer-events-none opacity-50': isSubmitting }"
>
<ExtensionDefinitionListItem :extension-definition="item">
<template #selection-indicator>
<input
:value="item.metadata.name"
type="radio"
name="activated-extension"
:checked="item.metadata.name === value"
:disabled="isSubmitting"
@change="onExtensionChange"
/>
</template>
</ExtensionDefinitionListItem>
</label>
</li>
</ul>
</Transition>
</div>
</template>

View File

@ -0,0 +1,22 @@
import type { ExtensionPointDefinition } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import { useQuery } from "@tanstack/vue-query";
import { computed, type Ref } from "vue";
export function useExtensionDefinitionFetch(
extensionPointDefinition: Ref<ExtensionPointDefinition | undefined>
) {
return useQuery({
queryKey: ["extension-definitions", extensionPointDefinition],
queryFn: async () => {
const { data } =
await coreApiClient.plugin.extensionDefinition.listExtensionDefinition({
fieldSelector: [
`spec.extensionPointName=${extensionPointDefinition.value?.metadata.name}`,
],
});
return data;
},
enabled: computed(() => !!extensionPointDefinition.value),
});
}

View File

@ -1,8 +1,10 @@
import BasicLayout from "@console/layouts/BasicLayout.vue";
import { IconPlug } from "@halo-dev/components";
import { IconPlug, IconSettings } from "@halo-dev/components";
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import type { RouteRecordRaw } from "vue-router";
import PluginDetail from "./PluginDetail.vue";
import PluginExtensionPointSettings from "./PluginExtensionPointSettings.vue";
import PluginList from "./PluginList.vue";
export default definePlugin({
@ -29,6 +31,19 @@ export default definePlugin({
name: "Plugins",
component: PluginList,
},
{
path: "extension-point-settings",
name: "PluginExtensionPointSettings",
component: PluginExtensionPointSettings,
meta: {
title: "扩展点设置",
hideFooter: true,
menu: {
name: "扩展点设置",
icon: markRaw(IconSettings),
},
},
},
{
path: ":name",
name: "PluginDetail",
@ -39,6 +54,6 @@ export default definePlugin({
},
},
],
},
} as RouteRecordRaw,
],
});

View File

@ -452,3 +452,4 @@ export {
defaultPublicApiClient as publicApiClient,
defaultUcApiClient as ucApiClient
};