feat: make field items of singlePage data list extendable (#7553)

Signed-off-by: Ryan Wang <i@ryanc.cc>
pull/7558/head
Ryan Wang 2025-06-15 12:03:07 +08:00 committed by GitHub
parent 1bd6b5530e
commit a91d072cdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 390 additions and 185 deletions

View File

@ -1,33 +1,36 @@
<script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import { singlePageLabels } from "@/constants/labels";
import { formatDatetime, relativeTimeTo } from "@/utils/date";
import EntityFieldItems from "@/components/entity-fields/EntityFieldItems.vue";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
import { usePermission } from "@/utils/permission";
import { generateThumbnailUrl } from "@/utils/thumbnail";
import { useEntityFieldItemExtensionPoint } from "@console/composables/use-entity-extension-points";
import { useOperationItemExtensionPoint } from "@console/composables/use-operation-extension-points";
import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconExternalLinkLine,
IconEye,
IconEyeOff,
Toast,
VDropdownDivider,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { useQueryClient } from "@tanstack/vue-query";
import type { EntityFieldItem, OperationItem } from "packages/shared/dist";
import type { Ref } from "vue";
import { computed, inject, ref } from "vue";
import { computed, inject, markRaw, ref, toRefs } from "vue";
import { useI18n } from "vue-i18n";
import { RouterLink } from "vue-router";
import { useRouter } from "vue-router";
import ContributorsField from "./entity-fields/ContributorsField.vue";
import CoverField from "./entity-fields/CoverField.vue";
import PublishStatusField from "./entity-fields/PublishStatusField.vue";
import PublishTimeField from "./entity-fields/PublishTimeField.vue";
import TitleField from "./entity-fields/TitleField.vue";
import VisibleField from "./entity-fields/VisibleField.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const queryClient = useQueryClient();
const router = useRouter();
const props = withDefaults(
defineProps<{
@ -39,59 +42,14 @@ const props = withDefaults(
}
);
const { singlePage } = toRefs(props);
const emit = defineEmits<{
(event: "open-setting-modal", post: SinglePage): void;
(event: "open-setting-modal", singlePage: SinglePage): void;
}>();
const selectedPageNames = inject<Ref<string[]>>("selectedPageNames", ref([]));
const externalUrl = computed(() => {
const { metadata, status } = props.singlePage.page;
if (metadata.labels?.[singlePageLabels.PUBLISHED] === "true") {
return status?.permalink;
}
return `/preview/singlepages/${metadata.name}`;
});
const publishStatus = computed(() => {
const { labels } = props.singlePage.page.metadata;
return labels?.[singlePageLabels.PUBLISHED] === "true"
? t("core.page.filters.status.items.published")
: t("core.page.filters.status.items.draft");
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.singlePage.page;
return (
(spec.publish &&
metadata.labels?.[singlePageLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
const { mutate: changeVisibleMutation } = useMutation({
mutationFn: async (singlePage: SinglePage) => {
return await coreApiClient.content.singlePage.patchSinglePage({
name: singlePage.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/visible",
value: singlePage.spec.visible === "PRIVATE" ? "PUBLIC" : "PRIVATE",
},
],
});
},
retry: 3,
onSuccess: () => {
Toast.success(t("core.common.toast.operation_success"));
queryClient.invalidateQueries({ queryKey: ["singlePages"] });
},
onError: () => {
Toast.error(t("core.common.toast.operation_failed"));
},
});
const handleDelete = async () => {
Dialog.warning({
title: t("core.page.operations.delete.title"),
@ -117,6 +75,115 @@ const handleDelete = async () => {
},
});
};
const { startFields, endFields } =
useEntityFieldItemExtensionPoint<ListedSinglePage>(
"single-page:list-item:field:create",
singlePage,
computed((): EntityFieldItem[] => [
{
priority: 10,
position: "start",
component: markRaw(CoverField),
hidden: !props.singlePage.page.spec.cover,
props: {
singlePage: props.singlePage,
},
},
{
priority: 20,
position: "start",
component: markRaw(TitleField),
props: {
singlePage: props.singlePage,
},
},
{
priority: 10,
position: "end",
component: markRaw(ContributorsField),
props: {
singlePage: props.singlePage,
},
},
{
priority: 20,
position: "end",
component: markRaw(PublishStatusField),
props: {
singlePage: props.singlePage,
},
},
{
priority: 30,
position: "end",
component: markRaw(VisibleField),
permissions: ["system:singlepages:manage"],
props: {
singlePage: props.singlePage,
},
},
{
priority: 40,
position: "end",
component: markRaw(StatusDotField),
props: {
tooltip: t("core.common.status.deleting"),
state: "warning",
animate: true,
},
hidden: !props.singlePage?.page?.spec.deleted,
},
{
priority: 50,
position: "end",
component: markRaw(PublishTimeField),
hidden: !props.singlePage.page.spec.publishTime,
props: {
singlePage: props.singlePage,
},
},
])
);
const { operationItems } = useOperationItemExtensionPoint<ListedSinglePage>(
"single-page:list-item:operation:create",
singlePage,
computed((): OperationItem<ListedSinglePage>[] => [
{
priority: 0,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.edit"),
action: async () => {
router.push({
name: "SinglePageEditor",
query: { name: props.singlePage.page.metadata.name },
});
},
},
{
priority: 10,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.setting"),
action: () => {
emit("open-setting-modal", props.singlePage.page);
},
},
{
priority: 20,
component: markRaw(VDropdownDivider),
},
{
priority: 30,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.delete"),
action: handleDelete,
},
])
);
</script>
<template>
@ -132,137 +199,19 @@ const handleDelete = async () => {
/>
</template>
<template #start>
<VEntityField v-if="singlePage.page.spec.cover">
<template #description>
<div class="aspect-h-2 rounded-md overflow-hidden aspect-w-3 w-20">
<img
class="object-cover w-full h-full"
:src="generateThumbnailUrl(singlePage.page.spec.cover, 's')"
/>
</div>
</template>
</VEntityField>
<VEntityField
:title="singlePage.page.spec.title"
:route="{
name: 'SinglePageEditor',
query: { name: singlePage.page.metadata.name },
}"
>
<template #extra>
<VSpace>
<RouterLink
v-if="singlePage.page.status?.inProgress"
v-tooltip="$t('core.common.tooltips.unpublished_content_tip')"
:to="{
name: 'SinglePageEditor',
query: { name: singlePage.page.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 w-full flex-col gap-1">
<VSpace class="w-full">
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.visits", {
visits: singlePage.stats.visit || 0,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.comments", {
comments: singlePage.stats.totalComment || 0,
})
}}
</span>
</VSpace>
</div>
</template>
</VEntityField>
<EntityFieldItems :fields="startFields" />
</template>
<template #end>
<VEntityField>
<template #description>
<PostContributorList
:owner="singlePage.owner"
:contributors="singlePage.contributors"
/>
</template>
</VEntityField>
<VEntityField :description="publishStatus">
<template v-if="isPublishing" #description>
<VStatusDot :text="$t('core.common.tooltips.publishing')" animate />
</template>
</VEntityField>
<HasPermission :permissions="['system:singlepages:manage']">
<VEntityField>
<template #description>
<IconEye
v-if="singlePage.page.spec.visible === 'PUBLIC'"
v-tooltip="$t('core.page.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(singlePage.page)"
/>
<IconEyeOff
v-if="singlePage.page.spec.visible === 'PRIVATE'"
v-tooltip="$t('core.page.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(singlePage.page)"
/>
</template>
</VEntityField>
</HasPermission>
<VEntityField v-if="singlePage?.page?.spec.deleted">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField
v-if="singlePage.page.spec.publishTime"
v-tooltip="formatDatetime(singlePage.page.spec.publishTime)"
:description="relativeTimeTo(singlePage.page.spec.publishTime)"
>
</VEntityField>
<EntityFieldItems :fields="endFields" />
</template>
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#dropdownItems
>
<VDropdownItem
@click="
$router.push({
name: 'SinglePageEditor',
query: { name: singlePage.page.metadata.name },
})
"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="emit('open-setting-modal', singlePage.page)">
{{ $t("core.common.buttons.setting") }}
</VDropdownItem>
<VDropdownDivider />
<VDropdownItem type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
<EntityDropdownItems
:dropdown-items="operationItems"
:item="singlePage"
/>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,23 @@
<script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import type { ListedSinglePage } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
withDefaults(
defineProps<{
singlePage: ListedSinglePage;
}>(),
{}
);
</script>
<template>
<VEntityField>
<template #description>
<PostContributorList
:owner="singlePage.owner"
:contributors="singlePage.contributors"
/>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,25 @@
<script lang="ts" setup>
import { generateThumbnailUrl } from "@/utils/thumbnail";
import type { ListedSinglePage } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
withDefaults(
defineProps<{
singlePage: ListedSinglePage;
}>(),
{}
);
</script>
<template>
<VEntityField v-if="singlePage.page.spec.cover">
<template #description>
<div class="aspect-h-2 rounded-md overflow-hidden aspect-w-3 w-20">
<img
class="object-cover w-full h-full"
:src="generateThumbnailUrl(singlePage.page.spec.cover, 's')"
/>
</div>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,40 @@
<script lang="ts" setup>
import { singlePageLabels } from "@/constants/labels";
import type { ListedSinglePage } from "@halo-dev/api-client";
import { VEntityField, VStatusDot } from "@halo-dev/components";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
singlePage: ListedSinglePage;
}>(),
{}
);
const publishStatus = computed(() => {
const { labels } = props.singlePage.page.metadata;
return labels?.[singlePageLabels.PUBLISHED] === "true"
? t("core.page.filters.status.items.published")
: t("core.page.filters.status.items.draft");
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.singlePage.page;
return (
(spec.publish &&
metadata.labels?.[singlePageLabels.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,21 @@
<script lang="ts" setup>
import { formatDatetime, relativeTimeTo } from "@/utils/date";
import type { ListedSinglePage } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components";
withDefaults(
defineProps<{
singlePage: ListedSinglePage;
}>(),
{}
);
</script>
<template>
<VEntityField
v-if="singlePage.page.spec.publishTime"
v-tooltip="formatDatetime(singlePage.page.spec.publishTime)"
:description="relativeTimeTo(singlePage.page.spec.publishTime)"
>
</VEntityField>
</template>

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { singlePageLabels } from "@/constants/labels";
import type { ListedSinglePage } from "@halo-dev/api-client";
import {
IconExternalLinkLine,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
singlePage: ListedSinglePage;
}>(),
{}
);
const externalUrl = computed(() => {
const { metadata, status } = props.singlePage.page;
if (metadata.labels?.[singlePageLabels.PUBLISHED] === "true") {
return status?.permalink;
}
return `/preview/singlepages/${metadata.name}`;
});
</script>
<template>
<VEntityField
:title="singlePage.page.spec.title"
:route="{
name: 'SinglePageEditor',
query: { name: singlePage.page.metadata.name },
}"
>
<template #extra>
<VSpace>
<RouterLink
v-if="singlePage.page.status?.inProgress"
v-tooltip="$t('core.common.tooltips.unpublished_content_tip')"
:to="{
name: 'SinglePageEditor',
query: { name: singlePage.page.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 w-full flex-col gap-1">
<VSpace class="w-full">
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.visits", {
visits: singlePage.stats.visit || 0,
})
}}
</span>
<span class="text-xs text-gray-500">
{{
$t("core.page.list.fields.comments", {
comments: singlePage.stats.totalComment || 0,
})
}}
</span>
</VSpace>
</div>
</template>
</VEntityField>
</template>

