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/attachment-selector";
|
||||
export * from "./states/editor";
|
||||
export * from "./states/plugin-tab";
|
||||
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 { FunctionalPage } from "../states/pages";
|
||||
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 { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
|
||||
|
||||
|
@ -21,6 +21,8 @@ export interface ExtensionPoint {
|
|||
|
||||
"editor:create"?: () => EditorProvider[] | Promise<EditorProvider[]>;
|
||||
|
||||
"plugin:self:tabs:create"?: () => PluginTab[] | Promise<PluginTab[]>;
|
||||
|
||||
"default:editor:extension:create": () =>
|
||||
| AnyExtension[]
|
||||
| Promise<AnyExtension[]>;
|
||||
|
|
|
@ -788,8 +788,6 @@ core:
|
|||
license: License
|
||||
role_templates: Role Templates
|
||||
last_starttime: Last Start Time
|
||||
settings:
|
||||
title: Plugin settings
|
||||
loader:
|
||||
toast:
|
||||
entry_load_failed: "{name}: Failed to load plugin entry file"
|
||||
|
|
|
@ -788,8 +788,6 @@ core:
|
|||
license: 协议
|
||||
role_templates: 权限模板
|
||||
last_starttime: 最近一次启动
|
||||
settings:
|
||||
title: 插件设置
|
||||
loader:
|
||||
toast:
|
||||
entry_load_failed: "{name}:加载插件入口文件失败"
|
||||
|
|
|
@ -788,8 +788,6 @@ core:
|
|||
license: 協議
|
||||
role_templates: 權限模板
|
||||
last_starttime: 最近一次啟動
|
||||
settings:
|
||||
title: 插件設置
|
||||
loader:
|
||||
toast:
|
||||
entry_load_failed: "{name}:讀取插件入口文件失敗"
|
||||
|
|
|
@ -1,204 +1,148 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
VAlert,
|
||||
VDescription,
|
||||
VDescriptionItem,
|
||||
VSwitch,
|
||||
} from "@halo-dev/components";
|
||||
import type { Ref } from "vue";
|
||||
import { computed, inject } from "vue";
|
||||
// core libs
|
||||
import { provide, ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
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";
|
||||
|
||||
// libs
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
|
||||
// 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 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 { changeStatus } = usePluginLifeCycle(plugin);
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const { t } = useI18n();
|
||||
|
||||
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;
|
||||
const initialTabs = ref<PluginTab[]>([
|
||||
{
|
||||
id: "detail",
|
||||
label: t("core.plugin.tabs.detail"),
|
||||
component: markRaw(DetailTab),
|
||||
},
|
||||
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[]>(() => {
|
||||
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],
|
||||
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.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>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
></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
|
||||
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>
|
||||
</VCard>
|
||||
</div>
|
||||
</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 BasicLayout from "@/layouts/BasicLayout.vue";
|
||||
import BlankLayout from "@/layouts/BlankLayout.vue";
|
||||
import PluginLayout from "./layouts/PluginLayout.vue";
|
||||
import PluginList from "./PluginList.vue";
|
||||
import PluginSetting from "./PluginSetting.vue";
|
||||
import PluginDetail from "./PluginDetail.vue";
|
||||
import { IconPlug } from "@halo-dev/components";
|
||||
import { markRaw } from "vue";
|
||||
|
@ -39,10 +37,10 @@ export default definePlugin({
|
|||
},
|
||||
{
|
||||
path: ":name",
|
||||
component: PluginLayout,
|
||||
component: BasicLayout,
|
||||
children: [
|
||||
{
|
||||
path: "detail",
|
||||
path: "",
|
||||
name: "PluginDetail",
|
||||
component: PluginDetail,
|
||||
meta: {
|
||||
|
@ -50,15 +48,6 @@ export default definePlugin({
|
|||
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
|
||||
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 { useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||
import { toRaw } from "vue";
|
||||
|
@ -19,13 +19,13 @@ import { toRaw } from "vue";
|
|||
const { t } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const group = useRouteParams<string>("group");
|
||||
const group = useRouteQuery<string>("tab", undefined, { mode: "push" });
|
||||
|
||||
const plugin = inject<Ref<Plugin | undefined>>("plugin");
|
||||
const setting = inject<Ref<Setting | undefined>>("setting", ref());
|
||||
const saving = ref(false);
|
||||
|
||||
const { data: configMap, suspense } = useQuery<ConfigMap>({
|
||||
const { data: configMap } = useQuery<ConfigMap>({
|
||||
queryKey: ["plugin-configMap", plugin],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.plugin.fetchPluginConfig({
|
||||
|
@ -63,8 +63,6 @@ const handleSaveConfigMap = async () => {
|
|||
|
||||
saving.value = false;
|
||||
};
|
||||
|
||||
await suspense();
|
||||
</script>
|
||||
<template>
|
||||
<Transition mode="out-in" name="fade">
|
Loading…
Reference in New Issue