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
|
- $formkit: attachment
|
||||||
label: Logo
|
label: Logo
|
||||||
name: logo
|
name: logo
|
||||||
|
accepts:
|
||||||
|
- 'image/*'
|
||||||
- $formkit: attachment
|
- $formkit: attachment
|
||||||
label: Favicon
|
label: Favicon
|
||||||
name: favicon
|
name: favicon
|
||||||
|
accepts:
|
||||||
|
- 'image/*'
|
||||||
- group: post
|
- group: post
|
||||||
label: 文章设置
|
label: 文章设置
|
||||||
formSchema:
|
formSchema:
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
1. language: 目前支持 `yaml`, `html`, `css`, `javascript`, `json`
|
1. language: 目前支持 `yaml`, `html`, `css`, `javascript`, `json`
|
||||||
2. height: 编辑器高度,如:`100px`
|
2. height: 编辑器高度,如:`100px`
|
||||||
- `attachment`: 附件选择
|
- `attachment`: 附件选择
|
||||||
|
- 参数
|
||||||
|
1. accepts:允许上传的文件类型,如:`image/*`
|
||||||
- `repeater`: 定义一个对象集合,可以让使用者可视化的操作集合。
|
- `repeater`: 定义一个对象集合,可以让使用者可视化的操作集合。
|
||||||
- `menuCheckbox`:选择一组菜单
|
- `menuCheckbox`:选择一组菜单
|
||||||
- `menuRadio`:选择一个菜单
|
- `menuRadio`:选择一个菜单
|
||||||
|
|
|
@ -56,6 +56,9 @@ const onAttachmentSelect = (attachments: AttachmentLike[]) => {
|
||||||
</div>
|
</div>
|
||||||
<AttachmentSelectorModal
|
<AttachmentSelectorModal
|
||||||
v-model:visible="attachmentSelectorModal"
|
v-model:visible="attachmentSelectorModal"
|
||||||
|
:accepts="context.accepts as string[]"
|
||||||
|
:min="1"
|
||||||
|
:max="1"
|
||||||
@select="onAttachmentSelect"
|
@select="onAttachmentSelect"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import AttachmentInput from "./AttachmentInput.vue";
|
||||||
|
|
||||||
export const attachment = createInput(AttachmentInput, {
|
export const attachment = createInput(AttachmentInput, {
|
||||||
type: "input",
|
type: "input",
|
||||||
props: [],
|
props: ["accepts"],
|
||||||
forceTypeProp: "text",
|
forceTypeProp: "text",
|
||||||
features: [initialValue],
|
features: [initialValue],
|
||||||
});
|
});
|
||||||
|
|
|
@ -109,7 +109,7 @@ const iconClass = computed(() => {
|
||||||
<component :is="getIcon" :class="iconClass" />
|
<component :is="getIcon" :class="iconClass" />
|
||||||
<span
|
<span
|
||||||
v-if="getExtname && displayExt"
|
v-if="getExtname && displayExt"
|
||||||
class="font-sans text-xs text-gray-500"
|
class="select-none font-sans text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
{{ getExtname }}
|
{{ getExtname }}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { VButton, VModal, VTabbar } from "@halo-dev/components";
|
import { VButton, VModal, VSpace, VTabbar } from "@halo-dev/components";
|
||||||
import { ref, markRaw, onMounted } from "vue";
|
import { ref, markRaw, onMounted, computed } from "vue";
|
||||||
import CoreSelectorProvider from "./selector-providers/CoreSelectorProvider.vue";
|
import CoreSelectorProvider from "./selector-providers/CoreSelectorProvider.vue";
|
||||||
import type {
|
import type {
|
||||||
AttachmentLike,
|
AttachmentLike,
|
||||||
|
@ -12,12 +12,18 @@ import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
accepts?: string[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
visible: false,
|
visible: false,
|
||||||
|
accepts: () => ["*/*"],
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -84,6 +90,20 @@ const handleConfirm = () => {
|
||||||
emit("select", Array.from(selected.value));
|
emit("select", Array.from(selected.value));
|
||||||
onVisibleChange(false);
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VModal
|
<VModal
|
||||||
|
@ -112,6 +132,9 @@ const handleConfirm = () => {
|
||||||
:is="provider.component"
|
:is="provider.component"
|
||||||
v-if="activeId === provider.id"
|
v-if="activeId === provider.id"
|
||||||
v-model:selected="selected"
|
v-model:selected="selected"
|
||||||
|
:accepts="accepts"
|
||||||
|
:min="min"
|
||||||
|
:max="max"
|
||||||
@change-provider="onChangeProvider"
|
@change-provider="onChangeProvider"
|
||||||
></component>
|
></component>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
|
@ -121,16 +144,25 @@ const handleConfirm = () => {
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VButton type="secondary" @click="handleConfirm">
|
<VSpace>
|
||||||
{{ $t("core.common.buttons.confirm") }}
|
<VButton
|
||||||
<span v-if="selected.length">
|
type="secondary"
|
||||||
{{
|
:disabled="confirmDisabled"
|
||||||
$t("core.attachment.select_modal.operations.select.result", {
|
@click="handleConfirm"
|
||||||
count: selected.length,
|
>
|
||||||
})
|
{{ $t("core.common.buttons.confirm") }}
|
||||||
}}
|
<span v-if="selected.length || props.min || props.max">
|
||||||
</span>
|
{{
|
||||||
</VButton>
|
$t("core.attachment.select_modal.operations.select.result", {
|
||||||
|
count: confirmCountMessage,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</VButton>
|
||||||
|
<VButton @click="onVisibleChange(false)">
|
||||||
|
{{ $t("core.common.buttons.cancel") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -22,13 +22,20 @@ import AttachmentUploadModal from "../AttachmentUploadModal.vue";
|
||||||
import AttachmentFileTypeIcon from "../AttachmentFileTypeIcon.vue";
|
import AttachmentFileTypeIcon from "../AttachmentFileTypeIcon.vue";
|
||||||
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
|
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
|
||||||
import AttachmentGroupList from "../AttachmentGroupList.vue";
|
import AttachmentGroupList from "../AttachmentGroupList.vue";
|
||||||
|
import { matchMediaTypes } from "@/utils/media-type";
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
selected: AttachmentLike[];
|
selected: AttachmentLike[];
|
||||||
|
accepts?: string[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
selected: () => [],
|
selected: () => [],
|
||||||
|
accepts: () => ["*/*"],
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -66,6 +73,23 @@ const handleOpenDetail = (attachment: Attachment) => {
|
||||||
selectedAttachment.value = attachment;
|
selectedAttachment.value = attachment;
|
||||||
detailVisible.value = true;
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AttachmentGroupList
|
<AttachmentGroupList
|
||||||
|
@ -111,6 +135,8 @@ const handleOpenDetail = (attachment: Attachment) => {
|
||||||
:body-class="['!p-0']"
|
:body-class="['!p-0']"
|
||||||
:class="{
|
:class="{
|
||||||
'ring-1 ring-primary': isChecked(attachment),
|
'ring-1 ring-primary': isChecked(attachment),
|
||||||
|
'pointer-events-none !cursor-not-allowed opacity-50':
|
||||||
|
isDisabled(attachment),
|
||||||
}"
|
}"
|
||||||
class="hover:shadow"
|
class="hover:shadow"
|
||||||
@click.stop="handleSelect(attachment)"
|
@click.stop="handleSelect(attachment)"
|
||||||
|
|
|
@ -458,6 +458,7 @@ const { handleGenerateSlug } = useSlugify(
|
||||||
:label="$t('core.page.settings.fields.cover.label')"
|
:label="$t('core.page.settings.fields.cover.label')"
|
||||||
type="attachment"
|
type="attachment"
|
||||||
name="cover"
|
name="cover"
|
||||||
|
:accepts="['image/*']"
|
||||||
validation="length:0,1024"
|
validation="length:0,1024"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -238,6 +238,7 @@ const { handleGenerateSlug } = useSlugify(
|
||||||
name="cover"
|
name="cover"
|
||||||
:label="$t('core.post_category.editing_modal.fields.cover.label')"
|
:label="$t('core.post_category.editing_modal.fields.cover.label')"
|
||||||
type="attachment"
|
type="attachment"
|
||||||
|
:accepts="['image/*']"
|
||||||
validation="length:0,1024"
|
validation="length:0,1024"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
<FormKit
|
||||||
|
|
|
@ -432,6 +432,7 @@ const { handleGenerateSlug } = useSlugify(
|
||||||
name="cover"
|
name="cover"
|
||||||
:label="$t('core.post.settings.fields.cover.label')"
|
:label="$t('core.post.settings.fields.cover.label')"
|
||||||
type="attachment"
|
type="attachment"
|
||||||
|
:accepts="['image/*']"
|
||||||
validation="length:0,1024"
|
validation="length:0,1024"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -242,6 +242,7 @@ const { handleGenerateSlug } = useSlugify(
|
||||||
:help="$t('core.post_tag.editing_modal.fields.cover.help')"
|
:help="$t('core.post_tag.editing_modal.fields.cover.help')"
|
||||||
:label="$t('core.post_tag.editing_modal.fields.cover.label')"
|
:label="$t('core.post_tag.editing_modal.fields.cover.label')"
|
||||||
type="attachment"
|
type="attachment"
|
||||||
|
:accepts="['image/*']"
|
||||||
validation="length:0,1024"
|
validation="length:0,1024"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -210,6 +210,7 @@ const handleCreateUser = async () => {
|
||||||
:label="$t('core.user.editing_modal.fields.avatar.label')"
|
:label="$t('core.user.editing_modal.fields.avatar.label')"
|
||||||
type="attachment"
|
type="attachment"
|
||||||
name="avatar"
|
name="avatar"
|
||||||
|
:accepts="['image/*']"
|
||||||
validation="url|length:0,1024"
|
validation="url|length:0,1024"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<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