mirror of https://github.com/halo-dev/halo
feat: add accepts and min,max props for attachment selector modal component (#3827)
#### What type of PR is this? /kind feature /area console /milestone 2.5.x #### What this PR does / why we need it: 附件选择组件(AttachmentSelectorModal)支持 accepts、min、max 参数用来限定文件格式和数量。同时也为 FormKit 的 attachment 类型添加同样的参数。 另外,Console 的部分表单也跟着做了修改,包括:文章/页面设置中的封面图、系统设置中的 Favicon 和 Logo、分类/标签编辑表单中的封面图、用户资料的头像。 FormKit 中使用: 1. Component ```vue <FormKit name="cover" type="attachment" :accepts="['image/*']" ></FormKit> ``` 2. Schema ```yaml - $formkit: attachment name: cover accepts: - 'image/*' ``` #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/3800 #### Special notes for your reviewer: 测试方式: 1. 按照上述 FormKit 中的使用方式,自行在主题或者插件配置文件中测试。 2. 测试 Console 中修改的表单:文章/页面设置中的封面图、系统设置中的 Favicon 和 Logo、分类/标签编辑表单中的封面图、用户资料的头像。(均设置为仅允许选择图片(image/*)和最多选择一个(max=1))。 #### Does this PR introduce a user-facing change? ```release-note Console 端的附件选择组件支持 accepts、min、max 参数用来限定文件格式和数量。 ```pull/3832/merge
parent
60040ae428
commit
d441e4731e
|
@ -17,9 +17,13 @@ spec:
|
|||
- $formkit: attachment
|
||||
label: Logo
|
||||
name: logo
|
||||
accepts:
|
||||
- 'image/*'
|
||||
- $formkit: attachment
|
||||
label: Favicon
|
||||
name: favicon
|
||||
accepts:
|
||||
- 'image/*'
|
||||
- group: post
|
||||
label: 文章设置
|
||||
formSchema:
|
||||
|
|
|
@ -13,6 +13,8 @@
|
|||
1. language: 目前支持 `yaml`, `html`, `css`, `javascript`, `json`
|
||||
2. height: 编辑器高度,如:`100px`
|
||||
- `attachment`: 附件选择
|
||||
- 参数
|
||||
1. accepts:允许上传的文件类型,如:`image/*`
|
||||
- `repeater`: 定义一个对象集合,可以让使用者可视化的操作集合。
|
||||
- `menuCheckbox`:选择一组菜单
|
||||
- `menuRadio`:选择一个菜单
|
||||
|
|
|
@ -56,6 +56,9 @@ const onAttachmentSelect = (attachments: AttachmentLike[]) => {
|
|||
</div>
|
||||
<AttachmentSelectorModal
|
||||
v-model:visible="attachmentSelectorModal"
|
||||
:accepts="context.accepts as string[]"
|
||||
:min="1"
|
||||
:max="1"
|
||||
@select="onAttachmentSelect"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -4,7 +4,7 @@ import AttachmentInput from "./AttachmentInput.vue";
|
|||
|
||||
export const attachment = createInput(AttachmentInput, {
|
||||
type: "input",
|
||||
props: [],
|
||||
props: ["accepts"],
|
||||
forceTypeProp: "text",
|
||||
features: [initialValue],
|
||||
});
|
||||
|
|
|
@ -109,7 +109,7 @@ const iconClass = computed(() => {
|
|||
<component :is="getIcon" :class="iconClass" />
|
||||
<span
|
||||
v-if="getExtname && displayExt"
|
||||
class="font-sans text-xs text-gray-500"
|
||||
class="select-none font-sans text-xs text-gray-500"
|
||||
>
|
||||
{{ getExtname }}
|
||||
</span>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts" setup>
|
||||
import { VButton, VModal, VTabbar } from "@halo-dev/components";
|
||||
import { ref, markRaw, onMounted } from "vue";
|
||||
import { VButton, VModal, VSpace, VTabbar } from "@halo-dev/components";
|
||||
import { ref, markRaw, onMounted, computed } from "vue";
|
||||
import CoreSelectorProvider from "./selector-providers/CoreSelectorProvider.vue";
|
||||
import type {
|
||||
AttachmentLike,
|
||||
|
@ -12,12 +12,18 @@ import { useI18n } from "vue-i18n";
|
|||
|
||||
const { t } = useI18n();
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
accepts?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
accepts: () => ["*/*"],
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -84,6 +90,20 @@ const handleConfirm = () => {
|
|||
emit("select", Array.from(selected.value));
|
||||
onVisibleChange(false);
|
||||
};
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
if (props.min === undefined) {
|
||||
return false;
|
||||
}
|
||||
return selected.value.length < props.min;
|
||||
});
|
||||
|
||||
const confirmCountMessage = computed(() => {
|
||||
if (!props.min && !props.max) {
|
||||
return selected.value.length;
|
||||
}
|
||||
return `${selected.value.length} / ${props.max || props.min}`;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<VModal
|
||||
|
@ -112,6 +132,9 @@ const handleConfirm = () => {
|
|||
:is="provider.component"
|
||||
v-if="activeId === provider.id"
|
||||
v-model:selected="selected"
|
||||
:accepts="accepts"
|
||||
:min="min"
|
||||
:max="max"
|
||||
@change-provider="onChangeProvider"
|
||||
></component>
|
||||
<template #fallback>
|
||||
|
@ -121,16 +144,25 @@ const handleConfirm = () => {
|
|||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<VButton type="secondary" @click="handleConfirm">
|
||||
{{ $t("core.common.buttons.confirm") }}
|
||||
<span v-if="selected.length">
|
||||
{{
|
||||
$t("core.attachment.select_modal.operations.select.result", {
|
||||
count: selected.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</VButton>
|
||||
<VSpace>
|
||||
<VButton
|
||||
type="secondary"
|
||||
:disabled="confirmDisabled"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ $t("core.common.buttons.confirm") }}
|
||||
<span v-if="selected.length || props.min || props.max">
|
||||
{{
|
||||
$t("core.attachment.select_modal.operations.select.result", {
|
||||
count: confirmCountMessage,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</VButton>
|
||||
<VButton @click="onVisibleChange(false)">
|
||||
{{ $t("core.common.buttons.cancel") }}
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
||||
|
|
|
@ -22,13 +22,20 @@ import AttachmentUploadModal from "../AttachmentUploadModal.vue";
|
|||
import AttachmentFileTypeIcon from "../AttachmentFileTypeIcon.vue";
|
||||
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
|
||||
import AttachmentGroupList from "../AttachmentGroupList.vue";
|
||||
import { matchMediaTypes } from "@/utils/media-type";
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
selected: AttachmentLike[];
|
||||
accepts?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
}>(),
|
||||
{
|
||||
selected: () => [],
|
||||
accepts: () => ["*/*"],
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -66,6 +73,23 @@ const handleOpenDetail = (attachment: Attachment) => {
|
|||
selectedAttachment.value = attachment;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
const isDisabled = (attachment: Attachment) => {
|
||||
const isMatchMediaType = matchMediaTypes(
|
||||
attachment.spec.mediaType || "*/*",
|
||||
props.accepts
|
||||
);
|
||||
|
||||
if (
|
||||
props.max !== undefined &&
|
||||
props.max <= selectedAttachments.value.size &&
|
||||
!isChecked(attachment)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isMatchMediaType;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<AttachmentGroupList
|
||||
|
@ -111,6 +135,8 @@ const handleOpenDetail = (attachment: Attachment) => {
|
|||
:body-class="['!p-0']"
|
||||
:class="{
|
||||
'ring-1 ring-primary': isChecked(attachment),
|
||||
'pointer-events-none !cursor-not-allowed opacity-50':
|
||||
isDisabled(attachment),
|
||||
}"
|
||||
class="hover:shadow"
|
||||
@click.stop="handleSelect(attachment)"
|
||||
|
|
|
@ -458,6 +458,7 @@ const { handleGenerateSlug } = useSlugify(
|
|||
:label="$t('core.page.settings.fields.cover.label')"
|
||||
type="attachment"
|
||||
name="cover"
|
||||
:accepts="['image/*']"
|
||||
validation="length:0,1024"
|
||||
></FormKit>
|
||||
</div>
|
||||
|
|
|
@ -238,6 +238,7 @@ const { handleGenerateSlug } = useSlugify(
|
|||
name="cover"
|
||||
:label="$t('core.post_category.editing_modal.fields.cover.label')"
|
||||
type="attachment"
|
||||
:accepts="['image/*']"
|
||||
validation="length:0,1024"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
|
|
|
@ -432,6 +432,7 @@ const { handleGenerateSlug } = useSlugify(
|
|||
name="cover"
|
||||
:label="$t('core.post.settings.fields.cover.label')"
|
||||
type="attachment"
|
||||
:accepts="['image/*']"
|
||||
validation="length:0,1024"
|
||||
></FormKit>
|
||||
</div>
|
||||
|
|
|
@ -242,6 +242,7 @@ const { handleGenerateSlug } = useSlugify(
|
|||
:help="$t('core.post_tag.editing_modal.fields.cover.help')"
|
||||
:label="$t('core.post_tag.editing_modal.fields.cover.label')"
|
||||
type="attachment"
|
||||
:accepts="['image/*']"
|
||||
validation="length:0,1024"
|
||||
></FormKit>
|
||||
</div>
|
||||
|
|
|
@ -210,6 +210,7 @@ const handleCreateUser = async () => {
|
|||
:label="$t('core.user.editing_modal.fields.avatar.label')"
|
||||
type="attachment"
|
||||
name="avatar"
|
||||
:accepts="['image/*']"
|
||||
validation="url|length:0,1024"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { matchMediaType, matchMediaTypes } from "../media-type";
|
||||
|
||||
describe("matchMediaType", () => {
|
||||
it('should match all image types for "image/*"', () => {
|
||||
expect(matchMediaType("image/png", "image/*")).toBe(true);
|
||||
expect(matchMediaType("image/jpeg", "image/*")).toBe(true);
|
||||
expect(matchMediaType("image/gif", "image/*")).toBe(true);
|
||||
expect(matchMediaType("image/webp", "image/*")).toBe(true);
|
||||
});
|
||||
|
||||
it('should only match image/png for "image/png"', () => {
|
||||
expect(matchMediaType("image/png", "image/png")).toBe(true);
|
||||
expect(matchMediaType("image/jpeg", "image/png")).toBe(false);
|
||||
expect(matchMediaType("image/gif", "image/png")).toBe(false);
|
||||
expect(matchMediaType("image/webp", "image/png")).toBe(false);
|
||||
});
|
||||
|
||||
it('should match any type for "*/*"', () => {
|
||||
expect(matchMediaType("image/png", "*/*")).toBe(true);
|
||||
expect(matchMediaType("application/json", "*/*")).toBe(true);
|
||||
expect(matchMediaType("video/mp4", "*/*")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not match if type does not match accept", () => {
|
||||
expect(matchMediaType("image/png", "video/*")).toBe(false);
|
||||
expect(matchMediaType("video/mp4", "image/*")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match with case-insensitive comparison", () => {
|
||||
expect(matchMediaType("image/png", "IMAGE/*")).toBe(true);
|
||||
expect(matchMediaType("application/json", "APPLICATION/*")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchMediaTypes", () => {
|
||||
it("multi accepts", () => {
|
||||
expect(matchMediaTypes("image/png", ["image/*", "video/*"])).toBe(true);
|
||||
expect(matchMediaTypes("image/jpg", ["image/jpg", "video/*"])).toBe(true);
|
||||
expect(matchMediaTypes("image/png", ["video/mp4", "application/*"])).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,9 @@
|
|||
export function matchMediaType(mediaType: string, accept: string) {
|
||||
const regex = new RegExp(accept.toLowerCase().replace(/\*/g, ".*"));
|
||||
|
||||
return regex.test(mediaType);
|
||||
}
|
||||
|
||||
export function matchMediaTypes(mediaType: string, accepts: string[]) {
|
||||
return accepts.some((accept) => matchMediaType(mediaType, accept));
|
||||
}
|
Loading…
Reference in New Issue