feat: support for managing recycle bin posts and single pages (#677)

#### What type of PR is this?

/kind feature
/milestone 2.0

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

支持管理已删除的文章和自定义页面,优化删除的逻辑。

Fixes https://github.com/halo-dev/halo/issues/2647

#### Special notes for your reviewer:

/cc @halo-dev/sig-halo-console 

测试方式:

1. Halo 需要切换到 https://github.com/halo-dev/halo/pull/2648
2. 测试文章和自定义页面的删除功能,以及回收站的管理功能。

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

```release-note
支持管理已删除的文章和自定义页面,优化删除的逻辑。
```
pull/670/head
Ryan Wang 2022-11-02 17:48:23 +08:00 committed by GitHub
parent ff26058fc0
commit e3fc14abad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 1000 additions and 27 deletions

View File

@ -0,0 +1,413 @@
<script lang="ts" setup>
import {
IconAddCircle,
IconRefreshLine,
IconDeleteBin,
VButton,
VCard,
VPagination,
VSpace,
Dialog,
VEmpty,
VAvatar,
VEntity,
VEntityField,
VPageHeader,
VStatusDot,
} from "@halo-dev/components";
import { onMounted, ref, watch } from "vue";
import type { ListedSinglePageList, SinglePage } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date";
import { onBeforeRouteLeave, RouterLink } from "vue-router";
import cloneDeep from "lodash.clonedeep";
import { usePermission } from "@/utils/permission";
const { currentUserHasPermission } = usePermission();
const singlePages = ref<ListedSinglePageList>({
page: 1,
size: 50,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
});
const loading = ref(false);
const selectedPageNames = ref<string[]>([]);
const checkedAll = ref(false);
const refreshInterval = ref();
const keyword = ref("");
const handleFetchSinglePages = async () => {
try {
clearInterval(refreshInterval.value);
loading.value = true;
const { data } = await apiClient.singlePage.listSinglePages({
labelSelector: [`content.halo.run/deleted=true`],
page: singlePages.value.page,
size: singlePages.value.size,
keyword: keyword.value,
});
singlePages.value = data;
const deletedSinglePages = singlePages.value.items.filter(
(singlePage) =>
!!singlePage.page.metadata.deletionTimestamp ||
!singlePage.page.spec.deleted
);
if (deletedSinglePages.length) {
refreshInterval.value = setInterval(() => {
handleFetchSinglePages();
}, 3000);
}
} catch (error) {
console.error("Failed to fetch deleted single pages", error);
} finally {
loading.value = false;
}
};
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
});
const handlePaginationChange = ({
page,
size,
}: {
page: number;
size: number;
}) => {
singlePages.value.page = page;
singlePages.value.size = size;
handleFetchSinglePages();
};
const checkSelection = (singlePage: SinglePage) => {
return selectedPageNames.value.includes(singlePage.metadata.name);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPageNames.value =
singlePages.value.items.map((singlePage) => {
return singlePage.page.metadata.name;
}) || [];
} else {
selectedPageNames.value = [];
}
};
const handleDeletePermanently = async (singlePage: SinglePage) => {
Dialog.warning({
title: "是否确认永久删除该自定义页面?",
description: "删除之后将无法恢复",
confirmType: "danger",
onConfirm: async () => {
await apiClient.extension.singlePage.deletecontentHaloRunV1alpha1SinglePage(
{
name: singlePage.metadata.name,
}
);
await handleFetchSinglePages();
},
});
};
const handleDeletePermanentlyInBatch = async () => {
Dialog.warning({
title: "是否确认永久删除选中的自定义页面?",
description: "删除之后将无法恢复",
confirmType: "danger",
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
return apiClient.extension.singlePage.deletecontentHaloRunV1alpha1SinglePage(
{
name,
}
);
})
);
await handleFetchSinglePages();
selectedPageNames.value = [];
},
});
};
const handleRecovery = async (singlePage: SinglePage) => {
Dialog.warning({
title: "是否确认恢复该自定义页面?",
description: "此操作会将自定义页面恢复到被删除之前的状态",
onConfirm: async () => {
const singlePageToUpdate = cloneDeep(singlePage);
singlePageToUpdate.spec.deleted = false;
await apiClient.extension.singlePage.updatecontentHaloRunV1alpha1SinglePage(
{
name: singlePageToUpdate.metadata.name,
singlePage: singlePageToUpdate,
}
);
await handleFetchSinglePages();
},
});
};
const handleRecoveryInBatch = async () => {
Dialog.warning({
title: "是否确认恢复选中的自定义页面?",
description: "此操作会将自定义页面恢复到被删除之前的状态",
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
const singlePage = singlePages.value.items.find(
(item) => item.page.metadata.name === name
)?.page;
if (!singlePage) {
return Promise.resolve();
}
singlePage.spec.deleted = false;
return apiClient.extension.singlePage.updatecontentHaloRunV1alpha1SinglePage(
{
name: singlePage.metadata.name,
singlePage: singlePage,
}
);
})
);
await handleFetchSinglePages();
selectedPageNames.value = [];
},
});
};
watch(selectedPageNames, (newValue) => {
checkedAll.value = newValue.length === singlePages.value.items?.length;
});
onMounted(handleFetchSinglePages);
</script>
<template>
<VPageHeader title="自定义页面回收站">
<template #icon>
<IconDeleteBin class="mr-2 self-center text-green-600" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'SinglePages' }" size="sm">返回</VButton>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div
v-permission="['system:singlepages:manage']"
class="mr-4 hidden items-center sm:flex"
>
<input
v-model="checkedAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<div
v-if="!selectedPageNames.length"
class="flex items-center gap-2"
>
<FormKit
v-model="keyword"
placeholder="输入关键词搜索"
type="text"
@keyup.enter="handleFetchSinglePages"
></FormKit>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeletePermanentlyInBatch">
永久删除
</VButton>
<VButton type="default" @click="handleRecoveryInBatch">
恢复
</VButton>
</VSpace>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg">
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchSinglePages"
>
<IconRefreshLine
:class="{ 'animate-spin text-gray-900': loading }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</div>
</template>
<VEmpty
v-if="!singlePages.items.length && !loading"
message="你可以尝试刷新或者返回自定义页面管理"
title="没有自定义页面被放入回收站"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchSinglePages"></VButton>
<VButton
v-permission="['system:singlepages:view']"
:route="{ name: 'SinglePages' }"
type="primary"
>
返回
</VButton>
</VSpace>
</template>
</VEmpty>
<ul
v-else
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(singlePage, index) in singlePages.items" :key="index">
<VEntity :is-selected="checkSelection(singlePage.page)">
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#checkbox
>
<input
v-model="selectedPageNames"
:value="singlePage.page.metadata.name"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
/>
</template>
<template #start>
<VEntityField :title="singlePage.page.spec.title">
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
{{ singlePage.page.status?.permalink }}
</span>
<span class="text-xs text-gray-500">
访问量 {{ singlePage.stats.visit || 0 }}
</span>
<span class="text-xs text-gray-500">
评论 {{ singlePage.stats.totalComment || 0 }}
</span>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<RouterLink
v-for="(
contributor, contributorIndex
) in singlePage.contributors"
:key="contributorIndex"
:to="{
name: 'UserDetail',
params: { name: contributor.name },
}"
class="flex items-center"
>
<VAvatar
v-tooltip="contributor.displayName"
size="xs"
:src="contributor.avatar"
:alt="contributor.displayName"
circle
></VAvatar>
</RouterLink>
</template>
</VEntityField>
<VEntityField v-if="!singlePage?.page?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="success" animate />
</template>
</VEntityField>
<VEntityField v-if="singlePage?.page?.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(singlePage.page.spec.publishTime) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#dropdownItems
>
<VButton
v-close-popper
block
type="danger"
@click="handleDeletePermanently(singlePage.page)"
>
永久删除
</VButton>
<VButton
v-close-popper
block
type="default"
@click="handleRecovery(singlePage.page)"
>
恢复
</VButton>
</template>
</VEntity>
</li>
</ul>
<template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination
:page="singlePages.page"
:size="singlePages.size"
:total="singlePages.total"
:size-options="[20, 30, 50, 100]"
@change="handlePaginationChange"
/>
</div>
</template>
</VCard>
</div>
</template>

