feat: add extension point for extend data list operation items (#4452)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.9.x

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

添加扩展数据列表中操作按钮列表的基础能力,并为文章和插件管理列表的操作按钮列表添加扩展点以测试此扩展能力。

todo:

- [x] 场景测试
- [x] 文档 

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

Ref https://github.com/halo-dev/halo/issues/4177

#### Special notes for your reviewer:

可以使用以下插件进行测试:

- 源码:[plugin-export-md.zip](https://github.com/halo-dev/halo/files/12436956/plugin-export-md.zip)
- 可安装的 JAR 包:[plugin-export-md-1.0.0-SNAPSHOT.jar.zip](https://github.com/halo-dev/halo/files/12436950/plugin-export-md-1.0.0-SNAPSHOT.jar.zip)

安装之后可以在文章列表的操作按钮列表中新增一个 `导出为 Markdown 文档` 的按钮,点击之后会导出一个 Markdown 文档。

<img width="374" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/eb0b7c61-f5c8-4af3-bf13-579681d36097">


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

```release-note
Console 端的文章和插件列表的操作按钮列表支持扩展。
```
pull/4461/head^2
Ryan Wang 2023-08-25 04:02:12 -05:00 committed by GitHub
parent acb738a8ac
commit 70eb039468
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 327 additions and 49 deletions

View File

@ -0,0 +1,68 @@
# Entity 数据列表操作菜单扩展点
## 原由
目前 Halo 2 的 Console 中,展示数据列表是统一使用 Entity 组件Entity 组件中提供了用于放置操作按钮的插槽,此扩展点用于支持通过插件扩展部分数据列表的操作菜单项。
## 定义方式
目前支持扩展的数据列表:
- 文章:`"post:list-item:operation:create"?: () => | EntityDropdownItem<ListedPost>[] | Promise<EntityDropdownItem<ListedPost>[]>`
- 插件:`"plugin:list-item:operation:create"?: () => | EntityDropdownItem<Plugin>[] | Promise<EntityDropdownItem<Plugin>[]>`
示例:
> 此示例是在文章列表中添加一个`导出为 Markdown 文档`的操作菜单项。
```ts
import type { ListedPost } from "@halo-dev/api-client";
import { VDropdownItem } from "@halo-dev/components";
import { definePlugin } from "@halo-dev/console-shared";
import axios from "axios";
import { markRaw } from "vue";
export default definePlugin({
extensionPoints: {
"post:list-item:operation:create": () => {
return [
{
priority: 21,
component: markRaw(VDropdownItem),
label: "导出为 Markdown 文档",
visible: true,
permissions: [],
action: async (post: ListedPost) => {
const { data } = await axios.get(
`/apis/api.console.halo.run/v1alpha1/posts/${post.post.metadata.name}/head-content`
);
const blob = new Blob([data.raw], {
type: "text/plain;charset=utf-8",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${post.post.spec.title}.md`;
link.click();
},
},
];
},
},
});
```
`EntityDropdownItem` 类型:
```ts
export interface EntityDropdownItem<T> {
priority: number; // 优先级,越小越靠前
component: Raw<Component>; // 菜单项组件,可以使用 `@halo-dev/components` 中提供的 `VDropdownItem`,也可以自定义
props?: Record<string, unknown>; // 组件的 props
action?: (item?: T) => void; // 点击事件
label?: string; // 菜单项名称
visible?: boolean; // 是否可见
permissions?: string[]; // 权限
children?: EntityDropdownItem<T>[]; // 子菜单
}
```

View File

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

View File

@ -0,0 +1,12 @@
import type { Component, Raw } from "vue";
export interface EntityDropdownItem<T> {
priority: number;
component: Raw<Component>;
props?: Record<string, unknown>;
action?: (item?: T) => void;
label?: string;
visible?: boolean;
permissions?: string[];
children?: EntityDropdownItem<T>[];
}

View File

@ -6,6 +6,8 @@ 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";
import type { BackupTab } from "@/states/backup"; import type { BackupTab } from "@/states/backup";
import type { EntityDropdownItem } from "@/states/entity";
import type { ListedPost, Plugin } from "@halo-dev/api-client";
export interface RouteRecordAppend { export interface RouteRecordAppend {
parentName: RouteRecordName; parentName: RouteRecordName;
@ -31,6 +33,14 @@ export interface ExtensionPoint {
"comment:subject-ref:create"?: () => CommentSubjectRefProvider[]; "comment:subject-ref:create"?: () => CommentSubjectRefProvider[];
"backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>; "backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>;
"post:list-item:operation:create"?: () =>
| EntityDropdownItem<ListedPost>[]
| Promise<EntityDropdownItem<ListedPost>[]>;
"plugin:list-item:operation:create"?: () =>
| EntityDropdownItem<Plugin>[]
| Promise<EntityDropdownItem<Plugin>[]>;
} }
export interface PluginModule { export interface PluginModule {

View File

@ -0,0 +1,66 @@
<script setup lang="ts" generic="T">
import type { EntityDropdownItem } from "@halo-dev/console-shared";
import { VDropdown } from "@halo-dev/components";
const props = withDefaults(
defineProps<{
dropdownItems: EntityDropdownItem<T>[];
item?: T;
}>(),
{
item: undefined,
}
);
function action(dropdownItem: EntityDropdownItem<T>) {
if (!dropdownItem.action) {
return;
}
dropdownItem.action(props.item);
}
</script>
<template>
<template v-for="(dropdownItem, index) in dropdownItems">
<template v-if="dropdownItem.visible">
<VDropdown
v-if="dropdownItem.children?.length"
:key="`dropdown-children-items-${index}`"
v-permission="dropdownItem.permissions"
:triggers="['click']"
>
<component
:is="dropdownItem.component"
v-bind="dropdownItem.props"
@click="action(dropdownItem)"
>
{{ dropdownItem.label }}
</component>
<template #popper>
<template v-for="(childItem, childIndex) in dropdownItem.children">
<component
:is="childItem.component"
v-if="childItem.visible"
v-bind="childItem.props"
:key="`dropdown-child-item-${childIndex}`"
v-permission="childItem.permissions"
@click="action(childItem)"
>
{{ childItem.label }}
</component>
</template>
</template>
</VDropdown>
<component
:is="dropdownItem.component"
v-else
v-bind="dropdownItem.props"
:key="`dropdown-item-${index}`"
v-permission="dropdownItem.permissions"
@click="action(dropdownItem)"
>
{{ dropdownItem.label }}
</component>
</template>
</template>
</template>

View File

@ -0,0 +1,36 @@
import { usePluginModuleStore } from "@/stores/plugin";
import type {
EntityDropdownItem,
PluginModule,
} from "@halo-dev/console-shared";
import { onMounted, ref } from "vue";
export function useEntityDropdownItemExtensionPoint<T>(
extensionPointName: string,
presets: EntityDropdownItem<T>[]
) {
const { pluginModules } = usePluginModuleStore();
const dropdownItems = ref<EntityDropdownItem<T>[]>(presets);
onMounted(() => {
pluginModules.forEach((pluginModule: PluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.[extensionPointName]) {
return;
}
const items = extensionPoints[
extensionPointName
]() as EntityDropdownItem<T>[];
dropdownItems.value.push(...items);
});
dropdownItems.value.sort((a, b) => {
return a.priority - b.priority;
});
});
return { dropdownItems };
}

View File

@ -25,10 +25,15 @@ import { inject } from "vue";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { ref } from "vue"; import { ref } from "vue";
import { computed } from "vue"; import { computed } from "vue";
import { markRaw } from "vue";
import { useRouter } from "vue-router";
import { useEntityDropdownItemExtensionPoint } from "@/composables/use-entity-extension-points";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -113,6 +118,51 @@ const handleDelete = async () => {
}, },
}); });
}; };
const { dropdownItems } = useEntityDropdownItemExtensionPoint<ListedPost>(
"post:list-item:operation:create",
[
{
priority: 10,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.edit"),
visible: true,
permissions: [],
action: () => {
router.push({
name: "PostEditor",
query: { name: props.post.post.metadata.name },
});
},
},
{
priority: 20,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.setting"),
visible: true,
permissions: [],
action: () => {
emit("open-setting-modal", props.post.post);
},
},
{
priority: 30,
component: markRaw(VDropdownDivider),
visible: true,
},
{
priority: 40,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.delete"),
visible: true,
permissions: [],
action: handleDelete,
},
]
);
</script> </script>
<template> <template>
@ -273,23 +323,7 @@ const handleDelete = async () => {
v-if="currentUserHasPermission(['system:posts:manage'])" v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems #dropdownItems
> >
<VDropdownItem <EntityDropdownItems :dropdown-items="dropdownItems" :item="post" />
@click="
$router.push({
name: 'PostEditor',
query: { name: post.post.metadata.name },
})
"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="emit('open-setting-modal', post.post)">
{{ $t("core.common.buttons.setting") }}
</VDropdownItem>
<VDropdownDivider />
<VDropdownItem type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template> </template>
</VEntity> </VEntity>
</template> </template>

View File

@ -8,19 +8,22 @@ import {
Dialog, Dialog,
Toast, Toast,
VDropdownItem, VDropdownItem,
VDropdown,
VDropdownDivider, VDropdownDivider,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { toRefs } from "vue"; import { markRaw, toRefs } from "vue";
import { usePluginLifeCycle } from "../composables/use-plugin"; import { usePluginLifeCycle } from "../composables/use-plugin";
import type { Plugin } from "@halo-dev/api-client"; import type { Plugin } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useEntityDropdownItemExtensionPoint } from "@/composables/use-entity-extension-points";
import { useRouter } from "vue-router";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -64,6 +67,83 @@ const handleResetSettingConfig = async () => {
}, },
}); });
}; };
const { dropdownItems } = useEntityDropdownItemExtensionPoint<Plugin>(
"plugin:list-item:operation:create",
[
{
priority: 10,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.detail"),
visible: true,
permissions: [],
action: () => {
router.push({
name: "PluginDetail",
params: { name: props.plugin?.metadata.name },
});
},
},
{
priority: 20,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.upgrade"),
visible: true,
permissions: [],
action: () => {
emit("open-upgrade-modal", props.plugin);
},
},
{
priority: 30,
component: markRaw(VDropdownDivider),
visible: true,
},
{
priority: 40,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.uninstall"),
visible: true,
children: [
{
priority: 10,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.uninstall"),
visible: true,
action: () => uninstall(),
},
{
priority: 20,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.plugin.list.actions.uninstall_and_delete_config"),
visible: true,
action: () => uninstall(true),
},
],
},
{
priority: 50,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.reset"),
visible: true,
action: () => {
handleResetSettingConfig();
},
},
]
);
</script> </script>
<template> <template>
<VEntity> <VEntity>
@ -135,36 +215,7 @@ const handleResetSettingConfig = async () => {
v-if="currentUserHasPermission(['system:plugins:manage'])" v-if="currentUserHasPermission(['system:plugins:manage'])"
#dropdownItems #dropdownItems
> >
<VDropdownItem <EntityDropdownItems :dropdown-items="dropdownItems" :item="plugin" />
@click="
$router.push({
name: 'PluginDetail',
params: { name: plugin?.metadata.name },
})
"
>
{{ $t("core.common.buttons.detail") }}
</VDropdownItem>
<VDropdownItem @click="emit('open-upgrade-modal', plugin)">
{{ $t("core.common.buttons.upgrade") }}
</VDropdownItem>
<VDropdownDivider />
<VDropdown placement="left" :triggers="['click']">
<VDropdownItem type="danger">
{{ $t("core.common.buttons.uninstall") }}
</VDropdownItem>
<template #popper>
<VDropdownItem type="danger" @click="uninstall">
{{ $t("core.common.buttons.uninstall") }}
</VDropdownItem>
<VDropdownItem type="danger" @click="uninstall(true)">
{{ $t("core.plugin.list.actions.uninstall_and_delete_config") }}
</VDropdownItem>
</template>
</VDropdown>
<VDropdownItem type="danger" @click="handleResetSettingConfig">
{{ $t("core.common.buttons.reset") }}
</VDropdownItem>
</template> </template>
</VEntity> </VEntity>
</template> </template>