| | |
| | | <template> |
| | | <view class="attachment-upload"> |
| | | <!-- 附件悬浮按钮 --> |
| | | <view class="attachment-btn" :style="{ |
| | | right: position.right, |
| | | bottom: position.bottom, |
| | | backgroundColor: bgColor, |
| | | display: showButton ? 'flex' : 'none', |
| | | }" @click="togglePopup"> |
| | | <view |
| | | class="attachment-btn" |
| | | :style="{ |
| | | right: position.right, |
| | | bottom: position.bottom, |
| | | backgroundColor: bgColor, |
| | | display: showButton ? 'flex' : 'none', |
| | | }" |
| | | @click="togglePopup" |
| | | > |
| | | <text>附件</text> |
| | | <text class="badge" v-if="totalFileCount > 0">{{ totalFileCount }}</text> |
| | | </view> |
| | | |
| | | <!-- 附件弹层 --> |
| | | <uni-popup ref="popup" type="bottom" :safe-area="false" @change="onPopupChange"> |
| | | <uni-popup |
| | | ref="popup" |
| | | type="bottom" |
| | | :safe-area="false" |
| | | @change="onPopupChange" |
| | | > |
| | | <view class="attachment-popup"> |
| | | <view class="popup-header"> |
| | | <text class="title">附件管理</text> |
| | | <uni-icons v-if="!readonly" type="plus" size="24" :color="mainColor" @click="chooseFile" /> |
| | | <uni-icons |
| | | v-if="!readonly" |
| | | type="plus" |
| | | size="24" |
| | | :color="mainColor" |
| | | @click="chooseFile" |
| | | /> |
| | | <uni-icons type="close" size="24" color="#999" @click="closePopup" /> |
| | | </view> |
| | | |
| | | <!-- 标签页 --> |
| | | <view v-if="showGradeSlip" class="attachment-tabs"> |
| | | <view class="tab-item" :class="{ active: currentTab === 'base' }" @click="currentTab = 'base'"> |
| | | <view |
| | | class="tab-item" |
| | | :class="{ active: currentTab === 'base' }" |
| | | @click="currentTab = 'base'" |
| | | > |
| | | <text>基础附件</text> |
| | | </view> |
| | | <view class="tab-item" :class="{ active: currentTab === 'grade' }" @click="currentTab = 'grade'"> |
| | | <view |
| | | class="tab-item" |
| | | :class="{ active: currentTab === 'grade' }" |
| | | @click="currentTab = 'grade'" |
| | | > |
| | | <text>成绩单附件</text> |
| | | <text class="required-mark" v-if="isGradeRequired">*</text> |
| | | </view> |
| | |
| | | <!-- 附件列表 --> |
| | | <scroll-view scroll-y class="file-list"> |
| | | <template v-if="currentTab === 'base' || !showGradeSlip"> |
| | | <view class="file-item" v-for="(file, index) in baseFiles" :key="'base-' + index"> |
| | | <view |
| | | class="file-item" |
| | | v-for="(file, index) in baseFiles" |
| | | :key="'base-' + index" |
| | | > |
| | | <view class="file-icon" @click="previewFile(file)"> |
| | | <uni-icons :type="getFileIcon(file.type)" size="24" :color="getFileColor(file.type)" /> |
| | | <uni-icons |
| | | :type="getFileIcon(file.type)" |
| | | size="24" |
| | | :color="getFileColor(file.type)" |
| | | /> |
| | | </view> |
| | | <view class="file-info" @click="previewFile(file)"> |
| | | <text class="file-name">{{ file.originalFilename || file.name }}</text> |
| | | <text class="file-name">{{ |
| | | file.originalFilename || file.name |
| | | }}</text> |
| | | <text class="file-size">{{ formatFileSize(file.size) }}</text> |
| | | <text class="file-status" v-if="file.status === 'uploading'">上传中...</text> |
| | | <text class="file-status error" v-else-if="file.status === 'error'">上传失败</text> |
| | | <text class="file-status" v-if="file.status === 'uploading'" |
| | | >上传中...</text |
| | | > |
| | | <text |
| | | class="file-status error" |
| | | v-else-if="file.status === 'error'" |
| | | >上传失败</text |
| | | > |
| | | </view> |
| | | <uni-icons v-if="!readonly" type="trash" size="20" color="#ff4d4f" |
| | | @click="(e) => removeFile('base', index, e)" /> |
| | | <uni-icons |
| | | v-if="!readonly" |
| | | type="trash" |
| | | size="20" |
| | | color="#ff4d4f" |
| | | @click="(e) => removeFile('base', index, e)" |
| | | /> |
| | | </view> |
| | | </template> |
| | | |
| | | <template v-if="currentTab === 'grade' && showGradeSlip"> |
| | | <view class="file-item" v-for="(file, index) in gradeFiles" :key="'grade-' + index"> |
| | | <view |
| | | class="file-item" |
| | | v-for="(file, index) in gradeFiles" |
| | | :key="'grade-' + index" |
| | | > |
| | | <view class="file-icon" @click="previewFile(file)"> |
| | | <uni-icons :type="getFileIcon(file.type)" size="24" :color="getFileColor(file.type)" /> |
| | | <uni-icons |
| | | :type="getFileIcon(file.type)" |
| | | size="24" |
| | | :color="getFileColor(file.type)" |
| | | /> |
| | | </view> |
| | | <view class="file-info" @click="previewFile(file)"> |
| | | <text class="file-name">{{ file.originalFilename || file.name }}</text> |
| | | <text class="file-name">{{ |
| | | file.originalFilename || file.name |
| | | }}</text> |
| | | <text class="file-size">{{ formatFileSize(file.size) }}</text> |
| | | <text class="file-status" v-if="file.status === 'uploading'">上传中...</text> |
| | | <text class="file-status error" v-else-if="file.status === 'error'">上传失败</text> |
| | | <text class="file-status" v-if="file.status === 'uploading'" |
| | | >上传中...</text |
| | | > |
| | | <text |
| | | class="file-status error" |
| | | v-else-if="file.status === 'error'" |
| | | >上传失败</text |
| | | > |
| | | </view> |
| | | <uni-icons v-if="!readonly" type="trash" size="20" color="#ff4d4f" |
| | | @click="(e) => removeFile('grade', index, e)" /> |
| | | <uni-icons |
| | | v-if="!readonly" |
| | | type="trash" |
| | | size="20" |
| | | color="#ff4d4f" |
| | | @click="(e) => removeFile('grade', index, e)" |
| | | /> |
| | | </view> |
| | | </template> |
| | | |
| | |
| | | files: { type: Array, default: () => [] }, |
| | | gradesFiles: { type: Array, default: () => [] }, |
| | | readonly: { type: Boolean, default: false }, |
| | | position: { type: Object, default: () => ({ right: "30rpx", bottom: "200rpx" }) }, |
| | | position: { |
| | | type: Object, |
| | | default: () => ({ right: "30rpx", bottom: "200rpx" }), |
| | | }, |
| | | bgColor: { type: String, default: "#67AFAB" }, |
| | | maxCount: { type: Number, default: 9 }, |
| | | showGradeSlip: { type: Boolean, default: false }, |
| | | isGradeRequired: { type: Boolean, default: false } |
| | | isGradeRequired: { type: Boolean, default: false }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:files", "update:gradesFiles", "upload", "preview", "upload-grade", "upload-base"]); |
| | | const emit = defineEmits([ |
| | | "update:files", |
| | | "update:gradesFiles", |
| | | "upload", |
| | | "preview", |
| | | "upload-grade", |
| | | "upload-base", |
| | | ]); |
| | | |
| | | const popup = ref(null); |
| | | const baseFiles = ref([]); |
| | | const gradeFiles = ref([]); |
| | | const showButton = ref(true); |
| | | const mainColor = ref("#67AFAB"); |
| | | const baseUrlHt = userStore.baseUrlHt; |
| | | const currentTab = ref('base'); |
| | | const blobUrls = ref([]); // 存储 H5 blob URL 以便释放内存 |
| | | const currentTab = ref("base"); |
| | | const blobUrls = ref([]); |
| | | |
| | | const getBaseUrlHt = () => { |
| | | // #ifdef H5 |
| | | try { |
| | | const href = window.location.href; |
| | | |
| | | // 规则 1:192.168.120.27:8091 → 120.27 + :9095 |
| | | if (href.startsWith("http://192.168.120.27:8091")) { |
| | | return "http://192.168.120.27:9095"; |
| | | } |
| | | |
| | | // 规则 2:192.168.76.25:8091 → 76.25 + :9095 |
| | | if (href.startsWith("http://192.168.76.25:8091")) { |
| | | return "http://192.168.76.25:9095"; |
| | | } |
| | | |
| | | // 规则 3:其余全部走生产 |
| | | return "https://opo.qduh.cn"; |
| | | } catch { |
| | | return "https://opo.qduh.cn"; |
| | | } |
| | | // #endif |
| | | |
| | | // #ifdef MP-WEIXIN |
| | | return userStore.baseUrlHt; |
| | | // #endif |
| | | }; |
| | | |
| | | |
| | | const baseUrlHt = getBaseUrlHt(); |
| | | |
| | | const currentFileList = computed(() => { |
| | | if (!props.showGradeSlip) return baseFiles.value; |
| | | return currentTab.value === 'base' ? baseFiles.value : gradeFiles.value; |
| | | return currentTab.value === "base" ? baseFiles.value : gradeFiles.value; |
| | | }); |
| | | const totalFileCount = computed(() => baseFiles.value.length + gradeFiles.value.length); |
| | | const totalFileCount = computed( |
| | | () => baseFiles.value.length + gradeFiles.value.length, |
| | | ); |
| | | |
| | | watch(() => props.files, (newFiles) => { baseFiles.value = [...newFiles]; }, { immediate: true }); |
| | | watch(() => props.gradesFiles, (newFiles) => { gradeFiles.value = [...newFiles]; }, { immediate: true }); |
| | | watch( |
| | | () => props.files, |
| | | (newFiles) => { |
| | | baseFiles.value = [...newFiles]; |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | watch( |
| | | () => props.gradesFiles, |
| | | (newFiles) => { |
| | | gradeFiles.value = [...newFiles]; |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | |
| | | // 获取完整URL - 平台差异化处理 |
| | | const getFullUrl = (path) => { |
| | | if (!path) return ''; |
| | | if (path.startsWith('http://') || path.startsWith('https://')) { |
| | | if (!path) return ""; |
| | | |
| | | // 如果是 blob 或 file 协议的本地路径,直接返回 |
| | | if (path.startsWith("blob:") || path.startsWith("file://")) { |
| | | return path; |
| | | } |
| | | |
| | | // 如果是完整的 http/https 链接 |
| | | if (path.startsWith("http://") || path.startsWith("https://")) { |
| | | // #ifdef H5 |
| | | // H5 环境:使用 new URL 解析 |
| | | try { |
| | | const url = new URL(path); |
| | | return `${baseUrlHt}${url.pathname}${url.search}${url.hash}`; |
| | | } catch { return path; } |
| | | } catch (error) { |
| | | console.error("URL解析失败:", error); |
| | | // 降级处理:手动截取路径 |
| | | const pathIndex = path.indexOf("/", 8); // 跳过 http:// 或 https:// |
| | | if (pathIndex !== -1) { |
| | | return `${baseUrlHt}${path.substring(pathIndex)}`; |
| | | } |
| | | return path; |
| | | } |
| | | // #endif |
| | | |
| | | // #ifdef MP-WEIXIN |
| | | // 小程序环境:不使用 new URL,手动处理 |
| | | const protocolEnd = path.indexOf("://") + 3; |
| | | const pathStart = path.indexOf("/", protocolEnd); |
| | | if (pathStart !== -1) { |
| | | // 提取路径部分(包括路径、查询参数和hash) |
| | | return `${baseUrlHt}${path.substring(pathStart)}`; |
| | | } |
| | | return baseUrlHt; |
| | | // #endif |
| | | } |
| | | return `${baseUrlHt}${path.startsWith('/') ? '' : '/'}${path}`; |
| | | |
| | | // 相对路径处理 |
| | | return `${baseUrlHt}${path.startsWith("/") ? "" : "/"}${path}`; |
| | | }; |
| | | |
| | | const supportedImageTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]; |
| | | const supportedImageTypes = [ |
| | | "image/jpeg", |
| | | "image/png", |
| | | "image/gif", |
| | | "image/webp", |
| | | "image/bmp", |
| | | "image/svg+xml", |
| | | ]; |
| | | |
| | | const onPopupChange = (e) => { showButton.value = !e.show; }; |
| | | const onPopupChange = (e) => { |
| | | showButton.value = !e.show; |
| | | }; |
| | | const getFileIcon = (type) => { |
| | | if (type && supportedImageTypes.includes(type)) return "image"; |
| | | if (type && type.includes("pdf")) return "paperclip"; |
| | |
| | | if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`; |
| | | return `${(size / (1024 * 1024)).toFixed(1)}MB`; |
| | | }; |
| | | const togglePopup = () => { popup.value?.open(); }; |
| | | const closePopup = () => { popup.value?.close(); }; |
| | | const togglePopup = () => { |
| | | popup.value?.open(); |
| | | }; |
| | | const closePopup = () => { |
| | | popup.value?.close(); |
| | | }; |
| | | |
| | | const addFilesToCurrentTab = (newFiles) => { |
| | | const isGradeTab = props.showGradeSlip && currentTab.value === 'grade'; |
| | | const isGradeTab = props.showGradeSlip && currentTab.value === "grade"; |
| | | const targetFiles = isGradeTab ? gradeFiles.value : baseFiles.value; |
| | | if (targetFiles.length + newFiles.length > props.maxCount) { |
| | | uni.showToast({ title: `最多只能上传${props.maxCount}个文件`, icon: "none" }); |
| | | newFiles.forEach(f => { if (f.url && f.url.startsWith('blob:')) URL.revokeObjectURL(f.url); }); |
| | | uni.showToast({ |
| | | title: `最多只能上传${props.maxCount}个文件`, |
| | | icon: "none", |
| | | }); |
| | | newFiles.forEach((f) => { |
| | | if (f.url && f.url.startsWith("blob:")) URL.revokeObjectURL(f.url); |
| | | }); |
| | | return; |
| | | } |
| | | if (isGradeTab) { |
| | |
| | | // 选择文件(平台差异) |
| | | const chooseFile = () => { |
| | | // #ifdef H5 |
| | | const input = document.createElement('input'); |
| | | input.type = 'file'; |
| | | input.accept = 'image/*,application/pdf'; |
| | | const input = document.createElement("input"); |
| | | input.type = "file"; |
| | | input.accept = "image/*,application/pdf"; |
| | | input.multiple = true; |
| | | input.onchange = (e) => { |
| | | const files = Array.from(e.target.files); |
| | | if (files.length === 0) return; |
| | | const newFiles = files.map(file => { |
| | | const ext = file.name.split('.').pop().toLowerCase(); |
| | | const isImage = supportedImageTypes.includes(file.type) || ['jpg','jpeg','png','gif','webp','bmp','svg'].includes(ext); |
| | | const isPDF = file.type.includes('pdf') || ext === 'pdf'; |
| | | if (!isImage && !isPDF) { |
| | | uni.showToast({ title: `文件 ${file.name} 格式不支持`, icon: 'none' }); |
| | | return null; |
| | | } |
| | | const blobUrl = URL.createObjectURL(file); |
| | | blobUrls.value.push(blobUrl); |
| | | return { |
| | | name: file.name, |
| | | path: blobUrl, |
| | | url: blobUrl, |
| | | uploadPath: blobUrl, // 关键:上传路径使用 blob URL 字符串 |
| | | type: file.type, |
| | | size: file.size, |
| | | raw: file, |
| | | status: 'pending' |
| | | }; |
| | | }).filter(f => f); |
| | | const newFiles = files |
| | | .map((file) => { |
| | | const ext = file.name.split(".").pop().toLowerCase(); |
| | | const isImage = |
| | | supportedImageTypes.includes(file.type) || |
| | | ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(ext); |
| | | const isPDF = file.type.includes("pdf") || ext === "pdf"; |
| | | if (!isImage && !isPDF) { |
| | | uni.showToast({ |
| | | title: `文件 ${file.name} 格式不支持`, |
| | | icon: "none", |
| | | }); |
| | | return null; |
| | | } |
| | | const blobUrl = URL.createObjectURL(file); |
| | | blobUrls.value.push(blobUrl); |
| | | return { |
| | | name: file.name, |
| | | path: blobUrl, |
| | | url: blobUrl, |
| | | uploadPath: blobUrl, |
| | | type: file.type, |
| | | size: file.size, |
| | | raw: file, |
| | | status: "pending", |
| | | }; |
| | | }) |
| | | .filter((f) => f); |
| | | addFilesToCurrentTab(newFiles); |
| | | input.remove(); |
| | | }; |
| | |
| | | |
| | | // #ifdef MP-WEIXIN |
| | | uni.showActionSheet({ |
| | | itemList: ['拍照/相册', '从聊天记录选择文件'], |
| | | itemList: ["拍照/相册", "从聊天记录选择文件"], |
| | | success: (res) => { |
| | | const remain = props.maxCount - currentFileList.value.length; |
| | | if (remain <= 0) { |
| | | uni.showToast({ title: `最多上传${props.maxCount}个文件`, icon: 'none' }); |
| | | uni.showToast({ |
| | | title: `最多上传${props.maxCount}个文件`, |
| | | icon: "none", |
| | | }); |
| | | return; |
| | | } |
| | | if (res.tapIndex === 0) { |
| | | uni.chooseImage({ |
| | | count: remain, |
| | | sizeType: ['original', 'compressed'], |
| | | sourceType: ['album', 'camera'], |
| | | sizeType: ["original", "compressed"], |
| | | sourceType: ["album", "camera"], |
| | | success: (chooseRes) => { |
| | | const files = chooseRes.tempFiles.map(file => ({ |
| | | name: file.name || 'image.jpg', |
| | | const files = chooseRes.tempFiles.map((file) => ({ |
| | | name: file.name || "image.jpg", |
| | | path: file.path, |
| | | url: file.path, |
| | | uploadPath: file.path, // 小程序临时文件路径 |
| | | type: file.type || 'image/jpeg', |
| | | uploadPath: file.path, |
| | | type: file.type || "image/jpeg", |
| | | size: file.size, |
| | | raw: file, |
| | | status: 'pending' |
| | | status: "pending", |
| | | })); |
| | | addFilesToCurrentTab(files); |
| | | } |
| | | }, |
| | | }); |
| | | } else if (res.tapIndex === 1) { |
| | | uni.chooseMessageFile({ |
| | | count: remain, |
| | | type: 'all', |
| | | type: "all", |
| | | success: (chooseRes) => { |
| | | const files = chooseRes.tempFiles.map(file => ({ |
| | | const files = chooseRes.tempFiles.map((file) => ({ |
| | | name: file.name, |
| | | path: file.path, |
| | | url: file.path, |
| | |
| | | type: file.type, |
| | | size: file.size, |
| | | raw: file, |
| | | status: 'pending' |
| | | status: "pending", |
| | | })); |
| | | addFilesToCurrentTab(files); |
| | | } |
| | | }, |
| | | }); |
| | | } |
| | | } |
| | | }, |
| | | }); |
| | | // #endif |
| | | }; |
| | | |
| | | const removeFile = (type, index, event) => { |
| | | if (event) event.stopPropagation(); |
| | | const file = type === 'grade' ? gradeFiles.value[index] : baseFiles.value[index]; |
| | | if (file.url && file.url.startsWith('blob:')) { |
| | | const file = |
| | | type === "grade" ? gradeFiles.value[index] : baseFiles.value[index]; |
| | | if (file.url && file.url.startsWith("blob:")) { |
| | | URL.revokeObjectURL(file.url); |
| | | const idx = blobUrls.value.indexOf(file.url); |
| | | if (idx !== -1) blobUrls.value.splice(idx, 1); |
| | | } |
| | | if (type === 'grade') { |
| | | if (type === "grade") { |
| | | gradeFiles.value.splice(index, 1); |
| | | emit("update:gradesFiles", gradeFiles.value); |
| | | } else { |
| | |
| | | // 预览文件 |
| | | const previewFile = (file) => { |
| | | let previewUrl = file.url; |
| | | if (previewUrl && !previewUrl.startsWith('blob:') && !previewUrl.startsWith('file://') && !previewUrl.startsWith('/')) { |
| | | if ( |
| | | previewUrl && |
| | | !previewUrl.startsWith("blob:") && |
| | | !previewUrl.startsWith("file://") && |
| | | !previewUrl.startsWith("/") |
| | | ) { |
| | | previewUrl = getFullUrl(previewUrl); |
| | | } |
| | | if (file.type && supportedImageTypes.includes(file.type)) { |
| | | const allFiles = [...baseFiles.value, ...gradeFiles.value]; |
| | | const imageUrls = allFiles |
| | | .filter(f => f.type && supportedImageTypes.includes(f.type)) |
| | | .map(f => f.url.startsWith('blob:') || f.url.startsWith('file://') || f.url.startsWith('/') ? f.url : getFullUrl(f.url)); |
| | | .filter((f) => f.type && supportedImageTypes.includes(f.type)) |
| | | .map((f) => |
| | | f.url.startsWith("blob:") || |
| | | f.url.startsWith("file://") || |
| | | f.url.startsWith("/") |
| | | ? f.url |
| | | : getFullUrl(f.url), |
| | | ); |
| | | uni.previewImage({ urls: imageUrls, current: previewUrl }); |
| | | } else if (file.type && file.type.includes("pdf")) { |
| | | uni.downloadFile({ |
| | | url: previewUrl, |
| | | success: (res) => { uni.openDocument({ filePath: res.tempFilePath, fileType: "pdf" }); }, |
| | | fail: () => uni.showToast({ title: "打开PDF失败", icon: "none" }) |
| | | success: (res) => { |
| | | uni.openDocument({ filePath: res.tempFilePath, fileType: "pdf" }); |
| | | }, |
| | | fail: () => uni.showToast({ title: "打开PDF失败", icon: "none" }), |
| | | }); |
| | | } else { |
| | | emit("preview", file); |
| | | } |
| | | }; |
| | | |
| | | // 上传文件(关键修复) |
| | | // 上传文件(平台自适应) |
| | | const uploadFile = (file, type) => { |
| | | return new Promise((resolve, reject) => { |
| | | const token = uni.getStorageSync('token'); |
| | | let filePath = file.uploadPath || file.url || file.path; |
| | | const filePath = file.uploadPath || file.url || file.path; |
| | | |
| | | if (!filePath || typeof filePath !== 'string') { |
| | | reject(new Error('无效的文件路径')); |
| | | return; |
| | | } |
| | | |
| | | // 根据平台构造上传URL |
| | | let uploadUrl = '/api/common/upload'; |
| | | // #ifdef H5 |
| | | const uploadUrl = '/api/common/upload'; |
| | | // #endif |
| | | |
| | | // #ifdef MP-WEIXIN |
| | | // 小程序需要完整URL,baseUrlHt 来自 userStore(如 https://opo.qduh.cn) |
| | | uploadUrl = 'https://opo.qduh.cn/common/upload'; |
| | | const uploadUrl = `${userStore.baseUrlHt}/common/upload`; |
| | | // #endif |
| | | |
| | | uni.uploadFile({ |
| | | url: uploadUrl, |
| | | filePath: filePath, |
| | | filePath, |
| | | name: 'file', |
| | | header: { 'Authorization': `Bearer ${token}` }, |
| | | header: { |
| | | Authorization: `Bearer ${token}` |
| | | }, |
| | | success: (res) => { |
| | | if (res.statusCode === 200) { |
| | | try { |
| | | const data = JSON.parse(res.data); |
| | | if (data.code === 200) resolve({ ...data, fileName: data.fileName }); |
| | | else reject(new Error(data.msg || '上传失败')); |
| | | } catch { reject(new Error('解析响应失败')); } |
| | | } else reject(new Error(`上传失败,状态码: ${res.statusCode}`)); |
| | | if (data.code === 200) { |
| | | resolve({ ...data, fileName: data.fileName }); |
| | | } else { |
| | | reject(new Error(data.msg || '上传失败')); |
| | | } |
| | | } catch { |
| | | reject(new Error('响应解析失败')); |
| | | } |
| | | } else { |
| | | reject(new Error(`上传失败,状态码: ${res.statusCode}`)); |
| | | } |
| | | }, |
| | | fail: reject |
| | | }); |
| | |
| | | |
| | | // 确认上传 |
| | | const confirmUpload = async () => { |
| | | if (props.showGradeSlip && props.isGradeRequired && gradeFiles.value.length === 0) { |
| | | if ( |
| | | props.showGradeSlip && |
| | | props.isGradeRequired && |
| | | gradeFiles.value.length === 0 |
| | | ) { |
| | | uni.showToast({ title: "请上传成绩单附件", icon: "none" }); |
| | | currentTab.value = 'grade'; |
| | | currentTab.value = "grade"; |
| | | return; |
| | | } |
| | | const totalPending = [...baseFiles.value, ...gradeFiles.value].filter(f => !f.url || f.status === 'pending').length; |
| | | const totalPending = [...baseFiles.value, ...gradeFiles.value].filter( |
| | | (f) => !f.url || f.status === "pending", |
| | | ).length; |
| | | if (totalPending === 0) { |
| | | uni.showToast({ title: "没有待上传的文件", icon: "none" }); |
| | | return; |
| | | } |
| | | uni.showLoading({ title: '上传中', mask: true }); |
| | | uni.showLoading({ title: "上传中", mask: true }); |
| | | try { |
| | | const pendingBase = baseFiles.value.filter(f => !f.url || f.status === 'pending'); |
| | | const pendingGrade = gradeFiles.value.filter(f => !f.url || f.status === 'pending'); |
| | | const pendingBase = baseFiles.value.filter( |
| | | (f) => !f.url || f.status === "pending", |
| | | ); |
| | | const pendingGrade = gradeFiles.value.filter( |
| | | (f) => !f.url || f.status === "pending", |
| | | ); |
| | | for (const file of pendingBase) { |
| | | try { |
| | | file.status = 'uploading'; |
| | | const res = await uploadFile(file, 'base'); |
| | | file.status = "uploading"; |
| | | const res = await uploadFile(file, "base"); |
| | | Object.assign(file, { |
| | | url: res.url, |
| | | fileName: res.name, |
| | | newFileName: res.newFileName, |
| | | originalFilename: res.originalFilename, |
| | | status: 'success', |
| | | size: res.size |
| | | status: "success", |
| | | size: res.size, |
| | | }); |
| | | emit("upload-base", file); |
| | | } catch (err) { |
| | | file.status = 'error'; |
| | | uni.showToast({ title: `文件 ${file.name} 上传失败`, icon: 'none' }); |
| | | file.status = "error"; |
| | | uni.showToast({ title: `文件 ${file.name} 上传失败`, icon: "none" }); |
| | | } |
| | | } |
| | | for (const file of pendingGrade) { |
| | | try { |
| | | file.status = 'uploading'; |
| | | const res = await uploadFile(file, 'grade'); |
| | | file.status = "uploading"; |
| | | const res = await uploadFile(file, "grade"); |
| | | Object.assign(file, { |
| | | url: res.fileName, |
| | | fileName: res.fileName, |
| | | newFileName: res.newFileName, |
| | | originalFilename: res.originalFilename, |
| | | status: 'success' |
| | | status: "success", |
| | | }); |
| | | emit("upload-grade", file); |
| | | } catch (err) { |
| | | file.status = 'error'; |
| | | uni.showToast({ title: `文件 ${file.name} 上传失败`, icon: 'none' }); |
| | | file.status = "error"; |
| | | uni.showToast({ title: `文件 ${file.name} 上传失败`, icon: "none" }); |
| | | } |
| | | } |
| | | emit("update:files", baseFiles.value); |
| | | emit("update:gradesFiles", gradeFiles.value); |
| | | uni.showToast({ title: '上传完成', icon: 'success' }); |
| | | uni.showToast({ title: "上传完成", icon: "success" }); |
| | | closePopup(); |
| | | } catch (error) { |
| | | console.error('上传出错:', error); |
| | | uni.showToast({ title: '上传出错', icon: 'none' }); |
| | | console.error("上传出错:", error); |
| | | uni.showToast({ title: "上传出错", icon: "none" }); |
| | | } finally { |
| | | uni.hideLoading(); |
| | | } |
| | | }; |
| | | |
| | | const getFilesByType = (type) => type === 'grade' ? gradeFiles.value : baseFiles.value; |
| | | const getFilesByType = (type) => |
| | | type === "grade" ? gradeFiles.value : baseFiles.value; |
| | | const getAllFiles = () => [...baseFiles.value, ...gradeFiles.value]; |
| | | |
| | | onBeforeUnmount(() => { |
| | | blobUrls.value.forEach(url => URL.revokeObjectURL(url)); |
| | | blobUrls.value.forEach((url) => URL.revokeObjectURL(url)); |
| | | blobUrls.value = []; |
| | | }); |
| | | |
| | |
| | | font-size: 28rpx; |
| | | color: #666; |
| | | &.active { |
| | | color: #67AFAB; |
| | | color: #67afab; |
| | | font-weight: bold; |
| | | border-bottom: 4rpx solid #67AFAB; |
| | | border-bottom: 4rpx solid #67afab; |
| | | } |
| | | .required-mark { |
| | | color: red; |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.15); |
| | | box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15); |
| | | z-index: 999; |
| | | color: #fff; |
| | | font-size: 28rpx; |
| | | font-weight: bold; |
| | | transition: all 0.3s; |
| | | &:active { opacity: 0.8; transform: scale(0.95); } |
| | | &:active { |
| | | opacity: 0.8; |
| | | transform: scale(0.95); |
| | | } |
| | | .badge { |
| | | position: absolute; |
| | | top: -10rpx; |
| | |
| | | margin-bottom: 30rpx; |
| | | padding-bottom: 20rpx; |
| | | border-bottom: 1rpx solid #f5f5f5; |
| | | .title { font-size: 32rpx; font-weight: bold; color: #333; } |
| | | .uni-icons { padding: 10rpx; &:active { opacity: 0.7; } } |
| | | .title { |
| | | font-size: 32rpx; |
| | | font-weight: bold; |
| | | color: #333; |
| | | } |
| | | .uni-icons { |
| | | padding: 10rpx; |
| | | &:active { |
| | | opacity: 0.7; |
| | | } |
| | | } |
| | | } |
| | | .file-list { |
| | | max-height: 50vh; |
| | |
| | | align-items: center; |
| | | padding: 20rpx 0; |
| | | border-bottom: 1rpx solid #f5f5f5; |
| | | &:active { background-color: #f9f9f9; } |
| | | .file-icon { margin-right: 20rpx; } |
| | | &:active { |
| | | background-color: #f9f9f9; |
| | | } |
| | | .file-icon { |
| | | margin-right: 20rpx; |
| | | } |
| | | .file-info { |
| | | flex: 1; |
| | | overflow: hidden; |
| | | .file-name { font-size: 28rpx; color: #333; display: block; margin-bottom: 8rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
| | | .file-size { font-size: 24rpx; color: #999; display: block; } |
| | | .file-status { font-size: 24rpx; color: #666; display: block; &.error { color: #ff4d4f; } } |
| | | .file-name { |
| | | font-size: 28rpx; |
| | | color: #333; |
| | | display: block; |
| | | margin-bottom: 8rpx; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | } |
| | | .file-size { |
| | | font-size: 24rpx; |
| | | color: #999; |
| | | display: block; |
| | | } |
| | | .file-status { |
| | | font-size: 24rpx; |
| | | color: #666; |
| | | display: block; |
| | | &.error { |
| | | color: #ff4d4f; |
| | | } |
| | | } |
| | | } |
| | | .uni-icons { padding: 10rpx; &:active { opacity: 0.7; } } |
| | | .uni-icons { |
| | | padding: 10rpx; |
| | | &:active { |
| | | opacity: 0.7; |
| | | } |
| | | } |
| | | } |
| | | .empty { |
| | | display: flex; |
| | |
| | | padding: 60rpx 0; |
| | | color: #999; |
| | | font-size: 28rpx; |
| | | .uni-icons { margin-bottom: 20rpx; } |
| | | .uni-icons { |
| | | margin-bottom: 20rpx; |
| | | } |
| | | } |
| | | } |
| | | .popup-footer { |
| | |
| | | background-color: #f5f5f5; |
| | | color: #666; |
| | | border: none; |
| | | &:active { opacity: 0.8; } |
| | | &.primary { background-color: #67afab; color: #fff; } |
| | | &:active { |
| | | opacity: 0.8; |
| | | } |
| | | &.primary { |
| | | background-color: #67afab; |
| | | color: #fff; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | | </style> |