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";
|
} from "@halo-dev/components";
|
||||||
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
|
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
|
||||||
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
|
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
|
||||||
import { onMounted, ref, watchEffect } from "vue";
|
import { onMounted, ref, watch, watchEffect } from "vue";
|
||||||
import type {
|
import type {
|
||||||
ListedSinglePageList,
|
ListedSinglePageList,
|
||||||
SinglePage,
|
SinglePage,
|
||||||
|
@ -57,7 +57,8 @@ const loading = ref(false);
|
||||||
const settingModal = ref(false);
|
const settingModal = ref(false);
|
||||||
const selectedSinglePage = ref<SinglePage>();
|
const selectedSinglePage = ref<SinglePage>();
|
||||||
const selectedSinglePageWithContent = ref<SinglePageRequest>();
|
const selectedSinglePageWithContent = ref<SinglePageRequest>();
|
||||||
const checkAll = ref(false);
|
const selectedPageNames = ref<string[]>([]);
|
||||||
|
const checkedAll = ref(false);
|
||||||
const refreshInterval = ref();
|
const refreshInterval = ref();
|
||||||
|
|
||||||
const handleFetchSinglePages = async () => {
|
const handleFetchSinglePages = async () => {
|
||||||
|
@ -73,6 +74,7 @@ const handleFetchSinglePages = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await apiClient.singlePage.listSinglePages({
|
const { data } = await apiClient.singlePage.listSinglePages({
|
||||||
|
labelSelector: [`content.halo.run/deleted=false`],
|
||||||
page: singlePages.value.page,
|
page: singlePages.value.page,
|
||||||
size: singlePages.value.size,
|
size: singlePages.value.size,
|
||||||
visible: selectedVisibleItem.value.value,
|
visible: selectedVisibleItem.value.value,
|
||||||
|
@ -85,7 +87,7 @@ const handleFetchSinglePages = async () => {
|
||||||
singlePages.value = data;
|
singlePages.value = data;
|
||||||
|
|
||||||
const deletedSinglePages = singlePages.value.items.filter(
|
const deletedSinglePages = singlePages.value.items.filter(
|
||||||
(singlePage) => !!singlePage.page.metadata.deletionTimestamp
|
(singlePage) => singlePage.page.spec.deleted
|
||||||
);
|
);
|
||||||
|
|
||||||
if (deletedSinglePages.length) {
|
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) => {
|
const handleDelete = async (singlePage: SinglePage) => {
|
||||||
Dialog.warning({
|
Dialog.warning({
|
||||||
title: "是否确认删除该自定义页面?",
|
title: "是否确认删除该自定义页面?",
|
||||||
|
description: "此操作会将自定义页面放入回收站,后续可以从回收站恢复",
|
||||||
confirmType: "danger",
|
confirmType: "danger",
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
const singlePageToUpdate = cloneDeep(singlePage);
|
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) => {
|
const finalStatus = (singlePage: SinglePage) => {
|
||||||
if (singlePage.status?.phase) {
|
if (singlePage.status?.phase) {
|
||||||
return SinglePagePhase[singlePage.status.phase];
|
return SinglePagePhase[singlePage.status.phase];
|
||||||
|
@ -217,6 +271,28 @@ const finalStatus = (singlePage: SinglePage) => {
|
||||||
return "";
|
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);
|
onMounted(handleFetchSinglePages);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
|
@ -351,15 +427,20 @@ function handleSortItemChange(sortItem?: SortItem) {
|
||||||
class="mr-4 hidden items-center sm:flex"
|
class="mr-4 hidden items-center sm:flex"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="checkAll"
|
v-model="checkedAll"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@change="handleCheckAllChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-1 items-center sm:w-auto">
|
<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
|
<FormKit
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
|
outer-class="!p-0"
|
||||||
placeholder="输入关键词搜索"
|
placeholder="输入关键词搜索"
|
||||||
type="text"
|
type="text"
|
||||||
@keyup.enter="handleFetchSinglePages"
|
@keyup.enter="handleFetchSinglePages"
|
||||||
|
@ -414,8 +495,7 @@ function handleSortItemChange(sortItem?: SortItem) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VSpace v-else>
|
<VSpace v-else>
|
||||||
<VButton type="default">设置</VButton>
|
<VButton type="danger" @click="handleDeleteInBatch">删除</VButton>
|
||||||
<VButton type="danger">删除</VButton>
|
|
||||||
</VSpace>
|
</VSpace>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex sm:mt-0">
|
<div class="mt-4 flex sm:mt-0">
|
||||||
|
@ -561,13 +641,14 @@ function handleSortItemChange(sortItem?: SortItem) {
|
||||||
role="list"
|
role="list"
|
||||||
>
|
>
|
||||||
<li v-for="(singlePage, index) in singlePages.items" :key="index">
|
<li v-for="(singlePage, index) in singlePages.items" :key="index">
|
||||||
<VEntity :is-selected="checkAll">
|
<VEntity :is-selected="checkSelection(singlePage.page)">
|
||||||
<template
|
<template
|
||||||
v-if="currentUserHasPermission(['system:singlepages:manage'])"
|
v-if="currentUserHasPermission(['system:singlepages:manage'])"
|
||||||
#checkbox
|
#checkbox
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="checkAll"
|
v-model="selectedPageNames"
|
||||||
|
:value="singlePage.page.metadata.name"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
|
@ -652,6 +733,11 @@ function handleSortItemChange(sortItem?: SortItem) {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VEntityField>
|
</VEntityField>
|
||||||
|
<VEntityField v-if="singlePage?.page?.spec.deleted">
|
||||||
|
<template #description>
|
||||||
|
<VStatusDot v-tooltip="`删除中`" state="warning" animate />
|
||||||
|
</template>
|
||||||
|
</VEntityField>
|
||||||
<VEntityField>
|
<VEntityField>
|
||||||
<template #description>
|
<template #description>
|
||||||
<span class="truncate text-xs tabular-nums text-gray-500">
|
<span class="truncate text-xs tabular-nums text-gray-500">
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
VTabbar,
|
VTabbar,
|
||||||
IconPages,
|
IconPages,
|
||||||
VButton,
|
VButton,
|
||||||
|
VSpace,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
@ -70,6 +71,10 @@ watchEffect(() => {
|
||||||
<IconPages class="mr-2 self-center" />
|
<IconPages class="mr-2 self-center" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<VSpace>
|
||||||
|
<VButton :route="{ name: 'DeletedSinglePages' }" size="sm">
|
||||||
|
回收站
|
||||||
|
</VButton>
|
||||||
<VButton
|
<VButton
|
||||||
v-permission="['system:singlepages:manage']"
|
v-permission="['system:singlepages:manage']"
|
||||||
:route="{ name: 'SinglePageEditor' }"
|
:route="{ name: 'SinglePageEditor' }"
|
||||||
|
@ -80,6 +85,7 @@ watchEffect(() => {
|
||||||
</template>
|
</template>
|
||||||
新建
|
新建
|
||||||
</VButton>
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
</template>
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
<div class="m-0 md:m-4">
|
<div class="m-0 md:m-4">
|
||||||
|
|
|
@ -4,6 +4,7 @@ import BlankLayout from "@/layouts/BlankLayout.vue";
|
||||||
import PageLayout from "./layouts/PageLayout.vue";
|
import PageLayout from "./layouts/PageLayout.vue";
|
||||||
import FunctionalPageList from "./FunctionalPageList.vue";
|
import FunctionalPageList from "./FunctionalPageList.vue";
|
||||||
import SinglePageList from "./SinglePageList.vue";
|
import SinglePageList from "./SinglePageList.vue";
|
||||||
|
import DeletedSinglePageList from "./DeletedSinglePageList.vue";
|
||||||
import SinglePageEditor from "./SinglePageEditor.vue";
|
import SinglePageEditor from "./SinglePageEditor.vue";
|
||||||
import { IconPages } from "@halo-dev/components";
|
import { IconPages } from "@halo-dev/components";
|
||||||
import { markRaw } from "vue";
|
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",
|
path: "editor",
|
||||||
component: BasicLayout,
|
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 { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
|
||||||
import { usePermission } from "@/utils/permission";
|
import { usePermission } from "@/utils/permission";
|
||||||
import { onBeforeRouteLeave } from "vue-router";
|
import { onBeforeRouteLeave } from "vue-router";
|
||||||
|
import cloneDeep from "lodash.clonedeep";
|
||||||
|
|
||||||
const { currentUserHasPermission } = usePermission();
|
const { currentUserHasPermission } = usePermission();
|
||||||
|
|
||||||
|
@ -93,6 +94,7 @@ const handleFetchPosts = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = await apiClient.post.listPosts({
|
const { data } = await apiClient.post.listPosts({
|
||||||
|
labelSelector: [`content.halo.run/deleted=false`],
|
||||||
page: posts.value.page,
|
page: posts.value.page,
|
||||||
size: posts.value.size,
|
size: posts.value.size,
|
||||||
visible: selectedVisibleItem.value?.value,
|
visible: selectedVisibleItem.value?.value,
|
||||||
|
@ -107,7 +109,7 @@ const handleFetchPosts = async () => {
|
||||||
posts.value = data;
|
posts.value = data;
|
||||||
|
|
||||||
const deletedPosts = posts.value.items.filter(
|
const deletedPosts = posts.value.items.filter(
|
||||||
(post) => !!post.post.metadata.deletionTimestamp
|
(post) => post.post.spec.deleted
|
||||||
);
|
);
|
||||||
|
|
||||||
if (deletedPosts.length) {
|
if (deletedPosts.length) {
|
||||||
|
@ -217,17 +219,21 @@ const handleCheckAllChange = (e: Event) => {
|
||||||
return post.post.metadata.name;
|
return post.post.metadata.name;
|
||||||
}) || [];
|
}) || [];
|
||||||
} else {
|
} else {
|
||||||
selectedPostNames.value.length = 0;
|
selectedPostNames.value = [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (post: Post) => {
|
const handleDelete = async (post: Post) => {
|
||||||
Dialog.warning({
|
Dialog.warning({
|
||||||
title: "是否确认删除该文章?",
|
title: "是否确认删除该文章?",
|
||||||
|
description: "此操作会将文章放入回收站,后续可以从回收站恢复",
|
||||||
confirmType: "danger",
|
confirmType: "danger",
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
await apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
|
const postToUpdate = cloneDeep(post);
|
||||||
name: post.metadata.name,
|
postToUpdate.spec.deleted = true;
|
||||||
|
await apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
|
||||||
|
name: postToUpdate.metadata.name,
|
||||||
|
post: postToUpdate,
|
||||||
});
|
});
|
||||||
await handleFetchPosts();
|
await handleFetchPosts();
|
||||||
},
|
},
|
||||||
|
@ -237,17 +243,28 @@ const handleDelete = async (post: Post) => {
|
||||||
const handleDeleteInBatch = async () => {
|
const handleDeleteInBatch = async () => {
|
||||||
Dialog.warning({
|
Dialog.warning({
|
||||||
title: "是否确认删除选中的文章?",
|
title: "是否确认删除选中的文章?",
|
||||||
|
description: "此操作会将文章放入回收站,后续可以从回收站恢复",
|
||||||
confirmType: "danger",
|
confirmType: "danger",
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
selectedPostNames.value.map((name) => {
|
selectedPostNames.value.map((name) => {
|
||||||
return apiClient.extension.post.deletecontentHaloRunV1alpha1Post({
|
const post = posts.value.items.find(
|
||||||
name,
|
(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();
|
await handleFetchPosts();
|
||||||
selectedPostNames.value.length = 0;
|
selectedPostNames.value = [];
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -418,6 +435,7 @@ function handleContributorChange(user?: User) {
|
||||||
<VSpace>
|
<VSpace>
|
||||||
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
|
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
|
||||||
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
|
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
|
||||||
|
<VButton :route="{ name: 'DeletedPosts' }" size="sm">回收站</VButton>
|
||||||
<VButton
|
<VButton
|
||||||
v-permission="['system:posts:manage']"
|
v-permission="['system:posts:manage']"
|
||||||
:route="{ name: 'PostEditor' }"
|
:route="{ name: 'PostEditor' }"
|
||||||
|
@ -457,6 +475,7 @@ function handleContributorChange(user?: User) {
|
||||||
>
|
>
|
||||||
<FormKit
|
<FormKit
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
|
outer-class="!p-0"
|
||||||
placeholder="输入关键词搜索"
|
placeholder="输入关键词搜索"
|
||||||
type="text"
|
type="text"
|
||||||
@keyup.enter="handleFetchPosts"
|
@keyup.enter="handleFetchPosts"
|
||||||
|
@ -936,7 +955,7 @@ function handleContributorChange(user?: User) {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VEntityField>
|
</VEntityField>
|
||||||
<VEntityField v-if="post?.post?.metadata.deletionTimestamp">
|
<VEntityField v-if="post?.post?.spec.deleted">
|
||||||
<template #description>
|
<template #description>
|
||||||
<VStatusDot v-tooltip="`删除中`" state="warning" animate />
|
<VStatusDot v-tooltip="`删除中`" state="warning" animate />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||||
import BlankLayout from "@/layouts/BlankLayout.vue";
|
import BlankLayout from "@/layouts/BlankLayout.vue";
|
||||||
import { IconBookRead } from "@halo-dev/components";
|
import { IconBookRead } from "@halo-dev/components";
|
||||||
import PostList from "./PostList.vue";
|
import PostList from "./PostList.vue";
|
||||||
|
import DeletedPostList from "./DeletedPostList.vue";
|
||||||
import PostEditor from "./PostEditor.vue";
|
import PostEditor from "./PostEditor.vue";
|
||||||
import CategoryList from "./categories/CategoryList.vue";
|
import CategoryList from "./categories/CategoryList.vue";
|
||||||
import TagList from "./tags/TagList.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",
|
path: "editor",
|
||||||
name: "PostEditor",
|
name: "PostEditor",
|
||||||
|
|
Loading…
Reference in New Issue