| | |
| | | @change="onPopupChange" |
| | | > |
| | | <view class="attachment-popup"> |
| | | <!-- 弹层标题 --> |
| | | <view class="popup-header"> |
| | | <text class="title">附件管理</text> |
| | | <uni-icons |
| | |
| | | <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' }" |
| | | <view |
| | | class="tab-item" |
| | | :class="{ active: currentTab === 'base' }" |
| | | @click="currentTab = 'base'" |
| | | > |
| | | <text>基础附件</text> |
| | | </view> |
| | | <view |
| | | class="tab-item" |
| | | :class="{ active: currentTab === 'grade' }" |
| | | <view |
| | | class="tab-item" |
| | | :class="{ active: currentTab === 'grade' }" |
| | | @click="currentTab = 'grade'" |
| | | > |
| | | <text>成绩单附件</text> |
| | |
| | | |
| | | <!-- 附件列表 --> |
| | | <scroll-view scroll-y class="file-list"> |
| | | <!-- 基础附件列表 --> |
| | | <template v-if="currentTab === 'base' || !showGradeSlip"> |
| | | <view |
| | | class="file-item" |
| | |
| | | /> |
| | | </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" |
| | |
| | | </view> |
| | | </template> |
| | | |
| | | <!-- 成绩单附件列表 --> |
| | | <template v-if="currentTab === 'grade' && showGradeSlip"> |
| | | <view |
| | | class="file-item" |
| | |
| | | /> |
| | | </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" |
| | |
| | | </view> |
| | | </template> |
| | | |
| | | <!-- 空状态 --> |
| | | <view class="empty" v-if="currentFileList.length === 0"> |
| | | <uni-icons type="info" size="24" color="#999" /> |
| | | <text v-if="currentTab === 'base' || !showGradeSlip">暂无基础附件</text> |
| | | <text v-if="currentTab === 'base' || !showGradeSlip">暂无附件</text> |
| | | <text v-else-if="currentTab === 'grade'">暂无成绩单附件</text> |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <!-- 操作按钮 --> |
| | | <view class="popup-footer" v-if="!readonly"> |
| | | <button class="btn" @click="chooseFile">添加{{ currentTab === 'grade' ? '成绩单' : '基础' }}附件</button> |
| | | <button class="btn" @click="chooseFile">添加</button> |
| | | <button class="btn primary" @click="confirmUpload">确认上传</button> |
| | | </view> |
| | | </view> |
| | | </uni-popup> |
| | | |
| | | <!-- 文件选择器 --> |
| | | <uni-file-picker |
| | | ref="filePicker" |
| | | v-if="!readonly" |
| | | :auto-upload="false" |
| | | file-mediatype="all" |
| | | :limit="maxCount - currentFileList.length" |
| | | :image-styles="imageStyles" |
| | | @select="onFileSelect" |
| | | @delete="onFileDelete" |
| | | style="display: none" |
| | | /> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, computed, watch, onMounted } from "vue"; |
| | | import { ref, computed, watch, onBeforeUnmount } from "vue"; |
| | | import { useUserStore } from "@/stores/user"; |
| | | |
| | | const userStore = useUserStore(); |
| | | const props = defineProps({ |
| | | files: { |
| | | type: Array, |
| | | default: () => [], |
| | | }, |
| | | gradesFiles: { // 新增属性:成绩单附件列表 |
| | | type: Array, |
| | | default: () => [], |
| | | }, |
| | | readonly: { |
| | | type: Boolean, |
| | | default: false, |
| | | }, |
| | | files: { type: Array, default: () => [] }, |
| | | gradesFiles: { type: Array, default: () => [] }, |
| | | readonly: { type: Boolean, default: false }, |
| | | position: { |
| | | type: Object, |
| | | default: () => ({ |
| | | right: "30rpx", |
| | | bottom: "200rpx", |
| | | }), |
| | | 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 |
| | | } |
| | | bgColor: { type: String, default: "#67AFAB" }, |
| | | maxCount: { type: Number, default: 9 }, |
| | | showGradeSlip: { type: Boolean, default: false }, |
| | | isGradeRequired: { type: Boolean, default: false }, |
| | | }); |
| | | |
| | | const emit = defineEmits([ |
| | | "update:files", |
| | | "update:gradesFiles", // 新增事件:更新成绩单附件 |
| | | "upload", |
| | | "update:files", |
| | | "update:gradesFiles", |
| | | "upload", |
| | | "preview", |
| | | "upload-grade", // 新增事件:上传成绩单附件 |
| | | "upload-base" // 新增事件:上传基础附件 |
| | | "upload-grade", |
| | | "upload-base", |
| | | ]); |
| | | |
| | | const popup = ref(null); |
| | | const filePicker = 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 currentTab = ref("base"); |
| | | const blobUrls = ref([]); |
| | | |
| | | // 计算当前显示的文件列表 |
| | | const currentFileList = computed(() => { |
| | | if (!props.showGradeSlip) { |
| | | return baseFiles.value; |
| | | 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"; |
| | | } |
| | | return currentTab.value === 'base' ? baseFiles.value : gradeFiles.value; |
| | | // #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; |
| | | }); |
| | | const totalFileCount = computed( |
| | | () => baseFiles.value.length + gradeFiles.value.length, |
| | | ); |
| | | |
| | | // 计算文件总数 |
| | | const totalFileCount = computed(() => { |
| | | return baseFiles.value.length + gradeFiles.value.length; |
| | | }); |
| | | watch( |
| | | () => props.files, |
| | | (newFiles) => { |
| | | baseFiles.value = [...newFiles]; |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | watch( |
| | | () => props.gradesFiles, |
| | | (newFiles) => { |
| | | gradeFiles.value = [...newFiles]; |
| | | }, |
| | | { immediate: true }, |
| | | ); |
| | | |
| | | // 监听外部传入的files,初始化文件列表 |
| | | watch(() => props.files, (newFiles) => { |
| | | baseFiles.value = [...newFiles]; |
| | | }, { immediate: true }); |
| | | |
| | | // 监听外部传入的gradesFiles,初始化成绩单附件列表 |
| | | watch(() => props.gradesFiles, (newFiles) => { |
| | | gradeFiles.value = [...newFiles]; |
| | | }, { immediate: true }); |
| | | |
| | | // 获取完整URL |
| | | // 获取完整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; |
| | | } |
| | | return `${baseUrlHt}${path.startsWith('/') ? '' : '/'}${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 (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}`; |
| | | }; |
| | | |
| | | // 文件选择器样式 |
| | | const imageStyles = { |
| | | width: 120, |
| | | height: 120, |
| | | border: false, |
| | | }; |
| | | |
| | | // 支持的图片格式 |
| | | const supportedImageTypes = [ |
| | | "image/jpeg", |
| | | "image/png", |
| | |
| | | "image/svg+xml", |
| | | ]; |
| | | |
| | | // 弹层状态变化 |
| | | 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 (type && type.includes("powerpoint")) return "file-ppt"; |
| | | return "file"; |
| | | }; |
| | | |
| | | // 获取文件颜色 |
| | | const getFileColor = (type) => { |
| | | if (type && supportedImageTypes.includes(type)) return mainColor.value; |
| | | if (type && type.includes("pdf")) return "#ff4d4f"; |
| | |
| | | if (type && type.includes("powerpoint")) return "#b7472a"; |
| | | return "#666"; |
| | | }; |
| | | |
| | | // 格式化文件大小 |
| | | const formatFileSize = (size) => { |
| | | if (!size) return ""; |
| | | if (size < 1024) return `${size}B`; |
| | | if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`; |
| | | return `${(size / (1024 * 1024)).toFixed(1)}MB`; |
| | | }; |
| | | |
| | | // 切换弹层显示 |
| | | const togglePopup = () => { |
| | | if (popup.value) { |
| | | popup.value.open(); |
| | | } |
| | | popup.value?.open(); |
| | | }; |
| | | |
| | | // 关闭弹层 |
| | | const closePopup = () => { |
| | | if (popup.value) { |
| | | popup.value.close(); |
| | | } |
| | | popup.value?.close(); |
| | | }; |
| | | |
| | | // 选择文件 |
| | | const chooseFile = () => { |
| | | filePicker.value?.choose(); |
| | | }; |
| | | |
| | | // 从文件名获取类型 |
| | | const getFileTypeFromName = (filename) => { |
| | | if (!filename) return "application/octet-stream"; |
| | | const ext = filename.split(".").pop().toLowerCase(); |
| | | const typeMap = { |
| | | jpg: "image/jpeg", |
| | | jpeg: "image/jpeg", |
| | | png: "image/png", |
| | | gif: "image/gif", |
| | | webp: "image/webp", |
| | | bmp: "image/bmp", |
| | | SVG: "image/svg+xml", |
| | | pdf: "application/pdf", |
| | | }; |
| | | return typeMap[ext] || "application/octet-stream"; |
| | | }; |
| | | |
| | | // 文件选中回调 |
| | | const onFileSelect = (e) => { |
| | | // 明确确定当前是基础附件还是成绩单附件 |
| | | const isGradeTab = props.showGradeSlip && currentTab.value === 'grade'; |
| | | const addFilesToCurrentTab = (newFiles) => { |
| | | const isGradeTab = props.showGradeSlip && currentTab.value === "grade"; |
| | | const targetFiles = isGradeTab ? gradeFiles.value : baseFiles.value; |
| | | |
| | | const newFiles = e.tempFiles |
| | | .filter((file) => { |
| | | const fileExt = file.name ? file.name.split(".").pop().toLowerCase() : ""; |
| | | const isImage = supportedImageTypes.includes(file.type) || |
| | | ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(fileExt); |
| | | const isPDF = (file.type && file.type.includes("pdf")) || fileExt === "pdf"; |
| | | return isImage || isPDF; |
| | | }) |
| | | .map((file) => ({ |
| | | name: file.name, |
| | | url: file.path || file.url, |
| | | type: file.type || getFileTypeFromName(file.name), |
| | | size: file.size, |
| | | file: file, |
| | | status: 'pending', |
| | | // 明确标记附件类型 |
| | | attachmentType: isGradeTab ? 'grade' : 'base' |
| | | })); |
| | | |
| | | 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); |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | // 严格区分存储位置 |
| | | if (isGradeTab) { |
| | | gradeFiles.value = [...gradeFiles.value, ...newFiles]; |
| | | emit("update:gradesFiles", gradeFiles.value); |
| | |
| | | } |
| | | }; |
| | | |
| | | // 获取所有文件 |
| | | const getAllFiles = () => { |
| | | return [...baseFiles.value, ...gradeFiles.value]; |
| | | // 选择文件(平台差异) |
| | | const chooseFile = () => { |
| | | // #ifdef H5 |
| | | 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, |
| | | type: file.type, |
| | | size: file.size, |
| | | raw: file, |
| | | status: "pending", |
| | | }; |
| | | }) |
| | | .filter((f) => f); |
| | | addFilesToCurrentTab(newFiles); |
| | | input.remove(); |
| | | }; |
| | | input.click(); |
| | | // #endif |
| | | |
| | | // #ifdef MP-WEIXIN |
| | | uni.showActionSheet({ |
| | | itemList: ["拍照/相册", "从聊天记录选择文件"], |
| | | success: (res) => { |
| | | const remain = props.maxCount - currentFileList.value.length; |
| | | if (remain <= 0) { |
| | | uni.showToast({ |
| | | title: `最多上传${props.maxCount}个文件`, |
| | | icon: "none", |
| | | }); |
| | | return; |
| | | } |
| | | if (res.tapIndex === 0) { |
| | | uni.chooseImage({ |
| | | count: remain, |
| | | sizeType: ["original", "compressed"], |
| | | sourceType: ["album", "camera"], |
| | | success: (chooseRes) => { |
| | | 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", |
| | | size: file.size, |
| | | raw: file, |
| | | status: "pending", |
| | | })); |
| | | addFilesToCurrentTab(files); |
| | | }, |
| | | }); |
| | | } else if (res.tapIndex === 1) { |
| | | uni.chooseMessageFile({ |
| | | count: remain, |
| | | type: "all", |
| | | success: (chooseRes) => { |
| | | const files = chooseRes.tempFiles.map((file) => ({ |
| | | name: file.name, |
| | | path: file.path, |
| | | url: file.path, |
| | | uploadPath: file.path, |
| | | type: file.type, |
| | | size: file.size, |
| | | raw: file, |
| | | status: "pending", |
| | | })); |
| | | addFilesToCurrentTab(files); |
| | | }, |
| | | }); |
| | | } |
| | | }, |
| | | }); |
| | | // #endif |
| | | }; |
| | | |
| | | const removeFile = (type, index, event) => { |
| | | if (event) { |
| | | event.stopPropagation(); |
| | | if (event) event.stopPropagation(); |
| | | 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) => { |
| | | const fullUrl = getFullUrl(file.url); |
| | | |
| | | let previewUrl = file.url; |
| | | if ( |
| | | previewUrl && |
| | | !previewUrl.startsWith("blob:") && |
| | | !previewUrl.startsWith("file://") && |
| | | !previewUrl.startsWith("/") |
| | | ) { |
| | | previewUrl = getFullUrl(previewUrl); |
| | | } |
| | | if (file.type && supportedImageTypes.includes(file.type)) { |
| | | // 预览图片 |
| | | const allFiles = getAllFiles(); |
| | | uni.previewImage({ |
| | | urls: allFiles |
| | | .filter(f => f.type && supportedImageTypes.includes(f.type)) |
| | | .map(f => getFullUrl(f.url)), |
| | | current: fullUrl, |
| | | }); |
| | | 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), |
| | | ); |
| | | uni.previewImage({ urls: imageUrls, current: previewUrl }); |
| | | } else if (file.type && file.type.includes("pdf")) { |
| | | // 预览PDF |
| | | uni.downloadFile({ |
| | | url: fullUrl, |
| | | url: previewUrl, |
| | | success: (res) => { |
| | | const filePath = res.tempFilePath; |
| | | uni.openDocument({ |
| | | filePath: filePath, |
| | | fileType: "pdf", |
| | | success: () => console.log("打开PDF成功"), |
| | | fail: (err) => { |
| | | console.error("打开PDF失败", err); |
| | | uni.showToast({ |
| | | title: "打开PDF失败", |
| | | icon: "none", |
| | | }); |
| | | }, |
| | | }); |
| | | uni.openDocument({ filePath: res.tempFilePath, fileType: "pdf" }); |
| | | }, |
| | | fail: (err) => { |
| | | console.error("下载PDF失败", err); |
| | | uni.showToast({ |
| | | title: "下载PDF失败", |
| | | icon: "none", |
| | | }); |
| | | }, |
| | | 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'); |
| | | |
| | | const filePath = file.uploadPath || file.url || file.path; |
| | | |
| | | if (!filePath || typeof filePath !== 'string') { |
| | | reject(new Error('无效的文件路径')); |
| | | return; |
| | | } |
| | | |
| | | // #ifdef H5 |
| | | const uploadUrl = '/api/common/upload'; |
| | | // #endif |
| | | |
| | | // #ifdef MP-WEIXIN |
| | | const uploadUrl = `${userStore.baseUrlHt}/common/upload`; |
| | | // #endif |
| | | |
| | | uni.uploadFile({ |
| | | url: '/api/common/upload', |
| | | filePath: file.path || file.url, |
| | | url: uploadUrl, |
| | | filePath, |
| | | name: 'file', |
| | | header: { |
| | | 'Authorization': `Bearer ${token}` |
| | | Authorization: `Bearer ${token}` |
| | | }, |
| | | success: (res) => { |
| | | if (res.statusCode === 200) { |
| | | const data = JSON.parse(res.data); |
| | | if (data.code === 200) { |
| | | resolve({ |
| | | ...data, |
| | | fileName: data.fileName |
| | | }); |
| | | } else { |
| | | reject(new Error(data.msg || '上传失败')); |
| | | 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}`)); |
| | | } |
| | | }, |
| | | fail: (err) => { |
| | | reject(err); |
| | | } |
| | | fail: reject |
| | | }); |
| | | }); |
| | | }; |
| | | |
| | | // 确认上传 |
| | | const confirmUpload = async () => { |
| | | // 检查成绩单附件是否必填 |
| | | if (props.showGradeSlip && props.isGradeRequired && gradeFiles.value.length === 0) { |
| | | uni.showToast({ |
| | | title: "请上传成绩单附件", |
| | | icon: "none", |
| | | }); |
| | | currentTab.value = 'grade'; |
| | | if ( |
| | | props.showGradeSlip && |
| | | props.isGradeRequired && |
| | | gradeFiles.value.length === 0 |
| | | ) { |
| | | uni.showToast({ title: "请上传成绩单附件", icon: "none" }); |
| | | currentTab.value = "grade"; |
| | | return; |
| | | } |
| | | |
| | | const allFiles = getAllFiles(); |
| | | if (allFiles.length === 0) { |
| | | uni.showToast({ |
| | | title: "请先添加附件", |
| | | icon: "none", |
| | | }); |
| | | 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 pendingBaseFiles = baseFiles.value.filter(file => |
| | | !file.url || file.status === 'pending'); |
| | | const pendingGradeFiles = gradeFiles.value.filter(file => |
| | | !file.url || file.status === 'pending'); |
| | | |
| | | // 上传基础附件 |
| | | for (const file of pendingBaseFiles) { |
| | | 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.file, 'base'); |
| | | file.status = "uploading"; |
| | | const res = await uploadFile(file, "base"); |
| | | Object.assign(file, { |
| | | url: res.fileName, |
| | | fileName: res.fileName, |
| | | url: res.url, |
| | | fileName: res.name, |
| | | newFileName: res.newFileName, |
| | | originalFilename: res.originalFilename, |
| | | status: 'success' |
| | | status: "success", |
| | | size: res.size, |
| | | }); |
| | | emit("upload-base", file); |
| | | } catch (error) { |
| | | console.error('基础附件上传失败:', error); |
| | | file.status = 'error'; |
| | | uni.showToast({ |
| | | title: `文件 ${file.name} 上传失败`, |
| | | icon: 'none' |
| | | }); |
| | | } catch (err) { |
| | | file.status = "error"; |
| | | uni.showToast({ title: `文件 ${file.name} 上传失败`, icon: "none" }); |
| | | } |
| | | } |
| | | |
| | | // 上传成绩单附件 |
| | | for (const file of pendingGradeFiles) { |
| | | for (const file of pendingGrade) { |
| | | try { |
| | | file.status = 'uploading'; |
| | | const res = await uploadFile(file.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 (error) { |
| | | console.error('成绩单附件上传失败:', error); |
| | | file.status = 'error'; |
| | | uni.showToast({ |
| | | title: `文件 ${file.name} 上传失败`, |
| | | icon: 'none' |
| | | }); |
| | | } catch (err) { |
| | | file.status = "error"; |
| | | uni.showToast({ title: `文件 ${file.name} 上传失败`, icon: "none" }); |
| | | } |
| | | } |
| | | console.log(baseFiles.value,'1'); |
| | | console.log(gradeFiles.value,'2'); |
| | | |
| | | // 更新文件列表 |
| | | emit("update:files", baseFiles.value); |
| | | emit("update:gradesFiles", gradeFiles.value); |
| | | // emit("upload", allFiles); |
| | | |
| | | 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) => { |
| | | return type === 'grade' ? gradeFiles.value : baseFiles.value; |
| | | }; |
| | | const getFilesByType = (type) => |
| | | type === "grade" ? gradeFiles.value : baseFiles.value; |
| | | const getAllFiles = () => [...baseFiles.value, ...gradeFiles.value]; |
| | | |
| | | // 暴露方法给父组件 |
| | | defineExpose({ |
| | | getFilesByType, |
| | | getAllFiles |
| | | onBeforeUnmount(() => { |
| | | blobUrls.value.forEach((url) => URL.revokeObjectURL(url)); |
| | | blobUrls.value = []; |
| | | }); |
| | | |
| | | defineExpose({ getFilesByType, getAllFiles }); |
| | | </script> |
| | | |
| | | <style lang="scss"> |
| | |
| | | display: flex; |
| | | border-bottom: 1px solid #eee; |
| | | margin-bottom: 20rpx; |
| | | |
| | | .tab-item { |
| | | flex: 1; |
| | | text-align: center; |
| | | padding: 20rpx 0; |
| | | position: relative; |
| | | 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; |
| | | position: absolute; |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 其他样式保持不变 |
| | | .attachment-btn { |
| | | position: fixed; |
| | | width: 160rpx; |
| | |
| | | font-size: 28rpx; |
| | | font-weight: bold; |
| | | transition: all 0.3s; |
| | | |
| | | &:active { |
| | | opacity: 0.8; |
| | | transform: scale(0.95); |
| | | } |
| | | |
| | | .badge { |
| | | position: absolute; |
| | | top: -10rpx; |
| | |
| | | justify-content: center; |
| | | } |
| | | } |
| | | |
| | | .attachment-popup { |
| | | background-color: #fff; |
| | | border-radius: 24rpx 24rpx 0 0; |
| | | padding: 30rpx; |
| | | max-height: 70vh; |
| | | |
| | | .popup-header { |
| | | display: flex; |
| | | justify-content: space-between; |
| | |
| | | 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 { |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | .file-list { |
| | | max-height: 50vh; |
| | | margin-bottom: 30rpx; |
| | | |
| | | .file-item { |
| | | display: flex; |
| | | align-items: center; |
| | | padding: 20rpx 0; |
| | | border-bottom: 1rpx solid #f5f5f5; |
| | | transition: all 0.2s; |
| | | |
| | | &:active { |
| | | background-color: #f9f9f9; |
| | | } |
| | | |
| | | .file-icon { |
| | | margin-right: 20rpx; |
| | | } |
| | | |
| | | .file-info { |
| | | flex: 1; |
| | | overflow: hidden; |
| | | |
| | | .file-name { |
| | | font-size: 28rpx; |
| | | color: #333; |
| | |
| | | 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 { |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | .empty { |
| | | display: flex; |
| | | flex-direction: column; |
| | |
| | | padding: 60rpx 0; |
| | | color: #999; |
| | | font-size: 28rpx; |
| | | |
| | | .uni-icons { |
| | | margin-bottom: 20rpx; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .popup-footer { |
| | | display: flex; |
| | | justify-content: space-between; |
| | | gap: 20rpx; |
| | | |
| | | .btn { |
| | | flex: 1; |
| | | height: 80rpx; |
| | |
| | | background-color: #f5f5f5; |
| | | color: #666; |
| | | border: none; |
| | | transition: all 0.2s; |
| | | |
| | | &:active { |
| | | opacity: 0.8; |
| | | } |
| | | |
| | | &.primary { |
| | | background-color: #67afab; |
| | | color: #fff; |
| | |
| | | } |
| | | } |
| | | } |
| | | </style> |
| | | </style> |