mirror of https://github.com/halo-dev/halo-admin
feat: add full-functional post category select component for formkit (#818)
#### What type of PR is this? /kind feature #### What this PR does / why we need it: 添加功能更为全面的文章分类选择器,支持以下特性: 1. 按层级展示分类 2. 支持搜索 3. 选中结果支持显示层级 todo list: - [x] 样式整理 - [x] 支持创建新分类 #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2670 Fixes https://github.com/halo-dev/halo/issues/2485 #### Screenshots: <img width="832" alt="image" src="https://user-images.githubusercontent.com/21301288/211768419-087d9727-1468-41a1-868a-62d90eef9cca.png"> <img width="858" alt="image" src="https://user-images.githubusercontent.com/21301288/211768478-dcc70d79-127b-42b0-ae44-e48a6a22273a.png"> #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 重构 Console 端的文章分类选择器。 ```pull/826/head
parent
b132a950a8
commit
9d5529b827
|
@ -20,6 +20,8 @@
|
||||||
- `postSelect`:选择文章
|
- `postSelect`:选择文章
|
||||||
- `singlePageSelect`:选择自定义页面
|
- `singlePageSelect`:选择自定义页面
|
||||||
- `categorySelect`:选择分类
|
- `categorySelect`:选择分类
|
||||||
|
- 参数
|
||||||
|
1. multiple: 是否多选,默认为 `false`
|
||||||
- `categoryCheckbox`:选择多个分类
|
- `categoryCheckbox`:选择多个分类
|
||||||
- `tagSelect`:选择标签
|
- `tagSelect`:选择标签
|
||||||
- 参数
|
- 参数
|
||||||
|
|
|
@ -11,10 +11,10 @@ import { menuRadio } from "./inputs/menu-radio";
|
||||||
import { menuItemSelect } from "./inputs/menu-item-select";
|
import { menuItemSelect } from "./inputs/menu-item-select";
|
||||||
import { postSelect } from "./inputs/post-select";
|
import { postSelect } from "./inputs/post-select";
|
||||||
import { singlePageSelect } from "./inputs/singlePage-select";
|
import { singlePageSelect } from "./inputs/singlePage-select";
|
||||||
|
import { tagSelect } from "./inputs/tag-select";
|
||||||
import { categorySelect } from "./inputs/category-select";
|
import { categorySelect } from "./inputs/category-select";
|
||||||
import { categoryCheckbox } from "./inputs/category-checkbox";
|
import { categoryCheckbox } from "./inputs/category-checkbox";
|
||||||
import { tagCheckbox } from "./inputs/tag-checkbox";
|
import { tagCheckbox } from "./inputs/tag-checkbox";
|
||||||
import { tagSelect } from "./inputs/tag-select/index";
|
|
||||||
|
|
||||||
import radioAlt from "./plugins/radio-alt";
|
import radioAlt from "./plugins/radio-alt";
|
||||||
import stopImplicitSubmission from "./plugins/stop-implicit-submission";
|
import stopImplicitSubmission from "./plugins/stop-implicit-submission";
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
import { apiClient } from "@/utils/api-client";
|
|
||||||
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
|
|
||||||
import { select, selects, defaultIcon } from "@formkit/inputs";
|
|
||||||
|
|
||||||
function optionsHandler(node: FormKitNode) {
|
|
||||||
node.on("created", async () => {
|
|
||||||
const { data } =
|
|
||||||
await apiClient.extension.category.listcontentHaloRunV1alpha1Category();
|
|
||||||
|
|
||||||
node.props.options = data.items.map((category) => {
|
|
||||||
return {
|
|
||||||
value: category.metadata.name,
|
|
||||||
label: category.spec.displayName,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const categorySelect: FormKitTypeDefinition = {
|
|
||||||
...select,
|
|
||||||
props: ["placeholder"],
|
|
||||||
forceTypeProp: "select",
|
|
||||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
|
||||||
};
|
|
|
@ -0,0 +1,315 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { FormKitFrameworkContext } from "@formkit/core";
|
||||||
|
import type { Category } from "@halo-dev/api-client";
|
||||||
|
import { computed, provide, ref, watch, type PropType, type Ref } from "vue";
|
||||||
|
import { IconArrowRight } from "@halo-dev/components";
|
||||||
|
import { usePostCategory } from "@/modules/contents/posts/categories/composables/use-post-category";
|
||||||
|
import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
|
||||||
|
import { convertTreeToCategories } from "@/modules/contents/posts/categories/utils";
|
||||||
|
import CategoryListItem from "./components/CategoryListItem.vue";
|
||||||
|
import { onClickOutside } from "@vueuse/core";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
import CategoryTag from "./components/CategoryTag.vue";
|
||||||
|
import SearchResultListItem from "./components/SearchResultListItem.vue";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import { usePermission } from "@/utils/permission";
|
||||||
|
|
||||||
|
const { currentUserHasPermission } = usePermission();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
context: {
|
||||||
|
type: Object as PropType<FormKitFrameworkContext>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const multiple = computed(() => {
|
||||||
|
const { multiple } = props.context;
|
||||||
|
if (multiple === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof multiple === "boolean") {
|
||||||
|
return multiple;
|
||||||
|
}
|
||||||
|
return multiple === "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
const { categories, categoriesTree, handleFetchCategories } = usePostCategory({
|
||||||
|
fetchOnMounted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
provide<Ref<CategoryTree[]>>("categoriesTree", categoriesTree);
|
||||||
|
|
||||||
|
const selectedCategory = ref<Category | CategoryTree>();
|
||||||
|
|
||||||
|
provide<Ref<Category | CategoryTree | undefined>>(
|
||||||
|
"selectedCategory",
|
||||||
|
selectedCategory
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownVisible = ref(false);
|
||||||
|
const text = ref("");
|
||||||
|
const wrapperRef = ref<HTMLElement>();
|
||||||
|
|
||||||
|
onClickOutside(wrapperRef, () => {
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// search
|
||||||
|
let fuse: Fuse<Category> | undefined = undefined;
|
||||||
|
|
||||||
|
const searchResults = computed(() => {
|
||||||
|
if (!text.value) {
|
||||||
|
return categories.value;
|
||||||
|
}
|
||||||
|
return fuse?.search(text.value).map((item) => item.item) || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => searchResults.value,
|
||||||
|
(value) => {
|
||||||
|
if (value?.length > 0 && text.value) {
|
||||||
|
selectedCategory.value = value[0];
|
||||||
|
scrollToSelected();
|
||||||
|
} else {
|
||||||
|
selectedCategory.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => categories.value,
|
||||||
|
() => {
|
||||||
|
fuse = new Fuse(categories.value, {
|
||||||
|
keys: ["spec.displayName", "spec.slug"],
|
||||||
|
useExtendedSearch: true,
|
||||||
|
threshold: 0.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedCategories = computed(() => {
|
||||||
|
if (multiple.value) {
|
||||||
|
const currentValue = props.context._value || [];
|
||||||
|
return currentValue
|
||||||
|
.map((categoryName): Category | undefined => {
|
||||||
|
return categories.value.find(
|
||||||
|
(category) => category.metadata.name === categoryName
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = categories.value.find(
|
||||||
|
(category) => category.metadata.name === props.context._value
|
||||||
|
);
|
||||||
|
return [category].filter(Boolean) as Category[];
|
||||||
|
});
|
||||||
|
|
||||||
|
const isSelected = (category: CategoryTree | Category) => {
|
||||||
|
if (multiple.value) {
|
||||||
|
return (props.context._value || []).includes(category.metadata.name);
|
||||||
|
}
|
||||||
|
return props.context._value === category.metadata.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
provide<(category: CategoryTree | Category) => boolean>(
|
||||||
|
"isSelected",
|
||||||
|
isSelected
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (category: CategoryTree | Category) => {
|
||||||
|
if (multiple.value) {
|
||||||
|
const currentValue = props.context._value || [];
|
||||||
|
if (currentValue.includes(category.metadata.name)) {
|
||||||
|
props.context.node.input(
|
||||||
|
currentValue.filter((name: string) => name !== category.metadata.name)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
props.context.node.input([...currentValue, category.metadata.name]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.context.node.input(
|
||||||
|
category.metadata.name === props.context._value
|
||||||
|
? ""
|
||||||
|
: category.metadata.name
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const categoryIndices = text.value
|
||||||
|
? searchResults.value
|
||||||
|
: convertTreeToCategories(categoriesTree.value);
|
||||||
|
|
||||||
|
const index = categoryIndices.findIndex(
|
||||||
|
(category) =>
|
||||||
|
category.metadata.name === selectedCategory.value?.metadata.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index < searchResults.value.length - 1) {
|
||||||
|
selectedCategory.value = categoryIndices[index + 1];
|
||||||
|
}
|
||||||
|
scrollToSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const categoryIndices = text.value
|
||||||
|
? searchResults.value
|
||||||
|
: convertTreeToCategories(categoriesTree.value);
|
||||||
|
|
||||||
|
const index = categoryIndices.findIndex(
|
||||||
|
(tag) => tag.metadata.name === selectedCategory.value?.metadata.name
|
||||||
|
);
|
||||||
|
if (index > 0) {
|
||||||
|
selectedCategory.value = categoryIndices[index - 1];
|
||||||
|
}
|
||||||
|
scrollToSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (searchResults.value.length === 0 && text.value) {
|
||||||
|
handleCreateCategory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedCategory.value) {
|
||||||
|
handleSelect(selectedCategory.value);
|
||||||
|
text.value = "";
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToSelected = () => {
|
||||||
|
if (!selectedCategory.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedNode = document.getElementById(
|
||||||
|
`category-${selectedCategory.value?.metadata.name}`
|
||||||
|
);
|
||||||
|
if (selectedNode) {
|
||||||
|
selectedNode.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
inline: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateCategory = async () => {
|
||||||
|
if (!currentUserHasPermission(["system:posts:manage"])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } =
|
||||||
|
await apiClient.extension.category.createcontentHaloRunV1alpha1Category({
|
||||||
|
category: {
|
||||||
|
spec: {
|
||||||
|
displayName: text.value,
|
||||||
|
slug: text.value,
|
||||||
|
description: "",
|
||||||
|
cover: "",
|
||||||
|
template: "",
|
||||||
|
priority: categories.value.length + 1,
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
apiVersion: "content.halo.run/v1alpha1",
|
||||||
|
kind: "Category",
|
||||||
|
metadata: {
|
||||||
|
name: "",
|
||||||
|
generateName: "category-",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handleFetchCategories();
|
||||||
|
handleSelect(data);
|
||||||
|
text.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// update value immediately during IME composition
|
||||||
|
// please see https://vuejs.org//guide/essentials/forms.html#text
|
||||||
|
const onTextInput = (e: Event) => {
|
||||||
|
text.value = (e.target as HTMLInputElement).value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// delete last category when text input is empty
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (!text.value) {
|
||||||
|
if (multiple.value) {
|
||||||
|
const selectedTagNames = (props.context._value as string[]) || [];
|
||||||
|
props.context.node.input(selectedTagNames.slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
props.context.node.input("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="wrapperRef"
|
||||||
|
:class="context.classes['post-categories-wrapper']"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<div :class="context.classes['post-categories']">
|
||||||
|
<CategoryTag
|
||||||
|
v-for="(category, index) in selectedCategories"
|
||||||
|
:key="index"
|
||||||
|
:category="category"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:value="text"
|
||||||
|
:class="context.classes.input"
|
||||||
|
type="text"
|
||||||
|
@input="onTextInput"
|
||||||
|
@focus="dropdownVisible = true"
|
||||||
|
@keydown.delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:class="context.classes['post-categories-button']"
|
||||||
|
@click="dropdownVisible = !dropdownVisible"
|
||||||
|
>
|
||||||
|
<IconArrowRight class="rotate-90 text-gray-500 hover:text-gray-700" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dropdownVisible" :class="context.classes['dropdown-wrapper']">
|
||||||
|
<ul class="p-1">
|
||||||
|
<li
|
||||||
|
v-if="text.trim() && searchResults.length <= 0"
|
||||||
|
v-permission="['system:posts:manage']"
|
||||||
|
class="group flex cursor-pointer items-center justify-between rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-700 group-hover:text-gray-900">
|
||||||
|
创建 {{ text }} 分类
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<template v-if="text">
|
||||||
|
<SearchResultListItem
|
||||||
|
v-for="category in searchResults"
|
||||||
|
:key="category.metadata.name"
|
||||||
|
:category="category"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<CategoryListItem
|
||||||
|
v-for="category in categoriesTree"
|
||||||
|
:key="category.metadata.name"
|
||||||
|
:category="category"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,68 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
|
||||||
|
import type { Category } from "@halo-dev/api-client";
|
||||||
|
import { IconCheckboxCircle } from "@halo-dev/components";
|
||||||
|
import { inject, ref, type Ref } from "vue";
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
category: CategoryTree;
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelected = inject<(category: CategoryTree) => boolean>("isSelected");
|
||||||
|
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
|
||||||
|
"selectedCategory",
|
||||||
|
ref(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "select", category: CategoryTree): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const onSelect = (childCategory: CategoryTree) => {
|
||||||
|
emit("select", childCategory);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li :id="`category-${category.metadata.name}`">
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded p-2 hover:bg-gray-100"
|
||||||
|
:class="{
|
||||||
|
'bg-gray-100':
|
||||||
|
selectedCategory?.metadata.name === category.metadata.name,
|
||||||
|
}"
|
||||||
|
@click="emit('select', category)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex-1 truncate text-xs text-gray-700 group-hover:text-gray-900"
|
||||||
|
:class="{
|
||||||
|
'text-gray-900':
|
||||||
|
isSelected?.(category) &&
|
||||||
|
selectedCategory?.metadata.name === category.metadata.name,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ category.spec.displayName }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<IconCheckboxCircle
|
||||||
|
class="text-primary opacity-0"
|
||||||
|
:class="{ 'opacity-100': isSelected?.(category) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="category.spec.children.length > 0"
|
||||||
|
class="my-2.5 ml-2.5 border-l pl-1.5"
|
||||||
|
>
|
||||||
|
<CategoryListItem
|
||||||
|
v-for="(childCategory, index) in category.spec.children"
|
||||||
|
:key="index"
|
||||||
|
:category="childCategory"
|
||||||
|
@select="onSelect"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</template>
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
|
||||||
|
import { getCategoryPath } from "@/modules/contents/posts/categories/utils";
|
||||||
|
import type { Category } from "@halo-dev/api-client";
|
||||||
|
import { computed, inject, ref, type Ref } from "vue";
|
||||||
|
import { IconClose } from "@halo-dev/components";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
category: Category;
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "select", category: CategoryTree | Category): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const categoriesTree = inject<Ref<CategoryTree[]>>("categoriesTree", ref([]));
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
const categories = getCategoryPath(
|
||||||
|
categoriesTree.value,
|
||||||
|
props.category.metadata.name
|
||||||
|
);
|
||||||
|
return categories
|
||||||
|
.map((category: CategoryTree) => category.spec.displayName)
|
||||||
|
.join(" / ");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="inline-flex items-center p-1">
|
||||||
|
<div
|
||||||
|
class="box-border inline-flex min-h-[1.25rem] items-center gap-1 rounded-full border border-solid border-[#d9d9d9] bg-white px-1 align-middle"
|
||||||
|
>
|
||||||
|
<span class="flex-1 text-xs">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<IconClose
|
||||||
|
class="h-4 w-4 cursor-pointer text-gray-600 hover:text-gray-900"
|
||||||
|
@click="emit('select', category)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,67 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
getCategoryPath,
|
||||||
|
type CategoryTree,
|
||||||
|
} from "@/modules/contents/posts/categories/utils";
|
||||||
|
import type { Category } from "@halo-dev/api-client";
|
||||||
|
import { IconCheckboxCircle } from "@halo-dev/components";
|
||||||
|
import { computed, inject, ref, type Ref } from "vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
category: Category;
|
||||||
|
}>(),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoriesTree = inject<Ref<CategoryTree[]>>("categoriesTree", ref([]));
|
||||||
|
const selectedCategory = inject<Ref<Category | CategoryTree | undefined>>(
|
||||||
|
"selectedCategory",
|
||||||
|
ref(undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelected =
|
||||||
|
inject<(category: Category | CategoryTree) => boolean>("isSelected");
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "select", category: CategoryTree | Category): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
const categories = getCategoryPath(
|
||||||
|
categoriesTree.value,
|
||||||
|
props.category.metadata.name
|
||||||
|
);
|
||||||
|
return categories
|
||||||
|
.map((category: CategoryTree) => category.spec.displayName)
|
||||||
|
.join(" / ");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li :id="`category-${category.metadata.name}`">
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center justify-between rounded p-2 hover:bg-gray-100"
|
||||||
|
:class="{
|
||||||
|
'bg-gray-100':
|
||||||
|
selectedCategory?.metadata.name === category.metadata.name,
|
||||||
|
}"
|
||||||
|
@click="emit('select', category)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex-1 truncate text-xs text-gray-700 group-hover:text-gray-900"
|
||||||
|
:class="{
|
||||||
|
'text-gray-900':
|
||||||
|
isSelected?.(category) &&
|
||||||
|
selectedCategory?.metadata.name === category.metadata.name,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<IconCheckboxCircle
|
||||||
|
class="h-5 w-5 text-primary opacity-0"
|
||||||
|
:class="{ 'opacity-100': isSelected?.(category) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { FormKitTypeDefinition } from "@formkit/core";
|
||||||
|
import {
|
||||||
|
help,
|
||||||
|
icon,
|
||||||
|
inner,
|
||||||
|
label,
|
||||||
|
message,
|
||||||
|
messages,
|
||||||
|
outer,
|
||||||
|
prefix,
|
||||||
|
suffix,
|
||||||
|
wrapper,
|
||||||
|
} from "@formkit/inputs";
|
||||||
|
import CategorySelect from "./CategorySelect.vue";
|
||||||
|
import { CategorySelectSection } from "./sections";
|
||||||
|
|
||||||
|
export const categorySelect: FormKitTypeDefinition = {
|
||||||
|
schema: outer(
|
||||||
|
wrapper(
|
||||||
|
label("$label"),
|
||||||
|
inner(
|
||||||
|
icon("prefix"),
|
||||||
|
prefix(),
|
||||||
|
CategorySelectSection(),
|
||||||
|
suffix(),
|
||||||
|
icon("suffix")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
help("$help"),
|
||||||
|
messages(message("$message.value"))
|
||||||
|
),
|
||||||
|
type: "input",
|
||||||
|
props: ["multiple"],
|
||||||
|
library: {
|
||||||
|
CategorySelect: CategorySelect,
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createSection } from "@formkit/inputs";
|
||||||
|
|
||||||
|
export const CategorySelectSection = createSection(
|
||||||
|
"CategorySelectSection",
|
||||||
|
() => ({
|
||||||
|
$cmp: "CategorySelect",
|
||||||
|
props: {
|
||||||
|
context: "$node.context",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
|
@ -104,6 +104,17 @@ const theme: Record<string, Record<string, string>> = {
|
||||||
"dropdown-wrapper":
|
"dropdown-wrapper":
|
||||||
"absolute ring-1 ring-white top-full bottom-auto right-0 z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg",
|
"absolute ring-1 ring-white top-full bottom-auto right-0 z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg",
|
||||||
},
|
},
|
||||||
|
categorySelect: {
|
||||||
|
...textClassification,
|
||||||
|
inner: `${textClassification.inner} !overflow-visible !h-auto min-h-[2.25rem]`,
|
||||||
|
input: `w-0 flex-grow outline-0 bg-transparent py-1 px-3 block transition-all appearance-none text-sm antialiased`,
|
||||||
|
"post-categories-wrapper": "flex w-full items-center",
|
||||||
|
"post-categories": "flex w-full flex-wrap items-center",
|
||||||
|
"post-categories-button":
|
||||||
|
"inline-flex h-full cursor-pointer items-center px-1",
|
||||||
|
"dropdown-wrapper":
|
||||||
|
"absolute ring-1 ring-white top-full bottom-auto right-0 z-10 mt-1 max-h-96 w-full overflow-auto rounded bg-white shadow-lg",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default theme;
|
export default theme;
|
||||||
|
|
|
@ -121,3 +121,26 @@ export function convertCategoryTreeToCategory(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getCategoryPath = (
|
||||||
|
categories: CategoryTree[],
|
||||||
|
name: string,
|
||||||
|
path: CategoryTree[] = []
|
||||||
|
) => {
|
||||||
|
for (const category of categories) {
|
||||||
|
if (category.metadata && category.metadata.name === name) {
|
||||||
|
return path.concat([category]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.spec && category.spec.children) {
|
||||||
|
const found = getCategoryPath(
|
||||||
|
category.spec.children,
|
||||||
|
name,
|
||||||
|
path.concat([category])
|
||||||
|
);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -270,7 +270,8 @@ const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
|
||||||
v-model="formState.spec.categories"
|
v-model="formState.spec.categories"
|
||||||
label="分类目录"
|
label="分类目录"
|
||||||
name="categories"
|
name="categories"
|
||||||
type="categoryCheckbox"
|
type="categorySelect"
|
||||||
|
:multiple="true"
|
||||||
/>
|
/>
|
||||||
<FormKit
|
<FormKit
|
||||||
v-model="formState.spec.tags"
|
v-model="formState.spec.tags"
|
||||||
|
|
Loading…
Reference in New Issue