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

View File

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

View File

@ -1,148 +1,182 @@
<script lang="ts" setup>
import {
Dialog,
IconList,
VStatusDot,
Toast,
VDropdownItem,
VEntity,
VEntityField,
VDropdownItem,
VStatusDot,
} from "@halo-dev/components";
import Draggable from "vuedraggable";
import type { CategoryTree } from "../utils";
import { ref } from "vue";
import { VueDraggable } from "vue-draggable-plus";
import { type CategoryTree, convertCategoryTreeToCategory } from "../utils";
import { formatDatetime } from "@/utils/date";
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();
withDefaults(
defineProps<{
categories: CategoryTree[];
}>(),
{
categories: () => [],
}
);
const categories = defineModel({
type: Array as PropType<CategoryTree[]>,
default: [],
});
const emit = defineEmits<{
(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() {
emit("change");
}
function onOpenEditingModal(category: CategoryTree) {
emit("open-editing", category);
// 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) {
emit("open-create-by-parent", category);
}
const handleOpenEditingModal = (category: CategoryTree) => {
selectedCategory.value = convertCategoryTreeToCategory(category);
editingModal.value = true;
};
function onDelete(category: CategoryTree) {
emit("delete", category);
}
const handleOpenCreateByParentModal = (category: CategoryTree) => {
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>
<template>
<draggable
:list="categories"
<VueDraggable
v-model="categories"
class="box-border h-full w-full divide-y divide-gray-100"
ghost-class="opacity-50"
group="category-item"
handle=".drag-element"
item-key="metadata.name"
tag="ul"
@change="onChange"
@end="isDragging = false"
@start="isDragging = true"
@sort="onChange"
>
<template #item="{ element: category }">
<li>
<VEntity>
<template #prepend>
<div
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"
>
<IconList class="h-3.5 w-3.5" />
</div>
</template>
<template #start>
<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
<CategoryEditingModal
v-if="editingModal"
:category="selectedCategory"
:parent-category="selectedParentCategory"
@close="onEditingModalClose"
/>
<li v-for="category in categories" :key="category.metadata.name">
<VEntity>
<template #prepend>
<div
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"
>
<VDropdownItem
v-permission="['system:posts:manage']"
@click="onOpenEditingModal(category)"
>
{{ $t("core.common.buttons.edit") }}
</VDropdownItem>
<VDropdownItem @click="onOpenCreateByParentModal(category)">
{{ $t("core.post_category.operations.add_sub_category.button") }}
</VDropdownItem>
<VDropdownItem
v-permission="['system:posts:manage']"
type="danger"
@click="onDelete(category)"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
<CategoryListItem
:categories="category.spec.children"
class="pl-10 transition-all duration-300"
@change="onChange"
@delete="onDelete"
@open-editing="onOpenEditingModal"
@open-create-by-parent="onOpenCreateByParentModal"
/>
</li>
</template>
</draggable>
<IconList class="h-3.5 w-3.5" />
</div>
</template>
<template #start>
<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
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>

View File

@ -2,23 +2,18 @@ import { apiClient } from "@/utils/api-client";
import type { Category } from "@halo-dev/api-client";
import type { Ref } from "vue";
import { ref } from "vue";
import type { CategoryTree } from "@console/modules/contents/posts/categories/utils";
import { buildCategoriesTree } from "@console/modules/contents/posts/categories/utils";
import { Dialog, Toast } from "@halo-dev/components";
import type { CategoryTree } from "../utils";
import { buildCategoriesTree } from "../utils";
import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
interface usePostCategoryReturn {
categories: Ref<Category[] | undefined>;
categoriesTree: Ref<CategoryTree[]>;
isLoading: Ref<boolean>;
handleFetchCategories: () => void;
handleDelete: (category: CategoryTree) => void;
}
export function usePostCategory(): usePostCategoryReturn {
const { t } = useI18n();
const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]);
const {
@ -38,47 +33,21 @@ export function usePostCategory(): usePostCategoryReturn {
return data.items;
},
refetchInterval(data) {
const abnormalCategories = data?.filter(
const hasAbnormalCategory = data?.some(
(category) =>
!!category.metadata.deletionTimestamp || !category.status?.permalink
);
return abnormalCategories?.length ? 1000 : false;
return hasAbnormalCategory ? 1000 : false;
},
onSuccess(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 {
categories,
categoriesTree,
isLoading,
handleFetchCategories: refetch,
handleDelete,
};
}

View File

@ -100,8 +100,7 @@
"vue-draggable-plus": "^0.4.1",
"vue-grid-layout": "3.0.0-beta1",
"vue-i18n": "^9.9.1",
"vue-router": "^4.2.5",
"vuedraggable": "^4.1.0"
"vue-router": "^4.2.5"
},
"devDependencies": {
"@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",
"vue-grid-layout",
"transliteration",
"vuedraggable",
"vue-draggable-plus",
"emoji-mart",
"colorjs.io",
"jsencrypt",