mirror of https://github.com/halo-dev/halo-admin
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
parent
ff26058fc0
commit
e3fc14abad
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue