refactor: improve code base of post category-related (#5958)

#### What type of PR is this?

/area ui
/kind improvement
/milestone 2.16.x

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

优化文章分类管理相关的 UI 代码。

1. 使用 vue-draggable-plus 库代替 vuedraggable 库实现拖拽排序。vue-draggable-plus 是在 https://github.com/halo-dev/halo/pull/5914 中引入,替换的原因是 vuedraggable 库已经不再积极维护。
2. 改进分类编辑表单的逻辑,清理无用代码。

#### Special notes for your reviewer:

需要测试:

1. 测试文章分类拖拽排序功能是否表现正常。
2. 测试新增/编辑文章分类功能是否表现正常。

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/5965/head
Ryan Wang 2024-05-22 10:52:46 +08:00 committed by GitHub
parent d29da319e7
commit 9bfe3a66d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 2262 additions and 475 deletions

View File

@ -10,21 +10,14 @@ import {
VButton, VButton,
VCard, VCard,
VEmpty, VEmpty,
VLoading,
VPageHeader, VPageHeader,
VSpace, VSpace,
VLoading,
} from "@halo-dev/components"; } from "@halo-dev/components";
import CategoryEditingModal from "./components/CategoryEditingModal.vue"; import CategoryEditingModal from "./components/CategoryEditingModal.vue";
import CategoryListItem from "./components/CategoryListItem.vue"; import CategoryListItem from "./components/CategoryListItem.vue";
// types import { convertTreeToCategories, resetCategoriesTreePriority } from "./utils";
import type { Category } from "@halo-dev/api-client";
import type { CategoryTree } from "./utils";
import {
convertCategoryTreeToCategory,
convertTreeToCategories,
resetCategoriesTreePriority,
} from "./utils";
// libs // libs
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
@ -32,17 +25,12 @@ import { useDebounceFn } from "@vueuse/core";
// hooks // hooks
import { usePostCategory } from "./composables/use-post-category"; import { usePostCategory } from "./composables/use-post-category";
const editingModal = ref(false); const creationModal = ref(false);
const selectedCategory = ref<Category>();
const selectedParentCategory = ref<Category>();
const { const { categories, categoriesTree, isLoading, handleFetchCategories } =
categories, usePostCategory();
categoriesTree,
isLoading, const batchUpdating = ref(false);
handleFetchCategories,
handleDelete,
} = usePostCategory();
const handleUpdateInBatch = useDebounceFn(async () => { const handleUpdateInBatch = useDebounceFn(async () => {
const categoriesTreeToUpdate = resetCategoriesTreePriority( const categoriesTreeToUpdate = resetCategoriesTreePriority(
@ -50,6 +38,7 @@ const handleUpdateInBatch = useDebounceFn(async () => {
); );
const categoriesToUpdate = convertTreeToCategories(categoriesTreeToUpdate); const categoriesToUpdate = convertTreeToCategories(categoriesTreeToUpdate);
try { try {
batchUpdating.value = true;
const promises = categoriesToUpdate.map((category) => const promises = categoriesToUpdate.map((category) =>
apiClient.extension.category.updatecontentHaloRunV1alpha1Category({ apiClient.extension.category.updatecontentHaloRunV1alpha1Category({
name: category.metadata.name, name: category.metadata.name,
@ -61,32 +50,12 @@ const handleUpdateInBatch = useDebounceFn(async () => {
console.error("Failed to update categories", e); console.error("Failed to update categories", e);
} finally { } finally {
await handleFetchCategories(); await handleFetchCategories();
batchUpdating.value = false;
} }
}, 500); }, 300);
const handleOpenEditingModal = (category: CategoryTree) => {
selectedCategory.value = convertCategoryTreeToCategory(category);
editingModal.value = true;
};
const handleOpenCreateByParentModal = (category: CategoryTree) => {
selectedParentCategory.value = convertCategoryTreeToCategory(category);
editingModal.value = true;
};
const onEditingModalClose = () => {
selectedCategory.value = undefined;
selectedParentCategory.value = undefined;
handleFetchCategories();
};
</script> </script>
<template> <template>
<CategoryEditingModal <CategoryEditingModal v-if="creationModal" @close="creationModal = false" />
v-model:visible="editingModal"
:category="selectedCategory"
:parent-category="selectedParentCategory"
@close="onEditingModalClose"
/>
<VPageHeader :title="$t('core.post_category.title')"> <VPageHeader :title="$t('core.post_category.title')">
<template #icon> <template #icon>
<IconBookRead class="mr-2 self-center" /> <IconBookRead class="mr-2 self-center" />
@ -96,7 +65,7 @@ const onEditingModalClose = () => {
<VButton <VButton
v-permission="['system:posts:manage']" v-permission="['system:posts:manage']"
type="secondary" type="secondary"
@click="editingModal = true" @click="creationModal = true"
> >
<template #icon> <template #icon>
<IconAddCircle class="h-full w-full" /> <IconAddCircle class="h-full w-full" />
@ -138,7 +107,7 @@ const onEditingModalClose = () => {
<VButton <VButton
v-permission="['system:posts:manage']" v-permission="['system:posts:manage']"
type="primary" type="primary"
@click="editingModal = true" @click="creationModal = true"
> >
<template #icon> <template #icon>
<IconAddCircle class="h-full w-full" /> <IconAddCircle class="h-full w-full" />
@ -151,11 +120,11 @@ const onEditingModalClose = () => {
</Transition> </Transition>
<Transition v-else appear name="fade"> <Transition v-else appear name="fade">
<CategoryListItem <CategoryListItem
:categories="categoriesTree" v-model="categoriesTree"
:class="{
'cursor-progress opacity-60': batchUpdating,
}"
@change="handleUpdateInBatch" @change="handleUpdateInBatch"
@delete="handleDelete"
@open-editing="handleOpenEditingModal"
@open-create-by-parent="handleOpenCreateByParentModal"
/> />
</Transition> </Transition>
</VCard> </VCard>

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
// core libs // core libs
import { computed, nextTick, ref, watch } from "vue"; import { computed, nextTick, onMounted, ref, toRaw } from "vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
// components // components
@ -17,36 +17,33 @@ import SubmitButton from "@/components/button/SubmitButton.vue";
import type { Category } from "@halo-dev/api-client"; import type { Category } from "@halo-dev/api-client";
// libs // libs
import { cloneDeep } from "lodash-es";
import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus"; import { setFocus } from "@/formkit/utils/focus";
import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme"; import { useThemeCustomTemplates } from "@console/modules/interface/themes/composables/use-theme";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue"; import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import useSlugify from "@console/composables/use-slugify"; import useSlugify from "@console/composables/use-slugify";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { FormType } from "@/types/slug"; import { FormType } from "@/types/slug";
import { useQueryClient } from "@tanstack/vue-query";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
visible: boolean;
category?: Category; category?: Category;
parentCategory?: Category; parentCategory?: Category;
}>(), }>(),
{ {
visible: false,
category: undefined, category: undefined,
parentCategory: undefined, parentCategory: undefined,
} }
); );
const emit = defineEmits<{ const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void; (event: "close"): void;
}>(); }>();
const queryClient = useQueryClient();
const { t } = useI18n(); const { t } = useI18n();
const initialFormState: Category = { const formState = ref<Category>({
spec: { spec: {
displayName: "", displayName: "",
slug: "", slug: "",
@ -63,21 +60,16 @@ const initialFormState: Category = {
name: "", name: "",
generateName: "category-", generateName: "category-",
}, },
}; });
const selectedParentCategory = ref();
const formState = ref<Category>(cloneDeep(initialFormState));
const selectedParentCategory = ref("");
const saving = ref(false); const saving = ref(false);
const modal = ref();
const isUpdateMode = computed(() => { const isUpdateMode = !!props.category;
return !!formState.value.metadata.creationTimestamp;
});
const modalTitle = computed(() => { const modalTitle = props.category
return isUpdateMode.value ? t("core.post_category.editing_modal.titles.update")
? t("core.post_category.editing_modal.titles.update") : t("core.post_category.editing_modal.titles.create");
: t("core.post_category.editing_modal.titles.create");
});
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>(); const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
@ -98,7 +90,7 @@ const handleSaveCategory = async () => {
try { try {
saving.value = true; saving.value = true;
if (isUpdateMode.value) { if (isUpdateMode) {
await apiClient.extension.category.updatecontentHaloRunV1alpha1Category({ await apiClient.extension.category.updatecontentHaloRunV1alpha1Category({
name: formState.value.metadata.name, name: formState.value.metadata.name,
category: formState.value, category: formState.value,
@ -144,7 +136,10 @@ const handleSaveCategory = async () => {
); );
} }
} }
onVisibleChange(false);
modal.value.close();
queryClient.invalidateQueries({ queryKey: ["post-categories"] });
Toast.success(t("core.common.toast.save_success")); Toast.success(t("core.common.toast.save_success"));
} catch (e) { } catch (e) {
@ -154,37 +149,13 @@ const handleSaveCategory = async () => {
} }
}; };
const onVisibleChange = (visible: boolean) => { onMounted(() => {
emit("update:visible", visible); if (props.category) {
if (!visible) { formState.value = toRaw(props.category);
emit("close");
} }
}; selectedParentCategory.value = props.parentCategory?.metadata.name;
setFocus("displayNameInput");
const handleResetForm = () => { });
selectedParentCategory.value = "";
formState.value = cloneDeep(initialFormState);
reset("category-form");
};
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.parentCategory) {
selectedParentCategory.value = props.parentCategory.metadata.name;
}
if (props.category) {
formState.value = cloneDeep(props.category);
}
setFocus("displayNameInput");
} else {
handleResetForm();
}
}
);
// custom templates // custom templates
const { templates } = useThemeCustomTemplates("category"); const { templates } = useThemeCustomTemplates("category");
@ -200,17 +171,12 @@ const { handleGenerateSlug } = useSlugify(
formState.value.spec.slug = value; formState.value.spec.slug = value;
}, },
}), }),
computed(() => !isUpdateMode.value), computed(() => !isUpdateMode),
FormType.CATEGORY FormType.CATEGORY
); );
</script> </script>
<template> <template>
<VModal <VModal ref="modal" :title="modalTitle" :width="700" @close="emit('close')">
:title="modalTitle"
:visible="visible"
:width="700"
@update:visible="onVisibleChange"
>
<FormKit <FormKit
id="category-form" id="category-form"
type="form" type="form"
@ -331,14 +297,13 @@ const { handleGenerateSlug } = useSlugify(
<template #footer> <template #footer>
<VSpace> <VSpace>
<SubmitButton <SubmitButton
v-if="visible"
:loading="saving" :loading="saving"
type="secondary" type="secondary"
:text="$t('core.common.buttons.submit')" :text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('category-form')" @submit="$formkit.submit('category-form')"
> >
</SubmitButton> </SubmitButton>
<VButton @click="onVisibleChange(false)"> <VButton @click="modal.close()">
{{ $t("core.common.buttons.cancel_and_shortcut") }} {{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton> </VButton>
</VSpace> </VSpace>

View File

@ -1,148 +1,182 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import {
Dialog,
IconList, IconList,
VStatusDot, Toast,
VDropdownItem,
VEntity, VEntity,
VEntityField, VEntityField,
VDropdownItem, VStatusDot,
} from "@halo-dev/components"; } from "@halo-dev/components";
import Draggable from "vuedraggable"; import { VueDraggable } from "vue-draggable-plus";
import type { CategoryTree } from "../utils"; import { type CategoryTree, convertCategoryTreeToCategory } from "../utils";
import { ref } from "vue";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import type { PropType } from "vue";
import { ref } from "vue";
import CategoryEditingModal from "./CategoryEditingModal.vue";
import type { Category } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { apiClient } from "@/utils/api-client";
import { useQueryClient } from "@tanstack/vue-query";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
withDefaults( const categories = defineModel({
defineProps<{ type: Array as PropType<CategoryTree[]>,
categories: CategoryTree[]; default: [],
}>(), });
{
categories: () => [],
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(event: "change"): void; (event: "change"): void;
(event: "open-editing", category: CategoryTree): void;
(event: "open-create-by-parent", category: CategoryTree): void;
(event: "delete", category: CategoryTree): void;
}>(); }>();
const isDragging = ref(false); const queryClient = useQueryClient();
const { t } = useI18n();
function onChange() { function onChange() {
emit("change"); emit("change");
} }
function onOpenEditingModal(category: CategoryTree) { // Editing category
emit("open-editing", category); const editingModal = ref(false);
const selectedCategory = ref<Category>();
const selectedParentCategory = ref<Category>();
function onEditingModalClose() {
selectedCategory.value = undefined;
selectedParentCategory.value = undefined;
editingModal.value = false;
} }
function onOpenCreateByParentModal(category: CategoryTree) { const handleOpenEditingModal = (category: CategoryTree) => {
emit("open-create-by-parent", category); selectedCategory.value = convertCategoryTreeToCategory(category);
} editingModal.value = true;
};
function onDelete(category: CategoryTree) { const handleOpenCreateByParentModal = (category: CategoryTree) => {
emit("delete", category); selectedParentCategory.value = convertCategoryTreeToCategory(category);
} editingModal.value = true;
};
const handleDelete = async (category: CategoryTree) => {
Dialog.warning({
title: t("core.post_category.operations.delete.title"),
description: t("core.post_category.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.category.deletecontentHaloRunV1alpha1Category(
{
name: category.metadata.name,
}
);
Toast.success(t("core.common.toast.delete_success"));
queryClient.invalidateQueries({ queryKey: ["post-categories"] });
} catch (e) {
console.error("Failed to delete tag", e);
}
},
});
};
</script> </script>
<template> <template>
<draggable <VueDraggable
:list="categories" v-model="categories"
class="box-border h-full w-full divide-y divide-gray-100" class="box-border h-full w-full divide-y divide-gray-100"
ghost-class="opacity-50" ghost-class="opacity-50"
group="category-item" group="category-item"
handle=".drag-element" handle=".drag-element"
item-key="metadata.name"
tag="ul" tag="ul"
@change="onChange" @sort="onChange"
@end="isDragging = false"
@start="isDragging = true"
> >
<template #item="{ element: category }"> <CategoryEditingModal
<li> v-if="editingModal"
<VEntity> :category="selectedCategory"
<template #prepend> :parent-category="selectedParentCategory"
<div @close="onEditingModalClose"
v-permission="['system:posts:manage']" />
class="drag-element absolute inset-y-0 left-0 hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex" <li v-for="category in categories" :key="category.metadata.name">
> <VEntity>
<IconList class="h-3.5 w-3.5" /> <template #prepend>
</div> <div
</template> v-permission="['system:posts:manage']"
<template #start> class="drag-element absolute inset-y-0 left-0 hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex"
<VEntityField :title="category.spec.displayName">
<template #description>
<a
v-if="category.status.permalink"
:href="category.status.permalink"
:title="category.status.permalink"
target="_blank"
class="truncate text-xs text-gray-500 group-hover:text-gray-900"
>
{{ category.status.permalink }}
</a>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="category.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: category.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(category.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
> >
<VDropdownItem <IconList class="h-3.5 w-3.5" />
v-permission="['system:posts:manage']" </div>
@click="onOpenEditingModal(category)" </template>
> <template #start>
{{ $t("core.common.buttons.edit") }} <VEntityField :title="category.spec.displayName">
</VDropdownItem> <template #description>
<VDropdownItem @click="onOpenCreateByParentModal(category)"> <a
{{ $t("core.post_category.operations.add_sub_category.button") }} v-if="category.status?.permalink"
</VDropdownItem> :href="category.status.permalink"
<VDropdownItem :title="category.status.permalink"
v-permission="['system:posts:manage']" target="_blank"
type="danger" class="truncate text-xs text-gray-500 group-hover:text-gray-900"
@click="onDelete(category)" >
> {{ category.status.permalink }}
{{ $t("core.common.buttons.delete") }} </a>
</VDropdownItem> </template>
</template> </VEntityField>
</VEntity> </template>
<CategoryListItem <template #end>
:categories="category.spec.children" <VEntityField v-if="category.metadata.deletionTimestamp">
class="pl-10 transition-all duration-300" <template #description>
@change="onChange" <VStatusDot
@delete="onDelete" v-tooltip="$t('core.common.status.deleting')"
@open-editing="onOpenEditingModal" state="warning"
@open-create-by-parent="onOpenCreateByParentModal" animate
/> />
</li> </template>
</template> </VEntityField>
</draggable> <VEntityField
:description="
$t('core.common.fields.post_count', {
count: category.status?.postCount || 0,
})
"
/>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(category.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:posts:manage'])"
#dropdownItems
>
<VDropdownItem
v-permission="['system:posts:manage']"
@click="handleOpenEditingModal(category)"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="handleOpenCreateByParentModal(category)">
{{ $t("core.post_category.operations.add_sub_category.button") }}
</VDropdownItem>
<VDropdownItem
v-permission="['system:posts:manage']"
type="danger"
@click="handleDelete(category)"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
<CategoryListItem
v-model="category.spec.children"
class="pl-10 transition-all duration-300"
@change="onChange"
/>
</li>
</VueDraggable>
</template> </template>

View File

@ -2,23 +2,18 @@ import { apiClient } from "@/utils/api-client";
import type { Category } from "@halo-dev/api-client"; import type { Category } from "@halo-dev/api-client";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { ref } from "vue"; import { ref } from "vue";
import type { CategoryTree } from "@console/modules/contents/posts/categories/utils"; import type { CategoryTree } from "../utils";
import { buildCategoriesTree } from "@console/modules/contents/posts/categories/utils"; import { buildCategoriesTree } from "../utils";
import { Dialog, Toast } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
interface usePostCategoryReturn { interface usePostCategoryReturn {
categories: Ref<Category[] | undefined>; categories: Ref<Category[] | undefined>;
categoriesTree: Ref<CategoryTree[]>; categoriesTree: Ref<CategoryTree[]>;
isLoading: Ref<boolean>; isLoading: Ref<boolean>;
handleFetchCategories: () => void; handleFetchCategories: () => void;
handleDelete: (category: CategoryTree) => void;
} }
export function usePostCategory(): usePostCategoryReturn { export function usePostCategory(): usePostCategoryReturn {
const { t } = useI18n();
const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]); const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]);
const { const {
@ -38,47 +33,21 @@ export function usePostCategory(): usePostCategoryReturn {
return data.items; return data.items;
}, },
refetchInterval(data) { refetchInterval(data) {
const abnormalCategories = data?.filter( const hasAbnormalCategory = data?.some(
(category) => (category) =>
!!category.metadata.deletionTimestamp || !category.status?.permalink !!category.metadata.deletionTimestamp || !category.status?.permalink
); );
return abnormalCategories?.length ? 1000 : false; return hasAbnormalCategory ? 1000 : false;
}, },
onSuccess(data) { onSuccess(data) {
categoriesTree.value = buildCategoriesTree(data); categoriesTree.value = buildCategoriesTree(data);
}, },
}); });
const handleDelete = async (category: CategoryTree) => {
Dialog.warning({
title: t("core.post_category.operations.delete.title"),
description: t("core.post_category.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await apiClient.extension.category.deletecontentHaloRunV1alpha1Category(
{
name: category.metadata.name,
}
);
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete tag", e);
} finally {
await refetch();
}
},
});
};
return { return {
categories, categories,
categoriesTree, categoriesTree,
isLoading, isLoading,
handleFetchCategories: refetch, handleFetchCategories: refetch,
handleDelete,
}; };
} }

View File

@ -100,8 +100,7 @@
"vue-draggable-plus": "^0.4.1", "vue-draggable-plus": "^0.4.1",
"vue-grid-layout": "3.0.0-beta1", "vue-grid-layout": "3.0.0-beta1",
"vue-i18n": "^9.9.1", "vue-i18n": "^9.9.1",
"vue-router": "^4.2.5", "vue-router": "^4.2.5"
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.25.2", "@changesets/cli": "^2.25.2",

File diff suppressed because it is too large Load Diff

View File

@ -87,7 +87,7 @@ export function createViteConfig(options: Options) {
"axios", "axios",
"vue-grid-layout", "vue-grid-layout",
"transliteration", "transliteration",
"vuedraggable", "vue-draggable-plus",
"emoji-mart", "emoji-mart",
"colorjs.io", "colorjs.io",
"jsencrypt", "jsencrypt",