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": {
"type": "string"
}
},
{
"in": "query",
"name": "async",
"schema": {
"type": "boolean"
}
}
],
"responses": {

View File

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

View File

@ -1,14 +1,19 @@
package run.halo.app.core.extension.reconciler;
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 run.halo.app.extension.ExtensionUtil.addFinalizers;
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 com.google.common.hash.Hashing;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
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.ControllerBuilder;
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.router.selector.FieldSelector;
import run.halo.app.infra.Condition;
@ -96,27 +102,14 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
events.forEach(eventPublisher::publishEvent);
return;
}
addFinalizers(post.getMetadata(), Set.of(FINALIZER_NAME));
populateLabels(post);
schedulePublishIfNecessary(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();
if (status == null) {
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)
.toString();
var annotations = nullSafeAnnotations(post);
var oldConfigChecksum = annotations.get(Constant.CHECKSUM_CONFIG_ANNO);
if (!Objects.equals(oldConfigChecksum, configSha256sum)) {
// if the checksum doesn't match
@ -138,37 +132,12 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
annotations.put(Constant.CHECKSUM_CONFIG_ANNO, configSha256sum);
}
var expectDelete = defaultIfNull(post.getSpec().getDeleted(), false);
var expectPublish = defaultIfNull(post.getSpec().getPublish(), false);
if (expectDelete || !expectPublish) {
if (shouldUnPublish(post)) {
unPublishPost(post, events);
} else {
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();
annotations.put(Constant.PERMALINK_PATTERN_ANNO, permalinkPattern);
@ -195,7 +164,6 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
status.setExcerpt(excerpt.getRaw());
}
var ref = Ref.of(post);
// handle contributors
var headSnapshot = post.getSpec().getHeadSnapshot();
@ -227,19 +195,75 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
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
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Post())
.onAddMatcher(DefaultExtensionMatcher.builder(client, Post.GVK)
.fieldSelector(FieldSelector.of(
equal(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, BooleanUtils.TRUE))
equal(Post.REQUIRE_SYNC_ON_STARTUP_INDEX_NAME, TRUE))
)
.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) {
var subscriber = new Subscription.Subscriber();
subscriber.setName(post.getSpec().getOwner());

View File

@ -133,9 +133,7 @@ const {
}
if (selectedPublishStatus.value !== undefined) {
labelSelector.push(
`${postLabels.PUBLISHED}=${selectedPublishStatus.value}`
);
labelSelector.push(selectedPublishStatus.value);
}
const { data } = await apiClient.post.listPosts({
@ -158,7 +156,9 @@ const {
const { spec, metadata, status } = post.post;
return (
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)
);
});
@ -357,11 +357,15 @@ watch(selectedPostNames, (newValue) => {
},
{
label: t('core.post.filters.status.items.published'),
value: 'true',
value: `${postLabels.PUBLISHED}=true`,
},
{
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,
VDropdownItem,
VEntity,
VEntityField,
} from "@halo-dev/components";
import { formatDatetime } from "@/utils/date";
import type { ListedPost, Post } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { usePermission } from "@/utils/permission";
@ -26,6 +24,7 @@ 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";
import PublishTimeField from "./entity-fields/PublishTimeField.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
@ -160,9 +159,9 @@ const { startFields, endFields } = useEntityFieldItemExtensionPoint<ListedPost>(
{
priority: 50,
position: "end",
component: markRaw(VEntityField),
component: markRaw(PublishTimeField),
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 { postLabels } from "@/constants/labels";
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 { submitForm } from "@formkit/core";
import useSlugify from "@console/composables/use-slugify";
@ -209,6 +209,7 @@ const handleUnpublish = async () => {
}
};
// publish time
watch(
() => props.post,
(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
const { templates } = useThemeCustomTemplates("post");
@ -397,6 +413,7 @@ const { handleGenerateSlug } = useSlugify(
type="datetime-local"
min="0000-01-01T00:00"
max="9999-12-31T23:59"
:help="publishTimeHelp"
></FormKit>
<FormKit
v-model="formState.spec.template"
@ -450,7 +467,11 @@ const { handleGenerateSlug } = useSlugify(
type="secondary"
@click="handlePublishClick()"
>
{{ $t("core.common.buttons.publish") }}
{{
isScheduledPublish
? $t("core.common.buttons.schedule_publish")
: $t("core.common.buttons.publish")
}}
</VButton>
<VButton
v-else

View File

@ -24,7 +24,9 @@ const publishStatus = computed(() => {
const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post;
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)
);
});

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.
* @param {string} name
* @param {string} [headSnapshot] Head snapshot name of content.
* @param {boolean} [async]
* @param {*} [options] Override http request option.
* @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
assertParamExists('publishPost', 'name', name)
const localVarPath = `/apis/api.console.halo.run/v1alpha1/posts/{name}/publish`
@ -410,6 +411,10 @@ export const ApiConsoleHaloRunV1alpha1PostApiAxiosParamCreator = function (confi
localVarQueryParameter['headSnapshot'] = headSnapshot;
}
if (async !== undefined) {
localVarQueryParameter['async'] = async;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -750,11 +755,12 @@ export const ApiConsoleHaloRunV1alpha1PostApiFp = function(configuration?: Confi
* Publish a post.
* @param {string} name
* @param {string} [headSnapshot] Head snapshot name of content.
* @param {boolean} [async]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async publishPost(name: string, headSnapshot?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.publishPost(name, headSnapshot, options);
async publishPost(name: string, headSnapshot?: string, async?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Post>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.publishPost(name, headSnapshot, async, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ApiConsoleHaloRunV1alpha1PostApi.publishPost']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -902,7 +908,7 @@ export const ApiConsoleHaloRunV1alpha1PostApiFactory = function (configuration?:
* @throws {RequiredError}
*/
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.
@ -1125,6 +1131,13 @@ export interface ApiConsoleHaloRunV1alpha1PostApiPublishPostRequest {
* @memberof ApiConsoleHaloRunV1alpha1PostApiPublishPost
*/
readonly headSnapshot?: string
/**
*
* @type {boolean}
* @memberof ApiConsoleHaloRunV1alpha1PostApiPublishPost
*/
readonly async?: boolean
}
/**
@ -1310,7 +1323,7 @@ export class ApiConsoleHaloRunV1alpha1PostApi extends BaseAPI {
* @memberof ApiConsoleHaloRunV1alpha1PostApi
*/
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 IconToolsFill from "~icons/ri/tools-fill";
import IconHistoryLine from "~icons/ri/history-line";
import IconTimerLine from "~icons/ri/timer-line";
export {
IconDashboard,
@ -150,4 +151,5 @@ export {
IconImageAddLine,
IconToolsFill,
IconHistoryLine,
IconTimerLine,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,7 +73,8 @@ const {
const { spec, metadata, status } = post.post;
return (
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)
);
});

View File

@ -4,6 +4,7 @@ import {
IconExternalLinkLine,
IconEye,
IconEyeOff,
IconTimerLine,
Toast,
VAvatar,
VDropdownItem,
@ -51,7 +52,9 @@ const publishStatus = computed(() => {
const isPublishing = computed(() => {
const { spec, status, metadata } = props.post.post;
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)
);
});
@ -204,10 +207,23 @@ function handleUnpublish() {
state="warning"
animate
/>
<VEntityField
v-if="post.post.spec.publishTime"
:description="formatDatetime(post.post.spec.publishTime)"
></VEntityField>
<VEntityField v-if="post.post.spec.publishTime">
<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>
<template #dropdownItems>
<VDropdownItem

View File

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