feat: add extension point for extend field items of data list (#4514)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.9.x

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

添加扩展数据列表中字段的基础能力,并为插件管理列表的字段添加扩展点以测试此扩展能力。

<img width="1650" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/0b43d7fe-c1d3-4df5-913b-77cc6dc899eb">

文档:https://github.com/halo-dev/halo/pull/4514/files#diff-3fa20be5b2061cc7b68fb5581f1f9ba64daac5831c6ccfdc9ff99f1b4b77a0a4


todo:

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

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

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


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

```release-note
Console 端的插件列表的显示字段支持扩展。
```
pull/4515/head^2
Ryan Wang 2023-08-30 22:32:22 -05:00 committed by GitHub
parent 28d62aeed7
commit aa95bbbf4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 350 additions and 70 deletions

View File

@ -0,0 +1,53 @@
# Entity 数据列表显示字段扩展点
## 原由
目前 Halo 2 的 Console 中,展示数据列表是统一使用 Entity 组件,此扩展点用于支持通过插件扩展部分数据列表的显示字段。
## 定义方式
目前支持扩展的数据列表:
- 插件:`"plugin:list-item:field:create"?: (plugin: Ref<Plugin>) => | EntityFieldItem[] | Promise<EntityFieldItem[]>`
示例:
> 此示例是在插件列表项中添加一个显示插件启动时间的字段。
```ts
import { definePlugin } from "@halo-dev/console-shared";
import { markRaw, type Ref } from "vue";
import type { Plugin } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components"
export default definePlugin({
extensionPoints: {
"plugin:list-item:field:create": (plugin: Ref<Plugin>) => {
return [
{
priority: 40,
position: "end",
component: markRaw(VEntityField),
props: {
title: "启动时间"
description: plugin.value.status.lastStartTime
},
},
];
},
},
});
```
`EntityFieldItem` 类型:
```ts
export interface EntityFieldItem {
priority: number; // 优先级,越小越靠前
position: "start" | "end"; // 显示字段的位置
component: Raw<Component>; // 字段组件,可以使用 `@halo-dev/components` 中提供的 `VEntityField`,也可以自定义
props?: Record<string, unknown>; // 组件的 props
permissions?: string[]; // 权限设置
visible?: boolean; // 是否可见
}
```

View File

@ -10,3 +10,12 @@ export interface EntityDropdownItem<T> {
permissions?: string[];
children?: EntityDropdownItem<T>[];
}
export interface EntityFieldItem {
priority: number;
position: "start" | "end";
component: Raw<Component>;
props?: Record<string, unknown>;
permissions?: string[];
visible?: boolean;
}

View File

@ -1,4 +1,4 @@
import type { Component } from "vue";
import type { Component, Ref } from "vue";
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
import type { FunctionalPage } from "../states/pages";
import type { AttachmentSelectProvider } from "../states/attachment-selector";
@ -7,7 +7,7 @@ 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 { EntityDropdownItem, EntityFieldItem } from "@/states/entity";
import type { ThemeListTab } from "@/states/theme-list-tabs";
import type { Backup, ListedPost, Plugin } from "@halo-dev/api-client";
@ -52,6 +52,10 @@ export interface ExtensionPoint {
| EntityDropdownItem<Backup>[]
| Promise<EntityDropdownItem<Backup>[]>;
"plugin:list-item:field:create"?: (
plugin: Ref<Plugin>
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
"theme:list:tabs:create"?: () => ThemeListTab[] | Promise<ThemeListTab[]>;
}

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { EntityFieldItem } from "packages/shared/dist";
withDefaults(
defineProps<{
fields: EntityFieldItem[];
}>(),
{}
);
</script>
<template>
<template
v-for="(field, index) in fields"
:key="`${field.position}-${index}`"
>
<component
:is="field.component"
v-bind="field.props"
v-if="field.visible"
/>
</template>
</template>

View File

@ -0,0 +1,31 @@
<script lang="ts" setup>
import { VEntityField, VStatusDot } from "@halo-dev/components";
withDefaults(
defineProps<{
tooltip?: string;
state?: "default" | "success" | "warning" | "error";
animate?: boolean;
text?: string;
}>(),
{
tooltip: undefined,
state: "default",
animate: false,
text: undefined,
}
);
</script>
<template>
<VEntityField>
<template #description>
<VStatusDot
v-tooltip="tooltip"
:state="state"
:animate="animate"
:text="text"
/>
</template>
</VEntityField>
</template>

View File

@ -1,9 +1,11 @@
import { usePluginModuleStore } from "@/stores/plugin";
import { usePermission } from "@/utils/permission";
import type {
EntityDropdownItem,
EntityFieldItem,
PluginModule,
} from "@halo-dev/console-shared";
import { onMounted, ref } from "vue";
import { onMounted, ref, type Ref, computed, type ComputedRef } from "vue";
export function useEntityDropdownItemExtensionPoint<T>(
extensionPointName: string,
@ -34,3 +36,54 @@ export function useEntityDropdownItemExtensionPoint<T>(
return { dropdownItems };
}
export function useEntityFieldItemExtensionPoint<T>(
extensionPointName: string,
entity: Ref<T>,
presets: ComputedRef<EntityFieldItem[]>
) {
const { pluginModules } = usePluginModuleStore();
const { currentUserHasPermission } = usePermission();
const itemsFromPlugins = ref<EntityFieldItem[]>([]);
const allItems = computed(() => {
return [...presets.value, ...itemsFromPlugins.value].map((item) => {
return {
...item,
visible:
item.visible !== false && currentUserHasPermission(item.permissions),
};
});
});
onMounted(() => {
pluginModules.forEach((pluginModule: PluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.[extensionPointName]) {
return;
}
const items = extensionPoints[extensionPointName](
entity
) as EntityFieldItem[];
itemsFromPlugins.value.push(...items);
});
});
const startFields = computed(() => {
return allItems.value
.filter((item) => item.position === "start")
.sort((a, b) => {
return a.priority - b.priority;
});
});
const endFields = computed(() => {
return allItems.value
.filter((item) => item.position === "end")
.sort((a, b) => {
return a.priority - b.priority;
});
});
return { startFields, endFields };
}

View File

@ -1,10 +1,7 @@
<script lang="ts" setup>
import {
VSwitch,
VStatusDot,
VEntity,
VEntityField,
VAvatar,
Dialog,
Toast,
VDropdownItem,
@ -19,9 +16,18 @@ import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n";
import type { Ref } from "vue";
import { ref } from "vue";
import { useEntityDropdownItemExtensionPoint } from "@/composables/use-entity-extension-points";
import {
useEntityDropdownItemExtensionPoint,
useEntityFieldItemExtensionPoint,
} from "@/composables/use-entity-extension-points";
import { useRouter } from "vue-router";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
import EntityFieldItems from "@/components/entity-fields/EntityFieldItems.vue";
import LogoField from "./entity-fields/LogoField.vue";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import AuthorField from "./entity-fields/AuthorField.vue";
import SwitchField from "./entity-fields/SwitchField.vue";
import { computed } from "vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
@ -43,8 +49,7 @@ const { plugin } = toRefs(props);
const selectedNames = inject<Ref<string[]>>("selectedNames", ref([]));
const { getFailedMessage, changeStatus, changingStatus, uninstall } =
usePluginLifeCycle(plugin);
const { getFailedMessage, uninstall } = usePluginLifeCycle(plugin);
const handleResetSettingConfig = async () => {
Dialog.warning({
@ -147,6 +152,91 @@ const { dropdownItems } = useEntityDropdownItemExtensionPoint<Plugin>(
},
]
);
const { startFields, endFields } = useEntityFieldItemExtensionPoint<Plugin>(
"plugin:list-item:field:create",
plugin,
computed(() => [
{
position: "start",
priority: 10,
component: markRaw(LogoField),
props: {
plugin: props.plugin,
},
},
{
position: "start",
priority: 20,
component: markRaw(VEntityField),
props: {
title: props.plugin.spec.displayName,
description: props.plugin.spec.description,
route: {
name: "PluginDetail",
params: { name: props.plugin.metadata.name },
},
},
},
{
position: "end",
priority: 10,
component: markRaw(StatusDotField),
props: {
tooltip: getFailedMessage(),
state: "error",
animate: true,
},
visible: props.plugin.status?.phase === "FAILED",
},
{
position: "end",
priority: 20,
component: markRaw(StatusDotField),
props: {
tooltip: t("core.common.status.deleting"),
state: "warning",
animate: true,
},
visible: !!props.plugin.metadata.deletionTimestamp,
},
{
position: "end",
priority: 30,
component: markRaw(AuthorField),
props: {
plugin: props.plugin,
},
visible: !!props.plugin.spec.author,
},
{
position: "end",
priority: 40,
component: markRaw(VEntityField),
props: {
description: props.plugin.spec.version,
},
},
{
position: "end",
priority: 50,
component: markRaw(VEntityField),
props: {
description: formatDatetime(props.plugin.metadata.creationTimestamp),
},
visible: !!props.plugin.metadata.creationTimestamp,
},
{
position: "end",
priority: 60,
component: markRaw(SwitchField),
props: {
plugin: props.plugin,
},
permissions: ["system:plugins:manage"],
},
])
);
</script>
<template>
<VEntity :is-selected="isSelected">
@ -163,69 +253,10 @@ const { dropdownItems } = useEntityDropdownItemExtensionPoint<Plugin>(
/>
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar
:alt="plugin.spec.displayName"
:src="plugin.status?.logo"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
:title="plugin.spec.displayName"
:description="plugin.spec.description"
:route="{
name: 'PluginDetail',
params: { name: plugin.metadata.name },
}"
/>
<EntityFieldItems :fields="startFields" />
</template>
<template #end>
<VEntityField v-if="plugin.status?.phase === 'FAILED'">
<template #description>
<VStatusDot v-tooltip="getFailedMessage()" state="error" animate />
</template>
</VEntityField>
<VEntityField v-if="plugin.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField v-if="plugin.spec.author">
<template #description>
<a
:href="plugin.spec.author.website"
class="hidden text-sm text-gray-500 hover:text-gray-900 sm:block"
target="_blank"
>
@{{ plugin.spec.author.name }}
</a>
</template>
</VEntityField>
<VEntityField :description="plugin.spec.version" />
<VEntityField v-if="plugin.metadata.creationTimestamp">
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(plugin.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
<VEntityField v-permission="['system:plugins:manage']">
<template #description>
<div class="flex items-center">
<VSwitch
:model-value="plugin.spec.enabled"
:disabled="changingStatus"
@click="changeStatus"
/>
</div>
</template>
</VEntityField>
<EntityFieldItems :fields="endFields" />
</template>
<template
v-if="currentUserHasPermission(['system:plugins:manage'])"

View File

@ -0,0 +1,24 @@
<script lang="ts" setup>
import { VEntityField } from "@halo-dev/components";
import type { Plugin } from "@halo-dev/api-client";
withDefaults(
defineProps<{
plugin: Plugin;
}>(),
{}
);
</script>
<template>
<VEntityField v-if="plugin.spec.author">
<template #description>
<a
:href="plugin.spec.author.website"
class="hidden text-sm text-gray-500 hover:text-gray-900 sm:block"
target="_blank"
>
@{{ plugin.spec.author.name }}
</a>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,22 @@
<script lang="ts" setup>
import { VAvatar, VEntityField } from "@halo-dev/components";
import type { Plugin } from "@halo-dev/api-client";
withDefaults(
defineProps<{
plugin: Plugin;
}>(),
{}
);
</script>
<template>
<VEntityField>
<template #description>
<VAvatar
:alt="plugin.spec.displayName"
:src="plugin.status?.logo"
size="md"
></VAvatar>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
import { VEntityField, VSwitch } from "@halo-dev/components";
import type { Plugin } from "@halo-dev/api-client";
import { usePluginLifeCycle } from "../../composables/use-plugin";
import { toRefs } from "vue";
const props = withDefaults(
defineProps<{
plugin: Plugin;
}>(),
{}
);
const { plugin } = toRefs(props);
const { changingStatus, changeStatus } = usePluginLifeCycle(plugin);
</script>
<template>
<VEntityField v-permission="['system:plugins:manage']">
<template #description>
<div class="flex items-center">
<VSwitch
:model-value="plugin.spec.enabled"
:disabled="changingStatus"
@click="changeStatus"
/>
</div>
</template>
</VEntityField>
</template>