View File

@ -22,7 +22,7 @@ import {
} from "@halo-dev/components";
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
import { onMounted, ref, watchEffect } from "vue";
import { onMounted, ref, watch, watchEffect } from "vue";
import type {
ListedSinglePageList,
SinglePage,
@ -57,7 +57,8 @@ const loading = ref(false);
const settingModal = ref(false);
const selectedSinglePage = ref<SinglePage>();
const selectedSinglePageWithContent = ref<SinglePageRequest>();
const checkAll = ref(false);
const selectedPageNames = ref<string[]>([]);
const checkedAll = ref(false);
const refreshInterval = ref();
const handleFetchSinglePages = async () => {
@ -73,6 +74,7 @@ const handleFetchSinglePages = async () => {
}
const { data } = await apiClient.singlePage.listSinglePages({
labelSelector: [`content.halo.run/deleted=false`],
page: singlePages.value.page,
size: singlePages.value.size,
visible: selectedVisibleItem.value.value,
@ -85,7 +87,7 @@ const handleFetchSinglePages = async () => {
singlePages.value = data;
const deletedSinglePages = singlePages.value.items.filter(
(singlePage) => !!singlePage.page.metadata.deletionTimestamp
(singlePage) => singlePage.page.spec.deleted
);
if (deletedSinglePages.length) {
@ -192,9 +194,30 @@ const handleSelectNext = async () => {
}
};
const checkSelection = (singlePage: SinglePage) => {
return (
singlePage.metadata.name === selectedSinglePage.value?.metadata.name ||
selectedPageNames.value.includes(singlePage.metadata.name)
);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPageNames.value =
singlePages.value.items.map((singlePage) => {
return singlePage.page.metadata.name;
}) || [];
} else {
selectedPageNames.value = [];
}
};
const handleDelete = async (singlePage: SinglePage) => {
Dialog.warning({
title: "是否确认删除该自定义页面?",
description: "此操作会将自定义页面放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
const singlePageToUpdate = cloneDeep(singlePage);
@ -210,6 +233,37 @@ const handleDelete = async (singlePage: SinglePage) => {
});
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "是否确认删除选中的自定义页面?",
description: "此操作会将自定义页面放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
await Promise.all(
selectedPageNames.value.map((name) => {
const page = singlePages.value.items.find(
(item) => item.page.metadata.name === name
)?.page;
if (!page) {
return Promise.resolve();
}
page.spec.deleted = true;
return apiClient.extension.singlePage.updatecontentHaloRunV1alpha1SinglePage(
{
name: page.metadata.name,
singlePage: page,
}
);
})
);
await handleFetchSinglePages();
selectedPageNames.value = [];
},
});
};
const finalStatus = (singlePage: SinglePage) => {
if (singlePage.status?.phase) {
return SinglePagePhase[singlePage.status.phase];
@ -217,6 +271,28 @@ const finalStatus = (singlePage: SinglePage) => {
return "";
};
watch(selectedPageNames, (newValue) => {
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);
// Filters
@ -351,15 +427,20 @@ function handleSortItemChange(sortItem?: SortItem) {
class="mr-4 hidden items-center sm:flex"
>
<input
v-model="checkAll"
v-model="checkedAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<div v-if="!checkAll" class="flex items-center gap-2">
<div
v-if="!selectedPageNames.length"
class="flex items-center gap-2"
>
<FormKit
v-model="keyword"
outer-class="!p-0"
placeholder="输入关键词搜索"
type="text"
@keyup.enter="handleFetchSinglePages"
@ -414,8 +495,7 @@ function handleSortItemChange(sortItem?: SortItem) {
</div>
</div>
<VSpace v-else>
<VButton type="default">设置</VButton>
<VButton type="danger">删除</VButton>
<VButton type="danger" @click="handleDeleteInBatch"></VButton>
</VSpace>
</div>
<div class="mt-4 flex sm:mt-0">
@ -561,13 +641,14 @@ function handleSortItemChange(sortItem?: SortItem) {
role="list"
>
<li v-for="(singlePage, index) in singlePages.items" :key="index">
<VEntity :is-selected="checkAll">
<VEntity :is-selected="checkSelection(singlePage.page)">
<template
v-if="currentUserHasPermission(['system:singlepages:manage'])"
#checkbox
>
<input
v-model="checkAll"
v-model="selectedPageNames"
:value="singlePage.page.metadata.name"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
/>
@ -652,6 +733,11 @@ function handleSortItemChange(sortItem?: SortItem) {
/>
</template>
</VEntityField>
<VEntityField v-if="singlePage?.page?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">

View File

@ -7,6 +7,7 @@ import {
VTabbar,
IconPages,
VButton,
VSpace,
} from "@halo-dev/components";
import BasicLayout from "@/layouts/BasicLayout.vue";
import { useRoute, useRouter } from "vue-router";
@ -70,16 +71,21 @@ watchEffect(() => {
<IconPages class="mr-2 self-center" />
</template>
<template #actions>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
<VSpace>
<VButton :route="{ name: 'DeletedSinglePages' }" size="sm">
回收站
</VButton>
<VButton
v-permission="['system:singlepages:manage']"
:route="{ name: 'SinglePageEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">

View File

@ -4,6 +4,7 @@ import BlankLayout from "@/layouts/BlankLayout.vue";
import PageLayout from "./layouts/PageLayout.vue";
import FunctionalPageList from "./FunctionalPageList.vue";
import SinglePageList from "./SinglePageList.vue";
import DeletedSinglePageList from "./DeletedSinglePageList.vue";
import SinglePageEditor from "./SinglePageEditor.vue";
import { IconPages } from "@halo-dev/components";
import { markRaw } from "vue";
@ -63,6 +64,22 @@ export default definePlugin({
},
],
},
{
path: "deleted",
component: BasicLayout,
children: [
{
path: "",
name: "DeletedSinglePages",
component: DeletedSinglePageList,
meta: {
title: "自定义页面回收站",
searchable: true,
permissions: ["system:singlepages:view"],
},
},
],
},
{
path: "editor",
component: BasicLayout,

View File

@ -0,0 +1,421 @@
<script lang="ts" setup>
import {
IconAddCircle,
IconDeleteBin,
IconRefreshLine,
Dialog,
VButton,
VCard,
VEmpty,
VPageHeader,
VPagination,
VSpace,
VAvatar,
VStatusDot,
VEntity,
VEntityField,
} from "@halo-dev/components";
import PostTag from "./tags/components/PostTag.vue";
import { onMounted, ref, watch } from "vue";
import type { ListedPostList, Post } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import cloneDeep from "lodash.clonedeep";
const { currentUserHasPermission } = usePermission();
const posts = ref<ListedPostList>({
page: 1,
size: 50,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
});
const loading = ref(false);
const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]);
const refreshInterval = ref();
const keyword = ref("");
const handleFetchPosts = async () => {
try {
clearInterval(refreshInterval.value);
loading.value = true;
const { data } = await apiClient.post.listPosts({
labelSelector: [`content.halo.run/deleted=true`],
page: posts.value.page,
size: posts.value.size,
keyword: keyword.value,
});
posts.value = data;
const deletedPosts = posts.value.items.filter(
(post) =>
!!post.post.metadata.deletionTimestamp || !post.post.spec.deleted
);
if (deletedPosts.length) {
refreshInterval.value = setInterval(() => {
handleFetchPosts();
}, 3000);
}
} catch (e) {
console.error("Failed to fetch deleted posts", e);
} finally {
loading.value = false;
}
};
onBeforeRouteLeave(() => {
clearInterval(refreshInterval.value);
});
const handlePaginationChange = ({
page,
size,
}: {
page: number;
size: number;
}) => {
posts.value.page = page;
posts.value.size = size;
handleFetchPosts();
};
const checkSelection = (post: Post) => {
return selectedPostNames.value.includes(post.metadata.name);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPostNames.value =
posts.value.items.map((post) => {
return post.post.metadata.name;
}) || [];
} else {
selectedPostNames.value = [];
}
};
const handleDeletePermanently = async (post: Post) => {
Dialog.warning({
title: "是否确认永久删除该文章?",
description: "删除之后将无法恢复",
confirmType: "danger",
onConfirm: async () => {
await apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
name: post.metadata.name,
});
await handleFetchPosts();
},
});
};
const handleDeletePermanentlyInBatch = async () => {
Dialog.warning({
title: "是否确认永久删除选中的文章?",
description: "删除之后将无法恢复",
confirmType: "danger",
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
return apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
name,
});
})
);
await handleFetchPosts();
selectedPostNames.value = [];
},
});
};
const handleRecovery = async (post: Post) => {
Dialog.warning({
title: "是否确认恢复该文章?",
description: "此操作会将文章恢复到被删除之前的状态",
onConfirm: async () => {
const postToUpdate = cloneDeep(post);
postToUpdate.spec.deleted = false;
await apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: postToUpdate.metadata.name,
post: postToUpdate,
});
await handleFetchPosts();
},
});
};
const handleRecoveryInBatch = async () => {
Dialog.warning({
title: "是否确认恢复选中的文章?",
description: "此操作会将文章恢复到被删除之前的状态",
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
const post = posts.value.items.find(
(item) => item.post.metadata.name === name
)?.post;
if (!post) {
return Promise.resolve();
}
post.spec.deleted = false;
return apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: post.metadata.name,
post: post,
});
})
);
await handleFetchPosts();
selectedPostNames.value = [];
},
});
};
watch(selectedPostNames, (newValue) => {
checkedAll.value = newValue.length === posts.value.items?.length;
});
onMounted(() => {
handleFetchPosts();
});
</script>
<template>
<VPageHeader title="文章回收站">
<template #icon>
<IconDeleteBin class="mr-2 self-center text-green-600" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'Posts' }" size="sm">返回</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
type="secondary"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div
v-permission="['system:posts:manage']"
class="mr-4 hidden items-center sm:flex"
>
<input
v-model="checkedAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<div
v-if="!selectedPostNames.length"
class="flex items-center gap-2"
>
<FormKit
v-model="keyword"
outer-class="!p-0"
placeholder="输入关键词搜索"
type="text"
@keyup.enter="handleFetchPosts"
></FormKit>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeletePermanentlyInBatch">
永久删除
</VButton>
<VButton type="default" @click="handleRecoveryInBatch">
恢复
</VButton>
</VSpace>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg">
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchPosts"
>
<IconRefreshLine
:class="{ 'animate-spin text-gray-900': loading }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</div>
</template>
<VEmpty
v-if="!posts.items.length && !loading"
message="你可以尝试刷新或者返回文章管理"
title="没有文章被放入回收站"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchPosts"></VButton>
<VButton :route="{ name: 'Posts' }" type="primary"> 返回 </VButton>
</VSpace>
</template>
</VEmpty>
<ul
v-else
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(post, index) in posts.items" :key="index">
<VEntity :is-selected="checkSelection(post.post)">
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#checkbox
>
<input
v-model="selectedPostNames"
:value="post.post.metadata.name"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
name="post-checkbox"
type="checkbox"
/>
</template>
<template #start>
<VEntityField :title="post.post.spec.title">
<template #extra>
<VSpace class="mt-1 sm:mt-0">
<PostTag
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:tag="tag"
route
></PostTag>
</VSpace>
</template>
<template #description>
<VSpace>
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
分类<span
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
class="cursor-pointer hover:text-gray-900"
>
{{ category.spec.displayName }}
</span>
</p>
<span class="text-xs text-gray-500">
访问量 {{ post.stats.visit || 0 }}
</span>
<span class="text-xs text-gray-500">
评论 {{ post.stats.totalComment || 0 }}
</span>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField>
<template #description>
<RouterLink
v-for="(contributor, contributorIndex) in post.contributors"
:key="contributorIndex"
:to="{
name: 'UserDetail',
params: { name: contributor.name },
}"
class="flex items-center"
>
<VAvatar
v-tooltip="contributor.displayName"
size="xs"
:src="contributor.avatar"
:alt="contributor.displayName"
circle
></VAvatar>
</RouterLink>
</template>
</VEntityField>
<VEntityField v-if="!post?.post?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="success" animate />
</template>
</VEntityField>
<VEntityField v-if="post?.post?.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(post.post.spec.publishTime) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
>
<VButton
v-close-popper
block
type="danger"
@click="handleDeletePermanently(post.post)"
>
永久删除
</VButton>
<VButton
v-close-popper
block
type="default"
@click="handleRecovery(post.post)"
>
恢复
</VButton>
</template>
</VEntity>
</li>
</ul>
<template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination
:page="posts.page"
:size="posts.size"
:total="posts.total"
:size-options="[20, 30, 50, 100]"
@change="handlePaginationChange"
/>
</div>
</template>
</VCard>
</div>
</template>

