diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml
index cd7aa58aa..f287d1274 100644
--- a/application/src/main/resources/extensions/system-setting.yaml
+++ b/application/src/main/resources/extensions/system-setting.yaml
@@ -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:
diff --git a/console/docs/custom-formkit-input/README.md b/console/docs/custom-formkit-input/README.md
index 39d1084ca..7a54ec6ab 100644
--- a/console/docs/custom-formkit-input/README.md
+++ b/console/docs/custom-formkit-input/README.md
@@ -13,6 +13,8 @@
1. language: 目前支持 `yaml`, `html`, `css`, `javascript`, `json`
2. height: 编辑器高度,如:`100px`
- `attachment`: 附件选择
+ - 参数
+ 1. accepts:允许上传的文件类型,如:`image/*`
- `repeater`: 定义一个对象集合,可以让使用者可视化的操作集合。
- `menuCheckbox`:选择一组菜单
- `menuRadio`:选择一个菜单
diff --git a/console/src/formkit/inputs/attachment/AttachmentInput.vue b/console/src/formkit/inputs/attachment/AttachmentInput.vue
index 53acbecad..15e694262 100644
--- a/console/src/formkit/inputs/attachment/AttachmentInput.vue
+++ b/console/src/formkit/inputs/attachment/AttachmentInput.vue
@@ -56,6 +56,9 @@ const onAttachmentSelect = (attachments: AttachmentLike[]) => {
diff --git a/console/src/formkit/inputs/attachment/index.ts b/console/src/formkit/inputs/attachment/index.ts
index bdc7253ed..ba74535ed 100644
--- a/console/src/formkit/inputs/attachment/index.ts
+++ b/console/src/formkit/inputs/attachment/index.ts
@@ -4,7 +4,7 @@ import AttachmentInput from "./AttachmentInput.vue";
export const attachment = createInput(AttachmentInput, {
type: "input",
- props: [],
+ props: ["accepts"],
forceTypeProp: "text",
features: [initialValue],
});
diff --git a/console/src/modules/contents/attachments/components/AttachmentFileTypeIcon.vue b/console/src/modules/contents/attachments/components/AttachmentFileTypeIcon.vue
index fd54e6b5c..125b58a17 100644
--- a/console/src/modules/contents/attachments/components/AttachmentFileTypeIcon.vue
+++ b/console/src/modules/contents/attachments/components/AttachmentFileTypeIcon.vue
@@ -109,7 +109,7 @@ const iconClass = computed(() => {
{{ getExtname }}
diff --git a/console/src/modules/contents/attachments/components/AttachmentSelectorModal.vue b/console/src/modules/contents/attachments/components/AttachmentSelectorModal.vue
index ef411f811..8005c917b 100644
--- a/console/src/modules/contents/attachments/components/AttachmentSelectorModal.vue
+++ b/console/src/modules/contents/attachments/components/AttachmentSelectorModal.vue
@@ -1,6 +1,6 @@
{
:is="provider.component"
v-if="activeId === provider.id"
v-model:selected="selected"
+ :accepts="accepts"
+ :min="min"
+ :max="max"
@change-provider="onChangeProvider"
>
@@ -121,16 +144,25 @@ const handleConfirm = () => {
-
- {{ $t("core.common.buttons.confirm") }}
-
- {{
- $t("core.attachment.select_modal.operations.select.result", {
- count: selected.length,
- })
- }}
-
-
+
+
+ {{ $t("core.common.buttons.confirm") }}
+
+ {{
+ $t("core.attachment.select_modal.operations.select.result", {
+ count: confirmCountMessage,
+ })
+ }}
+
+
+
+ {{ $t("core.common.buttons.cancel") }}
+
+
diff --git a/console/src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue b/console/src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue
index 64322478e..f5291e72b 100644
--- a/console/src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue
+++ b/console/src/modules/contents/attachments/components/selector-providers/CoreSelectorProvider.vue
@@ -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;
+};
{
: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)"
diff --git a/console/src/modules/contents/pages/components/SinglePageSettingModal.vue b/console/src/modules/contents/pages/components/SinglePageSettingModal.vue
index ba37b49e5..499ad656a 100644
--- a/console/src/modules/contents/pages/components/SinglePageSettingModal.vue
+++ b/console/src/modules/contents/pages/components/SinglePageSettingModal.vue
@@ -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"
>
diff --git a/console/src/modules/contents/posts/categories/components/CategoryEditingModal.vue b/console/src/modules/contents/posts/categories/components/CategoryEditingModal.vue
index 796ee1e80..87e5acda9 100644
--- a/console/src/modules/contents/posts/categories/components/CategoryEditingModal.vue
+++ b/console/src/modules/contents/posts/categories/components/CategoryEditingModal.vue
@@ -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"
>
diff --git a/console/src/modules/contents/posts/tags/components/TagEditingModal.vue b/console/src/modules/contents/posts/tags/components/TagEditingModal.vue
index 44f1c6620..f93fc1849 100644
--- a/console/src/modules/contents/posts/tags/components/TagEditingModal.vue
+++ b/console/src/modules/contents/posts/tags/components/TagEditingModal.vue
@@ -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"
>
diff --git a/console/src/modules/system/users/components/UserEditingModal.vue b/console/src/modules/system/users/components/UserEditingModal.vue
index 676e1df5e..1906b8d24 100644
--- a/console/src/modules/system/users/components/UserEditingModal.vue
+++ b/console/src/modules/system/users/components/UserEditingModal.vue
@@ -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"
>
{
+ 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
+ );
+ });
+});
diff --git a/console/src/utils/media-type.ts b/console/src/utils/media-type.ts
new file mode 100644
index 000000000..5b4b4ccfc
--- /dev/null
+++ b/console/src/utils/media-type.ts
@@ -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));
+}