refactor: logic of publishing post (#5987)

#### What type of PR is this?

/area ui
/kind improvement
/milestone 2.16.x

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

重构文章的发布逻辑,以下是主要改动:

1. 如果文章未发布,点击文章设置的发布按钮时,会先保存文章。
2. 在文章列表的操作菜单中添加发布 / 取消发布的选项。
3. 重构文章设置中,发布 / 取消发布按钮的显示条件。
4. 优化文章设置对话框的显示条件,减少不必要的渲染开销和请求。

#### Special notes for your reviewer:

需要测试:

1. 文章正常新建和发布的逻辑。
2. 文章设置未来时间的发布逻辑。
3. 取消定时发布和已发布文章的逻辑。

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

```release-note
优化 Console 文章管理中的文章发布逻辑。
```
pull/5994/head^2
Ryan Wang 2024-05-27 16:26:58 +08:00 committed by GitHub
parent 6124ab9831
commit 54e088741e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 246 additions and 137 deletions

View File

@ -131,16 +131,14 @@ const {
return data.items;
},
refetchInterval(data) {
const abnormalSinglePages = data?.filter((singlePage) => {
const { spec, metadata, status } = singlePage.page;
const hasAbnormalSinglePage = data?.some((singlePage) => {
const { spec, metadata } = singlePage.page;
return (
spec.deleted ||
(spec.publish &&
metadata.labels?.[singlePageLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
metadata.labels?.[singlePageLabels.PUBLISHED] !== spec.publish + ""
);
});
return abnormalSinglePages?.length ? 1000 : false;
return hasAbnormalSinglePage ? 1000 : false;
},
});

View File

@ -67,8 +67,8 @@ const formState = ref<SinglePage>({
name: randomUUID(),
},
});
const modal = ref();
const saving = ref(false);
const modal = ref<InstanceType<typeof VModal>>();
const isSubmitting = ref(false);
const publishing = ref(false);
const publishCanceling = ref(false);
const submitType = ref<"publish" | "save">();
@ -125,12 +125,12 @@ const handleSave = async () => {
if (props.onlyEmit) {
emit("saved", formState.value);
modal.value.close();
modal.value?.close();
return;
}
try {
saving.value = true;
isSubmitting.value = true;
const { data } = isUpdateMode
? await singlePageUpdateMutate(formState.value)
@ -143,13 +143,13 @@ const handleSave = async () => {
formState.value = data;
emit("saved", data);
modal.value.close();
modal.value?.close();
Toast.success(t("core.common.toast.save_success"));
} catch (error) {
console.error("Failed to save single page", error);
} finally {
saving.value = false;
isSubmitting.value = false;
}
};
@ -170,7 +170,7 @@ const handlePublish = async () => {
if (props.onlyEmit) {
emit("published", formState.value);
modal.value.close();
modal.value?.close();
return;
}
@ -195,7 +195,7 @@ const handlePublish = async () => {
emit("published", data);
modal.value.close();
modal.value?.close();
Toast.success(t("core.common.toast.publish_success"));
} catch (error) {
@ -227,7 +227,7 @@ const handleUnpublish = async () => {
formState.value = data;
modal.value.close();
modal.value?.close();
Toast.success(t("core.common.toast.cancel_publish_success"));
} catch (error) {
@ -457,10 +457,11 @@ const { handleGenerateSlug } = useSlugify(
</div>
<template #footer>
<VSpace>
<template v-if="publishSupport">
<div class="flex items-center justify-between">
<VSpace>
<VButton
v-if="
publishSupport &&
formState.metadata.labels?.[singlePageLabels.PUBLISHED] !== 'true'
"
:loading="publishing"
@ -470,21 +471,28 @@ const { handleGenerateSlug } = useSlugify(
{{ $t("core.common.buttons.publish") }}
</VButton>
<VButton
v-else
:loading="publishCanceling"
type="danger"
@click="handleUnpublish()"
:loading="isSubmitting"
type="secondary"
@click="handleSaveClick"
>
{{ $t("core.common.buttons.cancel_publish") }}
{{ $t("core.common.buttons.save") }}
</VButton>
</template>
<VButton :loading="saving" type="secondary" @click="handleSaveClick">
{{ $t("core.common.buttons.save") }}
<VButton type="default" @click="modal?.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
<VButton
v-if="
formState.metadata.labels?.[singlePageLabels.PUBLISHED] === 'true'
"
:loading="publishCanceling"
type="danger"
@click="handleUnpublish()"
>
{{ $t("core.common.buttons.cancel_publish") }}
</VButton>
<VButton type="default" @click="modal.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
</div>
</template>
</VModal>
</template>

View File

@ -342,7 +342,6 @@ const onSettingSaved = (post: Post) => {
}
formState.value.post = post;
settingModal.value = false;
if (!isUpdateMode.value) {
handleSave();
@ -351,7 +350,6 @@ const onSettingSaved = (post: Post) => {
const onSettingPublished = (post: Post) => {
formState.value.post = post;
settingModal.value = false;
handlePublish();
};
@ -454,10 +452,11 @@ async function handleUploadImage(file: File, options?: AxiosRequestConfig) {
<template>
<PostSettingModal
v-model:visible="settingModal"
v-if="settingModal"
:post="formState.post"
:publish-support="!isUpdateMode"
:only-emit="!isUpdateMode"
@close="settingModal = false"
@saved="onSettingSaved"
@published="onSettingPublished"
/>

View File

@ -1,23 +1,24 @@
<script lang="ts" setup>
import {
Dialog,
IconAddCircle,
IconArrowLeft,
IconArrowRight,
IconBookRead,
IconRefreshLine,
Dialog,
Toast,
VButton,
VCard,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
VLoading,
Toast,
} from "@halo-dev/components";
import PostSettingModal from "./components/PostSettingModal.vue";
import { computed, ref, watch } from "vue";
import type { Post, ListedPost } from "@halo-dev/api-client";
import type { Ref } from "vue";
import { computed, provide, ref, watch } from "vue";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { postLabels } from "@/constants/labels";
import { useQuery } from "@tanstack/vue-query";
@ -27,8 +28,6 @@ import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import CategoryFilterDropdown from "@/components/filter/CategoryFilterDropdown.vue";
import TagFilterDropdown from "@/components/filter/TagFilterDropdown.vue";
import PostListItem from "./components/PostListItem.vue";
import { provide } from "vue";
import type { Ref } from "vue";
const { t } = useI18n();
@ -152,18 +151,34 @@ const {
return data.items;
},
refetchInterval: (data) => {
const abnormalPosts = data?.some((post) => {
const { spec, metadata, status } = post.post;
const hasDeletingPost = data?.some((post) => post.post.spec.deleted);
if (hasDeletingPost) {
return 1000;
}
const hasPublishingPost = data?.some((post) => {
const { spec, metadata } = post.post;
return (
spec.deleted ||
(spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
metadata.labels?.[postLabels.PUBLISHED] !== spec.publish + "" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true"
);
});
return abnormalPosts ? 1000 : false;
if (hasPublishingPost) {
return 1000;
}
const hasCancelingPublishPost = data?.some((post) => {
const { spec, metadata } = post.post;
return (
!spec.publish &&
(metadata.labels?.[postLabels.PUBLISHED] === "true" ||
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] === "true")
);
});
return hasCancelingPublishPost ? 1000 : false;
},
});
@ -179,6 +194,7 @@ const handleOpenSettingModal = async (post: Post) => {
const onSettingModalClose = () => {
selectedPost.value = undefined;
settingModal.value = false;
refetch();
};
@ -274,7 +290,7 @@ watch(selectedPostNames, (newValue) => {
</script>
<template>
<PostSettingModal
v-model:visible="settingModal"
v-if="settingModal"
:post="selectedPost"
@close="onSettingModalClose"
>

View File

@ -12,7 +12,7 @@ import { usePermission } from "@/utils/permission";
import { apiClient } from "@/utils/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import type { Ref } from "vue";
import { computed, toRefs, markRaw, ref, inject } from "vue";
import { computed, inject, markRaw, ref, toRefs } from "vue";
import { useRouter } from "vue-router";
import { useEntityFieldItemExtensionPoint } from "@console/composables/use-entity-extension-points";
import { useOperationItemExtensionPoint } from "@console/composables/use-operation-extension-points";
@ -25,6 +25,7 @@ import PublishStatusField from "./entity-fields/PublishStatusField.vue";
import VisibleField from "./entity-fields/VisibleField.vue";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import PublishTimeField from "./entity-fields/PublishTimeField.vue";
import { postLabels } from "@/constants/labels";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
@ -71,6 +72,26 @@ const { operationItems } = useOperationItemExtensionPoint<ListedPost>(
"post:list-item:operation:create",
post,
computed((): OperationItem<ListedPost>[] => [
{
priority: 0,
component: markRaw(VDropdownItem),
label: t("core.common.buttons.publish"),
action: async () => {
await apiClient.post.publishPost({
name: props.post.post.metadata.name,
});
Toast.success(t("core.common.toast.publish_success"));
queryClient.invalidateQueries({
queryKey: ["posts"],
});
},
hidden:
props.post.post.metadata.labels?.[postLabels.PUBLISHED] == "true" ||
props.post.post.metadata.labels?.[postLabels.SCHEDULING_PUBLISH] ==
"true",
},
{
priority: 10,
component: markRaw(VDropdownItem),
@ -102,6 +123,29 @@ const { operationItems } = useOperationItemExtensionPoint<ListedPost>(
props: {
type: "danger",
},
label: t("core.common.buttons.cancel_publish"),
action: async () => {
await apiClient.post.unpublishPost({
name: props.post.post.metadata.name,
});
Toast.success(t("core.common.toast.cancel_publish_success"));
queryClient.invalidateQueries({
queryKey: ["posts"],
});
},
hidden:
props.post.post.metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
props.post.post.metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !==
"true",
},
{
priority: 50,
component: markRaw(VDropdownItem),
props: {
type: "danger",
},
label: t("core.common.buttons.delete"),
permissions: [],
action: handleDelete,

View File

@ -6,9 +6,8 @@ import {
VModal,
VSpace,
} from "@halo-dev/components";
import { computed, nextTick, ref, toRaw, watch } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import type { Post } from "@halo-dev/api-client";
import { cloneDeep } from "lodash-es";
import { apiClient } from "@/utils/api-client";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { postLabels } from "@/constants/labels";
@ -20,8 +19,31 @@ import useSlugify from "@console/composables/use-slugify";
import { useI18n } from "vue-i18n";
import { usePostUpdateMutate } from "../composables/use-post-update-mutate";
import { FormType } from "@/types/slug";
import { cloneDeep } from "lodash-es";
const initialFormState: Post = {
const props = withDefaults(
defineProps<{
post?: Post;
publishSupport?: boolean;
onlyEmit?: boolean;
}>(),
{
post: undefined,
publishSupport: true,
onlyEmit: false,
}
);
const emit = defineEmits<{
(event: "close"): void;
(event: "saved", post: Post): void;
(event: "published", post: Post): void;
}>();
const { t } = useI18n();
const modal = ref<InstanceType<typeof VModal>>();
const formState = ref<Post>({
spec: {
title: "",
slug: "",
@ -47,34 +69,8 @@ const initialFormState: Post = {
metadata: {
name: randomUUID(),
},
};
const props = withDefaults(
defineProps<{
visible: boolean;
post?: Post;
publishSupport?: boolean;
onlyEmit?: boolean;
}>(),
{
visible: false,
post: undefined,
publishSupport: true,
onlyEmit: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
(event: "saved", post: Post): void;
(event: "published", post: Post): void;
}>();
const { t } = useI18n();
const formState = ref<Post>(cloneDeep(initialFormState));
const saving = ref(false);
});
const isSubmitting = ref(false);
const publishing = ref(false);
const publishCanceling = ref(false);
const submitType = ref<"publish" | "save">();
@ -84,13 +80,6 @@ const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const handleVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleSubmit = () => {
if (submitType.value === "publish") {
handlePublish();
@ -139,11 +128,12 @@ const handleSave = async () => {
if (props.onlyEmit) {
emit("saved", formState.value);
modal.value?.close();
return;
}
try {
saving.value = true;
isSubmitting.value = true;
const { data } = isUpdateMode.value
? await postUpdateMutate(formState.value)
@ -154,25 +144,28 @@ const handleSave = async () => {
formState.value = data;
emit("saved", data);
handleVisibleChange(false);
modal.value?.close();
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to save post", e);
} finally {
saving.value = false;
isSubmitting.value = false;
}
};
const handlePublish = async () => {
if (props.onlyEmit) {
emit("published", formState.value);
modal.value?.close();
return;
}
try {
publishing.value = true;
await postUpdateMutate(formState.value);
const { data } = await apiClient.post.publishPost({
name: formState.value.metadata.name,
});
@ -181,7 +174,7 @@ const handlePublish = async () => {
emit("published", data);
handleVisibleChange(false);
modal.value?.close();
Toast.success(t("core.common.toast.publish_success"));
} catch (e) {
@ -199,7 +192,7 @@ const handleUnpublish = async () => {
name: formState.value.metadata.name,
});
handleVisibleChange(false);
modal.value?.close();
Toast.success(t("core.common.toast.cancel_publish_success"));
} catch (e) {
@ -214,7 +207,7 @@ watch(
() => props.post,
(value) => {
if (value) {
formState.value = toRaw(value);
formState.value = cloneDeep(value);
publishTime.value = toDatetimeLocal(formState.value.spec.publishTime);
}
},
@ -264,14 +257,37 @@ const { handleGenerateSlug } = useSlugify(
computed(() => !isUpdateMode.value),
FormType.POST
);
// Buttons condition
const showPublishButton = computed(() => {
if (!props.publishSupport) {
return false;
}
const {
[postLabels.PUBLISHED]: published,
[postLabels.SCHEDULING_PUBLISH]: schedulingPublish,
} = formState.value.metadata.labels || {};
return published !== "true" && schedulingPublish !== "true";
});
const showCancelPublishButton = computed(() => {
const {
[postLabels.PUBLISHED]: published,
[postLabels.SCHEDULING_PUBLISH]: schedulingPublish,
} = formState.value.metadata.labels || {};
return published === "true" || schedulingPublish === "true";
});
</script>
<template>
<VModal
:visible="visible"
ref="modal"
:width="700"
:title="$t('core.post.settings.title')"
:centered="false"
@update:visible="handleVisibleChange"
@close="emit('close')"
>
<template #actions>
<slot name="actions"></slot>
@ -459,10 +475,10 @@ const { handleGenerateSlug } = useSlugify(
</div>
<template #footer>
<VSpace>
<template v-if="publishSupport">
<div class="flex items-center justify-between">
<VSpace>
<VButton
v-if="formState.metadata.labels?.[postLabels.PUBLISHED] !== 'true'"
v-if="showPublishButton"
:loading="publishing"
type="secondary"
@click="handlePublishClick()"
@ -474,21 +490,26 @@ const { handleGenerateSlug } = useSlugify(
}}
</VButton>
<VButton
v-else
:loading="publishCanceling"
type="danger"
@click="handleUnpublish()"
:loading="isSubmitting"
type="secondary"
@click="handleSaveClick()"
>
{{ $t("core.common.buttons.cancel_publish") }}
{{ $t("core.common.buttons.save") }}
</VButton>
</template>
<VButton :loading="saving" type="secondary" @click="handleSaveClick()">
{{ $t("core.common.buttons.save") }}
<VButton type="default" @click="modal?.close()">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
<VButton
v-if="showCancelPublishButton"
:loading="publishCanceling"
type="danger"
@click="handleUnpublish()"
>
{{ $t("core.common.buttons.cancel_publish") }}
</VButton>
<VButton type="default" @click="handleVisibleChange(false)">
{{ $t("core.common.buttons.close") }}
</VButton>
</VSpace>
</div>
</template>
</VModal>
</template>

View File

@ -22,12 +22,11 @@ const publishStatus = computed(() => {
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post;
const { spec, metadata } = props.post.post;
return (
(spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true"
);
});
</script>

View File

@ -15,8 +15,7 @@ import {
} from "@halo-dev/components";
import PostListItem from "./components/PostListItem.vue";
import { useRouteQuery } from "@vueuse/router";
import { computed } from "vue";
import { watch } from "vue";
import { computed, watch } from "vue";
import { postLabels } from "@/constants/labels";
const page = useRouteQuery<number>("page", 1, {
@ -69,17 +68,34 @@ const {
size.value = data.size;
},
refetchInterval: (data) => {
const hasAbnormalPost = data?.items.some((post) => {
const { spec, metadata, status } = post.post;
const hasDeletingPosts = data?.items.some((post) => post.post.spec.deleted);
if (hasDeletingPosts) {
return 1000;
}
const hasPublishingPost = data?.items.some((post) => {
const { spec, metadata } = post.post;
return (
spec.deleted ||
(metadata.labels?.[postLabels.PUBLISHED] !== spec.publish + "" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
metadata.labels?.[postLabels.PUBLISHED] !== spec.publish + "" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true"
);
});
return hasAbnormalPost ? 1000 : false;
if (hasPublishingPost) {
return 1000;
}
const hasCancelingPublishPost = data?.items.some((post) => {
const { spec, metadata } = post.post;
return (
!spec.publish &&
(metadata.labels?.[postLabels.PUBLISHED] === "true" ||
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] === "true")
);
});
return hasCancelingPublishPost ? 1000 : false;
},
});
</script>

View File

@ -7,6 +7,7 @@ import {
IconTimerLine,
Toast,
VAvatar,
VDropdownDivider,
VDropdownItem,
VEntity,
VEntityField,
@ -49,13 +50,20 @@ const publishStatus = computed(() => {
: t("core.post.filters.status.items.draft");
});
const isPublished = computed(() => {
const {
[postLabels.PUBLISHED]: published,
[postLabels.SCHEDULING_PUBLISH]: schedulingPublish,
} = props.post.post.metadata.labels || {};
return published !== "true" && schedulingPublish !== "true";
});
const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post;
const { spec, metadata } = props.post.post;
return (
(spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true"
);
});
@ -226,6 +234,11 @@ function handleUnpublish() {
</VEntityField>
</template>
<template #dropdownItems>
<HasPermission v-if="isPublished" :permissions="['uc:posts:publish']">
<VDropdownItem @click="handlePublish">
{{ $t("core.common.buttons.publish") }}
</VDropdownItem>
</HasPermission>
<VDropdownItem
@click="
$router.push({
@ -236,14 +249,9 @@ function handleUnpublish() {
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<HasPermission :permissions="['uc:posts:publish']">
<VDropdownItem
v-if="post.post.metadata.labels?.[postLabels.PUBLISHED] === 'false'"
@click="handlePublish"
>
{{ $t("core.common.buttons.publish") }}
</VDropdownItem>
<VDropdownItem v-else type="danger" @click="handleUnpublish">
<HasPermission v-if="!isPublished" :permissions="['uc:posts:publish']">
<VDropdownDivider />
<VDropdownItem type="danger" @click="handleUnpublish">
{{ $t("core.common.buttons.cancel_publish") }}
</VDropdownItem>
</HasPermission>