View File

@ -40,6 +40,7 @@ import { usePostCategory } from "@/modules/contents/posts/categories/composables
import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import cloneDeep from "lodash.clonedeep";
const { currentUserHasPermission } = usePermission();
@ -93,6 +94,7 @@ const handleFetchPosts = async () => {
}
const { data } = await apiClient.post.listPosts({
labelSelector: [`content.halo.run/deleted=false`],
page: posts.value.page,
size: posts.value.size,
visible: selectedVisibleItem.value?.value,
@ -107,7 +109,7 @@ const handleFetchPosts = async () => {
posts.value = data;
const deletedPosts = posts.value.items.filter(
(post) => !!post.post.metadata.deletionTimestamp
(post) => post.post.spec.deleted
);
if (deletedPosts.length) {
@ -217,17 +219,21 @@ const handleCheckAllChange = (e: Event) => {
return post.post.metadata.name;
}) || [];
} else {
selectedPostNames.value.length = 0;
selectedPostNames.value = [];
}
};
const handleDelete = async (post: Post) => {
Dialog.warning({
title: "是否确认删除该文章?",
description: "此操作会将文章放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
await apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
name: post.metadata.name,
const postToUpdate = cloneDeep(post);
postToUpdate.spec.deleted = true;
await apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: postToUpdate.metadata.name,
post: postToUpdate,
});
await handleFetchPosts();
},
@ -237,17 +243,28 @@ const handleDelete = async (post: Post) => {
const handleDeleteInBatch = async () => {
Dialog.warning({
title: "是否确认删除选中的文章?",
description: "此操作会将文章放入回收站,后续可以从回收站恢复",
confirmType: "danger",
onConfirm: async () => {
await Promise.all(
selectedPostNames.value.map((name) => {
return apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
name,
const post = posts.value.items.find(
(item) => item.post.metadata.name === name
)?.post;
if (!post) {
return Promise.resolve();
}
post.spec.deleted = true;
return apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: post.metadata.name,
post: post,
});
})
);
await handleFetchPosts();
selectedPostNames.value.length = 0;
selectedPostNames.value = [];
},
});
};
@ -418,6 +435,7 @@ function handleContributorChange(user?: User) {
<VSpace>
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
<VButton :route="{ name: 'DeletedPosts' }" size="sm">回收站</VButton>
<VButton
v-permission="['system:posts:manage']"
:route="{ name: 'PostEditor' }"
@ -457,6 +475,7 @@ function handleContributorChange(user?: User) {
>
<FormKit
v-model="keyword"
outer-class="!p-0"
placeholder="输入关键词搜索"
type="text"
@keyup.enter="handleFetchPosts"
@ -936,7 +955,7 @@ function handleContributorChange(user?: User) {
/>
</template>
</VEntityField>
<VEntityField v-if="post?.post?.metadata.deletionTimestamp">
<VEntityField v-if="post?.post?.spec.deleted">
<template #description>
<VStatusDot v-tooltip="``" state="warning" animate />
</template>

View File

@ -3,6 +3,7 @@ import BasicLayout from "@/layouts/BasicLayout.vue";
import BlankLayout from "@/layouts/BlankLayout.vue";
import { IconBookRead } from "@halo-dev/components";
import PostList from "./PostList.vue";
import DeletedPostList from "./DeletedPostList.vue";
import PostEditor from "./PostEditor.vue";
import CategoryList from "./categories/CategoryList.vue";
import TagList from "./tags/TagList.vue";
@ -33,6 +34,16 @@ export default definePlugin({
},
},
},
{
path: "deleted",
name: "DeletedPosts",
component: DeletedPostList,
meta: {
title: "文章回收站",
searchable: true,
permissions: ["system:posts:view"],
},
},
{
path: "editor",
name: "PostEditor",