refactor: post tag management page (#5593)

#### What type of PR is this?

/kind improvement
/area ui

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

使用分页列表的形式重新展示文章标签页,移除原有的 grid 形式。

#### How to test it?

查看文章标签页面的分页列表功能显示是否正常。
完成增、删、改之后是否能够正常回显。

#### Which issue(s) this PR fixes:

Fixes #5415 

#### Does this PR introduce a user-facing change?
```release-note
使用分页列表的形式重构文章标签页 UI
```
pull/5685/head^2
Takagi 2024-04-11 15:50:10 +08:00 committed by GitHub
parent 95ec1c1cce
commit 26db6036a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 283 additions and 154 deletions

View File

@ -1,63 +1,75 @@
<script lang="ts" setup> <script lang="ts" setup>
// core libs import type { Tag } from "@halo-dev/api-client";
import { onMounted, ref } from "vue"; import { onMounted, ref, watch } from "vue";
// components
import { import {
IconAddCircle, IconAddCircle,
IconBookRead, IconBookRead,
IconGrid,
IconList,
VButton, VButton,
VCard, VCard,
VEmpty, VEmpty,
VPageHeader, VPageHeader,
VSpace, VSpace,
VStatusDot,
VEntity,
VEntityField,
VLoading, VLoading,
VDropdownItem, VPagination,
} from "@halo-dev/components"; } from "@halo-dev/components";
import HasPermission from "@/components/permission/HasPermission.vue";
import TagEditingModal from "./components/TagEditingModal.vue"; import TagEditingModal from "./components/TagEditingModal.vue";
import PostTag from "./components/PostTag.vue";
// types
import type { Tag } from "@halo-dev/api-client";
import { usePostTag } from "./composables/use-post-tag";
import { formatDatetime } from "@/utils/date";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission"; import { usePostTag } from "./composables/use-post-tag";
import TagListItem from "./components/TagListItem.vue";
const { currentUserHasPermission } = usePermission();
const viewTypes = [
{
name: "list",
icon: IconList,
},
{
name: "grid",
icon: IconGrid,
},
];
const viewType = ref("list");
const { tags, isLoading, handleFetchTags, handleDelete } = usePostTag();
const editingModal = ref(false); const editingModal = ref(false);
const selectedTag = ref<Tag | null>(null); const selectedTag = ref<Tag | null>(null);
const selectedTagNames = ref<string[]>([]);
const checkedAll = ref(false);
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const {
tags,
total,
hasNext,
hasPrevious,
isLoading,
handleFetchTags,
handleDelete,
handleDeleteInBatch,
} = usePostTag({
page,
size,
});
const handleOpenEditingModal = (tag: Tag | null) => { const handleOpenEditingModal = (tag: Tag | null) => {
selectedTag.value = tag; selectedTag.value = tag;
editingModal.value = true; editingModal.value = true;
}; };
const handleSelectPrevious = () => { const handleDeleteTagInBatch = () => {
handleDeleteInBatch(selectedTagNames.value).then(() => {
selectedTagNames.value = [];
});
};
const handleCheckAllChange = () => {
if (checkedAll.value) {
selectedTagNames.value = tags.value?.map((tag) => tag.metadata.name) || [];
} else {
selectedTagNames.value = [];
}
};
const handleSelectPrevious = async () => {
if (!hasPrevious.value) {
return;
}
if (!tags.value) return; if (!tags.value) return;
const currentIndex = tags.value.findIndex( const currentIndex = tags.value.findIndex(
@ -69,12 +81,18 @@ const handleSelectPrevious = () => {
return; return;
} }
if (currentIndex <= 0) { if (currentIndex === 0 && hasPrevious.value) {
selectedTag.value = null; page.value--;
await handleFetchTags();
selectedTag.value = tags.value[tags.value.length - 1];
} }
}; };
const handleSelectNext = () => { const handleSelectNext = async () => {
if (!hasNext.value) {
return;
}
if (!tags.value) return; if (!tags.value) return;
if (!selectedTag.value) { if (!selectedTag.value) {
@ -87,6 +105,12 @@ const handleSelectNext = () => {
if (currentIndex !== tags.value.length - 1) { if (currentIndex !== tags.value.length - 1) {
selectedTag.value = tags.value[currentIndex + 1]; selectedTag.value = tags.value[currentIndex + 1];
} }
if (currentIndex === tags.value.length - 1 && hasNext.value) {
page.value++;
await handleFetchTags();
selectedTag.value = tags.value[0];
}
}; };
const onEditingModalClose = () => { const onEditingModalClose = () => {
@ -108,6 +132,10 @@ onMounted(async () => {
editingModal.value = true; editingModal.value = true;
} }
}); });
watch(selectedTagNames, (newVal) => {
checkedAll.value = newVal.length === tags.value?.length;
});
</script> </script>
<template> <template>
<TagEditingModal <TagEditingModal
@ -139,27 +167,23 @@ onMounted(async () => {
<template #header> <template #header>
<div class="block w-full bg-gray-50 px-4 py-3"> <div class="block w-full bg-gray-50 px-4 py-3">
<div <div
class="relative flex flex-col items-start sm:flex-row sm:items-center" class="relative flex h-9 flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
> >
<div class="flex w-full flex-1 sm:w-auto"> <HasPermission :permissions="['system:posts:manage']">
<span class="text-base font-medium"> <div class="hidden items-center sm:flex">
{{ <input
$t("core.post_tag.header.title", { count: tags?.length || 0 }) v-model="checkedAll"
}} type="checkbox"
</span> @change="handleCheckAllChange"
</div> />
<div class="flex flex-row gap-2">
<div
v-for="(item, index) in viewTypes"
:key="index"
:class="{
'bg-gray-200 font-bold text-black': viewType === item.name,
}"
class="cursor-pointer rounded p-1 hover:bg-gray-200"
@click="viewType = item.name"
>
<component :is="item.icon" />
</div> </div>
</HasPermission>
<div class="flex w-full flex-1 items-center sm:w-auto">
<VSpace v-if="selectedTagNames.length > 0">
<VButton type="danger" @click="handleDeleteTagInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div> </div>
</div> </div>
</div> </div>
@ -172,7 +196,7 @@ onMounted(async () => {
> >
<template #actions> <template #actions>
<VSpace> <VSpace>
<VButton @click="handleFetchTags"> <VButton @click="() => handleFetchTags">
{{ $t("core.common.buttons.refresh") }} {{ $t("core.common.buttons.refresh") }}
</VButton> </VButton>
<VButton type="primary" @click="editingModal = true"> <VButton type="primary" @click="editingModal = true">
@ -186,93 +210,42 @@ onMounted(async () => {
</VEmpty> </VEmpty>
</Transition> </Transition>
<div v-else> <Transition appear name="fade">
<Transition v-if="viewType === 'list'" appear name="fade"> <ul
<ul class="box-border h-full w-full divide-y divide-gray-100"
class="box-border h-full w-full divide-y divide-gray-100" role="list"
role="list" >
> <li v-for="(tag, index) in tags" :key="index">
<li v-for="(tag, index) in tags" :key="index"> <TagListItem
<VEntity
:is-selected="selectedTag?.metadata.name === tag.metadata.name"
>
<template #start>
<VEntityField>
<template #title>
<PostTag :tag="tag" />
</template>
<template #description>
<a
v-if="tag.status?.permalink"
:href="tag.status?.permalink"
:title="tag.status?.permalink"
target="_blank"
class="truncate text-xs text-gray-500 group-hover:text-gray-900"
>
{{ tag.status.permalink }}
</a>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="tag.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField
:description="
$t('core.common.fields.post_count', {
count: tag.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(tag.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
>
<VDropdownItem
v-permission="['system:posts:manage']"
@click="handleOpenEditingModal(tag)"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem
v-permission="['system:posts:manage']"
type="danger"
@click="handleDelete(tag)"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
</li>
</ul>
</Transition>
<Transition v-else appear name="fade">
<div class="flex flex-wrap gap-3 p-4" role="list">
<PostTag
v-for="(tag, index) in tags"
:key="index"
:tag="tag" :tag="tag"
@click="handleOpenEditingModal(tag)" :is-selected="selectedTag?.metadata.name === tag.metadata.name"
/> @editing="handleOpenEditingModal"
</div> @delete="handleDelete"
</Transition> >
</div> <template #checkbox>
<input
v-model="selectedTagNames"
:value="tag.metadata.name"
type="checkbox"
/>
</template>
</TagListItem>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:total="total"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard> </VCard>
</div> </div>
</template> </template>

View File

@ -0,0 +1,98 @@
<script lang="ts" setup>
import HasPermission from "@/components/permission/HasPermission.vue";
import { formatDatetime } from "@/utils/date";
import type { Tag } from "@halo-dev/api-client";
import {
VStatusDot,
VEntity,
VEntityField,
VDropdownItem,
IconExternalLinkLine,
VSpace,
} from "@halo-dev/components";
import PostTag from "./PostTag.vue";
withDefaults(
defineProps<{
tag: Tag;
isSelected?: boolean;
}>(),
{ isSelected: false }
);
const emit = defineEmits<{
(event: "editing", tag: Tag): void;
(event: "delete", tag: Tag): void;
}>();
</script>
<template>
<VEntity :is-selected="isSelected">
<template #checkbox>
<HasPermission :permissions="['system:posts:manage']">
<slot name="checkbox" />
</HasPermission>
</template>
<template #start>
<VEntityField>
<template #title>
<PostTag :tag="tag" />
</template>
<template #description>
<VSpace>
<div
v-if="tag.status?.permalink"
:title="tag.status?.permalink"
target="_blank"
class="truncate text-xs text-gray-500 group-hover:text-gray-900"
>
{{ tag.status.permalink }}
</div>
<a
target="_blank"
:href="tag.status?.permalink"
class="hidden text-gray-600 transition-all hover:text-gray-900 group-hover:inline-block"
>
<IconExternalLinkLine class="h-3.5 w-3.5" />
</a>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="tag.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField
:description="
$t('core.common.fields.post_count', {
count: tag.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(tag.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template #dropdownItems>
<HasPermission :permissions="['system:posts:manage']">
<VDropdownItem @click="emit('editing', tag)">
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem type="danger" @click="emit('delete', tag)">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</HasPermission>
</template>
</VEntity>
</template>

View File

@ -1,34 +1,52 @@
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import type { Tag } from "@halo-dev/api-client"; import type { Tag } from "@halo-dev/api-client";
import type { Ref } from "vue"; import { ref, type Ref } from "vue";
import { Dialog, Toast } from "@halo-dev/components"; import { Dialog, Toast } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query"; import { useQuery, type QueryObserverResult } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
interface usePostTagReturn { interface usePostTagReturn {
tags: Ref<Tag[] | undefined>; tags: Ref<Tag[] | undefined>;
total: Ref<number>;
hasPrevious: Ref<boolean>;
hasNext: Ref<boolean>;
isLoading: Ref<boolean>; isLoading: Ref<boolean>;
handleFetchTags: () => void; handleFetchTags: () => Promise<QueryObserverResult<Tag[], unknown>>;
handleDelete: (tag: Tag) => void; handleDelete: (tag: Tag) => void;
handleDeleteInBatch: (tagNames: string[]) => Promise<void>;
} }
export function usePostTag(): usePostTagReturn { export function usePostTag(filterOptions?: {
sort?: Ref<string[]>;
page?: Ref<number>;
size?: Ref<number>;
}): usePostTagReturn {
const { t } = useI18n(); const { t } = useI18n();
const { sort, page, size } = filterOptions || {};
const total = ref(0);
const hasPrevious = ref(false);
const hasNext = ref(false);
const { const {
data: tags, data: tags,
isLoading, isLoading,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["post-tags"], queryKey: ["post-tags", sort, page, size],
queryFn: async () => { queryFn: async () => {
const { data } = const { data } =
await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({ await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({
page: 0, page: page?.value || 0,
size: 0, size: size?.value || 0,
sort: ["metadata.creationTimestamp,desc"], sort: sort?.value || ["metadata.creationTimestamp,desc"],
}); });
total.value = data.total;
hasPrevious.value = data.hasPrevious;
hasNext.value = data.hasNext;
return data.items; return data.items;
}, },
refetchInterval(data) { refetchInterval(data) {
@ -62,10 +80,44 @@ export function usePostTag(): usePostTagReturn {
}); });
}; };
const handleDeleteInBatch = (tagNames: string[]) => {
return new Promise<void>((resolve) => {
Dialog.warning({
title: t("core.post_tag.operations.delete_in_batch.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await Promise.all(
tagNames.map((tagName) => {
apiClient.extension.tag.deletecontentHaloRunV1alpha1Tag({
name: tagName,
});
})
);
Toast.success(t("core.common.toast.delete_success"));
resolve();
} catch (e) {
console.error("Failed to delete tags in batch", e);
} finally {
await refetch();
}
},
});
});
};
return { return {
tags, tags,
total,
hasPrevious,
hasNext,
isLoading, isLoading,
handleFetchTags: refetch, handleFetchTags: refetch,
handleDelete, handleDelete,
handleDeleteInBatch,
}; };
} }

View File

@ -290,6 +290,8 @@ core:
description: >- description: >-
After deleting this tag, the association with the corresponding After deleting this tag, the association with the corresponding
article will be removed. This operation cannot be undone. article will be removed. This operation cannot be undone.
delete_in_batch:
title: Delete the selected tags
editing_modal: editing_modal:
titles: titles:
update: Update post tag update: Update post tag

View File

@ -292,6 +292,8 @@ core:
delete: delete:
title: 删除标签 title: 删除标签
description: 删除此标签之后,对应文章的关联将被解除。该操作不可恢复。 description: 删除此标签之后,对应文章的关联将被解除。该操作不可恢复。
delete_in_batch:
title: 删除所选标签
editing_modal: editing_modal:
titles: titles:
update: 编辑文章标签 update: 编辑文章标签

View File

@ -280,6 +280,8 @@ core:
delete: delete:
title: 刪除標籤 title: 刪除標籤
description: 刪除此標籤之後,對應文章的關聯將被解除。該操作不可恢復。 description: 刪除此標籤之後,對應文章的關聯將被解除。該操作不可恢復。
delete_in_batch:
title: 刪除所選標籤
editing_modal: editing_modal:
titles: titles:
update: 編輯文章標籤 update: 編輯文章標籤