Refactor plugin installation modal to support extend (#4461)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.9.x

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

重构插件安装的界面,以支持扩展。

文档:https://github.com/halo-dev/halo/pull/4461/files?short_path=fe4adc6#diff-fe4adc66005d24150478d0919c5f330ee695c2b5657ee7baed56929830c7eb90

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

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

#### Special notes for your reviewer:

需要测试插件的本地上传和远程下载的安装的更新。

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

```release-note
重构 Console 端插件安装界面,支持通过插件扩展选项卡。
```
pull/4482/head
Ryan Wang 2023-08-25 10:28:12 -05:00 committed by GitHub
parent 7603b21dd2
commit 63bbd4fa81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 528 additions and 304 deletions

View File

@ -0,0 +1,54 @@
# 插件安装界面选项卡扩展点
## 原由
目前 Halo 原生支持本地上传和远程下载的方式安装插件,此扩展点用于扩展插件安装界面的选项卡,以支持更多的安装方式。
## 定义方式
> 此示例为添加一个安装选项卡用于从 GitHub 上下载插件。
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw } from "vue";
import GitHubDownloadTab from "./components/GitHubDownloadTab.vue";
export default definePlugin({
extensionPoints: {
"plugin:installation:tabs:create": () => {
return [
{
id: "github",
label: "GitHub",
component: markRaw(GitHubDownload),
props: {
foo: "bar",
},
priority: 30,
},
];
},
},
});
```
扩展点类型:
```ts
"plugin:installation:tabs:create"?: () =>
| PluginInstallationTab[]
| Promise<PluginInstallationTab[]>;
```
`PluginInstallationTab`:
```ts
export interface PluginInstallationTab {
id: string; // 选项卡的唯一标识
label: string; // 选项卡的名称
component: Raw<Component>; // 选项卡面板的组件
props?: Record<string, unknown>; // 选项卡组件的 props
permissions?: string[]; // 权限
priority: number; // 优先级
}
```

View File

@ -7,4 +7,5 @@ export * from "./states/editor";
export * from "./states/plugin-tab";
export * from "./states/comment-subject-ref";
export * from "./states/backup";
export * from "./states/plugin-installation-tabs";
export * from "./states/entity";

View File

@ -0,0 +1,10 @@
import type { Component, Raw } from "vue";
export interface PluginInstallationTab {
id: string;
label: string;
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
priority: number;
}

View File

@ -6,6 +6,7 @@ import type { EditorProvider, PluginTab } from "..";
import type { AnyExtension } from "@tiptap/vue-3";
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
import type { BackupTab } from "@/states/backup";
import type { PluginInstallationTab } from "@/states/plugin-installation-tabs";
import type { EntityDropdownItem } from "@/states/entity";
import type { ListedPost, Plugin } from "@halo-dev/api-client";
@ -34,6 +35,10 @@ export interface ExtensionPoint {
"backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>;
"plugin:installation:tabs:create"?: () =>
| PluginInstallationTab[]
| Promise<PluginInstallationTab[]>;
"post:list-item:operation:create"?: () =>
| EntityDropdownItem<ListedPost>[]
| Promise<EntityDropdownItem<ListedPost>[]>;

View File

@ -13,7 +13,7 @@ import {
Dialog,
} from "@halo-dev/components";
import PluginListItem from "./components/PluginListItem.vue";
import PluginUploadModal from "./components/PluginUploadModal.vue";
import PluginInstallationModal from "./components/PluginInstallationModal.vue";
import { computed, ref, onMounted } from "vue";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
@ -27,12 +27,12 @@ const { t } = useI18n();
const { currentUserHasPermission } = usePermission();
const pluginUploadModal = ref(false);
const pluginInstallationModal = ref(false);
const pluginToUpgrade = ref<Plugin>();
function handleOpenUploadModal(plugin?: Plugin) {
pluginToUpgrade.value = plugin;
pluginUploadModal.value = true;
pluginInstallationModal.value = true;
}
const keyword = ref("");
@ -115,10 +115,10 @@ onMounted(() => {
});
</script>
<template>
<PluginUploadModal
<PluginInstallationModal
v-if="currentUserHasPermission(['system:plugins:manage'])"
v-model:visible="pluginUploadModal"
:upgrade-plugin="pluginToUpgrade"
v-model:visible="pluginInstallationModal"
:plugin-to-upgrade="pluginToUpgrade"
/>
<VPageHeader :title="$t('core.plugin.title')">

View File

@ -0,0 +1,141 @@
<script lang="ts" setup>
import { VModal, VButton, VTabbar } from "@halo-dev/components";
import type { Plugin } from "@halo-dev/api-client";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRouteQuery } from "@vueuse/router";
import { provide } from "vue";
import { toRefs } from "vue";
import type { Ref } from "vue";
import LocalUpload from "./installation-tabs/LocalUpload.vue";
import RemoteDownload from "./installation-tabs/RemoteDownload.vue";
import { markRaw } from "vue";
import type {
PluginInstallationTab,
PluginModule,
} from "@halo-dev/console-shared";
import { usePluginModuleStore } from "@/stores/plugin";
import { onMounted } from "vue";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
visible: boolean;
pluginToUpgrade?: Plugin;
}>(),
{
visible: false,
pluginToUpgrade: undefined,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const { pluginToUpgrade } = toRefs(props);
provide<Ref<Plugin | undefined>>("pluginToUpgrade", pluginToUpgrade);
const tabs = ref<PluginInstallationTab[]>([
{
id: "local",
label: t("core.plugin.upload_modal.tabs.local"),
component: markRaw(LocalUpload),
priority: 10,
},
{
id: "remote",
label: t("core.plugin.upload_modal.tabs.remote.title"),
component: markRaw(RemoteDownload),
priority: 20,
},
]);
const activeTabId = ref();
const modalTitle = computed(() => {
return props.pluginToUpgrade
? t("core.plugin.upload_modal.titles.upgrade", {
display_name: props.pluginToUpgrade.spec.displayName,
})
: t("core.plugin.upload_modal.titles.install");
});
const handleVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
// handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url"
);
watch(
() => props.visible,
(visible) => {
if (visible && routeRemoteDownloadUrl.value) {
activeTabId.value = "remote";
}
}
);
const { pluginModules } = usePluginModuleStore();
onMounted(() => {
pluginModules.forEach((pluginModule: PluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.["plugin:installation:tabs:create"]) {
return;
}
const items = extensionPoints[
"plugin:installation:tabs:create"
]() as PluginInstallationTab[];
tabs.value.push(...items);
});
tabs.value.sort((a, b) => {
return a.priority - b.priority;
});
activeTabId.value = tabs.value[0].id;
});
</script>
<template>
<VModal
:visible="visible"
:title="modalTitle"
:centered="true"
:width="920"
height="calc(100vh - 20px)"
@update:visible="handleVisibleChange"
>
<VTabbar
v-model:active-id="activeTabId"
:items="
tabs.map((tab) => {
return { label: tab.label, id: tab.id };
})
"
type="outline"
/>
<div class="mt-2">
<template v-for="tab in tabs" :key="tab.id">
<component
:is="tab.component"
v-bind="tab.props"
v-if="tab.id === activeTabId"
@close-modal="handleVisibleChange(false)"
/>
</template>
</div>
<template #footer>
<VButton @click="handleVisibleChange(false)"></VButton>
</template>
</VModal>
</template>

View File

@ -1,298 +0,0 @@
<script lang="ts" setup>
import {
VModal,
Dialog,
Toast,
VTabs,
VTabItem,
VButton,
} from "@halo-dev/components";
import UppyUpload from "@/components/upload/UppyUpload.vue";
import { apiClient } from "@/utils/api-client";
import type { Plugin } from "@halo-dev/api-client";
import { computed, ref, watch, nextTick } from "vue";
import type { SuccessResponse, ErrorResponse, UppyFile } from "@uppy/core";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { submitForm } from "@formkit/core";
import AppDownloadAlert from "@/components/common/AppDownloadAlert.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
visible: boolean;
upgradePlugin?: Plugin;
}>(),
{
visible: false,
upgradePlugin: undefined,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const uploadVisible = ref(false);
const modalTitle = computed(() => {
return props.upgradePlugin
? t("core.plugin.upload_modal.titles.upgrade", {
display_name: props.upgradePlugin.spec.displayName,
})
: t("core.plugin.upload_modal.titles.install");
});
const handleVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const endpoint = computed(() => {
if (props.upgradePlugin) {
return `/apis/api.console.halo.run/v1alpha1/plugins/${props.upgradePlugin.metadata.name}/upgrade`;
}
return "/apis/api.console.halo.run/v1alpha1/plugins/install";
});
const onUploaded = async (response: SuccessResponse) => {
if (props.upgradePlugin) {
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
return;
}
handleVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["plugins"] });
handleShowActiveModalAfterInstall(response.body as Plugin);
};
const handleShowActiveModalAfterInstall = (plugin: Plugin) => {
Dialog.success({
title: t("core.plugin.upload_modal.operations.active_after_install.title"),
description: t(
"core.plugin.upload_modal.operations.active_after_install.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const { data: pluginToUpdate } =
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
name: plugin.metadata.name,
});
pluginToUpdate.spec.enabled = true;
await apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin({
name: pluginToUpdate.metadata.name,
plugin: pluginToUpdate,
});
window.location.reload();
} catch (e) {
console.error(e);
}
},
});
};
interface PluginInstallationErrorResponse {
detail: string;
instance: string;
pluginName: string;
requestId: string;
status: number;
timestamp: string;
title: string;
type: string;
}
const PLUGIN_ALREADY_EXISTS_TYPE =
"https://halo.run/probs/plugin-alreay-exists";
const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
const body = response.body as PluginInstallationErrorResponse;
if (body.type === PLUGIN_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(body, file.data as File);
}
};
const handleCatchExistsException = async (
error: PluginInstallationErrorResponse,
file?: File
) => {
Dialog.info({
title: t(
"core.plugin.upload_modal.operations.existed_during_installation.title"
),
description: t(
"core.plugin.upload_modal.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
if (activeTabId.value === "local") {
await apiClient.plugin.upgradePlugin({
name: error.pluginName,
file: file,
});
} else if (activeTabId.value === "remote") {
await apiClient.plugin.upgradePluginFromUri({
name: error.pluginName,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
} else {
throw new Error("Unknown tab id");
}
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
},
});
};
watch(
() => props.visible,
(newValue) => {
if (newValue) {
uploadVisible.value = true;
} else {
const uploadVisibleTimer = setTimeout(() => {
uploadVisible.value = false;
clearTimeout(uploadVisibleTimer);
}, 200);
}
}
);
// remote download
const activeTabId = ref("local");
const remoteDownloadUrl = ref("");
const downloading = ref(false);
const handleDownloadPlugin = async () => {
try {
downloading.value = true;
if (props.upgradePlugin) {
await apiClient.plugin.upgradePluginFromUri({
name: props.upgradePlugin.metadata.name,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
return;
}
const { data: plugin } = await apiClient.plugin.installPluginFromUri({
installFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
handleVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["plugins"] });
handleShowActiveModalAfterInstall(plugin);
// eslint-disable-next-line
} catch (error: any) {
const data = error?.response.data as PluginInstallationErrorResponse;
if (data?.type === PLUGIN_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(data);
}
console.error("Failed to download plugin", error);
} finally {
routeRemoteDownloadUrl.value = null;
downloading.value = false;
}
};
// handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url"
);
watch(
() => props.visible,
(visible) => {
if (routeRemoteDownloadUrl.value && visible) {
activeTabId.value = "remote";
remoteDownloadUrl.value = routeRemoteDownloadUrl.value;
nextTick(() => {
submitForm("plugin-remote-download-form");
});
}
}
);
</script>
<template>
<VModal
:visible="visible"
:width="600"
:title="modalTitle"
:centered="false"
@update:visible="handleVisibleChange"
>
<VTabs v-model:active-id="activeTabId" type="outline" class="!rounded-none">
<VTabItem id="local" :label="$t('core.plugin.upload_modal.tabs.local')">
<div class="pb-3">
<AppDownloadAlert />
</div>
<UppyUpload
v-if="uploadVisible"
:restrictions="{
maxNumberOfFiles: 1,
allowedFileTypes: ['.jar'],
}"
:endpoint="endpoint"
auto-proceed
@uploaded="onUploaded"
@error="onError"
/>
</VTabItem>
<VTabItem
id="remote"
:label="$t('core.plugin.upload_modal.tabs.remote.title')"
>
<FormKit
id="plugin-remote-download-form"
name="plugin-remote-download-form"
type="form"
:preserve="true"
@submit="handleDownloadPlugin"
>
<FormKit
v-model="remoteDownloadUrl"
:label="$t('core.plugin.upload_modal.tabs.remote.fields.url')"
type="text"
></FormKit>
</FormKit>
<div class="pt-5">
<VButton
:loading="downloading"
type="secondary"
@click="$formkit.submit('plugin-remote-download-form')"
>
{{ $t("core.common.buttons.download") }}
</VButton>
</div>
</VTabItem>
</VTabs>
</VModal>
</template>

View File

@ -0,0 +1,130 @@
<script lang="ts" setup>
import { type Ref, inject, ref } from "vue";
import type { Plugin } from "@halo-dev/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import type { SuccessResponse } from "@uppy/core";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
import UppyUpload from "@/components/upload/UppyUpload.vue";
import { computed } from "vue";
import type { UppyFile } from "@uppy/core";
import type { ErrorResponse } from "@uppy/core";
import type { PluginInstallationErrorResponse } from "../../types";
import { PLUGIN_ALREADY_EXISTS_TYPE } from "../../constants";
import { apiClient } from "@/utils/api-client";
import AppDownloadAlert from "@/components/common/AppDownloadAlert.vue";
const emit = defineEmits<{
(event: "close-modal"): void;
}>();
const { t } = useI18n();
const queryClient = useQueryClient();
const pluginToUpgrade = inject<Ref<Plugin | undefined>>(
"pluginToUpgrade",
ref()
);
const endpoint = computed(() => {
if (pluginToUpgrade.value) {
return `/apis/api.console.halo.run/v1alpha1/plugins/${pluginToUpgrade.value.metadata.name}/upgrade`;
}
return "/apis/api.console.halo.run/v1alpha1/plugins/install";
});
const onUploaded = async (response: SuccessResponse) => {
if (pluginToUpgrade.value) {
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
return;
}
emit("close-modal");
queryClient.invalidateQueries({ queryKey: ["plugins"] });
handleShowActiveModalAfterInstall(response.body as Plugin);
};
const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
const body = response.body as PluginInstallationErrorResponse;
if (body.type === PLUGIN_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(body, file.data as File);
}
};
const handleShowActiveModalAfterInstall = (plugin: Plugin) => {
Dialog.success({
title: t("core.plugin.upload_modal.operations.active_after_install.title"),
description: t(
"core.plugin.upload_modal.operations.active_after_install.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const { data: pluginToUpdate } =
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
name: plugin.metadata.name,
});
pluginToUpdate.spec.enabled = true;
await apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin({
name: pluginToUpdate.metadata.name,
plugin: pluginToUpdate,
});
window.location.reload();
} catch (e) {
console.error(e);
}
},
});
};
const handleCatchExistsException = async (
error: PluginInstallationErrorResponse,
file?: File
) => {
Dialog.info({
title: t(
"core.plugin.upload_modal.operations.existed_during_installation.title"
),
description: t(
"core.plugin.upload_modal.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.plugin.upgradePlugin({
name: error.pluginName,
file: file,
});
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
},
});
};
</script>
<template>
<div class="mb-3">
<AppDownloadAlert />
</div>
<UppyUpload
:restrictions="{
maxNumberOfFiles: 1,
allowedFileTypes: ['.jar'],
}"
:endpoint="endpoint"
width="100%"
auto-proceed
@uploaded="onUploaded"
@error="onError"
/>
</template>

View File

@ -0,0 +1,169 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast, VButton } from "@halo-dev/components";
import type { Plugin } from "@halo-dev/api-client";
import type { Ref } from "vue";
import { inject } from "vue";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
import type { PluginInstallationErrorResponse } from "../../types";
import { PLUGIN_ALREADY_EXISTS_TYPE } from "../../constants";
import { useRouteQuery } from "@vueuse/router";
import { onMounted } from "vue";
import { nextTick } from "vue";
import { submitForm } from "@formkit/core";
const emit = defineEmits<{
(event: "close-modal"): void;
}>();
const { t } = useI18n();
const queryClient = useQueryClient();
const pluginToUpgrade = inject<Ref<Plugin | undefined>>(
"pluginToUpgrade",
ref()
);
const remoteDownloadUrl = ref("");
const downloading = ref(false);
const handleDownloadPlugin = async () => {
try {
downloading.value = true;
if (pluginToUpgrade.value) {
await apiClient.plugin.upgradePluginFromUri({
name: pluginToUpgrade.value.metadata.name,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
return;
}
const { data: plugin } = await apiClient.plugin.installPluginFromUri({
installFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
emit("close-modal");
queryClient.invalidateQueries({ queryKey: ["plugins"] });
handleShowActiveModalAfterInstall(plugin);
// eslint-disable-next-line
} catch (error: any) {
const data = error?.response.data as PluginInstallationErrorResponse;
if (data?.type === PLUGIN_ALREADY_EXISTS_TYPE) {
handleCatchExistsException(data);
}
console.error("Failed to download plugin", error);
} finally {
routeRemoteDownloadUrl.value = null;
downloading.value = false;
}
};
const handleShowActiveModalAfterInstall = (plugin: Plugin) => {
Dialog.success({
title: t("core.plugin.upload_modal.operations.active_after_install.title"),
description: t(
"core.plugin.upload_modal.operations.active_after_install.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
const { data: pluginToUpdate } =
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
name: plugin.metadata.name,
});
pluginToUpdate.spec.enabled = true;
await apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin({
name: pluginToUpdate.metadata.name,
plugin: pluginToUpdate,
});
window.location.reload();
} catch (e) {
console.error(e);
}
},
});
};
const handleCatchExistsException = async (
error: PluginInstallationErrorResponse
) => {
Dialog.info({
title: t(
"core.plugin.upload_modal.operations.existed_during_installation.title"
),
description: t(
"core.plugin.upload_modal.operations.existed_during_installation.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
await apiClient.plugin.upgradePluginFromUri({
name: error.pluginName,
upgradeFromUriRequest: {
uri: remoteDownloadUrl.value,
},
});
Toast.success(t("core.common.toast.upgrade_success"));
window.location.reload();
},
});
};
// handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url"
);
onMounted(() => {
if (routeRemoteDownloadUrl.value) {
remoteDownloadUrl.value = routeRemoteDownloadUrl.value;
nextTick(() => {
submitForm("plugin-remote-download-form");
});
}
});
</script>
<template>
<FormKit
id="plugin-remote-download-form"
name="plugin-remote-download-form"
type="form"
:preserve="true"
@submit="handleDownloadPlugin"
>
<FormKit
v-model="remoteDownloadUrl"
:label="$t('core.plugin.upload_modal.tabs.remote.fields.url')"
type="text"
validation="required"
></FormKit>
</FormKit>
<div class="pt-5">
<VButton
:loading="downloading"
type="secondary"
@click="$formkit.submit('plugin-remote-download-form')"
>
{{ $t("core.common.buttons.download") }}
</VButton>
</div>
</template>

View File

@ -0,0 +1,2 @@
export const PLUGIN_ALREADY_EXISTS_TYPE =
"https://halo.run/probs/plugin-alreay-exists";

View File

@ -0,0 +1,10 @@
export interface PluginInstallationErrorResponse {
detail: string;
instance: string;
pluginName: string;
requestId: string;
status: number;
timestamp: string;
title: string;
type: string;
}