mirror of https://github.com/halo-dev/halo
feat: make plugin tabs extensible (#4041)
#### What type of PR is this? /area console /kind feature /milestone 2.7.x #### What this PR does / why we need it: 插件自身的详情页面中的 Tabs 选项卡支持拓展,允许开发者自行为插件编写设置界面、一些不常用的操作页面等。 示例: <img width="1358" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/1e414e4e-688d-47de-907f-5f2a0afa9350"> 扩展方式: 参考文档:https://github.com/ruibaby/halo/blob/feat/plugin-tabs-extend/console/docs/extension-points/plugin-self-tabs.md #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/3987 #### Special notes for your reviewer: 需要测试: 1. 测试任意插件的详情页面是否能够正常使用。 2. 测试任意插件的设置表单能否正常使用。 3. 可以尝试根据文档为某个插件添加自定义的选项卡,测试是否能够正常工作。 #### Does this PR introduce a user-facing change? ```release-note Console 端插件详情选项卡支持通过插件扩展。 ```pull/4195/head
parent
bf2b92e77c
commit
c0aae3a63c
|
@ -0,0 +1,40 @@
|
||||||
|
# 插件详情选项卡扩展点
|
||||||
|
|
||||||
|
## 原由
|
||||||
|
|
||||||
|
部分插件可能需要在 Console 端自行实现 UI 以完成一些自定义的需求,但可能并不希望在菜单中添加一个菜单项,所以希望可以在插件详情页面添加一个自定义 UI 的选项卡。
|
||||||
|
|
||||||
|
## 定义方式
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePlugin, PluginTab } from "@halo-dev/console-shared";
|
||||||
|
import MyComponent from "@/views/my-component.vue";
|
||||||
|
import { markRaw } from "vue";
|
||||||
|
export default definePlugin({
|
||||||
|
components: {},
|
||||||
|
routes: [],
|
||||||
|
extensionPoints: {
|
||||||
|
"plugin:self:tabs:create": () : PluginTab[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "my-tab-panel",
|
||||||
|
label: "Custom Panel",
|
||||||
|
component: markRaw(MyComponent),
|
||||||
|
permissions: []
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
PluginTab 类型:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface PluginTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
component: Raw<Component>;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
```
|
|
@ -4,4 +4,5 @@ export * from "./core/plugins";
|
||||||
export * from "./states/pages";
|
export * from "./states/pages";
|
||||||
export * from "./states/attachment-selector";
|
export * from "./states/attachment-selector";
|
||||||
export * from "./states/editor";
|
export * from "./states/editor";
|
||||||
|
export * from "./states/plugin-tab";
|
||||||
export * from "./states/comment-subject-ref";
|
export * from "./states/comment-subject-ref";
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { Component, Raw } from "vue";
|
||||||
|
|
||||||
|
export interface PluginTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
component: Raw<Component>;
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import type { Component } from "vue";
|
||||||
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
|
import type { RouteRecordRaw, RouteRecordName } 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 } from "..";
|
import type { EditorProvider, PluginTab } from "..";
|
||||||
import type { AnyExtension } from "@tiptap/vue-3";
|
import type { AnyExtension } from "@tiptap/vue-3";
|
||||||
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
|
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
|
||||||
|
|
||||||
|
@ -21,6 +21,8 @@ export interface ExtensionPoint {
|
||||||
|
|
||||||
"editor:create"?: () => EditorProvider[] | Promise<EditorProvider[]>;
|
"editor:create"?: () => EditorProvider[] | Promise<EditorProvider[]>;
|
||||||
|
|
||||||
|
"plugin:self:tabs:create"?: () => PluginTab[] | Promise<PluginTab[]>;
|
||||||
|
|
||||||
"default:editor:extension:create": () =>
|
"default:editor:extension:create": () =>
|
||||||
| AnyExtension[]
|
| AnyExtension[]
|
||||||
| Promise<AnyExtension[]>;
|
| Promise<AnyExtension[]>;
|
||||||
|
|
|
@ -788,8 +788,6 @@ core:
|
||||||
license: License
|
license: License
|
||||||
role_templates: Role Templates
|
role_templates: Role Templates
|
||||||
last_starttime: Last Start Time
|
last_starttime: Last Start Time
|
||||||
settings:
|
|
||||||
title: Plugin settings
|
|
||||||
loader:
|
loader:
|
||||||
toast:
|
toast:
|
||||||
entry_load_failed: "{name}: Failed to load plugin entry file"
|
entry_load_failed: "{name}: Failed to load plugin entry file"
|
||||||
|
|
|
@ -788,8 +788,6 @@ core:
|
||||||
license: 协议
|
license: 协议
|
||||||
role_templates: 权限模板
|
role_templates: 权限模板
|
||||||
last_starttime: 最近一次启动
|
last_starttime: 最近一次启动
|
||||||
settings:
|
|
||||||
title: 插件设置
|
|
||||||
loader:
|
loader:
|
||||||
toast:
|
toast:
|
||||||
entry_load_failed: "{name}:加载插件入口文件失败"
|
entry_load_failed: "{name}:加载插件入口文件失败"
|
||||||
|
|
|
@ -788,8 +788,6 @@ core:
|
||||||
license: 協議
|
license: 協議
|
||||||
role_templates: 權限模板
|
role_templates: 權限模板
|
||||||
last_starttime: 最近一次啟動
|
last_starttime: 最近一次啟動
|
||||||
settings:
|
|
||||||
title: 插件設置
|
|
||||||
loader:
|
loader:
|
||||||
toast:
|
toast:
|
||||||
entry_load_failed: "{name}:讀取插件入口文件失敗"
|
entry_load_failed: "{name}:讀取插件入口文件失敗"
|
||||||
|
|
|
@ -1,204 +1,148 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
// core libs
|
||||||
VAlert,
|
import { provide, ref, computed, onMounted } from "vue";
|
||||||
VDescription,
|
import { useRoute } from "vue-router";
|
||||||
VDescriptionItem,
|
|
||||||
VSwitch,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import { computed, inject } from "vue";
|
|
||||||
import { apiClient } from "@/utils/api-client";
|
import { apiClient } from "@/utils/api-client";
|
||||||
import type { Plugin, Role } from "@halo-dev/api-client";
|
|
||||||
import { pluginLabels, roleLabels } from "@/constants/labels";
|
// libs
|
||||||
import { rbacAnnotations } from "@/constants/annotations";
|
import cloneDeep from "lodash.clonedeep";
|
||||||
import { usePluginLifeCycle } from "./composables/use-plugin";
|
|
||||||
import { formatDatetime } from "@/utils/date";
|
// components
|
||||||
|
import { VCard, VPageHeader, VTabbar, VAvatar } from "@halo-dev/components";
|
||||||
|
|
||||||
|
// types
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import type { Plugin, Setting, SettingForm } from "@halo-dev/api-client";
|
||||||
|
import { usePermission } from "@/utils/permission";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import type { PluginTab } from "@halo-dev/console-shared";
|
||||||
|
import { usePluginModuleStore } from "@/stores/plugin";
|
||||||
|
import { markRaw } from "vue";
|
||||||
|
import DetailTab from "./tabs/Detail.vue";
|
||||||
|
import SettingTab from "./tabs/Setting.vue";
|
||||||
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
|
|
||||||
const plugin = inject<Ref<Plugin | undefined>>("plugin");
|
const { currentUserHasPermission } = usePermission();
|
||||||
const { changeStatus } = usePluginLifeCycle(plugin);
|
const { t } = useI18n();
|
||||||
|
|
||||||
interface RoleTemplateGroup {
|
const initialTabs = ref<PluginTab[]>([
|
||||||
module: string | null | undefined;
|
{
|
||||||
roles: Role[];
|
id: "detail",
|
||||||
}
|
label: t("core.plugin.tabs.detail"),
|
||||||
|
component: markRaw(DetailTab),
|
||||||
const { data: pluginRoleTemplates } = useQuery({
|
|
||||||
queryKey: ["plugin-roles", plugin?.value?.metadata.name],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await apiClient.extension.role.listv1alpha1Role({
|
|
||||||
page: 0,
|
|
||||||
size: 0,
|
|
||||||
labelSelector: [
|
|
||||||
`${pluginLabels.NAME}=${plugin?.value?.metadata.name}`,
|
|
||||||
`${roleLabels.TEMPLATE}=true`,
|
|
||||||
"!halo.run/hidden",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.items;
|
|
||||||
},
|
},
|
||||||
cacheTime: 0,
|
]);
|
||||||
enabled: computed(() => !!plugin?.value?.metadata.name),
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const tabs = ref<PluginTab[]>(cloneDeep(initialTabs.value));
|
||||||
|
const activeTab = useRouteQuery<string>("tab", tabs.value[0].id, {
|
||||||
|
mode: "push",
|
||||||
});
|
});
|
||||||
|
|
||||||
const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
|
provide<Ref<string | undefined>>("activeTab", activeTab);
|
||||||
const groups: RoleTemplateGroup[] = [];
|
|
||||||
pluginRoleTemplates.value?.forEach((role) => {
|
const { data: plugin } = useQuery({
|
||||||
const group = groups.find(
|
queryKey: ["plugin", route.params.name],
|
||||||
(group) =>
|
queryFn: async () => {
|
||||||
group.module === role.metadata.annotations?.[rbacAnnotations.MODULE]
|
const { data } =
|
||||||
);
|
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
|
||||||
if (group) {
|
name: route.params.name as string,
|
||||||
group.roles.push(role);
|
|
||||||
} else {
|
|
||||||
groups.push({
|
|
||||||
module: role.metadata.annotations?.[rbacAnnotations.MODULE],
|
|
||||||
roles: [role],
|
|
||||||
});
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
provide<Ref<Plugin | undefined>>("plugin", plugin);
|
||||||
|
|
||||||
|
const { data: setting } = useQuery({
|
||||||
|
queryKey: ["plugin-setting", plugin],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.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,
|
||||||
|
...forms.map((item: SettingForm) => {
|
||||||
|
return {
|
||||||
|
id: item.group,
|
||||||
|
label: item.label || "",
|
||||||
|
component: markRaw(SettingTab),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
] as PluginTab[];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
provide<Ref<Setting | undefined>>("setting", setting);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const { pluginModules } = usePluginModuleStore();
|
||||||
|
const currentPluginModule = pluginModules.find(
|
||||||
|
(item) => item.extension.metadata.name === route.params.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentPluginModule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { extensionPoints } = currentPluginModule;
|
||||||
|
|
||||||
|
if (!extensionPoints?.["plugin:self:tabs:create"]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraTabs = extensionPoints["plugin:self:tabs:create"]() as PluginTab[];
|
||||||
|
|
||||||
|
extraTabs.forEach((tab) => {
|
||||||
|
if (currentUserHasPermission(tab.permissions)) {
|
||||||
|
initialTabs.value.push(tab);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return groups;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition mode="out-in" name="fade">
|
<VPageHeader :title="plugin?.spec?.displayName">
|
||||||
<div>
|
<template #icon>
|
||||||
<div class="flex items-center justify-between bg-white px-4 py-4 sm:px-6">
|
<VAvatar
|
||||||
<div>
|
v-if="plugin"
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
:src="plugin.status?.logo"
|
||||||
{{ $t("core.plugin.detail.header.title") }}
|
:alt="plugin.spec.displayName"
|
||||||
</h3>
|
class="mr-2"
|
||||||
</div>
|
size="sm"
|
||||||
<div v-permission="['system:plugins:manage']">
|
/>
|
||||||
<VSwitch :model-value="plugin?.spec.enabled" @change="changeStatus" />
|
</template>
|
||||||
</div>
|
</VPageHeader>
|
||||||
|
|
||||||
|
<div class="m-0 md:m-4">
|
||||||
|
<VCard :body-class="['!p-0']">
|
||||||
|
<template #header>
|
||||||
|
<VTabbar
|
||||||
|
v-model:active-id="activeTab"
|
||||||
|
:items="tabs.map((item) => ({ id: item.id, label: item.label }))"
|
||||||
|
class="w-full !rounded-none"
|
||||||
|
type="outline"
|
||||||
|
></VTabbar>
|
||||||
|
</template>
|
||||||
|
<div class="bg-white">
|
||||||
|
<template v-for="tab in tabs" :key="tab.id">
|
||||||
|
<component :is="tab.component" v-if="activeTab === tab.id" />
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</VCard>
|
||||||
v-if="
|
</div>
|
||||||
plugin?.status?.phase === 'FAILED' &&
|
|
||||||
plugin?.status?.conditions?.length
|
|
||||||
"
|
|
||||||
class="w-full px-4 pb-2 sm:px-6"
|
|
||||||
>
|
|
||||||
<VAlert
|
|
||||||
type="error"
|
|
||||||
:title="plugin?.status?.conditions?.[0].reason"
|
|
||||||
:description="plugin?.status?.conditions?.[0].message"
|
|
||||||
:closable="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="border-t border-gray-200">
|
|
||||||
<VDescription>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.plugin.detail.fields.display_name')"
|
|
||||||
:content="plugin?.spec.displayName"
|
|
||||||
/>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.plugin.detail.fields.description')"
|
|
||||||
:content="plugin?.spec.description"
|
|
||||||
/>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.plugin.detail.fields.version')"
|
|
||||||
:content="plugin?.spec.version"
|
|
||||||
/>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.plugin.detail.fields.requires')"
|
|
||||||
:content="plugin?.spec.requires"
|
|
||||||
/>
|
|
||||||
<VDescriptionItem :label="$t('core.plugin.detail.fields.author')">
|
|
||||||
<a
|
|
||||||
v-if="plugin?.spec.author"
|
|
||||||
:href="plugin?.spec.author.website"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
{{ plugin?.spec.author.name }}
|
|
||||||
</a>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t("core.common.text.none") }}
|
|
||||||
</span>
|
|
||||||
</VDescriptionItem>
|
|
||||||
<VDescriptionItem :label="$t('core.plugin.detail.fields.license')">
|
|
||||||
<ul
|
|
||||||
v-if="plugin?.spec.license && plugin?.spec.license.length"
|
|
||||||
class="list-inside"
|
|
||||||
:class="{ 'list-disc': plugin?.spec.license.length > 1 }"
|
|
||||||
>
|
|
||||||
<li v-for="(license, index) in plugin.spec.license" :key="index">
|
|
||||||
<a v-if="license.url" :href="license.url" target="_blank">
|
|
||||||
{{ license.name }}
|
|
||||||
</a>
|
|
||||||
<span v-else>
|
|
||||||
{{ license.name }}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</VDescriptionItem>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.plugin.detail.fields.role_templates')"
|
|
||||||
>
|
|
||||||
<dl
|
|
||||||
v-if="pluginRoleTemplateGroups.length"
|
|
||||||
class="divide-y divide-gray-100"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(group, groupIndex) in pluginRoleTemplateGroups"
|
|
||||||
:key="groupIndex"
|
|
||||||
class="rounded bg-gray-50 px-4 py-5 hover:bg-gray-100 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
|
|
||||||
>
|
|
||||||
<dt class="text-sm font-medium text-gray-900">
|
|
||||||
{{ group.module }}
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li v-for="(role, index) in group.roles" :key="index">
|
|
||||||
<div
|
|
||||||
class="inline-flex w-72 cursor-pointer flex-row items-center gap-4 rounded border p-5 hover:border-primary"
|
|
||||||
>
|
|
||||||
<div class="inline-flex flex-col gap-y-3">
|
|
||||||
<span class="font-medium text-gray-900">
|
|
||||||
{{
|
|
||||||
role.metadata.annotations?.[
|
|
||||||
rbacAnnotations.DISPLAY_NAME
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="
|
|
||||||
role.metadata.annotations?.[
|
|
||||||
rbacAnnotations.DEPENDENCIES
|
|
||||||
]
|
|
||||||
"
|
|
||||||
class="text-xs text-gray-400"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
$t("core.role.common.text.dependent_on", {
|
|
||||||
roles: JSON.parse(
|
|
||||||
role.metadata.annotations?.[
|
|
||||||
rbacAnnotations.DEPENDENCIES
|
|
||||||
]
|
|
||||||
).join(", "),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<span v-else>
|
|
||||||
{{ $t("core.common.text.none") }}
|
|
||||||
</span>
|
|
||||||
</VDescriptionItem>
|
|
||||||
<VDescriptionItem
|
|
||||||
:label="$t('core.plugin.detail.fields.last_starttime')"
|
|
||||||
:content="formatDatetime(plugin?.status?.lastStartTime)"
|
|
||||||
/>
|
|
||||||
</VDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,184 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
// core libs
|
|
||||||
import { nextTick, provide, ref, computed, watch } from "vue";
|
|
||||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
|
||||||
import { apiClient } from "@/utils/api-client";
|
|
||||||
|
|
||||||
// libs
|
|
||||||
import cloneDeep from "lodash.clonedeep";
|
|
||||||
|
|
||||||
// components
|
|
||||||
import {
|
|
||||||
VCard,
|
|
||||||
VPageHeader,
|
|
||||||
VTabbar,
|
|
||||||
VAvatar,
|
|
||||||
VLoading,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
|
||||||
|
|
||||||
// types
|
|
||||||
import type { Ref } from "vue";
|
|
||||||
import type { Plugin, Setting, SettingForm } from "@halo-dev/api-client";
|
|
||||||
import { usePermission } from "@/utils/permission";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
|
|
||||||
const { currentUserHasPermission } = usePermission();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
interface PluginTab {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
route: {
|
|
||||||
name: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialTabs: PluginTab[] = [
|
|
||||||
{
|
|
||||||
id: "detail",
|
|
||||||
label: t("core.plugin.tabs.detail"),
|
|
||||||
route: {
|
|
||||||
name: "PluginDetail",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const tabs = ref<PluginTab[]>(cloneDeep(initialTabs));
|
|
||||||
const activeTab = ref<string>(tabs.value[0].id);
|
|
||||||
|
|
||||||
provide<Ref<string | undefined>>("activeTab", activeTab);
|
|
||||||
|
|
||||||
const { data: plugin } = useQuery({
|
|
||||||
queryKey: ["plugin", route.params.name],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } =
|
|
||||||
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
|
|
||||||
name: route.params.name as string,
|
|
||||||
});
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
provide<Ref<Plugin | undefined>>("plugin", plugin);
|
|
||||||
|
|
||||||
const { data: setting } = useQuery({
|
|
||||||
queryKey: ["plugin-setting", plugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await apiClient.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,
|
|
||||||
...forms.map((item: SettingForm) => {
|
|
||||||
return {
|
|
||||||
id: item.group,
|
|
||||||
label: item.label || "",
|
|
||||||
route: {
|
|
||||||
name: "PluginSetting",
|
|
||||||
params: {
|
|
||||||
group: item.group,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
] as PluginTab[];
|
|
||||||
}
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
handleTriggerTabChange();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
provide<Ref<Setting | undefined>>("setting", setting);
|
|
||||||
|
|
||||||
const handleTabChange = (id: string) => {
|
|
||||||
const tab = tabs.value.find((item) => item.id === id);
|
|
||||||
if (tab) {
|
|
||||||
activeTab.value = tab.id;
|
|
||||||
router.push(tab.route);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTriggerTabChange = () => {
|
|
||||||
if (route.name === "PluginSetting") {
|
|
||||||
const tab = tabs.value.find((tab) => {
|
|
||||||
return (
|
|
||||||
tab.route.name === route.name &&
|
|
||||||
tab.route.params?.group === route.params.group
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (tab) {
|
|
||||||
activeTab.value = tab.id;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleTabChange(tabs.value[0].id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tab = tabs.value.find((tab) => tab.route.name === route.name);
|
|
||||||
activeTab.value = tab ? tab.id : tabs.value[0].id;
|
|
||||||
};
|
|
||||||
|
|
||||||
watch([() => route.name, () => route.params], () => {
|
|
||||||
handleTriggerTabChange();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<BasicLayout>
|
|
||||||
<VPageHeader :title="plugin?.spec?.displayName">
|
|
||||||
<template #icon>
|
|
||||||
<VAvatar
|
|
||||||
v-if="plugin"
|
|
||||||
:src="plugin.status?.logo"
|
|
||||||
:alt="plugin.spec.displayName"
|
|
||||||
class="mr-2"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</VPageHeader>
|
|
||||||
|
|
||||||
<div class="m-0 md:m-4">
|
|
||||||
<VCard :body-class="['!p-0']">
|
|
||||||
<template #header>
|
|
||||||
<VTabbar
|
|
||||||
v-model:active-id="activeTab"
|
|
||||||
:items="tabs.map((item) => ({ id: item.id, label: item.label }))"
|
|
||||||
class="w-full !rounded-none"
|
|
||||||
type="outline"
|
|
||||||
@change="handleTabChange"
|
|
||||||
></VTabbar>
|
|
||||||
</template>
|
|
||||||
<div class="bg-white">
|
|
||||||
<RouterView :key="activeTab" v-slot="{ Component }">
|
|
||||||
<template v-if="Component">
|
|
||||||
<Suspense>
|
|
||||||
<component :is="Component"></component>
|
|
||||||
<template #fallback>
|
|
||||||
<VLoading />
|
|
||||||
</template>
|
|
||||||
</Suspense>
|
|
||||||
</template>
|
|
||||||
</RouterView>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
</BasicLayout>
|
|
||||||
</template>
|
|
|
@ -1,9 +1,7 @@
|
||||||
import { definePlugin } from "@halo-dev/console-shared";
|
import { definePlugin } from "@halo-dev/console-shared";
|
||||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||||
import BlankLayout from "@/layouts/BlankLayout.vue";
|
import BlankLayout from "@/layouts/BlankLayout.vue";
|
||||||
import PluginLayout from "./layouts/PluginLayout.vue";
|
|
||||||
import PluginList from "./PluginList.vue";
|
import PluginList from "./PluginList.vue";
|
||||||
import PluginSetting from "./PluginSetting.vue";
|
|
||||||
import PluginDetail from "./PluginDetail.vue";
|
import PluginDetail from "./PluginDetail.vue";
|
||||||
import { IconPlug } from "@halo-dev/components";
|
import { IconPlug } from "@halo-dev/components";
|
||||||
import { markRaw } from "vue";
|
import { markRaw } from "vue";
|
||||||
|
@ -39,10 +37,10 @@ export default definePlugin({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ":name",
|
path: ":name",
|
||||||
component: PluginLayout,
|
component: BasicLayout,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "detail",
|
path: "",
|
||||||
name: "PluginDetail",
|
name: "PluginDetail",
|
||||||
component: PluginDetail,
|
component: PluginDetail,
|
||||||
meta: {
|
meta: {
|
||||||
|
@ -50,15 +48,6 @@ export default definePlugin({
|
||||||
permissions: ["system:plugins:view"],
|
permissions: ["system:plugins:view"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "settings/:group",
|
|
||||||
name: "PluginSetting",
|
|
||||||
component: PluginSetting,
|
|
||||||
meta: {
|
|
||||||
title: "core.plugin.settings.title",
|
|
||||||
permissions: ["system:plugins:manage"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -0,0 +1,204 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
VAlert,
|
||||||
|
VDescription,
|
||||||
|
VDescriptionItem,
|
||||||
|
VSwitch,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import { computed, inject } from "vue";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import type { Plugin, Role } from "@halo-dev/api-client";
|
||||||
|
import { pluginLabels, roleLabels } from "@/constants/labels";
|
||||||
|
import { rbacAnnotations } from "@/constants/annotations";
|
||||||
|
import { usePluginLifeCycle } from "../composables/use-plugin";
|
||||||
|
import { formatDatetime } from "@/utils/date";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
|
||||||
|
const plugin = inject<Ref<Plugin | undefined>>("plugin");
|
||||||
|
const { changeStatus } = usePluginLifeCycle(plugin);
|
||||||
|
|
||||||
|
interface RoleTemplateGroup {
|
||||||
|
module: string | null | undefined;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: pluginRoleTemplates } = useQuery({
|
||||||
|
queryKey: ["plugin-roles", plugin?.value?.metadata.name],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.extension.role.listv1alpha1Role({
|
||||||
|
page: 0,
|
||||||
|
size: 0,
|
||||||
|
labelSelector: [
|
||||||
|
`${pluginLabels.NAME}=${plugin?.value?.metadata.name}`,
|
||||||
|
`${roleLabels.TEMPLATE}=true`,
|
||||||
|
"!halo.run/hidden",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.items;
|
||||||
|
},
|
||||||
|
cacheTime: 0,
|
||||||
|
enabled: computed(() => !!plugin?.value?.metadata.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
|
||||||
|
const groups: RoleTemplateGroup[] = [];
|
||||||
|
pluginRoleTemplates.value?.forEach((role) => {
|
||||||
|
const group = groups.find(
|
||||||
|
(group) =>
|
||||||
|
group.module === role.metadata.annotations?.[rbacAnnotations.MODULE]
|
||||||
|
);
|
||||||
|
if (group) {
|
||||||
|
group.roles.push(role);
|
||||||
|
} else {
|
||||||
|
groups.push({
|
||||||
|
module: role.metadata.annotations?.[rbacAnnotations.MODULE],
|
||||||
|
roles: [role],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition mode="out-in" name="fade">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between bg-white px-4 py-4 sm:px-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||||
|
{{ $t("core.plugin.detail.header.title") }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div v-permission="['system:plugins:manage']">
|
||||||
|
<VSwitch :model-value="plugin?.spec.enabled" @change="changeStatus" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
plugin?.status?.phase === 'FAILED' &&
|
||||||
|
plugin?.status?.conditions?.length
|
||||||
|
"
|
||||||
|
class="w-full px-4 pb-2 sm:px-6"
|
||||||
|
>
|
||||||
|
<VAlert
|
||||||
|
type="error"
|
||||||
|
:title="plugin?.status?.conditions?.[0].reason"
|
||||||
|
:description="plugin?.status?.conditions?.[0].message"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200">
|
||||||
|
<VDescription>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.plugin.detail.fields.display_name')"
|
||||||
|
:content="plugin?.spec.displayName"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.plugin.detail.fields.description')"
|
||||||
|
:content="plugin?.spec.description"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.plugin.detail.fields.version')"
|
||||||
|
:content="plugin?.spec.version"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.plugin.detail.fields.requires')"
|
||||||
|
:content="plugin?.spec.requires"
|
||||||
|
/>
|
||||||
|
<VDescriptionItem :label="$t('core.plugin.detail.fields.author')">
|
||||||
|
<a
|
||||||
|
v-if="plugin?.spec.author"
|
||||||
|
:href="plugin?.spec.author.website"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ plugin?.spec.author.name }}
|
||||||
|
</a>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t("core.common.text.none") }}
|
||||||
|
</span>
|
||||||
|
</VDescriptionItem>
|
||||||
|
<VDescriptionItem :label="$t('core.plugin.detail.fields.license')">
|
||||||
|
<ul
|
||||||
|
v-if="plugin?.spec.license && plugin?.spec.license.length"
|
||||||
|
class="list-inside"
|
||||||
|
:class="{ 'list-disc': plugin?.spec.license.length > 1 }"
|
||||||
|
>
|
||||||
|
<li v-for="(license, index) in plugin.spec.license" :key="index">
|
||||||
|
<a v-if="license.url" :href="license.url" target="_blank">
|
||||||
|
{{ license.name }}
|
||||||
|
</a>
|
||||||
|
<span v-else>
|
||||||
|
{{ license.name }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</VDescriptionItem>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.plugin.detail.fields.role_templates')"
|
||||||
|
>
|
||||||
|
<dl
|
||||||
|
v-if="pluginRoleTemplateGroups.length"
|
||||||
|
class="divide-y divide-gray-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(group, groupIndex) in pluginRoleTemplateGroups"
|
||||||
|
:key="groupIndex"
|
||||||
|
class="rounded bg-gray-50 px-4 py-5 hover:bg-gray-100 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
|
||||||
|
>
|
||||||
|
<dt class="text-sm font-medium text-gray-900">
|
||||||
|
{{ group.module }}
|
||||||
|
</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li v-for="(role, index) in group.roles" :key="index">
|
||||||
|
<div
|
||||||
|
class="inline-flex w-72 cursor-pointer flex-row items-center gap-4 rounded border p-5 hover:border-primary"
|
||||||
|
>
|
||||||
|
<div class="inline-flex flex-col gap-y-3">
|
||||||
|
<span class="font-medium text-gray-900">
|
||||||
|
{{
|
||||||
|
role.metadata.annotations?.[
|
||||||
|
rbacAnnotations.DISPLAY_NAME
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="
|
||||||
|
role.metadata.annotations?.[
|
||||||
|
rbacAnnotations.DEPENDENCIES
|
||||||
|
]
|
||||||
|
"
|
||||||
|
class="text-xs text-gray-400"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t("core.role.common.text.dependent_on", {
|
||||||
|
roles: JSON.parse(
|
||||||
|
role.metadata.annotations?.[
|
||||||
|
rbacAnnotations.DEPENDENCIES
|
||||||
|
]
|
||||||
|
).join(", "),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t("core.common.text.none") }}
|
||||||
|
</span>
|
||||||
|
</VDescriptionItem>
|
||||||
|
<VDescriptionItem
|
||||||
|
:label="$t('core.plugin.detail.fields.last_starttime')"
|
||||||
|
:content="formatDatetime(plugin?.status?.lastStartTime)"
|
||||||
|
/>
|
||||||
|
</VDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
|
@ -11,7 +11,7 @@ import { Toast, VButton } from "@halo-dev/components";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
import type { ConfigMap, Plugin, Setting } from "@halo-dev/api-client";
|
import type { ConfigMap, Plugin, Setting } from "@halo-dev/api-client";
|
||||||
import { useRouteParams } from "@vueuse/router";
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
import { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { toRaw } from "vue";
|
import { toRaw } from "vue";
|
||||||
|
@ -19,13 +19,13 @@ import { toRaw } from "vue";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const group = useRouteParams<string>("group");
|
const group = useRouteQuery<string>("tab", undefined, { mode: "push" });
|
||||||
|
|
||||||
const plugin = inject<Ref<Plugin | undefined>>("plugin");
|
const plugin = inject<Ref<Plugin | undefined>>("plugin");
|
||||||
const setting = inject<Ref<Setting | undefined>>("setting", ref());
|
const setting = inject<Ref<Setting | undefined>>("setting", ref());
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
const { data: configMap, suspense } = useQuery<ConfigMap>({
|
const { data: configMap } = useQuery<ConfigMap>({
|
||||||
queryKey: ["plugin-configMap", plugin],
|
queryKey: ["plugin-configMap", plugin],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.plugin.fetchPluginConfig({
|
const { data } = await apiClient.plugin.fetchPluginConfig({
|
||||||
|
@ -63,8 +63,6 @@ const handleSaveConfigMap = async () => {
|
||||||
|
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
await suspense();
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Transition mode="out-in" name="fade">
|
<Transition mode="out-in" name="fade">
|
Loading…
Reference in New Issue