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>
// core libs
import { onMounted, ref } from "vue";
// components
import type { Tag } from "@halo-dev/api-client";
import { onMounted, ref, watch } from "vue";
import {
IconAddCircle,
IconBookRead,
IconGrid,
IconList,
VButton,
VCard,
VEmpty,
VPageHeader,
VSpace,
VStatusDot,
VEntity,
VEntityField,
VLoading,
VDropdownItem,
VPagination,
} from "@halo-dev/components";
import HasPermission from "@/components/permission/HasPermission.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 { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
const { currentUserHasPermission } = usePermission();
const viewTypes = [
{
name: "list",
icon: IconList,
},
{
name: "grid",
icon: IconGrid,
},
];
const viewType = ref("list");
const { tags, isLoading, handleFetchTags, handleDelete } = usePostTag();
import { usePostTag } from "./composables/use-post-tag";
import TagListItem from "./components/TagListItem.vue";
const editingModal = ref(false);
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) => {
selectedTag.value = tag;
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;
const currentIndex = tags.value.findIndex(
@ -69,12 +81,18 @@ const handleSelectPrevious = () => {
return;
}
if (currentIndex <= 0) {
selectedTag.value = null;
if (currentIndex === 0 && hasPrevious.value) {
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 (!selectedTag.value) {
@ -87,6 +105,12 @@ const handleSelectNext = () => {
if (currentIndex !== tags.value.length - 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 = () => {
@ -108,6 +132,10 @@ onMounted(async () => {
editingModal.value = true;
}
});
watch(selectedTagNames, (newVal) => {
checkedAll.value = newVal.length === tags.value?.length;
});
</script>
<template>
<TagEditingModal
@ -139,27 +167,23 @@ onMounted(async () => {
<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"
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">
<span class="text-base font-medium">
{{
$t("core.post_tag.header.title", { count: tags?.length || 0 })
}}
</span>
</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" />
<HasPermission :permissions="['system:posts:manage']">
<div class="hidden items-center sm:flex">
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</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>
@ -172,7 +196,7 @@ onMounted(async () => {
>
<template #actions>
<VSpace>
<VButton @click="handleFetchTags">
<VButton @click="() => handleFetchTags">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="primary" @click="editingModal = true">
@ -186,93 +210,42 @@ onMounted(async () => {
</VEmpty>
</Transition>
<div v-else>
<Transition v-if="viewType === 'list'" appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(tag, index) in tags" :key="index">
<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"
<Transition appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(tag, index) in tags" :key="index">
<TagListItem
:tag="tag"
@click="handleOpenEditingModal(tag)"
/>
</div>
</Transition>
</div>
:is-selected="selectedTag?.metadata.name === tag.metadata.name"
@editing="handleOpenEditingModal"
@delete="handleDelete"
>
<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>
</div>
</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 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 { useQuery } from "@tanstack/vue-query";
import { useQuery, type QueryObserverResult } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
interface usePostTagReturn {
tags: Ref<Tag[] | undefined>;
total: Ref<number>;
hasPrevious: Ref<boolean>;
hasNext: Ref<boolean>;
isLoading: Ref<boolean>;
handleFetchTags: () => void;
handleFetchTags: () => Promise<QueryObserverResult<Tag[], unknown>>;
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 { sort, page, size } = filterOptions || {};
const total = ref(0);
const hasPrevious = ref(false);
const hasNext = ref(false);
const {
data: tags,
isLoading,
refetch,
} = useQuery({
queryKey: ["post-tags"],
queryKey: ["post-tags", sort, page, size],
queryFn: async () => {
const { data } =
await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({
page: 0,
size: 0,
sort: ["metadata.creationTimestamp,desc"],
page: page?.value || 0,
size: size?.value || 0,
sort: sort?.value || ["metadata.creationTimestamp,desc"],
});
total.value = data.total;
hasPrevious.value = data.hasPrevious;
hasNext.value = data.hasNext;
return data.items;
},
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 {
tags,
total,
hasPrevious,
hasNext,
isLoading,
handleFetchTags: refetch,
handleDelete,
handleDeleteInBatch,
};
}

View File

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

View File

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

View File

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