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
Ryan Wang 2023-04-24 15:45:44 +08:00 committed by GitHub
parent 60040ae428
commit d441e4731e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 141 additions and 16 deletions

View File

@ -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:

View File

@ -13,6 +13,8 @@
1. language: 目前支持 `yaml`, `html`, `css`, `javascript`, `json`
2. height: 编辑器高度,如:`100px`
- `attachment`: 附件选择
- 参数
1. accepts允许上传的文件类型`image/*`
- `repeater`: 定义一个对象集合,可以让使用者可视化的操作集合。
- `menuCheckbox`:选择一组菜单
- `menuRadio`:选择一个菜单

View File

@ -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>

View File

@ -4,7 +4,7 @@ import AttachmentInput from "./AttachmentInput.vue";
export const attachment = createInput(AttachmentInput, {
type: "input",
props: [],
props: ["accepts"],
forceTypeProp: "text",
features: [initialValue],
});

View File

@ -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>

View File

@ -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>

View File

@ -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)"

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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
);
});
});

View File

@ -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));
}