mirror of https://github.com/halo-dev/halo
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
parent
28d62aeed7
commit
aa95bbbf4e
|
@ -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; // 是否可见
|
||||
}
|
||||
```
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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[]>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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'])"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
Loading…
Reference in New Issue