feat: add scheduled post publishing feature (#5940)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.16.x

#### What this PR does / why we need it:
新增文章定时发布功能

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

#### Does this PR introduce a user-facing change?
```release-note
新增文章定时发布功能
```
pull/5984/head
guqing 2024-05-24 12:58:51 +08:00 committed by GitHub
parent de85156067
commit c1e8bdb568
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 251 additions and 73 deletions

View File

@ -3109,6 +3109,13 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"in": "query",
"name": "async",
"schema": {
"type": "boolean"
}
} }
], ],
"responses": { "responses": {

View File

@ -47,6 +47,12 @@ public class Post extends AbstractExtension {
public static final String STATS_ANNO = "content.halo.run/stats"; public static final String STATS_ANNO = "content.halo.run/stats";
/**
* <p>The key of the label that indicates that the post is scheduled to be published.</p>
* <p>Can be used to query posts that are scheduled to be published.</p>
*/
public static final String SCHEDULING_PUBLISH_LABEL = "content.halo.run/scheduling-publish";
public static final String DELETED_LABEL = "content.halo.run/deleted"; public static final String DELETED_LABEL = "content.halo.run/deleted";
public static final String PUBLISHED_LABEL = "content.halo.run/published"; public static final String PUBLISHED_LABEL = "content.halo.run/published";
public static final String OWNER_LABEL = "content.halo.run/owner"; public static final String OWNER_LABEL = "content.halo.run/owner";

View File

@ -4,13 +4,16 @@ import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static run.halo.app.extension.MetadataUtil.nullSafeLabels;
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration; import java.time.Duration;
import java.util.Objects; import java.util.Objects;
import java.util.function.Predicate;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.dao.OptimisticLockingFailureException;
@ -199,6 +202,11 @@ public class PostEndpoint implements CustomEndpoint {
.description("Head snapshot name of content.") .description("Head snapshot name of content.")
.in(ParameterIn.QUERY) .in(ParameterIn.QUERY)
.required(false)) .required(false))
.parameter(parameterBuilder()
.name("async")
.in(ParameterIn.QUERY)
.implementation(Boolean.class)
.required(false))
.response(responseBuilder() .response(responseBuilder()
.implementation(Post.class)) .implementation(Post.class))
) )
@ -319,6 +327,7 @@ public class PostEndpoint implements CustomEndpoint {
boolean asyncPublish = request.queryParam("async") boolean asyncPublish = request.queryParam("async")
.map(Boolean::parseBoolean) .map(Boolean::parseBoolean)
.orElse(false); .orElse(false);
return Mono.defer(() -> client.get(Post.class, name) return Mono.defer(() -> client.get(Post.class, name)
.doOnNext(post -> { .doOnNext(post -> {
var spec = post.getSpec(); var spec = post.getSpec();
@ -327,7 +336,6 @@ public class PostEndpoint implements CustomEndpoint {
if (spec.getHeadSnapshot() == null) { if (spec.getHeadSnapshot() == null) {
spec.setHeadSnapshot(spec.getBaseSnapshot()); spec.setHeadSnapshot(spec.getBaseSnapshot());
} }
// TODO Provide release snapshot query param to control
spec.setReleaseSnapshot(spec.getHeadSnapshot()); spec.setReleaseSnapshot(spec.getHeadSnapshot());
}) })
.flatMap(client::update) .flatMap(client::update)
@ -342,12 +350,17 @@ public class PostEndpoint implements CustomEndpoint {
} }
private Mono<Post> awaitPostPublished(String postName) { private Mono<Post> awaitPostPublished(String postName) {
Predicate<Post> schedulePublish = post -> {
var labels = nullSafeLabels(post);
return BooleanUtils.TRUE.equals(labels.get(Post.SCHEDULING_PUBLISH_LABEL));
};
return Mono.defer(() -> client.get(Post.class, postName) return Mono.defer(() -> client.get(Post.class, postName)
.filter(post -> { .filter(post -> {
var releasedSnapshot = MetadataUtil.nullSafeAnnotations(post) var releasedSnapshot = MetadataUtil.nullSafeAnnotations(post)
.get(Post.LAST_RELEASED_SNAPSHOT_ANNO); .get(Post.LAST_RELEASED_SNAPSHOT_ANNO);
var expectReleaseSnapshot = post.getSpec().getReleaseSnapshot(); var expectReleaseSnapshot = post.getSpec().getReleaseSnapshot();
return Objects.equals(releasedSnapshot, expectReleaseSnapshot); return Objects.equals(releasedSnapshot, expectReleaseSnapshot)
|| schedulePublish.test(post);
}) })
.switchIfEmpty(Mono.error( .switchIfEmpty(Mono.error(
() -> new RetryException("Retry to check post publish status")))) () -> new RetryException("Retry to check post publish status"))))

View File

@ -1,14 +1,19 @@
package run.halo.app.core.extension.reconciler; package run.halo.app.core.extension.reconciler;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.lang3.BooleanUtils.TRUE;
import static org.apache.commons.lang3.BooleanUtils.isFalse;
import static org.apache.commons.lang3.BooleanUtils.isTrue;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.extension.ExtensionUtil.addFinalizers; import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations;
import static run.halo.app.extension.MetadataUtil.nullSafeLabels;
import static run.halo.app.extension.index.query.QueryFactory.equal; import static run.halo.app.extension.index.query.QueryFactory.equal;
import com.google.common.hash.Hashing; import com.google.common.hash.Hashing;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -46,6 +51,7 @@ import run.halo.app.extension.Ref;
import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.RequeueException;
import run.halo.app.extension.index.query.QueryFactory; import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.Condition; import run.halo.app.infra.Condition;
@ -96,27 +102,14 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
events.forEach(eventPublisher::publishEvent); events.forEach(eventPublisher::publishEvent);
return; return;
} }
addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME)); addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
populateLabels(post);
schedulePublishIfNecessary(post);
subscribeNewCommentNotification(post); subscribeNewCommentNotification(post);
var labels = post.getMetadata().getLabels();
if (labels == null) {
labels = new HashMap<>();
post.getMetadata().setLabels(labels);
}
var annotations = post.getMetadata().getAnnotations();
if (annotations == null) {
annotations = new HashMap<>();
post.getMetadata().setAnnotations(annotations);
}
if (!annotations.containsKey(Post.PUBLISHED_LABEL)) {
labels.put(Post.PUBLISHED_LABEL, BooleanUtils.FALSE);
}
var status = post.getStatus(); var status = post.getStatus();
if (status == null) { if (status == null) {
status = new Post.PostStatus(); status = new Post.PostStatus();
@ -131,6 +124,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
var configSha256sum = Hashing.sha256().hashString(post.getSpec().toString(), UTF_8) var configSha256sum = Hashing.sha256().hashString(post.getSpec().toString(), UTF_8)
.toString(); .toString();
var annotations = nullSafeAnnotations(post);
var oldConfigChecksum = annotations.get(Constant.CHECKSUM_CONFIG_ANNO); var oldConfigChecksum = annotations.get(Constant.CHECKSUM_CONFIG_ANNO);
if (!Objects.equals(oldConfigChecksum, configSha256sum)) { if (!Objects.equals(oldConfigChecksum, configSha256sum)) {
// if the checksum doesn't match // if the checksum doesn't match
@ -138,37 +132,12 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
annotations.put(Constant.CHECKSUM_CONFIG_ANNO, configSha256sum); annotations.put(Constant.CHECKSUM_CONFIG_ANNO, configSha256sum);
} }
var expectDelete = defaultIfNull(post.getSpec().getDeleted(), false); if (shouldUnPublish(post)) {
var expectPublish = defaultIfNull(post.getSpec().getPublish(), false);
if (expectDelete || !expectPublish) {
unPublishPost(post, events); unPublishPost(post, events);
} else { } else {
publishPost(post, events); publishPost(post, events);
} }
labels.put(Post.DELETED_LABEL, expectDelete.toString());
var expectVisible = defaultIfNull(post.getSpec().getVisible(), VisibleEnum.PUBLIC);
var oldVisible = VisibleEnum.from(labels.get(Post.VISIBLE_LABEL));
if (!Objects.equals(oldVisible, expectVisible)) {
eventPublisher.publishEvent(
new PostVisibleChangedEvent(request.name(), oldVisible, expectVisible));
}
labels.put(Post.VISIBLE_LABEL, expectVisible.toString());
var ownerName = post.getSpec().getOwner();
if (StringUtils.isNotBlank(ownerName)) {
labels.put(Post.OWNER_LABEL, ownerName);
}
var publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime));
}
var permalinkPattern = postPermalinkPolicy.pattern(); var permalinkPattern = postPermalinkPolicy.pattern();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, permalinkPattern); annotations.put(Constant.PERMALINK_PATTERN_ANNO, permalinkPattern);
@ -195,7 +164,6 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
status.setExcerpt(excerpt.getRaw()); status.setExcerpt(excerpt.getRaw());
} }
var ref = Ref.of(post); var ref = Ref.of(post);
// handle contributors // handle contributors
var headSnapshot = post.getSpec().getHeadSnapshot(); var headSnapshot = post.getSpec().getHeadSnapshot();
@ -227,19 +195,75 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
return Result.doNotRetry(); return Result.doNotRetry();
} }
private void populateLabels(Post post) {
var labels = nullSafeLabels(post);
labels.put(Post.DELETED_LABEL, String.valueOf(isTrue(post.getSpec().getDeleted())));
var expectVisible = defaultIfNull(post.getSpec().getVisible(), VisibleEnum.PUBLIC);
var oldVisible = VisibleEnum.from(labels.get(Post.VISIBLE_LABEL));
if (!Objects.equals(oldVisible, expectVisible)) {
var postName = post.getMetadata().getName();
eventPublisher.publishEvent(
new PostVisibleChangedEvent(postName, oldVisible, expectVisible));
}
labels.put(Post.VISIBLE_LABEL, expectVisible.toString());
var ownerName = post.getSpec().getOwner();
if (StringUtils.isNotBlank(ownerName)) {
labels.put(Post.OWNER_LABEL, ownerName);
}
var publishTime = post.getSpec().getPublishTime();
if (publishTime != null) {
labels.put(Post.ARCHIVE_YEAR_LABEL, HaloUtils.getYearText(publishTime));
labels.put(Post.ARCHIVE_MONTH_LABEL, HaloUtils.getMonthText(publishTime));
labels.put(Post.ARCHIVE_DAY_LABEL, HaloUtils.getDayText(publishTime));
}
if (!labels.containsKey(Post.PUBLISHED_LABEL)) {
labels.put(Post.PUBLISHED_LABEL, BooleanUtils.FALSE);
}
}
private static boolean shouldUnPublish(Post post) {
return isTrue(post.getSpec().getDeleted()) || isFalse(post.getSpec().getPublish());
}
@Override @Override
public Controller setupWith(ControllerBuilder builder) { public Controller setupWith(ControllerBuilder builder) {
return builder return builder
.extension(new Post()) .extension(new Post())
.onAddMatcher(DefaultExtensionMatcher.builder(client, Post.GVK) .onAddMatcher(DefaultExtensionMatcher.builder(client, Post.GVK)
.fieldSelector(FieldSelector.of( .fieldSelector(FieldSelector.of(
equal(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, BooleanUtils.TRUE)) equal(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, TRUE))
) )
.build() .build()
) )
.build(); .build();
} }
void schedulePublishIfNecessary(Post post) {
var labels = nullSafeLabels(post);
// ensure the label is removed
labels.remove(Post.SCHEDULING_PUBLISH_LABEL);
final var now = Instant.now();
var publishTime = post.getSpec().getPublishTime();
if (post.isPublished() || publishTime == null) {
return;
}
// expect to publish in the future
if (isTrue(post.getSpec().getPublish()) && publishTime.isAfter(now)) {
labels.put(Post.SCHEDULING_PUBLISH_LABEL, TRUE);
// update post changes before requeue
client.update(post);
throw new RequeueException(Result.requeue(Duration.between(now, publishTime)),
"Requeue for scheduled publish.");
}
}
void subscribeNewCommentNotification(Post post) { void subscribeNewCommentNotification(Post post) {
var subscriber = new Subscription.Subscriber(); var subscriber = new Subscription.Subscriber();
subscriber.setName(post.getSpec().getOwner()); subscriber.setName(post.getSpec().getOwner());

View File

@ -133,9 +133,7 @@ const {
} }
if (selectedPublishStatus.value !== undefined) { if (selectedPublishStatus.value !== undefined) {
labelSelector.push( labelSelector.push(selectedPublishStatus.value);
`${postLabels.PUBLISHED}=${selectedPublishStatus.value}`
);
} }
const { data } = await apiClient.post.listPosts({ const { data } = await apiClient.post.listPosts({
@ -158,7 +156,9 @@ const {
const { spec, metadata, status } = post.post; const { spec, metadata, status } = post.post;
return ( return (
spec.deleted || spec.deleted ||
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") || (spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress) (spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
); );
}); });
@ -357,11 +357,15 @@ watch(selectedPostNames, (newValue) => {
}, },
{ {
label: t('core.post.filters.status.items.published'), label: t('core.post.filters.status.items.published'),
value: 'true', value: `${postLabels.PUBLISHED}=true`,
}, },
{ {
label: t('core.post.filters.status.items.draft'), label: t('core.post.filters.status.items.draft'),
value: 'false', value: `${postLabels.PUBLISHED}=false`,
},
{
label: t('core.post.filters.status.items.scheduling'),
value: `${postLabels.SCHEDULING_PUBLISH}=true`,
}, },
]" ]"
/> />

View File

@ -5,9 +5,7 @@ import {
VDropdownDivider, VDropdownDivider,
VDropdownItem, VDropdownItem,
VEntity, VEntity,
VEntityField,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { formatDatetime } from "@/utils/date";
import type { ListedPost, Post } from "@halo-dev/api-client"; import type { ListedPost, Post } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
@ -26,6 +24,7 @@ import ContributorsField from "./entity-fields/ContributorsField.vue";
import PublishStatusField from "./entity-fields/PublishStatusField.vue"; import PublishStatusField from "./entity-fields/PublishStatusField.vue";
import VisibleField from "./entity-fields/VisibleField.vue"; import VisibleField from "./entity-fields/VisibleField.vue";
import StatusDotField from "@/components/entity-fields/StatusDotField.vue"; import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import PublishTimeField from "./entity-fields/PublishTimeField.vue";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
@ -160,9 +159,9 @@ const { startFields, endFields } = useEntityFieldItemExtensionPoint<ListedPost>(
{ {
priority: 50, priority: 50,
position: "end", position: "end",
component: markRaw(VEntityField), component: markRaw(PublishTimeField),
props: { props: {
description: formatDatetime(props.post.post.spec.publishTime), post: props.post,
}, },
}, },
]) ])