View File

@ -0,0 +1,59 @@
<script lang="ts" setup>
import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client";
import { coreApiClient } 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<{
singlePage: ListedSinglePage;
}>(),
{}
);
const { mutate: changeVisibleMutation } = useMutation({
mutationFn: async (singlePage: SinglePage) => {
return await coreApiClient.content.singlePage.patchSinglePage({
name: singlePage.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/visible",
value: singlePage.spec.visible === "PRIVATE" ? "PUBLIC" : "PRIVATE",
},
],
});
},
retry: 3,
onSuccess: () => {
Toast.success(t("core.common.toast.operation_success"));
queryClient.invalidateQueries({ queryKey: ["singlePages"] });
},
onError: () => {
Toast.error(t("core.common.toast.operation_failed"));
},
});
</script>
<template>
<VEntityField>
<template #description>
<IconEye
v-if="singlePage.page.spec.visible === 'PUBLIC'"
v-tooltip="$t('core.page.filters.visible.items.public')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(singlePage.page)"
/>
<IconEyeOff
v-if="singlePage.page.spec.visible === 'PRIVATE'"
v-tooltip="$t('core.page.filters.visible.items.private')"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
@click="changeVisibleMutation(singlePage.page)"
/>
</template>
</VEntityField>
</template>

View File

@ -13,6 +13,7 @@ import type {
Theme,
ListedComment,
ListedReply,
ListedSinglePage,
} from "@halo-dev/api-client";
import type { AnyExtension } from "@halo-dev/richtext-editor";
import type { Component, Ref } from "vue";
@ -59,6 +60,10 @@ export interface ExtensionPoint {
post: Ref<ListedPost>
) => OperationItem<ListedPost>[];
"single-page:list-item:operation:create"?: (
singlePage: Ref<ListedSinglePage>
) => OperationItem<ListedSinglePage>[];
"comment:list-item:operation:create"?: (
comment: Ref<ListedComment>
) => OperationItem<ListedComment>[];
@ -83,6 +88,10 @@ export interface ExtensionPoint {
"post:list-item:field:create"?: (post: Ref<ListedPost>) => EntityFieldItem[];
"single-page:list-item:field:create"?: (
singlePage: Ref<ListedSinglePage>
) => EntityFieldItem[];
"theme:list:tabs:create"?: () => ThemeListTab[] | Promise<ThemeListTab[]>;
"theme:list-item:operation:create"?: (