diff --git a/src/modules/contents/pages/DeletedSinglePageList.vue b/src/modules/contents/pages/DeletedSinglePageList.vue new file mode 100644 index 00000000..a802f30e --- /dev/null +++ b/src/modules/contents/pages/DeletedSinglePageList.vue @@ -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> diff --git a/src/modules/contents/pages/SinglePageList.vue b/src/modules/contents/pages/SinglePageList.vue index ab95da76..3e17aeed 100644 --- a/src/modules/contents/pages/SinglePageList.vue +++ b/src/modules/contents/pages/SinglePageList.vue @@ -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"> diff --git a/src/modules/contents/pages/layouts/PageLayout.vue b/src/modules/contents/pages/layouts/PageLayout.vue index ba339592..7d36e276 100644 --- a/src/modules/contents/pages/layouts/PageLayout.vue +++ b/src/modules/contents/pages/layouts/PageLayout.vue @@ -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"> diff --git a/src/modules/contents/pages/module.ts b/src/modules/contents/pages/module.ts index 016f31b7..6ea57704 100644 --- a/src/modules/contents/pages/module.ts +++ b/src/modules/contents/pages/module.ts @@ -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, diff --git a/src/modules/contents/posts/DeletedPostList.vue b/src/modules/contents/posts/DeletedPostList.vue new file mode 100644 index 00000000..aea76693 --- /dev/null +++ b/src/modules/contents/posts/DeletedPostList.vue @@ -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> diff --git a/src/modules/contents/posts/PostList.vue b/src/modules/contents/posts/PostList.vue index d87aff76..feb673d3 100644 --- a/src/modules/contents/posts/PostList.vue +++ b/src/modules/contents/posts/PostList.vue @@ -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> diff --git a/src/modules/contents/posts/module.ts b/src/modules/contents/posts/module.ts index ca427a3c..6254703b 100644 --- a/src/modules/contents/posts/module.ts +++ b/src/modules/contents/posts/module.ts @@ -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",