mirror of https://github.com/halo-dev/halo
feat: add full-functional post tag select component for formkit (halo-dev/console#817)
#### 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 #### Screenshots: <img width="796" alt="image" src="https://user-images.githubusercontent.com/21301288/211768172-b8dbf1c2-5f7c-4411-b8cc-f0070320649d.png"> <img width="725" alt="image" src="https://user-images.githubusercontent.com/21301288/211768235-8d587374-abad-40c9-b9e0-ea2b27e58495.png"> #### Special notes for your reviewer: 1. 创建若干文章,并进行标签设置的操作。 2. 测试标签选择,搜索,创建,删除,取消选择等操作。 #### Does this PR introduce a user-facing change? ```release-note 重构 Console 端的文章标签选择器。 ```pull/3445/head
parent
c06affb46b
commit
e0df9ade6e
|
@ -22,6 +22,8 @@
|
|||
- `categorySelect`:选择分类
|
||||
- `categoryCheckbox`:选择多个分类
|
||||
- `tagSelect`:选择标签
|
||||
- 参数
|
||||
1. multiple: 是否多选,默认为 `false`
|
||||
- `tagCheckbox`:选择多个标签
|
||||
|
||||
在 Vue 单组件中使用:
|
||||
|
|
|
@ -12,9 +12,9 @@ import { menuItemSelect } from "./inputs/menu-item-select";
|
|||
import { postSelect } from "./inputs/post-select";
|
||||
import { singlePageSelect } from "./inputs/singlePage-select";
|
||||
import { categorySelect } from "./inputs/category-select";
|
||||
import { tagSelect } from "./inputs/tag-select";
|
||||
import { categoryCheckbox } from "./inputs/category-checkbox";
|
||||
import { tagCheckbox } from "./inputs/tag-checkbox";
|
||||
import { tagSelect } from "./inputs/tag-select/index";
|
||||
|
||||
import radioAlt from "./plugins/radio-alt";
|
||||
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.tag.listcontentHaloRunV1alpha1Tag();
|
||||
|
||||
node.props.options = data.items.map((tag) => {
|
||||
return {
|
||||
value: tag.metadata.name,
|
||||
label: tag.spec.displayName,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const tagSelect: FormKitTypeDefinition = {
|
||||
...select,
|
||||
props: ["placeholder"],
|
||||
forceTypeProp: "select",
|
||||
features: [optionsHandler, selects, defaultIcon("select", "select")],
|
||||
};
|
|
@ -0,0 +1,308 @@
|
|||
<script lang="ts" setup>
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import type { FormKitFrameworkContext } from "@formkit/core";
|
||||
import type { Tag } from "@halo-dev/api-client";
|
||||
import { computed, onMounted, ref, watch, type PropType } from "vue";
|
||||
import PostTag from "@/modules/contents/posts/tags/components/PostTag.vue";
|
||||
import {
|
||||
IconCheckboxCircle,
|
||||
IconArrowRight,
|
||||
IconClose,
|
||||
} from "@halo-dev/components";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import Fuse from "fuse.js";
|
||||
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 postTags = ref<Tag[]>([] as Tag[]);
|
||||
const selectedTag = ref<Tag>();
|
||||
const dropdownVisible = ref(false);
|
||||
const text = ref("");
|
||||
const wrapperRef = ref<HTMLElement>();
|
||||
|
||||
onClickOutside(wrapperRef, () => {
|
||||
dropdownVisible.value = false;
|
||||
});
|
||||
|
||||
// search
|
||||
|
||||
let fuse: Fuse<Tag> | undefined = undefined;
|
||||
|
||||
const searchResults = computed(() => {
|
||||
if (!fuse || !text.value) {
|
||||
return postTags.value;
|
||||
}
|
||||
|
||||
return fuse?.search(text.value).map((item) => item.item);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => searchResults.value,
|
||||
(value) => {
|
||||
if (value?.length > 0 && text.value) {
|
||||
selectedTag.value = value[0];
|
||||
scrollToSelected();
|
||||
} else {
|
||||
selectedTag.value = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleFetchTags = async () => {
|
||||
const { data } = await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag({
|
||||
page: 0,
|
||||
size: 0,
|
||||
});
|
||||
|
||||
postTags.value = data.items;
|
||||
|
||||
fuse = new Fuse(data.items, {
|
||||
keys: ["spec.displayName", "spec.slug"],
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.2,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTags = computed(() => {
|
||||
if (multiple.value) {
|
||||
const selectedTagNames = (props.context._value as string[]) || [];
|
||||
return selectedTagNames
|
||||
.map((tagName): Tag | undefined => {
|
||||
return postTags.value.find((tag) => tag.metadata.name === tagName);
|
||||
})
|
||||
.filter(Boolean) as Tag[];
|
||||
}
|
||||
|
||||
const tag = postTags.value.find(
|
||||
(tag) => tag.metadata.name === props.context._value
|
||||
);
|
||||
|
||||
return [tag].filter(Boolean) as Tag[];
|
||||
});
|
||||
|
||||
const isSelected = (tag: Tag) => {
|
||||
if (multiple.value) {
|
||||
return (props.context._value || []).includes(tag.metadata.name);
|
||||
}
|
||||
|
||||
return props.context._value === tag.metadata.name;
|
||||
};
|
||||
|
||||
const handleSelect = (tag: Tag) => {
|
||||
if (multiple.value) {
|
||||
const currentValue = props.context._value || [];
|
||||
if (currentValue.includes(tag.metadata.name)) {
|
||||
props.context.node.input(
|
||||
currentValue.filter((t) => t !== tag.metadata.name)
|
||||
);
|
||||
} else {
|
||||
props.context.node.input([...currentValue, tag.metadata.name]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
props.context.node.input(
|
||||
tag.metadata.name === props.context._value ? "" : tag.metadata.name
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
|
||||
const index = searchResults.value.findIndex(
|
||||
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
|
||||
);
|
||||
if (index < searchResults.value.length - 1) {
|
||||
selectedTag.value = searchResults.value[index + 1];
|
||||
}
|
||||
|
||||
scrollToSelected();
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
|
||||
const index = searchResults.value.findIndex(
|
||||
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
|
||||
);
|
||||
if (index > 0) {
|
||||
selectedTag.value = searchResults.value[index - 1];
|
||||
}
|
||||
|
||||
scrollToSelected();
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
if (searchResults.value.length === 0 && text.value) {
|
||||
handleCreateTag();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTag.value) {
|
||||
handleSelect(selectedTag.value);
|
||||
text.value = "";
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToSelected = () => {
|
||||
if (!selectedTag.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedNode = document.getElementById(
|
||||
selectedTag.value?.metadata.name
|
||||
);
|
||||
|
||||
if (selectedNode) {
|
||||
selectedNode.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "start",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTag = async () => {
|
||||
if (!currentUserHasPermission(["system:posts:manage"])) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } =
|
||||
await apiClient.extension.tag.createcontentHaloRunV1alpha1Tag({
|
||||
tag: {
|
||||
spec: {
|
||||
displayName: text.value,
|
||||
slug: text.value,
|
||||
color: "#ffffff",
|
||||
cover: "",
|
||||
},
|
||||
apiVersion: "content.halo.run/v1alpha1",
|
||||
kind: "Tag",
|
||||
metadata: {
|
||||
name: "",
|
||||
generateName: "tag-",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
handleFetchTags();
|
||||
|
||||
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 tag 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("");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(handleFetchTags);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wrapperRef"
|
||||
:class="context.classes['post-tags-wrapper']"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div :class="context.classes['post-tags']">
|
||||
<div
|
||||
v-for="(tag, index) in selectedTags"
|
||||
:key="index"
|
||||
:class="context.classes['post-tag-wrapper']"
|
||||
>
|
||||
<PostTag :tag="tag" rounded>
|
||||
<template #rightIcon>
|
||||
<IconClose
|
||||
:class="context.classes['post-tag-close']"
|
||||
@click="handleSelect(tag)"
|
||||
/>
|
||||
</template>
|
||||
</PostTag>
|
||||
</div>
|
||||
<input
|
||||
:value="text"
|
||||
:class="context.classes.input"
|
||||
type="text"
|
||||
@input="onTextInput"
|
||||
@focus="dropdownVisible = true"
|
||||
@keydown.delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="context.classes['post-tags-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"
|
||||
@click="handleCreateTag"
|
||||
>
|
||||
<span class="text-xs text-gray-700 group-hover:text-gray-900">
|
||||
创建 {{ text }} 标签
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="tag in searchResults"
|
||||
:id="tag.metadata.name"
|
||||
:key="tag.metadata.name"
|
||||
class="group flex cursor-pointer items-center justify-between rounded p-2 hover:bg-gray-100"
|
||||
:class="{
|
||||
'bg-gray-100': selectedTag?.metadata.name === tag.metadata.name,
|
||||
}"
|
||||
@click="handleSelect(tag)"
|
||||
>
|
||||
<div class="inline-flex items-center overflow-hidden">
|
||||
<PostTag :tag="tag" />
|
||||
</div>
|
||||
<IconCheckboxCircle v-if="isSelected(tag)" class="text-primary" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</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 TagSelect from "./TagSelect.vue";
|
||||
import { TagSelectSection } from "./sections";
|
||||
|
||||
export const tagSelect: FormKitTypeDefinition = {
|
||||
schema: outer(
|
||||
wrapper(
|
||||
label("$label"),
|
||||
inner(
|
||||
icon("prefix"),
|
||||
prefix(),
|
||||
TagSelectSection(),
|
||||
suffix(),
|
||||
icon("suffix")
|
||||
)
|
||||
),
|
||||
help("$help"),
|
||||
messages(message("$message.value"))
|
||||
),
|
||||
type: "input",
|
||||
props: ["multiple"],
|
||||
library: {
|
||||
TagSelect: TagSelect,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
import { createSection } from "@formkit/inputs";
|
||||
|
||||
export const TagSelectSection = createSection("TagSelectSection", () => ({
|
||||
$cmp: "TagSelect",
|
||||
props: {
|
||||
context: "$node.context",
|
||||
},
|
||||
}));
|
|
@ -91,6 +91,19 @@ const theme: Record<string, Record<string, string>> = {
|
|||
content: "flex-1 p-2 col-span-11",
|
||||
controls: "bg-gray-200 col-span-1 flex items-center justify-center",
|
||||
},
|
||||
tagSelect: {
|
||||
...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-tags-wrapper": "flex w-full items-center",
|
||||
"post-tags": "flex w-full flex-wrap items-center",
|
||||
"post-tag-wrapper": "inline-flex items-center p-1",
|
||||
"post-tag-close":
|
||||
"h-4 w-4 cursor-pointer text-gray-600 hover:text-gray-900",
|
||||
"post-tags-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;
|
||||
|
|
|
@ -276,7 +276,8 @@ const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
|
|||
v-model="formState.spec.tags"
|
||||
label="标签"
|
||||
name="tags"
|
||||
type="tagCheckbox"
|
||||
type="tagSelect"
|
||||
:multiple="true"
|
||||
/>
|
||||
<FormKit
|
||||
v-model="formState.spec.excerpt.autoGenerate"
|
||||
|
|
|
@ -8,9 +8,11 @@ import Color from "colorjs.io";
|
|||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tag: Tag;
|
||||
rounded?: boolean;
|
||||
route?: boolean;
|
||||
}>(),
|
||||
{
|
||||
rounded: false,
|
||||
route: false,
|
||||
}
|
||||
);
|
||||
|
@ -43,8 +45,13 @@ const handleRouteToDetail = () => {
|
|||
background: tag.spec.color,
|
||||
color: labelColor,
|
||||
}"
|
||||
:rounded="rounded"
|
||||
@click="handleRouteToDetail"
|
||||
>
|
||||
{{ tag.spec.displayName }}
|
||||
|
||||
<template #rightIcon>
|
||||
<slot name="rightIcon" />
|
||||
</template>
|
||||
</VTag>
|
||||
</template>
|
||||
|
|
Loading…
Reference in New Issue