hookehuyr

feat(attachment): 添加动态文件大小限制功能

实现附件类型配置的归一化处理,支持多种后端格式
添加文件大小限制映射和计算逻辑
更新上传组件以显示动态限制大小
添加相关测试用例
/*
* @Date: 2025-06-06 09:26:16
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-12-17 10:59:17
* @LastEditTime: 2026-01-21 13:15:07
* @FilePath: /mlaj/src/api/checkin.js
* @Description: 签到模块相关接口
*/
......@@ -39,12 +39,12 @@ export const getTaskListAPI = (params) => fn(fetch.get(Api.GET_TASK_LIST, param
* @param: month 月份
* @param: subtask_id 小作业ID
* @returns data: {
* id 大作业id, cover 封面图, title 大作业名称, note 大作业描述, frequency 交作业的频次, cycle 交作业的周期 {0=本周期 | 30=每月 | 7=每周 | 1=每日},
* attachment_type 上传附件的类型 [text=文本 image=图片 video=视频 audio=音频], begin_date 开始时间, end_date 结束时间,
* task_type 任务类型 [checkin=签到 | upload=上传附件 | count=计数], is_gray 作业是否应该置灰, is_finish 作业在当前周期是否已经达标,
* my_checkin_dates[] 我在日历中打过卡的日期, makeup_checkin_dates[] 我在日历中,可以补卡的日期, target_number 打卡的目标数量,
* checkin_number 已经打卡的数量, checkin_avatars 最后打卡的10个人的头像, my_today_gratitude_count 我在今天的感恩次数, my_total_gratitude_count 我累计感恩次数,
* subtask_list 小作业列表 [{id,title,cycle,frequency,attachment_type,begin_date,end_date,is_finish}] ,
* id 大作业id, cover 封面图, title 大作业名称, note 大作业描述, frequency 交作业的频次, cycle 交作业的周期 {0=本周期 | 30=每月 | 7=每周 | 1=每日},
* attachment_type 上传附件的类型 [text=文本 image=图片 video=视频 audio=音频], begin_date 开始时间, end_date 结束时间,
* task_type 任务类型 [checkin=签到 | upload=上传附件 | count=计数], is_gray 作业是否应该置灰, is_finish 作业在当前周期是否已经达标,
* my_checkin_dates[] 我在日历中打过卡的日期, makeup_checkin_dates[] 我在日历中,可以补卡的日期, target_number 打卡的目标数量,
* checkin_number 已经打卡的数量, checkin_avatars 最后打卡的10个人的头像, my_today_gratitude_count 我在今天的感恩次数, my_total_gratitude_count 我累计感恩次数,
* subtask_list 小作业列表 [{id,title,cycle,frequency,attachment_type,begin_date,end_date,is_finish}] ,
* }
*/
export const getTaskDetailAPI = (params) => fn(fetch.get(Api.GET_TASK_DETAIL, params))
......@@ -57,7 +57,8 @@ export const getTaskDetailAPI = (params) => fn(fetch.get(Api.GET_TASK_DETAIL, p
* id 作业id,
* title 作业名称 ,
* cycle 作业周期 [0=本周期 | 30=每月 | 7=每周 | 1=每日],
* frequency 交作业的频次,attachment_type 上传附件的类型 [text=文本 image=图片 video=视频 audio=音频],
* frequency 交作业的频次,
* attachment_type array [object] 提交类型 [{type,max_size}] type string 提交类型 [text=文本 image=图片 video=视频 audio=音频], max_size number 文件的最大尺寸(MB)
* begin_date 开始时间,
* end_date 结束时间,
* is_finish 作业在当前周期是否已经达标,
......
import { describe, expect, it, vi, beforeEach } from 'vitest'
vi.mock('vue-router', () => {
return {
useRoute: () => ({
query: {}
}),
useRouter: () => ({
push: vi.fn()
})
}
})
vi.mock('vant', () => {
return {
showToast: vi.fn(),
showLoadingToast: vi.fn(() => ({ close: vi.fn() }))
}
})
vi.mock('@/api/common', () => {
return {
qiniuTokenAPI: vi.fn(),
qiniuUploadAPI: vi.fn(),
saveFileAPI: vi.fn()
}
})
vi.mock('@/api/checkin', () => {
return {
addUploadTaskAPI: vi.fn(),
getUploadTaskInfoAPI: vi.fn(),
editUploadTaskInfoAPI: vi.fn()
}
})
vi.mock('@/utils/qiniuFileHash', () => {
return {
qiniuFileHash: vi.fn(async () => 'hash')
}
})
vi.mock('@/contexts/auth', async () => {
const { ref } = await import('vue')
return {
useAuth: () => ({
currentUser: ref({ mobile: '18800001111' })
})
}
})
import { useCheckin } from '../useCheckin'
import { showToast } from 'vant'
describe('useCheckin 上传大小限制', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('setMaxFileSizeMbMap 能更新并保留其他类型默认值', () => {
const { activeType, maxFileSizeMb, setMaxFileSizeMbMap } = useCheckin()
activeType.value = 'image'
expect(maxFileSizeMb.value).toBe(20)
setMaxFileSizeMbMap({ image: 500 })
expect(maxFileSizeMb.value).toBe(500)
activeType.value = 'video'
expect(maxFileSizeMb.value).toBe(20)
})
it('setMaxFileSizeMbMap 会忽略非法值', () => {
const { activeType, maxFileSizeMb, setMaxFileSizeMbMap } = useCheckin()
activeType.value = 'audio'
setMaxFileSizeMbMap({ audio: 0 })
expect(maxFileSizeMb.value).toBe(20)
setMaxFileSizeMbMap({ audio: -10 })
expect(maxFileSizeMb.value).toBe(20)
setMaxFileSizeMbMap({ audio: 'not_a_number' })
expect(maxFileSizeMb.value).toBe(20)
setMaxFileSizeMbMap({ audio: 300 })
expect(maxFileSizeMb.value).toBe(300)
})
it('beforeRead 超过动态大小会拦截并提示', () => {
const { activeType, beforeRead, setMaxFileSizeMbMap } = useCheckin()
activeType.value = 'image'
setMaxFileSizeMbMap({ image: 1 })
const ok = beforeRead({
type: 'image/jpeg',
size: 2 * 1024 * 1024
})
expect(ok).toBe(false)
expect(showToast).toHaveBeenCalled()
expect(String(showToast.mock.calls[0][0])).toContain('最大文件体积为1MB')
})
it('beforeRead 图片类型校验失败会拦截', () => {
const { activeType, beforeRead, setMaxFileSizeMbMap } = useCheckin()
activeType.value = 'image'
setMaxFileSizeMbMap({ image: 10 })
const ok = beforeRead({
type: 'application/pdf',
size: 1 * 1024 * 1024
})
expect(ok).toBe(false)
expect(showToast).toHaveBeenCalled()
})
})
......@@ -26,6 +26,38 @@ export function useCheckin() {
const selectedTaskValue = ref([]) // 选中的任务值(Picker使用)
const isMakeup = ref(false) // 是否为补录作业
const maxCount = ref(5)
const maxFileSizeMbMap = ref({
image: 20,
video: 20,
audio: 20
})
const maxFileSizeMb = computed(() => {
const type = String(activeType.value || '')
const raw = maxFileSizeMbMap.value?.[type]
const size = Number(raw)
if (Number.isFinite(size) && size > 0) return size
return 20
})
/**
* 设置最大文件大小映射
* @param {Object} map - 包含 image, video, audio 键的对象
*/
const setMaxFileSizeMbMap = (map = {}) => {
if (!map || typeof map !== 'object') return
const next = { ...(maxFileSizeMbMap.value || {}) }
for (const key of ['image', 'video', 'audio']) {
const raw = map[key]
const size = Number(raw)
if (Number.isFinite(size) && size > 0) {
next[key] = size
}
}
maxFileSizeMbMap.value = next
}
// 打卡类型
const checkinType = computed(() => route.query.task_type)
......@@ -178,9 +210,10 @@ export function useCheckin() {
const fileType = item.type.toLowerCase()
// 文件大小检查
if ((item.size / 1024 / 1024).toFixed(2) > 20) {
const file_size_mb = item.size / 1024 / 1024
if (Number.isFinite(file_size_mb) && file_size_mb > maxFileSizeMb.value) {
flag = false
showToast('最大文件体积为20MB')
showToast(`最大文件体积为${maxFileSizeMb.value}MB`)
break
}
......@@ -532,11 +565,13 @@ export function useCheckin() {
selectedTaskValue,
isMakeup,
maxCount,
maxFileSizeMb,
canSubmit,
gratitudeCount,
gratitudeFormList,
// 方法
setMaxFileSizeMbMap,
beforeRead,
afterRead,
onDelete,
......
import { describe, expect, it } from 'vitest'
import { normalizeAttachmentTypeConfig } from '../tools'
describe('normalizeAttachmentTypeConfig', () => {
it('兼容旧数组结构: ["image","video"]', () => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(['image', 'video'])
expect(options).toEqual([
{ key: 'image', value: '图片' },
{ key: 'video', value: '视频' }
])
expect(upload_size_limit_mb_map).toBeNull()
})
it('兼容新数组结构: [{type,max_size}]', () => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig([
{ type: 'image', max_size: 500 },
{ type: 'video', max_size: 1000 },
{ type: 'audio', max_size: 300 }
])
expect(options).toEqual([
{ key: 'image', value: '图片' },
{ key: 'video', value: '视频' },
{ key: 'audio', value: '音频' }
])
expect(upload_size_limit_mb_map).toEqual({ image: 500, video: 1000, audio: 300 })
})
it('兼容对象映射结构: [{image:500,video:1000}]', () => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig([
{ image: 500, video: 1000 }
])
expect(options).toEqual([
{ key: 'image', value: '图片' },
{ key: 'video', value: '视频' }
])
expect(upload_size_limit_mb_map).toEqual({ image: 500, video: 1000 })
})
it('兼容对象映射结构: {image:500,video:1000}', () => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig({
image: 500,
video: 1000
})
expect(options).toEqual([
{ key: 'image', value: '图片' },
{ key: 'video', value: '视频' }
])
expect(upload_size_limit_mb_map).toEqual({ image: 500, video: 1000 })
})
it('空值返回默认四种类型', () => {
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(null)
expect(options.length).toBe(4)
expect(upload_size_limit_mb_map).toBeNull()
})
})
/*
* @Date: 2022-04-18 15:59:42
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-21 10:51:15
* @LastEditTime: 2026-01-21 13:33:36
* @FilePath: /mlaj/src/utils/tools.js
* @Description: 文件描述
*/
......@@ -152,6 +152,134 @@ const normalizeCheckinTaskItems = (list) => {
});
};
/**
* @description 归一化作业提交类型配置(兼容多种后端格式),并提取各类型的上传大小限制(MB)。
* @param {any} attachment_type 后端返回的 attachment_type 字段,可能是多种结构。
* @returns {{options: Array<{key: string, value: string}>, upload_size_limit_mb_map: (null|{image?: number, video?: number, audio?: number})}}
*/
const normalizeAttachmentTypeConfig = (attachment_type) => {
const type_map = {
text: '文本',
image: '图片',
audio: '音频',
video: '视频'
};
// 支持的类型 key(用于过滤未知字段,避免误把 max_size / 其他字段当成类型)
const known_type_keys = ['text', 'image', 'audio', 'video'];
const upload_size_limit_mb_map = {};
let options = [];
if (Array.isArray(attachment_type)) {
const first = attachment_type[0];
if (first && typeof first === 'object' && !Array.isArray(first)) {
// 情况1:新结构(数组对象风格),例如:
// [{ type: 'image', max_size: 500 }, { type: 'video', max_size: 1000 }]
const has_type_style = attachment_type.some(
(item) => item && typeof item === 'object' && !Array.isArray(item) && ('type' in item || 'max_size' in item)
);
if (has_type_style) {
// 生成“可选类型”列表
options = attachment_type
.map((item) => {
const key = item?.type || item?.key || item?.name || '';
return {
key,
value: type_map[key] || key
};
})
.filter((item) => !!item.key);
// 提取上传大小限制(只处理 image/video/audio)
attachment_type.forEach((item) => {
const key = item?.type || item?.key || item?.name;
const size = Number(item?.max_size);
if ((key === 'image' || key === 'video' || key === 'audio') && Number.isFinite(size) && size > 0) {
upload_size_limit_mb_map[key] = size;
}
});
} else {
// 情况2:映射结构(数组里装对象映射),例如:
// [{ image: 500, video: 500 }]
// 这里先合并为一个对象,再统一处理
const merged = {};
attachment_type.forEach((item) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
Object.assign(merged, item);
}
});
// 映射结构里 key 就是类型,value 就是大小(MB)
const keys = Object.keys(merged).filter((k) => known_type_keys.includes(k));
options = keys.map((key) => ({
key,
value: type_map[key] || key
}));
['image', 'video', 'audio'].forEach((key) => {
const size = Number(merged[key]);
if (Number.isFinite(size) && size > 0) {
upload_size_limit_mb_map[key] = size;
}
});
}
} else {
// 情况3:旧结构(字符串数组),例如:['image', 'video']
options = attachment_type.map((key) => ({
key,
value: type_map[key] || key
}));
}
} else if (attachment_type && typeof attachment_type === 'object') {
// 情况4:对象结构(可能是类型中文映射,也可能是类型->大小)
const keys = Object.keys(attachment_type);
const has_size_mapping = keys.some((k) => (k === 'image' || k === 'video' || k === 'audio') && Number.isFinite(Number(attachment_type[k])));
if (has_size_mapping) {
// 类型 -> 大小(MB),例如:{ image: 500, video: 1000 }
options = keys
.filter((k) => known_type_keys.includes(k))
.map((key) => ({
key,
value: type_map[key] || key
}));
['image', 'video', 'audio'].forEach((key) => {
const size = Number(attachment_type[key]);
if (Number.isFinite(size) && size > 0) {
upload_size_limit_mb_map[key] = size;
}
});
} else {
// 类型 -> 中文显示,或其他自定义结构,例如:{ image: '图片', video: '视频' }
options = Object.entries(attachment_type).map(([key, value]) => ({
key,
value
}));
}
} else {
options = [];
}
if (options.length === 0) {
// 兜底:后端未配置时,默认给出四种类型
options = [
{ key: 'text', value: '文本' },
{ key: 'image', value: '图片' },
{ key: 'audio', value: '音频' },
{ key: 'video', value: '视频' }
];
}
return {
options,
// 没解析到任何大小限制时,返回 null,避免覆盖 useCheckin 内部默认值
upload_size_limit_mb_map: Object.keys(upload_size_limit_mb_map).length ? upload_size_limit_mb_map : null
};
};
export {
formatDate,
wxInfo,
......@@ -162,4 +290,5 @@ export {
stringifyQuery,
formatDuration,
normalizeCheckinTaskItems,
normalizeAttachmentTypeConfig,
};
......
......@@ -88,7 +88,7 @@
<!-- 文件上传区域 -->
<div v-if="activeType !== '' && activeType !== 'text'" class="upload-area">
<van-uploader v-model="fileList" :max-count="maxCount" :max-size="20 * 1024 * 1024"
<van-uploader v-model="fileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
:before-read="beforeRead" :after-read="afterRead" @delete="onDelete"
@click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file"
:deletable="true" upload-icon="plus" />
......@@ -106,7 +106,7 @@
</div> -->
<div class="upload-tips">
<div class="tip-text">最多上传{{ maxCount }}个文件,每个不超过20M</div>
<div class="tip-text">最多上传{{ maxCount }}个文件,每个不超过{{ maxFileSizeMb }}MB</div>
<div class="tip-text">{{ getUploadTips() }}</div>
</div>
</div>
......@@ -174,9 +174,9 @@
import { ref, computed, onMounted, nextTick, reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getTaskDetailAPI, getUploadTaskInfoAPI, getSubtaskListAPI, reuseGratitudeFormAPI } from "@/api/checkin"
import { getTeacherFindSettingsAPI } from '@/api/teacher'
import { useTitle } from '@vueuse/core'
import { useCheckin } from '@/composables/useCheckin'
import { normalizeAttachmentTypeConfig } from '@/utils/tools'
import AudioPlayer from '@/components/ui/AudioPlayer.vue'
import VideoPlayer from '@/components/ui/VideoPlayer.vue'
import AddTargetDialog from '@/components/count/AddTargetDialog.vue'
......@@ -200,7 +200,9 @@ const {
selectedTaskValue,
isMakeup,
maxCount,
maxFileSizeMb,
canSubmit,
setMaxFileSizeMbMap,
beforeRead,
afterRead,
onDelete,
......@@ -218,6 +220,12 @@ const dynamicFieldText = ref('感恩')
// 任务详情数据
const taskDetail = ref({})
const maxFileSizeBytes = computed(() => {
const size = Number(maxFileSizeMb.value || 0)
if (!Number.isFinite(size) || size <= 0) return 20 * 1024 * 1024
return Math.floor(size * 1024 * 1024)
})
// 显示的作业描述
const displayTaskNote = computed(() => {
const selected_subtask_id = selectedTaskValue.value?.[0]
......@@ -621,35 +629,12 @@ const getTaskDetail = async (month) => {
* @param {Array|Object} attachmentType - 附件类型数据
*/
const updateAttachmentTypeOptions = (attachmentType) => {
const typeMap = {
'text': '文本',
'image': '图片',
'audio': '音频',
'video': '视频'
}
const { options, upload_size_limit_mb_map } = normalizeAttachmentTypeConfig(attachmentType)
attachmentTypeOptions.value = options
if (Array.isArray(attachmentType)) {
attachmentTypeOptions.value = attachmentType.map(key => ({
key,
value: typeMap[key] || key
}))
} else if (attachmentType && typeof attachmentType === 'object') {
attachmentTypeOptions.value = Object.entries(attachmentType).map(([key, value]) => ({
key,
value
}))
} else {
attachmentTypeOptions.value = []
}
// 如果没有解析出任何类型,或者列表为空,则使用默认4种类型
if (attachmentTypeOptions.value.length === 0) {
attachmentTypeOptions.value = [
{ key: 'text', value: '文本' },
{ key: 'image', value: '图片' },
{ key: 'audio', value: '音频' },
{ key: 'video', value: '视频' }
]
// 设置最大文件大小映射
if (upload_size_limit_mb_map) {
setMaxFileSizeMbMap(upload_size_limit_mb_map)
}
// 如果是计数打卡(count),过滤掉文本(text)类型
......@@ -957,7 +942,7 @@ onMounted(async () => {
// 获取小作业列表
const subtask_list = await getSubtaskListAPI({ task_id: route.query.task_id, date: current_date })
if (subtask_list.code) {
if (subtask_list.code === 1) {
taskOptions.value = [...subtask_list.data.map(item => ({
text: item.is_makeup ? '补卡:' + item.title : item.title,
value: item.id,
......