feat: make field items of post data list extendable (#4528)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.9.x

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

文章数据列表的显示字段支持通过插件扩展。

<img width="717" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/3b3b7e1f-4626-4878-a234-48915dd34e8d">

#### Special notes for your reviewer:

需要测试文章管理功能是否正常。

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

```release-note
Console 端文章数据列表的显示字段支持通过插件扩展。
```
pull/4533/head v2.9.0
Ryan Wang 2023-09-01 11:02:12 +08:00 committed by GitHub
parent 6dd77af7f8
commit 272e279891
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 326 additions and 207 deletions

View File

@ -9,6 +9,7 @@
目前支持扩展的数据列表:
- 插件:`"plugin:list-item:field:create"?: (plugin: Ref<Plugin>) => | EntityFieldItem[] | Promise<EntityFieldItem[]>`
- 文章:`"post:list-item:field:create"?: (post: Ref<ListedPost>) => | EntityFieldItem[] | Promise<EntityFieldItem[]>`
示例:

View File

@ -57,6 +57,10 @@ export interface ExtensionPoint {
plugin: Ref<Plugin>
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
"post:list-item:field:create"?: (
post: Ref<ListedPost>
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
"theme:list:tabs:create"?: () => ThemeListTab[] | Promise<ThemeListTab[]>;
"theme:list-item:operation:create"?: (

View File

@ -32,11 +32,11 @@ import { usePermission } from "@/utils/permission";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule } from "@halo-dev/console-shared";
import type {
PluginModule,
CommentSubjectRefProvider,
CommentSubjectRefResult,
} from "packages/shared/dist";
} from "@halo-dev/console-shared";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();

View File

@ -472,7 +472,7 @@ watch(selectedPostNames, (newValue) => {
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(post, index) in posts" :key="index">
<li v-for="post in posts" :key="post.post.metadata.name">
<PostListItem
:post="post"
:is-selected="checkSelection(post.post)"

View File

@ -1,36 +1,31 @@
<script lang="ts" setup>
import {
Dialog,
IconExternalLinkLine,
IconEye,
IconEyeOff,
Toast,
VAvatar,
VDropdownDivider,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import PostTag from "../tags/components/PostTag.vue";
import { formatDatetime } from "@/utils/date";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { usePermission } from "@/utils/permission";
import { postLabels } from "@/constants/labels";
import { apiClient } from "@/utils/api-client";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { inject } from "vue";
import { useQueryClient } from "@tanstack/vue-query";
import type { Ref } from "vue";
import { ref } from "vue";
import { computed } from "vue";
import { markRaw } from "vue";
import { computed, toRefs, markRaw, ref, inject } from "vue";
import { useRouter } from "vue-router";
import { useEntityFieldItemExtensionPoint } from "@/composables/use-entity-extension-points";
import { useOperationItemExtensionPoint } from "@/composables/use-operation-extension-points";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
import { toRefs } from "vue";
import type { OperationItem } from "packages/shared/dist";
import type { EntityFieldItem, OperationItem } from "@halo-dev/console-shared";
import TitleField from "./entity-fields/TitleField.vue";
import EntityFieldItems from "@/components/entity-fields/EntityFieldItems.vue";
import ContributorsField from "./entity-fields/ContributorsField.vue";
import PublishStatusField from "./entity-fields/PublishStatusField.vue";
import VisibleField from "./entity-fields/VisibleField.vue";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
@ -55,56 +50,6 @@ const emit = defineEmits<{
const selectedPostNames = inject<Ref<string[]>>("selectedPostNames", ref([]));
const externalUrl = computed(() => {
const { status, metadata } = props.post.post;
if (metadata.labels?.[postLabels.PUBLISHED] === "true") {
return status?.permalink;
}
return `/preview/posts/${metadata.name}`;
});
const publishStatus = computed(() => {
const { labels } = props.post.post.metadata;
return labels?.[postLabels.PUBLISHED] === "true"
? t("core.post.filters.status.items.published")
: t("core.post.filters.status.items.draft");
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post;
return (
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
const { mutate: changeVisibleMutation } = useMutation({
mutationFn: async (post: Post) => {
const { data } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: post.metadata.name,
});
data.spec.visible = data.spec.visible === "PRIVATE" ? "PUBLIC" : "PRIVATE";
await apiClient.extension.post.updatecontentHaloRunV1alpha1Post(
{
name: post.metadata.name,
post: data,
},
{
mute: true,
}
);
await queryClient.invalidateQueries({ queryKey: ["posts"] });
},
retry: 3,
onSuccess: () => {
Toast.success(t("core.common.toast.operation_success"));
},
onError: () => {
Toast.error(t("core.common.toast.operation_failed"));
},
});
const handleDelete = async () => {
Dialog.warning({
title: t("core.post.operations.delete.title"),
@ -164,6 +109,64 @@ const { operationItems } = useOperationItemExtensionPoint<ListedPost>(
},
])
);
const { startFields, endFields } = useEntityFieldItemExtensionPoint<ListedPost>(
"post:list-item:field:create",
post,
computed((): EntityFieldItem[] => [
{
priority: 10,
position: "start",
component: markRaw(TitleField),
props: {
post: props.post,
},
},
{
priority: 10,
position: "end",
component: markRaw(ContributorsField),
props: {
post: props.post,
},
},
{
priority: 20,
position: "end",
component: markRaw(PublishStatusField),
props: {
post: props.post,
},
},
{
priority: 30,
position: "end",
component: markRaw(VisibleField),
props: {
post: props.post,
},
},
{
priority: 40,
position: "end",
component: markRaw(StatusDotField),
props: {
tooltip: t("core.common.status.deleting"),
state: "warning",
animate: true,
},
hidden: !props.post.post.spec.deleted,
},
{
priority: 50,
position: "end",
component: markRaw(VEntityField),
props: {
description: formatDatetime(props.post.post.spec.publishTime),
},
},
])
);
</script>
<template>
@ -181,144 +184,10 @@ const { operationItems } = useOperationItemExtensionPoint<ListedPost>(
/>
</template>
<template #start>
<VEntityField
:title="post.post.spec.title"
:route="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
width="27rem"
>
<template #extra>
<VSpace class="mt-1 sm:mt-0">
<RouterLink
v-if="post.post.status?.inProgress"
v-tooltip="$t('core.common.tooltips.unpublished_content_tip')"
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
class="flex items-center"
>
<VStatusDot state="success" animate />
</RouterLink>
<a
target="_blank"
:href="externalUrl"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</VSpace>
</template>
<template #description>
<div class="flex flex-col gap-1.5">
<VSpace class="flex-wrap !gap-y-1">
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
{{ $t("core.post.list.fields.categories") }}
<a
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
:href="category.status?.permalink"
:title="category.status?.permalink"
target="_blank"
class="cursor-pointer hover:text-gray-900"
>
{{ category.spec.displayName }}
</a>
</p>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.visits", {
visits: post.stats.visit,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
</span>
<span v-if="post.post.spec.pinned" class="text-xs text-gray-500">
{{ $t("core.post.list.fields.pinned") }}
</span>
</VSpace>
<VSpace v-if="post.tags.length" class="flex-wrap">
<PostTag
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:tag="tag"
route
></PostTag>
</VSpace>
</div>
</template>
</VEntityField>
<EntityFieldItems :fields="startFields" />
</template>
<template #end>
<VEntityField>
<template #description>
<RouterLink
v-for="(contributor, contributorIndex) in post.contributors"
:key="contributorIndex"
:to="{
name: 'UserDetail',
params: { name: contributor.name },
}"
class="flex items-center"
>
<VAvatar
v-tooltip="contributor.displayName"
size="xs"
:src="contributor.avatar"
:alt="contributor.displayName"
circle
></VAvatar>
</RouterLink>
</template>
</VEntityField>
<VEntityField :description="publishStatus">
<template v-if="isPublishing" #description>
<VStatusDot :text="$t('core.common.tooltips.publishing')" animate />
</template>
</VEntityField>
<VEntityField>
<template #description>
<IconEye
v-if="post.post.spec.visible === 'PUBLIC'"
v-tooltip="$t('core.post.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(post.post)"
/>
<IconEyeOff
v-if="post.post.spec.visible === 'PRIVATE'"
v-tooltip="$t('core.post.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(post.post)"
/>
</template>
</VEntityField>
<VEntityField v-if="post?.post?.spec.deleted">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(post.post.spec.publishTime) }}
</span>
</template>
</VEntityField>
<EntityFieldItems :fields="endFields" />
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"

View File

@ -0,0 +1,35 @@
<script lang="ts" setup>
import { VAvatar, VEntityField } from "@halo-dev/components";
import type { ListedPost } from "@halo-dev/api-client";
withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
</script>
<template>
<VEntityField>
<template #description>
<RouterLink
v-for="(contributor, contributorIndex) in post.contributors"
:key="contributorIndex"
:to="{
name: 'UserDetail',
params: { name: contributor.name },
}"
class="flex items-center"
>
<VAvatar
v-tooltip="contributor.displayName"
size="xs"
:src="contributor.avatar"
:alt="contributor.displayName"
circle
></VAvatar>
</RouterLink>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,39 @@
<script lang="ts" setup>
import { VEntityField, VStatusDot } from "@halo-dev/components";
import type { ListedPost } from "@halo-dev/api-client";
import { postLabels } from "@/constants/labels";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
const publishStatus = computed(() => {
const { labels } = props.post.post.metadata;
return labels?.[postLabels.PUBLISHED] === "true"
? t("core.post.filters.status.items.published")
: t("core.post.filters.status.items.draft");
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post;
return (
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
</script>
<template>
<VEntityField :description="publishStatus">
<template v-if="isPublishing" #description>
<VStatusDot :text="$t('core.common.tooltips.publishing')" animate />
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,108 @@
<script lang="ts" setup>
import {
IconExternalLinkLine,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import PostTag from "../../tags/components/PostTag.vue";
import type { ListedPost } from "@halo-dev/api-client";
import { postLabels } from "@/constants/labels";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
const externalUrl = computed(() => {
const { status, metadata } = props.post.post;
if (metadata.labels?.[postLabels.PUBLISHED] === "true") {
return status?.permalink;
}
return `/preview/posts/${metadata.name}`;
});
</script>
<template>
<VEntityField
:title="post.post.spec.title"
:route="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
width="27rem"
>
<template #extra>
<VSpace class="mt-1 sm:mt-0">
<RouterLink
v-if="post.post.status?.inProgress"
v-tooltip="$t('core.common.tooltips.unpublished_content_tip')"
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
class="flex items-center"
>
<VStatusDot state="success" animate />
</RouterLink>
<a
target="_blank"
:href="externalUrl"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</VSpace>
</template>
<template #description>
<div class="flex flex-col gap-1.5">
<VSpace class="flex-wrap !gap-y-1">
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
{{ $t("core.post.list.fields.categories") }}
<a
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
:href="category.status?.permalink"
:title="category.status?.permalink"
target="_blank"
class="cursor-pointer hover:text-gray-900"
>
{{ category.spec.displayName }}
</a>
</p>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.visits", {
visits: post.stats.visit,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.post.list.fields.comments", {
comments: post.stats.totalComment || 0,
})
}}
</span>
<span v-if="post.post.spec.pinned" class="text-xs text-gray-500">
{{ $t("core.post.list.fields.pinned") }}
</span>
</VSpace>
<VSpace v-if="post.tags.length" class="flex-wrap">
<PostTag
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:tag="tag"
route
></PostTag>
</VSpace>
</div>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,63 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { IconEye, IconEyeOff, Toast, VEntityField } from "@halo-dev/components";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
const queryClient = useQueryClient();
const { t } = useI18n();
withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
const { mutate: changeVisibleMutation } = useMutation({
mutationFn: async (post: Post) => {
const { data } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: post.metadata.name,
});
data.spec.visible = data.spec.visible === "PRIVATE" ? "PUBLIC" : "PRIVATE";
await apiClient.extension.post.updatecontentHaloRunV1alpha1Post(
{
name: post.metadata.name,
post: data,
},
{
mute: true,
}
);
await queryClient.invalidateQueries({ queryKey: ["posts"] });
},
retry: 3,
onSuccess: () => {
Toast.success(t("core.common.toast.operation_success"));
},
onError: () => {
Toast.error(t("core.common.toast.operation_failed"));
},
});
</script>
<template>
<VEntityField>
<template #description>
<IconEye
v-if="post.post.spec.visible === 'PUBLIC'"
v-tooltip="$t('core.post.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(post.post)"
/>
<IconEyeOff
v-if="post.post.spec.visible === 'PRIVATE'"
v-tooltip="$t('core.post.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(post.post)"
/>
</template>
</VEntityField>
</template>

View File

@ -22,7 +22,7 @@ import { markRaw } from "vue";
import { defineComponent } from "vue";
import UninstallOperationItem from "./operation/UninstallOperationItem.vue";
import { computed } from "vue";
import type { OperationItem } from "packages/shared/dist";
import type { OperationItem } from "@halo-dev/console-shared";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();

View File

@ -18,7 +18,7 @@ import { useI18n } from "vue-i18n";
import { useOperationItemExtensionPoint } from "@/composables/use-operation-extension-points";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
import { toRefs } from "vue";
import type { OperationItem } from "packages/shared/dist";
import type { OperationItem } from "@halo-dev/console-shared";
const queryClient = useQueryClient();
const { t } = useI18n();

View File

@ -26,7 +26,7 @@ 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";
import type { EntityFieldItem, OperationItem } from "packages/shared/dist";
import type { EntityFieldItem, OperationItem } from "@halo-dev/console-shared";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();