refactor: post and singlePage publishing (#685)

#### What type of PR is this?

/kind improvement
/milestone 2.0

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

重构文章和自定义页面的发布流程。

Ref https://github.com/halo-dev/halo/pull/2659

1. 修改文章、自定义页面标识发布状态的字段名。
2. 修改初始化页面中创建文章和自定义页面的逻辑,取消使用发布接口,改为直接将 `spec.publish` 设置为 `true`
3. 列表支持检测发布状态。

#### Special notes for your reviewer:

测试方式:

1. Halo 需要切换到 https://github.com/halo-dev/halo/pull/2659 分支。
2. Console 需要 `pnpm install && pnpm build:packages`
3. 测试文章和自定义页面的发布、保存等流程。需要完整测试整个流程。

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

```release-note
None
```
pull/690/head
Ryan Wang 2022-11-11 00:24:10 +08:00 committed by GitHub
parent f3e44717ee
commit fb446d382f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 643 additions and 432 deletions

View File

@ -33,7 +33,7 @@
"@formkit/inputs": "^1.0.0-beta.11", "@formkit/inputs": "^1.0.0-beta.11",
"@formkit/themes": "^1.0.0-beta.11", "@formkit/themes": "^1.0.0-beta.11",
"@formkit/vue": "^1.0.0-beta.11", "@formkit/vue": "^1.0.0-beta.11",
"@halo-dev/api-client": "^0.0.41", "@halo-dev/api-client": "^0.0.43",
"@halo-dev/components": "workspace:*", "@halo-dev/components": "workspace:*",
"@halo-dev/console-shared": "workspace:*", "@halo-dev/console-shared": "workspace:*",
"@halo-dev/richtext-editor": "^0.0.0-alpha.11", "@halo-dev/richtext-editor": "^0.0.0-alpha.11",

View File

@ -40,7 +40,7 @@ const classes = computed(() => {
} }
.status-dot-text { .status-dot-text {
@apply text-gray-500; @apply text-gray-500 text-xs;
} }
&.status-dot-animate { &.status-dot-animate {

View File

@ -52,6 +52,8 @@ import IconReplyLine from "~icons/ri/reply-line";
import IconExternalLinkLine from "~icons/ri/external-link-line"; import IconExternalLinkLine from "~icons/ri/external-link-line";
import IconRefreshLine from "~icons/ri/refresh-line"; import IconRefreshLine from "~icons/ri/refresh-line";
import IconWindowLine from "~icons/ri/window-line"; import IconWindowLine from "~icons/ri/window-line";
import IconSendPlaneFill from "~icons/ri/send-plane-fill";
import IconRocketLine from "~icons/ri/rocket-line";
export { export {
IconDashboard, IconDashboard,
@ -108,4 +110,6 @@ export {
IconExternalLinkLine, IconExternalLinkLine,
IconRefreshLine, IconRefreshLine,
IconWindowLine, IconWindowLine,
IconSendPlaneFill,
IconRocketLine,
}; };

View File

@ -13,7 +13,7 @@ importers:
'@formkit/inputs': ^1.0.0-beta.11 '@formkit/inputs': ^1.0.0-beta.11
'@formkit/themes': ^1.0.0-beta.11 '@formkit/themes': ^1.0.0-beta.11
'@formkit/vue': ^1.0.0-beta.11 '@formkit/vue': ^1.0.0-beta.11
'@halo-dev/api-client': ^0.0.41 '@halo-dev/api-client': ^0.0.43
'@halo-dev/components': workspace:* '@halo-dev/components': workspace:*
'@halo-dev/console-shared': workspace:* '@halo-dev/console-shared': workspace:*
'@halo-dev/richtext-editor': ^0.0.0-alpha.11 '@halo-dev/richtext-editor': ^0.0.0-alpha.11
@ -108,7 +108,7 @@ importers:
'@formkit/inputs': 1.0.0-beta.11 '@formkit/inputs': 1.0.0-beta.11
'@formkit/themes': 1.0.0-beta.11_tailwindcss@3.2.1 '@formkit/themes': 1.0.0-beta.11_tailwindcss@3.2.1
'@formkit/vue': 1.0.0-beta.11_vjnbgdptsk6bkj7ab5a6mk2cwm '@formkit/vue': 1.0.0-beta.11_vjnbgdptsk6bkj7ab5a6mk2cwm
'@halo-dev/api-client': 0.0.41 '@halo-dev/api-client': 0.0.43
'@halo-dev/components': link:packages/components '@halo-dev/components': link:packages/components
'@halo-dev/console-shared': link:packages/shared '@halo-dev/console-shared': link:packages/shared
'@halo-dev/richtext-editor': 0.0.0-alpha.11_vue@3.2.41 '@halo-dev/richtext-editor': 0.0.0-alpha.11_vue@3.2.41
@ -1953,8 +1953,8 @@ packages:
- windicss - windicss
dev: false dev: false
/@halo-dev/api-client/0.0.41: /@halo-dev/api-client/0.0.43:
resolution: {integrity: sha512-YpwoIyT+6BjNEfhQqZPSG7dewmC9AE7wxc/uaIRcVZdKr0C4GLmbiBKGOFFnVWIYr42islhMWjqYCGaiJSzkUg==} resolution: {integrity: sha512-bCh5P7AYCYA/nVbAB/t62acrtOaDs8UItFe4JmK1qfeb5vOmFT2FT9jeJFj4ABqa4t4xJxy9hnoxNm6H+iB3IQ==}
dev: false dev: false
/@halo-dev/richtext-editor/0.0.0-alpha.11_vue@3.2.41: /@halo-dev/richtext-editor/0.0.0-alpha.11_vue@3.2.41:

View File

@ -11,6 +11,16 @@ export enum roleLabels {
// post // post
export enum postLabels { export enum postLabels {
DELETED = "content.halo.run/deleted", DELETED = "content.halo.run/deleted",
PUBLISHED = "content.halo.run/published",
OWNER = "content.halo.run/owner",
VISIBLE = "content.halo.run/visible",
PHASE = "content.halo.run/phase",
}
// singlePage
export enum singlePageLabels {
DELETED = "content.halo.run/deleted",
PUBLISHED = "content.halo.run/published",
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",

View File

@ -2,6 +2,8 @@
import { import {
VPageHeader, VPageHeader,
IconPages, IconPages,
IconSettings,
IconSendPlaneFill,
VSpace, VSpace,
VButton, VButton,
IconSave, IconSave,
@ -9,12 +11,15 @@ import {
import DefaultEditor from "@/components/editor/DefaultEditor.vue"; import DefaultEditor from "@/components/editor/DefaultEditor.vue";
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue"; import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
import PostPreviewModal from "../posts/components/PostPreviewModal.vue"; import PostPreviewModal from "../posts/components/PostPreviewModal.vue";
import type { SinglePageRequest } from "@halo-dev/api-client"; import type { SinglePage, SinglePageRequest } from "@halo-dev/api-client";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { useRouter } from "vue-router";
const router = useRouter();
const initialFormState: SinglePageRequest = { const initialFormState: SinglePageRequest = {
page: { page: {
@ -24,7 +29,7 @@ const initialFormState: SinglePageRequest = {
template: "", template: "",
cover: "", cover: "",
deleted: false, deleted: false,
published: false, publish: false,
publishTime: "", publishTime: "",
pinned: false, pinned: false,
allowComment: true, allowComment: true,
@ -52,6 +57,7 @@ const initialFormState: SinglePageRequest = {
const formState = ref<SinglePageRequest>(cloneDeep(initialFormState)); const formState = ref<SinglePageRequest>(cloneDeep(initialFormState));
const saving = ref(false); const saving = ref(false);
const publishing = ref(false);
const settingModal = ref(false); const settingModal = ref(false);
const previewModal = ref(false); const previewModal = ref(false);
@ -77,10 +83,21 @@ const handleSave = async () => {
} }
if (isUpdateMode.value) { if (isUpdateMode.value) {
// Get latest single page
const { data: latestSinglePage } =
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage(
{
name: formState.value.page.metadata.name,
}
);
formState.value.page = latestSinglePage;
const { data } = await apiClient.singlePage.updateDraftSinglePage({ const { data } = await apiClient.singlePage.updateDraftSinglePage({
name: formState.value.page.metadata.name, name: formState.value.page.metadata.name,
singlePageRequest: formState.value, singlePageRequest: formState.value,
}); });
formState.value.page = data; formState.value.page = data;
} else { } else {
const { data } = await apiClient.singlePage.draftSinglePage({ const { data } = await apiClient.singlePage.draftSinglePage({
@ -89,6 +106,7 @@ const handleSave = async () => {
formState.value.page = data; formState.value.page = data;
routeQueryName.value = data.metadata.name; routeQueryName.value = data.metadata.name;
} }
await handleFetchContent(); await handleFetchContent();
} catch (error) { } catch (error) {
console.error("Failed to save single page", error); console.error("Failed to save single page", error);
@ -97,6 +115,76 @@ const handleSave = async () => {
} }
}; };
const handlePublish = async () => {
try {
publishing.value = true;
// Set rendered content
formState.value.content.content = formState.value.content.raw;
if (isUpdateMode.value) {
const { headSnapshot } = formState.value.page.spec;
const { name: singlePageName } = formState.value.page.metadata;
const { data: latestContent } =
await apiClient.content.updateSnapshotContent({
snapshotName: headSnapshot as string,
contentRequest: {
raw: formState.value.content.raw as string,
content: formState.value.content.content as string,
rawType: formState.value.content.rawType as string,
headSnapshotName: headSnapshot,
subjectRef: {
kind: "SinglePage",
version: "v1alpha1",
group: "content.halo.run",
name: singlePageName,
},
},
});
// Get latest single page
const { data: latestSinglePage } =
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage(
{
name: formState.value.page.metadata.name,
}
);
formState.value.page = latestSinglePage;
formState.value.page.spec.publish = true;
formState.value.page.spec.headSnapshot = latestContent.snapshotName;
formState.value.page.spec.releaseSnapshot =
formState.value.page.spec.headSnapshot;
await apiClient.extension.singlePage.updatecontentHaloRunV1alpha1SinglePage(
{
name: singlePageName,
singlePage: formState.value.page,
}
);
} else {
formState.value.page.spec.publish = true;
await apiClient.singlePage.draftSinglePage({
singlePageRequest: formState.value,
});
}
router.push({ name: "SinglePages" });
} catch (error) {
console.error("Failed to publish single page", error);
} finally {
publishing.value = false;
}
};
const handlePublishClick = () => {
if (isUpdateMode.value) {
handlePublish();
} else {
settingModal.value = true;
}
};
const handleFetchContent = async () => { const handleFetchContent = async () => {
if (!formState.value.page.spec.headSnapshot) { if (!formState.value.page.spec.headSnapshot) {
return; return;
@ -108,14 +196,33 @@ const handleFetchContent = async () => {
formState.value.content = data; formState.value.content = data;
}; };
const onSettingSaved = (page: SinglePageRequest) => { const handleOpenSettingModal = async () => {
const { data: latestSinglePage } =
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
name: formState.value.page.metadata.name,
});
formState.value.page = latestSinglePage;
settingModal.value = true;
};
const onSettingSaved = (page: SinglePage) => {
// Set route query parameter // Set route query parameter
if (!isUpdateMode.value) { if (!isUpdateMode.value) {
routeQueryName.value = page.page.metadata.name; routeQueryName.value = page.metadata.name;
} }
formState.value = page; formState.value.page = page;
settingModal.value = false; settingModal.value = false;
if (!isUpdateMode.value) {
handleSave();
}
};
const onSettingPublished = (singlePage: SinglePage) => {
formState.value.page = singlePage;
settingModal.value = false;
handlePublish();
}; };
onMounted(async () => { onMounted(async () => {
@ -135,8 +242,11 @@ onMounted(async () => {
<template> <template>
<SinglePageSettingModal <SinglePageSettingModal
v-model:visible="settingModal" v-model:visible="settingModal"
:single-page="formState" :single-page="formState.page"
:publish-support="!isUpdateMode"
:only-emit="!isUpdateMode"
@saved="onSettingSaved" @saved="onSettingSaved"
@published="onSettingPublished"
/> />
<PostPreviewModal v-model:visible="previewModal" /> <PostPreviewModal v-model:visible="previewModal" />
<VPageHeader title="自定义页面"> <VPageHeader title="自定义页面">
@ -155,12 +265,30 @@ onMounted(async () => {
预览 预览
</VButton> </VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave"> <VButton :loading="saving" size="sm" type="default" @click="handleSave">
保存
</VButton>
<VButton type="secondary" @click="settingModal = true">
<template #icon> <template #icon>
<IconSave class="h-full w-full" /> <IconSave class="h-full w-full" />
</template> </template>
保存
</VButton>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="handleOpenSettingModal"
>
<template #icon>
<IconSettings class="h-full w-full" />
</template>
设置
</VButton>
<VButton
type="secondary"
:loading="publishing"
@click="handlePublishClick"
>
<template #icon>
<IconSendPlaneFill class="h-full w-full" />
</template>
发布 发布
</VButton> </VButton>
</VSpace> </VSpace>

View File

@ -22,11 +22,10 @@ import {
} from "@halo-dev/components"; } from "@halo-dev/components";
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue"; import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue"; import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
import { onMounted, ref, watch, watchEffect } from "vue"; import { onMounted, ref, watch } from "vue";
import type { import type {
ListedSinglePageList, ListedSinglePageList,
SinglePage, SinglePage,
SinglePageRequest,
User, User,
} from "@halo-dev/api-client"; } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
@ -34,15 +33,10 @@ import { formatDatetime } from "@/utils/date";
import { onBeforeRouteLeave, RouterLink } from "vue-router"; import { onBeforeRouteLeave, RouterLink } from "vue-router";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { singlePageLabels } from "@/constants/labels";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
enum SinglePagePhase {
DRAFT = "未发布",
PENDING_APPROVAL = "待审核",
PUBLISHED = "已发布",
}
const singlePages = ref<ListedSinglePageList>({ const singlePages = ref<ListedSinglePageList>({
page: 1, page: 1,
size: 20, size: 20,
@ -56,7 +50,6 @@ const singlePages = ref<ListedSinglePageList>({
const loading = ref(false); const loading = ref(false);
const settingModal = ref(false); const settingModal = ref(false);
const selectedSinglePage = ref<SinglePage>(); const selectedSinglePage = ref<SinglePage>();
const selectedSinglePageWithContent = ref<SinglePageRequest>();
const selectedPageNames = ref<string[]>([]); const selectedPageNames = ref<string[]>([]);
const checkedAll = ref(false); const checkedAll = ref(false);
const refreshInterval = ref(); const refreshInterval = ref();
@ -68,32 +61,44 @@ const handleFetchSinglePages = async () => {
loading.value = true; loading.value = true;
let contributors: string[] | undefined; let contributors: string[] | undefined;
const labelSelector: string[] = ["content.halo.run/deleted=false"];
if (selectedContributor.value) { if (selectedContributor.value) {
contributors = [selectedContributor.value.metadata.name]; contributors = [selectedContributor.value.metadata.name];
} }
if (selectedPublishStatusItem.value.value !== undefined) {
labelSelector.push(
`${singlePageLabels.PUBLISHED}=${selectedPublishStatusItem.value.value}`
);
}
const { data } = await apiClient.singlePage.listSinglePages({ const { data } = await apiClient.singlePage.listSinglePages({
labelSelector: [`content.halo.run/deleted=false`], labelSelector,
page: singlePages.value.page, page: singlePages.value.page,
size: singlePages.value.size, size: singlePages.value.size,
visible: selectedVisibleItem.value.value, visible: selectedVisibleItem.value.value,
sort: selectedSortItem.value?.sort, sort: selectedSortItem.value?.sort,
publishPhase: selectedPublishPhaseItem.value.value,
sortOrder: selectedSortItem.value?.sortOrder, sortOrder: selectedSortItem.value?.sortOrder,
keyword: keyword.value, keyword: keyword.value,
contributor: contributors, contributor: contributors,
}); });
singlePages.value = data; singlePages.value = data;
const deletedSinglePages = singlePages.value.items.filter( const abnormalSinglePages = singlePages.value.items.filter((singlePage) => {
(singlePage) => singlePage.page.spec.deleted const { spec, metadata, status } = singlePage.page;
); return (
spec.deleted ||
(spec.publish &&
metadata.labels?.[singlePageLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
if (deletedSinglePages.length) { if (abnormalSinglePages.length) {
refreshInterval.value = setInterval(() => { refreshInterval.value = setInterval(() => {
handleFetchSinglePages(); handleFetchSinglePages();
}, 3000); }, 1000);
} }
} catch (error) { } catch (error) {
console.error("Failed to fetch single pages", error); console.error("Failed to fetch single pages", error);
@ -129,28 +134,9 @@ const handleOpenSettingModal = async (singlePage: SinglePage) => {
const onSettingModalClose = () => { const onSettingModalClose = () => {
selectedSinglePage.value = undefined; selectedSinglePage.value = undefined;
selectedSinglePageWithContent.value = undefined;
handleFetchSinglePages(); handleFetchSinglePages();
}; };
watchEffect(async () => {
if (
!selectedSinglePage.value ||
!selectedSinglePage.value.spec.headSnapshot
) {
return;
}
const { data: content } = await apiClient.content.obtainSnapshotContent({
snapshotName: selectedSinglePage.value.spec.headSnapshot,
});
selectedSinglePageWithContent.value = {
page: selectedSinglePage.value,
content: content,
};
});
const handleSelectPrevious = async () => { const handleSelectPrevious = async () => {
const { items, hasPrevious } = singlePages.value; const { items, hasPrevious } = singlePages.value;
const index = items.findIndex( const index = items.findIndex(
@ -264,35 +250,24 @@ const handleDeleteInBatch = async () => {
}); });
}; };
const finalStatus = (singlePage: SinglePage) => { const getPublishStatus = (singlePage: SinglePage) => {
if (singlePage.status?.phase) { const { labels } = singlePage.metadata;
return SinglePagePhase[singlePage.status.phase]; return labels?.[singlePageLabels.PUBLISHED] === "true" ? "已发布" : "未发布";
} };
return "";
const isPublishing = (singlePage: SinglePage) => {
const { spec, status, metadata } = singlePage;
return (
(spec.publish &&
metadata.labels?.[singlePageLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
}; };
watch(selectedPageNames, (newValue) => { watch(selectedPageNames, (newValue) => {
checkedAll.value = newValue.length === singlePages.value.items?.length; checkedAll.value = newValue.length === singlePages.value.items?.length;
}); });
watchEffect(async () => {
if (
!selectedSinglePage.value ||
!selectedSinglePage.value.spec.headSnapshot
) {
return;
}
const { data: content } = await apiClient.content.obtainSnapshotContent({
snapshotName: selectedSinglePage.value.spec.headSnapshot,
});
selectedSinglePageWithContent.value = {
page: selectedSinglePage.value,
content: content,
};
});
onMounted(handleFetchSinglePages); onMounted(handleFetchSinglePages);
// Filters // Filters
@ -302,9 +277,9 @@ interface VisibleItem {
value?: "PUBLIC" | "INTERNAL" | "PRIVATE"; value?: "PUBLIC" | "INTERNAL" | "PRIVATE";
} }
interface PublishPhaseItem { interface PublishStatusItem {
label: string; label: string;
value?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED"; value?: boolean;
} }
interface SortItem { interface SortItem {
@ -332,22 +307,18 @@ const VisibleItems: VisibleItem[] = [
}, },
]; ];
const PublishPhaseItems: PublishPhaseItem[] = [ const PublishStatusItems: PublishStatusItem[] = [
{ {
label: "全部", label: "全部",
value: undefined, value: undefined,
}, },
{ {
label: "已发布", label: "已发布",
value: "PUBLISHED", value: true,
}, },
{ {
label: "未发布", label: "未发布",
value: "DRAFT", value: false,
},
{
label: "待审核",
value: "PENDING_APPROVAL",
}, },
]; ];
@ -376,7 +347,7 @@ const SortItems: SortItem[] = [
const selectedContributor = ref<User>(); const selectedContributor = ref<User>();
const selectedVisibleItem = ref<VisibleItem>(VisibleItems[0]); const selectedVisibleItem = ref<VisibleItem>(VisibleItems[0]);
const selectedPublishPhaseItem = ref<PublishPhaseItem>(PublishPhaseItems[0]); const selectedPublishStatusItem = ref<PublishStatusItem>(PublishStatusItems[0]);
const selectedSortItem = ref<SortItem>(); const selectedSortItem = ref<SortItem>();
const keyword = ref(""); const keyword = ref("");
@ -390,8 +361,8 @@ const handleSelectUser = (user?: User) => {
handleFetchSinglePages(); handleFetchSinglePages();
}; };
function handlePublishPhaseItemChange(publishPhaseItem: PublishPhaseItem) { function handlePublishStatusItemChange(publishStatusItem: PublishStatusItem) {
selectedPublishPhaseItem.value = publishPhaseItem; selectedPublishStatusItem.value = publishStatusItem;
handleFetchSinglePages(); handleFetchSinglePages();
} }
@ -404,7 +375,7 @@ function handleSortItemChange(sortItem?: SortItem) {
<template> <template>
<SinglePageSettingModal <SinglePageSettingModal
v-model:visible="settingModal" v-model:visible="settingModal"
:single-page="selectedSinglePageWithContent" :single-page="selectedSinglePage"
@close="onSettingModalClose" @close="onSettingModalClose"
> >
<template #actions> <template #actions>
@ -446,15 +417,15 @@ function handleSortItemChange(sortItem?: SortItem) {
@keyup.enter="handleFetchSinglePages" @keyup.enter="handleFetchSinglePages"
></FormKit> ></FormKit>
<div <div
v-if="selectedPublishPhaseItem.value" v-if="selectedPublishStatusItem.value"
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300" class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
> >
<span class="text-xs text-gray-600 group-hover:text-gray-900"> <span class="text-xs text-gray-600 group-hover:text-gray-900">
状态{{ selectedPublishPhaseItem.label }} 状态{{ selectedPublishStatusItem.label }}
</span> </span>
<IconCloseCircle <IconCloseCircle
class="h-4 w-4 text-gray-600" class="h-4 w-4 text-gray-600"
@click="handlePublishPhaseItemChange(PublishPhaseItems[0])" @click="handlePublishStatusItemChange(PublishStatusItems[0])"
/> />
</div> </div>
<div <div
@ -513,15 +484,16 @@ function handleSortItemChange(sortItem?: SortItem) {
<div class="w-72 p-4"> <div class="w-72 p-4">
<ul class="space-y-1"> <ul class="space-y-1">
<li <li
v-for="(filterItem, index) in PublishPhaseItems" v-for="(filterItem, index) in PublishStatusItems"
:key="index" :key="index"
v-close-popper v-close-popper
:class="{ :class="{
'bg-gray-100': 'bg-gray-100':
selectedPublishPhaseItem.value === filterItem.value, selectedPublishStatusItem.value ===
filterItem.value,
}" }"
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900" class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handlePublishPhaseItemChange(filterItem)" @click="handlePublishStatusItemChange(filterItem)"
> >
<span class="truncate">{{ filterItem.label }}</span> <span class="truncate">{{ filterItem.label }}</span>
</li> </li>
@ -713,7 +685,11 @@ function handleSortItemChange(sortItem?: SortItem) {
</RouterLink> </RouterLink>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField :description="finalStatus(singlePage.page)" /> <VEntityField :description="getPublishStatus(singlePage.page)">
<template v-if="isPublishing(singlePage.page)" #description>
<VStatusDot text="发布中" animate />
</template>
</VEntityField>
<VEntityField> <VEntityField>
<template #description> <template #description>
<IconEye <IconEye

View File

@ -1,71 +1,70 @@
<script lang="ts" setup> <script lang="ts" setup>
import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components"; import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import type { SinglePageRequest } from "@halo-dev/api-client"; import type { SinglePage } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme"; import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme";
import { singlePageLabels } from "@/constants/labels";
const initialFormState: SinglePageRequest = { const initialFormState: SinglePage = {
page: { spec: {
spec: { title: "",
title: "", slug: "",
slug: "", template: "",
template: "", cover: "",
cover: "", deleted: false,
deleted: false, publish: false,
published: false, publishTime: "",
publishTime: "", pinned: false,
pinned: false, allowComment: true,
allowComment: true, visible: "PUBLIC",
visible: "PUBLIC", version: 1,
version: 1, priority: 0,
priority: 0, excerpt: {
excerpt: { autoGenerate: true,
autoGenerate: true, raw: "",
raw: "",
},
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "SinglePage",
metadata: {
name: uuid(),
}, },
htmlMetas: [],
}, },
content: { apiVersion: "content.halo.run/v1alpha1",
raw: "", kind: "SinglePage",
content: "", metadata: {
rawType: "HTML", name: uuid(),
}, },
}; };
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
visible: boolean; visible: boolean;
singlePage?: SinglePageRequest; singlePage?: SinglePage;
publishSupport?: boolean;
onlyEmit?: boolean;
}>(), }>(),
{ {
visible: false, visible: false,
singlePage: undefined, singlePage: undefined,
publishSupport: true,
onlyEmit: false,
} }
); );
const emit = defineEmits<{ const emit = defineEmits<{
(event: "update:visible", visible: boolean): void; (event: "update:visible", visible: boolean): void;
(event: "close"): void; (event: "close"): void;
(event: "saved", singlePage: SinglePageRequest): void; (event: "saved", singlePage: SinglePage): void;
(event: "published", singlePage: SinglePage): void;
}>(); }>();
const activeTab = ref("general"); const activeTab = ref("general");
const formState = ref<SinglePageRequest>(cloneDeep(initialFormState)); const formState = ref<SinglePage>(cloneDeep(initialFormState));
const saving = ref(false); const saving = ref(false);
const publishing = ref(false); const publishing = ref(false);
const publishCanceling = ref(false); const publishCanceling = ref(false);
const isUpdateMode = computed(() => { const isUpdateMode = computed(() => {
return !!formState.value.page.metadata.creationTimestamp; return !!formState.value.metadata.creationTimestamp;
}); });
const onVisibleChange = (visible: boolean) => { const onVisibleChange = (visible: boolean) => {
@ -76,26 +75,33 @@ const onVisibleChange = (visible: boolean) => {
}; };
const handleSave = async () => { const handleSave = async () => {
if (props.onlyEmit) {
emit("saved", formState.value);
return;
}
try { try {
saving.value = true; saving.value = true;
// Set rendered content saving.value = true;
formState.value.content.content = formState.value.content.raw;
if (isUpdateMode.value) { const { data } = isUpdateMode.value
const { data } = await apiClient.singlePage.updateDraftSinglePage({ ? await apiClient.extension.singlePage.updatecontentHaloRunV1alpha1SinglePage(
name: formState.value.page.metadata.name, {
singlePageRequest: formState.value, name: formState.value.metadata.name,
}); singlePage: formState.value,
formState.value.page = data; }
emit("saved", formState.value); )
} else { : await apiClient.extension.singlePage.createcontentHaloRunV1alpha1SinglePage(
const { data } = await apiClient.singlePage.draftSinglePage({ {
singlePageRequest: formState.value, singlePage: formState.value,
}); }
formState.value.page = data; );
emit("saved", formState.value);
} formState.value = data;
emit("saved", data);
onVisibleChange(false);
} catch (error) { } catch (error) {
console.error("Failed to save single page", error); console.error("Failed to save single page", error);
} finally { } finally {
@ -103,55 +109,48 @@ const handleSave = async () => {
} }
}; };
const handlePublish = async () => { const handleSwitchPublish = async (publish: boolean) => {
if (props.onlyEmit) {
emit("published", formState.value);
return;
}
try { try {
publishing.value = true; if (publish) {
publishing.value = true;
} else {
publishCanceling.value = true;
}
// Save single page if (publish) {
await handleSave(); formState.value.spec.releaseSnapshot = formState.value.spec.headSnapshot;
}
// Get latest version single page const { data } =
const { data: latestData } = await apiClient.extension.singlePage.updatecontentHaloRunV1alpha1SinglePage(
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({ {
name: formState.value.page.metadata.name, name: formState.value.metadata.name,
}); singlePage: {
formState.value.page = latestData; ...formState.value,
spec: {
...formState.value.spec,
publish: publish,
},
},
}
);
// Publish single page formState.value = data;
const { data } = await apiClient.singlePage.publishSinglePage({
name: formState.value.page.metadata.name, if (publish) {
}); emit("published", data);
formState.value.page = data; }
emit("saved", formState.value);
onVisibleChange(false);
} catch (error) { } catch (error) {
console.error("Failed to publish single page", error); console.error("Failed to publish single page", error);
} finally { } finally {
publishing.value = false; publishing.value = false;
}
};
const handleCancelPublish = async () => {
try {
publishCanceling.value = true;
// Update published spec = false
const singlePageToUpdate = cloneDeep(formState.value);
singlePageToUpdate.page.spec.published = false;
await apiClient.singlePage.updateDraftSinglePage({
name: singlePageToUpdate.page.metadata.name,
singlePageRequest: singlePageToUpdate,
});
// Get latest version single page
const { data: latestData } =
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
name: formState.value.page.metadata.name,
});
formState.value.page = latestData;
emit("saved", formState.value);
} catch (error) {
console.error("Failed to cancel publish single page", error);
} finally {
publishCanceling.value = false; publishCanceling.value = false;
} }
}; };
@ -188,21 +187,21 @@ const { templates } = useThemeCustomTemplates("page");
type="form" type="form"
> >
<FormKit <FormKit
v-model="formState.page.spec.title" v-model="formState.spec.title"
label="标题" label="标题"
type="text" type="text"
name="title" name="title"
validation="required" validation="required"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.page.spec.slug" v-model="formState.spec.slug"
label="别名" label="别名"
name="slug" name="slug"
type="text" type="text"
validation="required" validation="required"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.page.spec.excerpt.autoGenerate" v-model="formState.spec.excerpt.autoGenerate"
:options="[ :options="[
{ label: '是', value: true }, { label: '是', value: true },
{ label: '否', value: false }, { label: '否', value: false },
@ -213,8 +212,8 @@ const { templates } = useThemeCustomTemplates("page");
> >
</FormKit> </FormKit>
<FormKit <FormKit
v-if="!formState.page.spec.excerpt.autoGenerate" v-if="!formState.spec.excerpt.autoGenerate"
v-model="formState.page.spec.excerpt.raw" v-model="formState.spec.excerpt.raw"
name="raw" name="raw"
label="自定义摘要" label="自定义摘要"
type="textarea" type="textarea"
@ -231,7 +230,7 @@ const { templates } = useThemeCustomTemplates("page");
type="form" type="form"
> >
<FormKit <FormKit
v-model="formState.page.spec.allowComment" v-model="formState.spec.allowComment"
:options="[ :options="[
{ label: '是', value: true }, { label: '是', value: true },
{ label: '否', value: false }, { label: '否', value: false },
@ -241,7 +240,7 @@ const { templates } = useThemeCustomTemplates("page");
type="radio" type="radio"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.page.spec.pinned" v-model="formState.spec.pinned"
:options="[ :options="[
{ label: '是', value: true }, { label: '是', value: true },
{ label: '否', value: false }, { label: '否', value: false },
@ -251,7 +250,7 @@ const { templates } = useThemeCustomTemplates("page");
type="radio" type="radio"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.page.spec.visible" v-model="formState.spec.visible"
:options="[ :options="[
{ label: '公开', value: 'PUBLIC' }, { label: '公开', value: 'PUBLIC' },
{ label: '内部成员可访问', value: 'INTERNAL' }, { label: '内部成员可访问', value: 'INTERNAL' },
@ -262,20 +261,20 @@ const { templates } = useThemeCustomTemplates("page");
type="select" type="select"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.page.spec.publishTime" v-model="formState.spec.publishTime"
label="发表时间" label="发表时间"
type="datetime-local" type="datetime-local"
name="publishTime" name="publishTime"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.page.spec.template" v-model="formState.spec.template"
:options="templates" :options="templates"
label="自定义模板" label="自定义模板"
type="select" type="select"
name="template" name="template"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.page.spec.cover" v-model="formState.spec.cover"
label="封面图" label="封面图"
type="attachment" type="attachment"
name="cover" name="cover"
@ -287,27 +286,28 @@ const { templates } = useThemeCustomTemplates("page");
<template #footer> <template #footer>
<VSpace> <VSpace>
<VButton :loading="publishing" type="secondary" @click="handlePublish"> <template v-if="publishSupport">
{{ <VButton
formState.page.status?.phase === "PUBLISHED" ? "重新发布" : "发布" v-if="
}} formState.metadata.labels?.[singlePageLabels.PUBLISHED] !== 'true'
</VButton> "
<VButton :loading="publishing"
:loading="saving" type="secondary"
size="sm" @click="handleSwitchPublish(true)"
type="secondary" >
@click="handleSave" 发布
> </VButton>
仅保存 <VButton
</VButton> v-else
<VButton :loading="publishCanceling"
v-if="formState.page.status?.phase === 'PUBLISHED'" type="danger"
:loading="publishCanceling" @click="handleSwitchPublish(false)"
type="danger" >
size="sm" 取消发布
@click="handleCancelPublish" </VButton>
> </template>
取消发布 <VButton :loading="saving" type="secondary" @click="handleSave">
保存
</VButton> </VButton>
<VButton size="sm" type="default" @click="onVisibleChange(false)"> <VButton size="sm" type="default" @click="onVisibleChange(false)">
关闭 关闭

View File

@ -2,6 +2,8 @@
import { import {
IconBookRead, IconBookRead,
IconSave, IconSave,
IconSettings,
IconSendPlaneFill,
VButton, VButton,
VPageHeader, VPageHeader,
VSpace, VSpace,
@ -9,12 +11,15 @@ import {
import DefaultEditor from "@/components/editor/DefaultEditor.vue"; import DefaultEditor from "@/components/editor/DefaultEditor.vue";
import PostSettingModal from "./components/PostSettingModal.vue"; import PostSettingModal from "./components/PostSettingModal.vue";
import PostPreviewModal from "./components/PostPreviewModal.vue"; import PostPreviewModal from "./components/PostPreviewModal.vue";
import type { PostRequest } from "@halo-dev/api-client"; import type { Post, PostRequest } from "@halo-dev/api-client";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { useRouter } from "vue-router";
const router = useRouter();
const initialFormState: PostRequest = { const initialFormState: PostRequest = {
post: { post: {
@ -24,7 +29,7 @@ const initialFormState: PostRequest = {
template: "", template: "",
cover: "", cover: "",
deleted: false, deleted: false,
published: false, publish: false,
publishTime: "", publishTime: "",
pinned: false, pinned: false,
allowComment: true, allowComment: true,
@ -56,6 +61,7 @@ const formState = ref<PostRequest>(cloneDeep(initialFormState));
const settingModal = ref(false); const settingModal = ref(false);
const previewModal = ref(false); const previewModal = ref(false);
const saving = ref(false); const saving = ref(false);
const publishing = ref(false);
const isUpdateMode = computed(() => { const isUpdateMode = computed(() => {
return !!formState.value.post.metadata.creationTimestamp; return !!formState.value.post.metadata.creationTimestamp;
@ -72,15 +78,25 @@ const handleSave = async () => {
if (!formState.value.post.spec.title) { if (!formState.value.post.spec.title) {
formState.value.post.spec.title = "无标题文章"; formState.value.post.spec.title = "无标题文章";
} }
if (!formState.value.post.spec.slug) { if (!formState.value.post.spec.slug) {
formState.value.post.spec.slug = uuid(); formState.value.post.spec.slug = uuid();
} }
if (isUpdateMode.value) { if (isUpdateMode.value) {
// Get latest post
const { data: latestPost } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: formState.value.post.metadata.name,
});
formState.value.post = latestPost;
const { data } = await apiClient.post.updateDraftPost({ const { data } = await apiClient.post.updateDraftPost({
name: formState.value.post.metadata.name, name: formState.value.post.metadata.name,
postRequest: formState.value, postRequest: formState.value,
}); });
formState.value.post = data; formState.value.post = data;
} else { } else {
const { data } = await apiClient.post.draftPost({ const { data } = await apiClient.post.draftPost({
@ -98,10 +114,77 @@ const handleSave = async () => {
} }
}; };
const handlePublish = async () => {
try {
publishing.value = true;
// Set rendered content
formState.value.content.content = formState.value.content.raw;
if (isUpdateMode.value) {
const { headSnapshot } = formState.value.post.spec;
const { name: postName } = formState.value.post.metadata;
const { data: latestContent } =
await apiClient.content.updateSnapshotContent({
snapshotName: headSnapshot as string,
contentRequest: {
raw: formState.value.content.raw as string,
content: formState.value.content.content as string,
rawType: formState.value.content.rawType as string,
headSnapshotName: headSnapshot,
subjectRef: {
kind: "Post",
version: "v1alpha1",
group: "content.halo.run",
name: postName,
},
},
});
// Get latest post
const { data: latestPost } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: postName,
});
formState.value.post = latestPost;
formState.value.post.spec.publish = true;
formState.value.post.spec.headSnapshot = latestContent.snapshotName;
formState.value.post.spec.releaseSnapshot =
formState.value.post.spec.headSnapshot;
await apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: postName,
post: formState.value.post,
});
} else {
formState.value.post.spec.publish = true;
await apiClient.post.draftPost({
postRequest: formState.value,
});
}
router.push({ name: "Posts" });
} catch (error) {
console.error("Failed to publish post", error);
} finally {
publishing.value = false;
}
};
const handlePublishClick = () => {
if (isUpdateMode.value) {
handlePublish();
} else {
settingModal.value = true;
}
};
const handleFetchContent = async () => { const handleFetchContent = async () => {
if (!formState.value.post.spec.headSnapshot) { if (!formState.value.post.spec.headSnapshot) {
return; return;
} }
const { data } = await apiClient.content.obtainSnapshotContent({ const { data } = await apiClient.content.obtainSnapshotContent({
snapshotName: formState.value.post.spec.headSnapshot, snapshotName: formState.value.post.spec.headSnapshot,
}); });
@ -109,14 +192,33 @@ const handleFetchContent = async () => {
formState.value.content = data; formState.value.content = data;
}; };
const onSettingSaved = (post: PostRequest) => { const handleOpenSettingModal = async () => {
const { data: latestPost } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: formState.value.post.metadata.name,
});
formState.value.post = latestPost;
settingModal.value = true;
};
const onSettingSaved = (post: Post) => {
// Set route query parameter // Set route query parameter
if (!isUpdateMode.value) { if (!isUpdateMode.value) {
name.value = post.post.metadata.name; name.value = post.metadata.name;
} }
formState.value = post; formState.value.post = post;
settingModal.value = false; settingModal.value = false;
if (!isUpdateMode.value) {
handleSave();
}
};
const onSettingPublished = (post: Post) => {
formState.value.post = post;
settingModal.value = false;
handlePublish();
}; };
// Get post data when the route contains the name parameter // Get post data when the route contains the name parameter
@ -139,8 +241,11 @@ onMounted(async () => {
<template> <template>
<PostSettingModal <PostSettingModal
v-model:visible="settingModal" v-model:visible="settingModal"
:post="formState" :post="formState.post"
:publish-support="!isUpdateMode"
:only-emit="!isUpdateMode"
@saved="onSettingSaved" @saved="onSettingSaved"
@published="onSettingPublished"
/> />
<PostPreviewModal v-model:visible="previewModal" :post="formState.post" /> <PostPreviewModal v-model:visible="previewModal" :post="formState.post" />
<VPageHeader title="文章"> <VPageHeader title="文章">
@ -159,12 +264,30 @@ onMounted(async () => {
预览 预览
</VButton> </VButton>
<VButton :loading="saving" size="sm" type="default" @click="handleSave"> <VButton :loading="saving" size="sm" type="default" @click="handleSave">
保存
</VButton>
<VButton type="secondary" @click="settingModal = true">
<template #icon> <template #icon>
<IconSave class="h-full w-full" /> <IconSave class="h-full w-full" />
</template> </template>
保存
</VButton>
<VButton
v-if="isUpdateMode"
size="sm"
type="default"
@click="handleOpenSettingModal"
>
<template #icon>
<IconSettings class="h-full w-full" />
</template>
设置
</VButton>
<VButton
type="secondary"
:loading="publishing"
@click="handlePublishClick"
>
<template #icon>
<IconSendPlaneFill class="h-full w-full" />
</template>
发布 发布
</VButton> </VButton>
</VSpace> </VSpace>

View File

@ -25,13 +25,12 @@ import {
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue"; import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
import PostSettingModal from "./components/PostSettingModal.vue"; import PostSettingModal from "./components/PostSettingModal.vue";
import PostTag from "../posts/tags/components/PostTag.vue"; import PostTag from "../posts/tags/components/PostTag.vue";
import { onMounted, ref, watch, watchEffect } from "vue"; import { onMounted, ref, watch } from "vue";
import type { import type {
User, User,
Category, Category,
ListedPostList, ListedPostList,
Post, Post,
PostRequest,
Tag, Tag,
} from "@halo-dev/api-client"; } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
@ -41,15 +40,10 @@ import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-t
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router"; import { onBeforeRouteLeave } from "vue-router";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { postLabels } from "@/constants/labels";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
enum PostPhase {
DRAFT = "未发布",
PENDING_APPROVAL = "待审核",
PUBLISHED = "已发布",
}
const posts = ref<ListedPostList>({ const posts = ref<ListedPostList>({
page: 1, page: 1,
size: 20, size: 20,
@ -62,8 +56,7 @@ const posts = ref<ListedPostList>({
}); });
const loading = ref(false); const loading = ref(false);
const settingModal = ref(false); const settingModal = ref(false);
const selectedPost = ref<Post | null>(null); const selectedPost = ref<Post>();
const selectedPostWithContent = ref<PostRequest | null>(null);
const checkedAll = ref(false); const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]); const selectedPostNames = ref<string[]>([]);
const refreshInterval = ref(); const refreshInterval = ref();
@ -77,6 +70,7 @@ const handleFetchPosts = async () => {
let categories: string[] | undefined; let categories: string[] | undefined;
let tags: string[] | undefined; let tags: string[] | undefined;
let contributors: string[] | undefined; let contributors: string[] | undefined;
const labelSelector: string[] = ["content.halo.run/deleted=false"];
if (selectedCategory.value) { if (selectedCategory.value) {
categories = [ categories = [
@ -93,12 +87,17 @@ const handleFetchPosts = async () => {
contributors = [selectedContributor.value.metadata.name]; contributors = [selectedContributor.value.metadata.name];
} }
if (selectedPublishStatusItem.value.value !== undefined) {
labelSelector.push(
`${postLabels.PUBLISHED}=${selectedPublishStatusItem.value.value}`
);
}
const { data } = await apiClient.post.listPosts({ const { data } = await apiClient.post.listPosts({
labelSelector: [`content.halo.run/deleted=false`], labelSelector,
page: posts.value.page, page: posts.value.page,
size: posts.value.size, size: posts.value.size,
visible: selectedVisibleItem.value?.value, visible: selectedVisibleItem.value?.value,
publishPhase: selectedPublishPhaseItem.value?.value,
sort: selectedSortItem.value?.sort, sort: selectedSortItem.value?.sort,
sortOrder: selectedSortItem.value?.sortOrder, sortOrder: selectedSortItem.value?.sortOrder,
keyword: keyword.value, keyword: keyword.value,
@ -108,14 +107,20 @@ const handleFetchPosts = async () => {
}); });
posts.value = data; posts.value = data;
const deletedPosts = posts.value.items.filter( // When an post is in the process of deleting or publishing, the list needs to be refreshed regularly
(post) => post.post.spec.deleted const abnormalPosts = posts.value.items.filter((post) => {
); const { spec, metadata, status } = post.post;
return (
spec.deleted ||
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
});
if (deletedPosts.length) { if (abnormalPosts.length) {
refreshInterval.value = setInterval(() => { refreshInterval.value = setInterval(() => {
handleFetchPosts(); handleFetchPosts();
}, 3000); }, 1000);
} }
} catch (e) { } catch (e) {
console.error("Failed to fetch posts", e); console.error("Failed to fetch posts", e);
@ -151,8 +156,7 @@ const handleOpenSettingModal = async (post: Post) => {
}; };
const onSettingModalClose = () => { const onSettingModalClose = () => {
selectedPost.value = null; selectedPost.value = undefined;
selectedPostWithContent.value = null;
handleFetchPosts(); handleFetchPosts();
}; };
@ -203,11 +207,17 @@ const checkSelection = (post: Post) => {
); );
}; };
const finalStatus = (post: Post) => { const getPublishStatus = (post: Post) => {
if (post.status?.phase) { const { labels } = post.metadata;
return PostPhase[post.status.phase]; return labels?.[postLabels.PUBLISHED] === "true" ? "已发布" : "未发布";
} };
return "";
const isPublishing = (post: Post) => {
const { spec, status, metadata } = post;
return (
(spec.publish && metadata.labels?.[postLabels.PUBLISHED] !== "true") ||
(spec.releaseSnapshot === spec.headSnapshot && status?.inProgress)
);
}; };
const handleCheckAllChange = (e: Event) => { const handleCheckAllChange = (e: Event) => {
@ -273,21 +283,6 @@ watch(selectedPostNames, (newValue) => {
checkedAll.value = newValue.length === posts.value.items?.length; checkedAll.value = newValue.length === posts.value.items?.length;
}); });
watchEffect(async () => {
if (!selectedPost.value || !selectedPost.value.spec.headSnapshot) {
return;
}
const { data: content } = await apiClient.content.obtainSnapshotContent({
snapshotName: selectedPost.value.spec.headSnapshot,
});
selectedPostWithContent.value = {
post: selectedPost.value,
content: content,
};
});
onMounted(() => { onMounted(() => {
handleFetchPosts(); handleFetchPosts();
}); });
@ -299,9 +294,9 @@ interface VisibleItem {
value?: "PUBLIC" | "INTERNAL" | "PRIVATE"; value?: "PUBLIC" | "INTERNAL" | "PRIVATE";
} }
interface PublishPhaseItem { interface PublishStatuItem {
label: string; label: string;
value?: "DRAFT" | "PENDING_APPROVAL" | "PUBLISHED"; value?: boolean;
} }
interface SortItem { interface SortItem {
@ -329,22 +324,18 @@ const VisibleItems: VisibleItem[] = [
}, },
]; ];
const PublishPhaseItems: PublishPhaseItem[] = [ const PublishStatuItems: PublishStatuItem[] = [
{ {
label: "全部", label: "全部",
value: undefined, value: undefined,
}, },
{ {
label: "已发布", label: "已发布",
value: "PUBLISHED", value: true,
}, },
{ {
label: "未发布", label: "未发布",
value: "DRAFT", value: false,
},
{
label: "待审核",
value: "PENDING_APPROVAL",
}, },
]; ];
@ -375,7 +366,7 @@ const { categories } = usePostCategory({ fetchOnMounted: true });
const { tags } = usePostTag({ fetchOnMounted: true }); const { tags } = usePostTag({ fetchOnMounted: true });
const selectedVisibleItem = ref<VisibleItem>(VisibleItems[0]); const selectedVisibleItem = ref<VisibleItem>(VisibleItems[0]);
const selectedPublishPhaseItem = ref<PublishPhaseItem>(PublishPhaseItems[0]); const selectedPublishStatusItem = ref<PublishStatuItem>(PublishStatuItems[0]);
const selectedSortItem = ref<SortItem>(); const selectedSortItem = ref<SortItem>();
const selectedCategory = ref<Category>(); const selectedCategory = ref<Category>();
const selectedTag = ref<Tag>(); const selectedTag = ref<Tag>();
@ -387,8 +378,8 @@ function handleVisibleItemChange(visibleItem: VisibleItem) {
handleFetchPosts(); handleFetchPosts();
} }
function handlePublishPhaseItemChange(publishPhaseItem: PublishPhaseItem) { function handlePublishStatusItemChange(publishStatusItem: PublishStatuItem) {
selectedPublishPhaseItem.value = publishPhaseItem; selectedPublishStatusItem.value = publishStatusItem;
handleFetchPosts(); handleFetchPosts();
} }
@ -415,7 +406,7 @@ function handleContributorChange(user?: User) {
<template> <template>
<PostSettingModal <PostSettingModal
v-model:visible="settingModal" v-model:visible="settingModal"
:post="selectedPostWithContent" :post="selectedPost"
@close="onSettingModalClose" @close="onSettingModalClose"
> >
<template #actions> <template #actions>
@ -482,15 +473,15 @@ function handleContributorChange(user?: User) {
></FormKit> ></FormKit>
<div <div
v-if="selectedPublishPhaseItem.value" v-if="selectedPublishStatusItem.value"
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300" class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
> >
<span class="text-xs text-gray-600 group-hover:text-gray-900"> <span class="text-xs text-gray-600 group-hover:text-gray-900">
状态{{ selectedPublishPhaseItem.label }} 状态{{ selectedPublishStatusItem.label }}
</span> </span>
<IconCloseCircle <IconCloseCircle
class="h-4 w-4 text-gray-600" class="h-4 w-4 text-gray-600"
@click="handlePublishPhaseItemChange(PublishPhaseItems[0])" @click="handlePublishStatusItemChange(PublishStatuItems[0])"
/> />
</div> </div>
<div <div
@ -576,16 +567,16 @@ function handleContributorChange(user?: User) {
<div class="w-72 p-4"> <div class="w-72 p-4">
<ul class="space-y-1"> <ul class="space-y-1">
<li <li
v-for="(filterItem, index) in PublishPhaseItems" v-for="(filterItem, index) in PublishStatuItems"
:key="index" :key="index"
v-close-popper v-close-popper
:class="{ :class="{
'bg-gray-100': 'bg-gray-100':
selectedPublishPhaseItem.value === selectedPublishStatusItem.value ===
filterItem.value, filterItem.value,
}" }"
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900" class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handlePublishPhaseItemChange(filterItem)" @click="handlePublishStatusItemChange(filterItem)"
> >
<span class="truncate">{{ filterItem.label }}</span> <span class="truncate">{{ filterItem.label }}</span>
</li> </li>
@ -933,9 +924,11 @@ function handleContributorChange(user?: User) {
</RouterLink> </RouterLink>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField <VEntityField :description="getPublishStatus(post.post)">
:description="finalStatus(post.post)" <template v-if="isPublishing(post.post)" #description>
></VEntityField> <VStatusDot text="发布中" animate />
</template>
</VEntityField>
<VEntityField> <VEntityField>
<template #description> <template #description>
<IconEye <IconEye

View File

@ -1,73 +1,72 @@
<script lang="ts" setup> <script lang="ts" setup>
import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components"; import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components";
import { computed, ref, watchEffect } from "vue"; import { computed, ref, watchEffect } from "vue";
import type { PostRequest } from "@halo-dev/api-client"; import type { Post } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep"; import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme"; import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme";
import { postLabels } from "@/constants/labels";
const initialFormState: PostRequest = { const initialFormState: Post = {
post: { spec: {
spec: { title: "",
title: "", slug: "",
slug: "", template: "",
template: "", cover: "",
cover: "", deleted: false,
deleted: false, publish: false,
published: false, publishTime: "",
publishTime: "", pinned: false,
pinned: false, allowComment: true,
allowComment: true, visible: "PUBLIC",
visible: "PUBLIC", version: 1,
version: 1, priority: 0,
priority: 0, excerpt: {
excerpt: { autoGenerate: true,
autoGenerate: true, raw: "",
raw: "",
},
categories: [],
tags: [],
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
name: uuid(),
}, },
categories: [],
tags: [],
htmlMetas: [],
}, },
content: { apiVersion: "content.halo.run/v1alpha1",
raw: "", kind: "Post",
content: "", metadata: {
rawType: "HTML", name: uuid(),
}, },
}; };
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
visible: boolean; visible: boolean;
post?: PostRequest | null; post?: Post;
publishSupport?: boolean;
onlyEmit?: boolean;
}>(), }>(),
{ {
visible: false, visible: false,
post: null, post: undefined,
publishSupport: true,
onlyEmit: false,
} }
); );
const emit = defineEmits<{ const emit = defineEmits<{
(event: "update:visible", visible: boolean): void; (event: "update:visible", visible: boolean): void;
(event: "close"): void; (event: "close"): void;
(event: "saved", post: PostRequest): void; (event: "saved", post: Post): void;
(event: "published", post: Post): void;
}>(); }>();
const activeTab = ref("general"); const activeTab = ref("general");
const formState = ref<PostRequest>(cloneDeep(initialFormState)); const formState = ref<Post>(cloneDeep(initialFormState));
const saving = ref(false); const saving = ref(false);
const publishing = ref(false); const publishing = ref(false);
const publishCanceling = ref(false); const publishCanceling = ref(false);
const isUpdateMode = computed(() => { const isUpdateMode = computed(() => {
return !!formState.value.post.metadata.creationTimestamp; return !!formState.value.metadata.creationTimestamp;
}); });
const handleVisibleChange = (visible: boolean) => { const handleVisibleChange = (visible: boolean) => {
@ -78,26 +77,27 @@ const handleVisibleChange = (visible: boolean) => {
}; };
const handleSave = async () => { const handleSave = async () => {
if (props.onlyEmit) {
emit("saved", formState.value);
return;
}
try { try {
saving.value = true; saving.value = true;
// Set rendered content const { data } = isUpdateMode.value
formState.value.content.content = formState.value.content.raw; ? await apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: formState.value.metadata.name,
post: formState.value,
})
: await apiClient.extension.post.createcontentHaloRunV1alpha1Post({
post: formState.value,
});
if (isUpdateMode.value) { formState.value = data;
const { data } = await apiClient.post.updateDraftPost({ emit("saved", data);
name: formState.value.post.metadata.name,
postRequest: formState.value, handleVisibleChange(false);
});
formState.value.post = data;
emit("saved", formState.value);
} else {
const { data } = await apiClient.post.draftPost({
postRequest: formState.value,
});
formState.value.post = data;
emit("saved", formState.value);
}
} catch (e) { } catch (e) {
console.error("Failed to save post", e); console.error("Failed to save post", e);
} finally { } finally {
@ -105,56 +105,46 @@ const handleSave = async () => {
} }
}; };
const handlePublish = async () => { const handleSwitchPublish = async (publish: boolean) => {
if (props.onlyEmit) {
emit("published", formState.value);
return;
}
try { try {
publishing.value = true; if (publish) {
publishing.value = true;
} else {
publishCanceling.value = true;
}
// Save post if (publish) {
await handleSave(); formState.value.spec.releaseSnapshot = formState.value.spec.headSnapshot;
}
// Get latest version post const { data } =
const { data: latestData } = await apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({ name: formState.value.metadata.name,
name: formState.value.post.metadata.name, post: {
...formState.value,
spec: {
...formState.value.spec,
publish: publish,
},
},
}); });
formState.value.post = latestData;
// Publish post formState.value = data;
const { data } = await apiClient.post.publishPost({
name: formState.value.post.metadata.name, if (publish) {
}); emit("published", data);
formState.value.post = data; }
emit("saved", formState.value);
handleVisibleChange(false);
} catch (e) { } catch (e) {
console.error("Failed to publish post", e); console.error("Failed to publish post", e);
} finally { } finally {
publishing.value = false; publishing.value = false;
}
};
const handlePublishCanceling = async () => {
try {
publishCanceling.value = true;
// Update published spec = false
const postToUpdate = cloneDeep(formState.value);
postToUpdate.post.spec.published = false;
await apiClient.post.updateDraftPost({
name: postToUpdate.post.metadata.name,
postRequest: postToUpdate,
});
// Get latest version post
const { data: latestData } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: formState.value.post.metadata.name,
});
formState.value.post = latestData;
emit("saved", formState.value);
} catch (e) {
console.log("Failed to cancel publish", e);
} finally {
publishCanceling.value = false; publishCanceling.value = false;
} }
}; };
@ -190,33 +180,33 @@ const { templates } = useThemeCustomTemplates("post");
type="form" type="form"
> >
<FormKit <FormKit
v-model="formState.post.spec.title" v-model="formState.spec.title"
label="标题" label="标题"
type="text" type="text"
name="title" name="title"
validation="required" validation="required"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.post.spec.slug" v-model="formState.spec.slug"
label="别名" label="别名"
name="slug" name="slug"
type="text" type="text"
validation="required" validation="required"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.post.spec.categories" v-model="formState.spec.categories"
label="分类目录" label="分类目录"
name="categories" name="categories"
type="categoryCheckbox" type="categoryCheckbox"
/> />
<FormKit <FormKit
v-model="formState.post.spec.tags" v-model="formState.spec.tags"
label="标签" label="标签"
name="tags" name="tags"
type="tagCheckbox" type="tagCheckbox"
/> />
<FormKit <FormKit
v-model="formState.post.spec.excerpt.autoGenerate" v-model="formState.spec.excerpt.autoGenerate"
:options="[ :options="[
{ label: '是', value: true }, { label: '是', value: true },
{ label: '否', value: false }, { label: '否', value: false },
@ -227,8 +217,8 @@ const { templates } = useThemeCustomTemplates("post");
> >
</FormKit> </FormKit>
<FormKit <FormKit
v-if="!formState.post.spec.excerpt.autoGenerate" v-if="!formState.spec.excerpt.autoGenerate"
v-model="formState.post.spec.excerpt.raw" v-model="formState.spec.excerpt.raw"
label="自定义摘要" label="自定义摘要"
name="raw" name="raw"
type="textarea" type="textarea"
@ -245,7 +235,7 @@ const { templates } = useThemeCustomTemplates("post");
type="form" type="form"
> >
<FormKit <FormKit
v-model="formState.post.spec.allowComment" v-model="formState.spec.allowComment"
:options="[ :options="[
{ label: '是', value: true }, { label: '是', value: true },
{ label: '否', value: false }, { label: '否', value: false },
@ -254,7 +244,7 @@ const { templates } = useThemeCustomTemplates("post");
type="radio" type="radio"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.post.spec.pinned" v-model="formState.spec.pinned"
:options="[ :options="[
{ label: '是', value: true }, { label: '是', value: true },
{ label: '否', value: false }, { label: '否', value: false },
@ -264,7 +254,7 @@ const { templates } = useThemeCustomTemplates("post");
type="radio" type="radio"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.post.spec.visible" v-model="formState.spec.visible"
:options="[ :options="[
{ label: '公开', value: 'PUBLIC' }, { label: '公开', value: 'PUBLIC' },
{ label: '内部成员可访问', value: 'INTERNAL' }, { label: '内部成员可访问', value: 'INTERNAL' },
@ -275,19 +265,19 @@ const { templates } = useThemeCustomTemplates("post");
type="select" type="select"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.post.spec.publishTime" v-model="formState.spec.publishTime"
label="发表时间" label="发表时间"
type="datetime-local" type="datetime-local"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.post.spec.template" v-model="formState.spec.template"
:options="templates" :options="templates"
label="自定义模板" label="自定义模板"
name="template" name="template"
type="select" type="select"
></FormKit> ></FormKit>
<FormKit <FormKit
v-model="formState.post.spec.cover" v-model="formState.spec.cover"
name="cover" name="cover"
label="封面图" label="封面图"
type="attachment" type="attachment"
@ -299,29 +289,28 @@ const { templates } = useThemeCustomTemplates("post");
<template #footer> <template #footer>
<VSpace> <VSpace>
<VButton :loading="publishing" type="secondary" @click="handlePublish"> <template v-if="publishSupport">
{{ <VButton
formState.post.status?.phase === "PUBLISHED" ? "重新发布" : "发布" v-if="formState.metadata.labels?.[postLabels.PUBLISHED] !== 'true'"
}} :loading="publishing"
type="secondary"
@click="handleSwitchPublish(true)"
>
发布
</VButton>
<VButton
v-else
:loading="publishCanceling"
type="danger"
@click="handleSwitchPublish(false)"
>
取消发布
</VButton>
</template>
<VButton :loading="saving" type="secondary" @click="handleSave">
保存
</VButton> </VButton>
<VButton <VButton type="default" @click="handleVisibleChange(false)">
:loading="saving"
size="sm"
type="secondary"
@click="handleSave"
>
仅保存
</VButton>
<VButton
v-if="formState.post.status?.phase === 'PUBLISHED'"
:loading="publishCanceling"
type="danger"
size="sm"
@click="handlePublishCanceling"
>
取消发布
</VButton>
<VButton size="sm" type="default" @click="handleVisibleChange(false)">
关闭 关闭
</VButton> </VButton>
</VSpace> </VSpace>

View File

@ -4,14 +4,16 @@ import { onMounted, ref } from "vue";
import type { ListedPost } from "@halo-dev/api-client"; import type { ListedPost } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { postLabels } from "@/constants/labels";
const posts = ref<ListedPost[]>([] as ListedPost[]); const posts = ref<ListedPost[]>([] as ListedPost[]);
const handleFetchPosts = async () => { const handleFetchPosts = async () => {
try { try {
const { data } = await apiClient.post.listPosts({ const { data } = await apiClient.post.listPosts({
labelSelector: [`${postLabels.PUBLISHED}=true`],
sort: "PUBLISH_TIME", sort: "PUBLISH_TIME",
publishPhase: "PUBLISHED", sortOrder: false,
page: 1, page: 1,
size: 10, size: 10,
}); });

View File

@ -50,25 +50,11 @@ const handleSubmit = async () => {
}); });
await apiClient.post.draftPost({ postRequest: post as PostRequest }); await apiClient.post.draftPost({ postRequest: post as PostRequest });
try {
await apiClient.post.publishPost({ name: post.post.metadata.name });
} catch (error) {
console.error("Failed to publish post", error);
}
// Create singlePage // Create singlePage
await apiClient.singlePage.draftSinglePage({ await apiClient.singlePage.draftSinglePage({
singlePageRequest: singlePage as SinglePageRequest, singlePageRequest: singlePage as SinglePageRequest,
}); });
try {
await apiClient.singlePage.publishSinglePage({
name: singlePage.page.metadata.name,
});
} catch (error) {
console.error("Failed to publish singlePage", error);
}
// Create menu and menu items // Create menu and menu items
const menuItemPromises = menuItems.map((item) => { const menuItemPromises = menuItems.map((item) => {
return apiClient.extension.menuItem.createv1alpha1MenuItem({ return apiClient.extension.menuItem.createv1alpha1MenuItem({

View File

@ -6,7 +6,7 @@
"template": "", "template": "",
"cover": "", "cover": "",
"deleted": false, "deleted": false,
"published": false, "publish": true,
"publishTime": "", "publishTime": "",
"pinned": false, "pinned": false,
"allowComment": true, "allowComment": true,

View File

@ -6,7 +6,7 @@
"template": "", "template": "",
"cover": "", "cover": "",
"deleted": false, "deleted": false,
"published": false, "publish": true,
"publishTime": "", "publishTime": "",
"pinned": false, "pinned": false,
"allowComment": true, "allowComment": true,