View File

@ -13,7 +13,7 @@ import { apiClient } from "@/utils/api-client";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme"; import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import { postLabels } from "@/constants/labels"; import { postLabels } from "@/constants/labels";
import { randomUUID } from "@/utils/id"; import { randomUUID } from "@/utils/id";
import { toDatetimeLocal, toISOString } from "@/utils/date"; import { formatDatetime, toDatetimeLocal, toISOString } from "@/utils/date";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue"; import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { submitForm } from "@formkit/core"; import { submitForm } from "@formkit/core";
import useSlugify from "@console/composables/use-slugify"; import useSlugify from "@console/composables/use-slugify";
@ -209,6 +209,7 @@ const handleUnpublish = async () => {
} }
}; };
// publish time
watch( watch(
() => props.post, () => props.post,
(value) => { (value) => {
@ -229,6 +230,21 @@ watch(
} }
); );
const isScheduledPublish = computed(() => {
return (
formState.value.spec.publishTime &&
new Date(formState.value.spec.publishTime) > new Date()
);
});
const publishTimeHelp = computed(() => {
return isScheduledPublish.value
? t("core.post.settings.fields.publish_time.help.schedule_publish", {
datetime: formatDatetime(publishTime.value),
})
: "";
});
// custom templates // custom templates
const { templates } = useThemeCustomTemplates("post"); const { templates } = useThemeCustomTemplates("post");
@ -397,6 +413,7 @@ const { handleGenerateSlug } = useSlugify(
type="datetime-local" type="datetime-local"
min="0000-01-01T00:00" min="0000-01-01T00:00"
max="9999-12-31T23:59" max="9999-12-31T23:59"
:help="publishTimeHelp"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.spec.template" v-model="formState.spec.template"
@ -450,7 +467,11 @@ const { handleGenerateSlug } = useSlugify(
type="secondary" type="secondary"
@click="handlePublishClick()" @click="handlePublishClick()"
> >
{{ $t("core.common.buttons.publish") }} {{
isScheduledPublish
? $t("core.common.buttons.schedule_publish")
: $t("core.common.buttons.publish")
}}
</VButton> </VButton>
<VButton <VButton
v-else v-else

View File

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

View File

@ -0,0 +1,33 @@
<script lang="ts" setup>
import { postLabels } from "@/constants/labels";
import { formatDatetime } from "@/utils/date";
import { IconTimerLine, VEntityField } from "@halo-dev/components";
import type { ListedPost } from "packages/api-client/dist";
withDefaults(
defineProps<{
post: ListedPost;
}>(),
{}
);
</script>
<template>
<VEntityField>
<template #description>
<div class="inline-flex items-center space-x-2">
<span class="entity-field-description">
{{ formatDatetime(post.post.spec.publishTime) }}
</span>
<IconTimerLine
v-if="
post.post.metadata.labels?.[postLabels.SCHEDULING_PUBLISH] ===
'true'
"
v-tooltip="$t('core.post.list.fields.schedule_publish.tooltip')"
class="text-sm"
/>
</div>
</template>
</VEntityField>
</template>

View File

@ -379,10 +379,11 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
* Publish a post. * Publish a post.
* @param {string} name * @param {string} name
* @param {string} [headSnapshot] Head snapshot name of content. * @param {string} [headSnapshot] Head snapshot name of content.
* @param {boolean} [async]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
publishPost: async (name: string, headSnapshot?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => { publishPost: async (name: string, headSnapshot?: string, async?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined // verify required parameter 'name' is not null or undefined
assertParamExists('publishPost', 'name', name) assertParamExists('publishPost', 'name', name)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts/{name}/publish` const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts/{name}/publish`
@ -410,6 +411,10 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
localVarQueryParameter['headSnapshot'] = headSnapshot; localVarQueryParameter['headSnapshot'] = headSnapshot;
} }
if (async !== undefined) {
localVarQueryParameter['async'] = async;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -750,11 +755,12 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Confi
* Publish a post. * Publish a post.
* @param {string} name * @param {string} name
* @param {string} [headSnapshot] Head snapshot name of content. * @param {string} [headSnapshot] Head snapshot name of content.
* @param {boolean} [async]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async publishPost(name: string, headSnapshot?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>> { async publishPost(name: string, headSnapshot?: string, async?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.publishPost(name, headSnapshot, options); const localVarAxiosArgs = await localVarAxiosParamCreator.publishPost(name, headSnapshot, async, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.publishPost']?.[localVarOperationServerIndex]?.url; const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.publishPost']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -902,7 +908,7 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?:
* @throws {RequiredError} * @throws {RequiredError}
*/ */
publishPost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiPublishPostRequest, options?: RawAxiosRequestConfig): AxiosPromise<Post> { publishPost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiPublishPostRequest, options?: RawAxiosRequestConfig): AxiosPromise<Post> {
return localVarFp.publishPost(requestParameters.name, requestParameters.headSnapshot, options).then((request) => request(axios, basePath)); return localVarFp.publishPost(requestParameters.name, requestParameters.headSnapshot, requestParameters.async, options).then((request) => request(axios, basePath));
}, },
/** /**
* Recycle a post. * Recycle a post.
@ -1125,6 +1131,13 @@ export interface ApiConsoleHaloRunV1alpha1PostApiPublishPostRequest {
* @memberof ApiConsoleHaloRunV1alpha1PostApiPublishPost * @memberof ApiConsoleHaloRunV1alpha1PostApiPublishPost
*/ */
readonly headSnapshot?: string readonly headSnapshot?: string
/**
*
* @type {boolean}
* @memberof ApiConsoleHaloRunV1alpha1PostApiPublishPost
*/
readonly async?: boolean
} }
/** /**
@ -1310,7 +1323,7 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
* @memberof ApiConsoleHaloRunV1alpha1PostApi * @memberof ApiConsoleHaloRunV1alpha1PostApi
*/ */
public publishPost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiPublishPostRequest, options?: RawAxiosRequestConfig) { public publishPost(requestParameters: ApiConsoleHaloRunV1alpha1PostApiPublishPostRequest, options?: RawAxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).publishPost(requestParameters.name, requestParameters.headSnapshot, options).then((request) => request(this.axios, this.basePath)); return ApiConsoleHaloRunV1alpha1PostApiFp(this.configuration).publishPost(requestParameters.name, requestParameters.headSnapshot, requestParameters.async, options).then((request) => request(this.axios, this.basePath));
} }
/** /**

View File

@ -73,6 +73,7 @@ import IconSettings3Line from "~icons/ri/settings-3-line";
import IconImageAddLine from "~icons/ri/image-add-line"; import IconImageAddLine from "~icons/ri/image-add-line";
import IconToolsFill from "~icons/ri/tools-fill"; import IconToolsFill from "~icons/ri/tools-fill";
import IconHistoryLine from "~icons/ri/history-line"; import IconHistoryLine from "~icons/ri/history-line";
import IconTimerLine from "~icons/ri/timer-line";
export { export {
IconDashboard, IconDashboard,
@ -150,4 +151,5 @@ export {
IconImageAddLine, IconImageAddLine,
IconToolsFill, IconToolsFill,
IconHistoryLine, IconHistoryLine,
IconTimerLine,
}; };

View File

@ -22,6 +22,7 @@ export enum contentAnnotations {
PATCHED_CONTENT = "content.halo.run/patched-content", PATCHED_CONTENT = "content.halo.run/patched-content",
PATCHED_RAW = "content.halo.run/patched-raw", PATCHED_RAW = "content.halo.run/patched-raw",
CONTENT_JSON = "content.halo.run/content-json", CONTENT_JSON = "content.halo.run/content-json",
SCHEDULED_PUBLISH_AT = "content.halo.run/scheduled-publish-at",
} }
// pat // pat

View File

@ -17,6 +17,7 @@ export enum postLabels {
OWNER = "content.halo.run/owner", OWNER = "content.halo.run/owner",
VISIBLE = "content.halo.run/visible", VISIBLE = "content.halo.run/visible",
PHASE = "content.halo.run/phase", PHASE = "content.halo.run/phase",
SCHEDULING_PUBLISH = "content.halo.run/scheduling-publish",
} }
// singlePage // singlePage

View File

@ -196,6 +196,7 @@ core:
items: items:
published: Published published: Published
draft: Draft draft: Draft
scheduling: Scheduling publish
visible: visible:
label: Visible label: Visible
result: "Visible: {visible}" result: "Visible: {visible}"
@ -227,6 +228,8 @@ core:
visits: "{visits} Visits" visits: "{visits} Visits"
comments: "{comments} Comments" comments: "{comments} Comments"
pinned: Pinned pinned: Pinned
schedule_publish:
tooltip: Schedule publish
settings: settings:
title: Settings title: Settings
groups: groups:
@ -256,6 +259,8 @@ core:
label: Visible label: Visible
publish_time: publish_time:
label: Publish Time label: Publish Time
help:
schedule_publish: Schedule a timed task and publish it at {datetime}
template: template:
label: Template label: Template
cover: cover:
@ -1575,6 +1580,7 @@ core:
verify: Verify verify: Verify
modify: Modify modify: Modify
access: Access access: Access
schedule_publish: Schedule publish
radio: radio:
"yes": "Yes" "yes": "Yes"
"no": "No" "no": "No"

View File

@ -188,6 +188,7 @@ core:
items: items:
published: 已发布 published: 已发布
draft: 未发布 draft: 未发布
scheduling: 定时发布
visible: visible:
label: 可见性 label: 可见性
result: 可见性:{visible} result: 可见性:{visible}
@ -219,6 +220,8 @@ core:
visits: 访问量 {visits} visits: 访问量 {visits}
comments: 评论 {comments} comments: 评论 {comments}
pinned: 已置顶 pinned: 已置顶
schedule_publish:
tooltip: 定时发布
settings: settings:
title: 文章设置 title: 文章设置
groups: groups:
@ -248,6 +251,8 @@ core:
label: 可见性 label: 可见性
publish_time: publish_time:
label: 发表时间 label: 发表时间
help:
schedule_publish: 将设置定时任务,并于 {datetime} 发布
template: template:
label: 自定义模板 label: 自定义模板
cover: cover:
@ -1519,6 +1524,7 @@ core:
verify: 验证 verify: 验证
modify: 修改 modify: 修改
access: 访问 access: 访问
schedule_publish: 定时发布
radio: radio:
"yes": "yes":
"no": "no":

View File

@ -188,6 +188,7 @@ core:
items: items:
published: 已發布 published: 已發布
draft: 未發布 draft: 未發布
scheduling: 定時發佈
visible: visible:
label: 可見性 label: 可見性
result: 可見性:{visible} result: 可見性:{visible}
@ -219,6 +220,8 @@ core:
visits: 訪問量 {visits} visits: 訪問量 {visits}
comments: 留言 {comments} comments: 留言 {comments}
pinned: 已置頂 pinned: 已置頂
schedule_publish:
tooltip: 定時發佈
settings: settings:
title: 文章設置 title: 文章設置
groups: groups:
@ -248,6 +251,8 @@ core:
label: 可見性 label: 可見性
publish_time: publish_time:
label: 發表時間 label: 發表時間
help:
schedule_publish: 將設定定時任務,並於 {datetime} 發佈
template: template:
label: 自定義模板 label: 自定義模板
cover: cover:
@ -1477,6 +1482,7 @@ core:
modify: 修改 modify: 修改
verify: 驗證 verify: 驗證
access: 訪問 access: 訪問
schedule_publish: 定時發佈
radio: radio:
"yes": "yes":
"no": "no":

View File

@ -73,7 +73,8 @@ const {
const { spec, metadata, status } = post.post; const { spec, metadata, status } = post.post;
return ( return (
spec.deleted || spec.deleted ||
metadata.labels?.[postLabels.PUBLISHED] !== spec.publish + "" || (metadata.labels?.[postLabels.PUBLISHED] !== spec.publish + "" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress) (spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
); );
}); });

View File

@ -4,6 +4,7 @@ import {
IconExternalLinkLine, IconExternalLinkLine,
IconEye, IconEye,
IconEyeOff, IconEyeOff,
IconTimerLine,
Toast, Toast,
VAvatar, VAvatar,
VDropdownItem, VDropdownItem,
@ -51,7 +52,9 @@ const publishStatus = computed(() => {
const isPublishing = computed(() => { const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post; const { spec, status, metadata } = props.post.post;
return ( return (
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") || (spec.publish &&
metadata.labels?.[postLabels.PUBLISHED] !== "true" &&
metadata.labels?.[postLabels.SCHEDULING_PUBLISH] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress) (spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
); );
}); });
@ -204,10 +207,23 @@ function handleUnpublish() {
state="warning" state="warning"
animate animate
/> />
<VEntityField <VEntityField v-if="post.post.spec.publishTime">
v-if="post.post.spec.publishTime" <template #description>
:description="formatDatetime(post.post.spec.publishTime)" <div class="inline-flex items-center space-x-2">
></VEntityField> <span class="entity-field-description">
{{ formatDatetime(post.post.spec.publishTime) }}
</span>
<IconTimerLine
v-if="
post.post.metadata.labels?.[postLabels.SCHEDULING_PUBLISH] ===
'true'
"
v-tooltip="$t('core.post.list.fields.schedule_publish.tooltip')"
class="text-sm"
/>
</div>
</template>
</VEntityField>
</template> </template>
<template #dropdownItems> <template #dropdownItems>
<VDropdownItem <VDropdownItem

View File

@ -1,12 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { IconRefreshLine } from "@halo-dev/components"; import { IconRefreshLine } from "@halo-dev/components";
import type { PostFormState } from "../types"; import type { PostFormState } from "../types";
import { toISOString } from "@/utils/date"; import { formatDatetime, toISOString } from "@/utils/date";
import { computed } from "vue"; import { computed } from "vue";
import useSlugify from "@console/composables/use-slugify"; import useSlugify from "@console/composables/use-slugify";
import { FormType } from "@/types/slug"; import { FormType } from "@/types/slug";
import { ref } from "vue"; import { ref } from "vue";
import HasPermission from "@/components/permission/HasPermission.vue"; import HasPermission from "@/components/permission/HasPermission.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -60,6 +63,19 @@ const { handleGenerateSlug } = useSlugify(
computed(() => !props.updateMode), computed(() => !props.updateMode),
FormType.POST FormType.POST
); );
const isScheduledPublish = computed(() => {
const { publishTime } = internalFormState.value;
return publishTime && new Date(publishTime) > new Date();
});
const publishTimeHelp = computed(() => {
return isScheduledPublish.value
? t("core.post.settings.fields.publish_time.help.schedule_publish", {
datetime: formatDatetime(internalFormState.value.publishTime),
})
: "";
});
</script> </script>
<template> <template>
@ -189,6 +205,7 @@ const { handleGenerateSlug } = useSlugify(
type="datetime-local" type="datetime-local"
min="0000-01-01T00:00" min="0000-01-01T00:00"
max="9999-12-31T23:59" max="9999-12-31T23:59"
:help="publishTimeHelp"
></FormKit> ></FormKit>
<HasPermission :permissions="['system:attachments:view']"> <HasPermission :permissions="['system:attachments:view']">
<FormKit <FormKit