| | |
| | | <!-- 附件弹层 --> |
| | | <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 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'"> |
| | | <text>基础附件</text> |
| | |
| | | |
| | | <!-- 附件列表 --> |
| | | <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-icon" @click="previewFile(file)"> |
| | |
| | | </view> |
| | | </template> |
| | | |
| | | <!-- 成绩单附件列表 --> |
| | | <template v-if="currentTab === 'grade' && showGradeSlip"> |
| | | <view class="file-item" v-for="(file, index) in gradeFiles" :key="'grade-' + index"> |
| | | <view class="file-icon" @click="previewFile(file)"> |
| | |
| | | </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> |
| | |
| | | </view> |
| | | </scroll-view> |
| | | |
| | | <!-- 操作按钮 --> |
| | | <view class="popup-footer" v-if="!readonly"> |
| | | <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, |
| | | }, |
| | | 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 |
| | | } |
| | | files: { type: Array, default: () => [] }, |
| | | gradesFiles: { type: Array, default: () => [] }, |
| | | readonly: { type: Boolean, default: false }, |
| | | 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 } |
| | | }); |
| | | |
| | | 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 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 blobUrls = ref([]); // 存储 H5 blob URL 以便释放内存 |
| | | |
| | | // 计算当前显示的文件列表 |
| | | const currentFileList = computed(() => { |
| | | if (!props.showGradeSlip) { |
| | | return baseFiles.value; |
| | | } |
| | | 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 |
| | | const getFullUrl = (path) => { |
| | | if (!path) return ''; |
| | | if (path.startsWith('http://') || path.startsWith('https://')) { |
| | | return path; |
| | | try { |
| | | const url = new URL(path); |
| | | return `${baseUrlHt}${url.pathname}${url.search}${url.hash}`; |
| | | } catch { return path; } |
| | | } |
| | | return `${baseUrlHt}${path.startsWith('/') ? '' : '/'}${path}`; |
| | | }; |
| | | |
| | | // 文件选择器样式 |
| | | const imageStyles = { |
| | | width: 120, |
| | | height: 120, |
| | | border: false, |
| | | }; |
| | | 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 (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 = () => { popup.value?.open(); }; |
| | | const closePopup = () => { popup.value?.close(); }; |
| | | |
| | | // 切换弹层显示 |
| | | const togglePopup = () => { |
| | | if (popup.value) { |
| | | popup.value.open(); |
| | | } |
| | | }; |
| | | |
| | | // 关闭弹层 |
| | | const closePopup = () => { |
| | | if (popup.value) { |
| | | 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 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", |
| | | }); |
| | | 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, // 关键:上传路径使用 blob URL 字符串 |
| | | 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') { |
| | | gradeFiles.value.splice(index, 1); |
| | | emit("update:gradesFiles", gradeFiles.value); |
| | |
| | | |
| | | // 预览文件 |
| | | 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, |
| | | 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", |
| | | }); |
| | | }, |
| | | }); |
| | | }, |
| | | fail: (err) => { |
| | | console.error("下载PDF失败", err); |
| | | uni.showToast({ |
| | | title: "下载PDF失败", |
| | | icon: "none", |
| | | }); |
| | | }, |
| | | url: previewUrl, |
| | | 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; |
| | | if (!filePath || typeof filePath !== 'string') { |
| | | reject(new Error('无效的文件路径')); |
| | | return; |
| | | } |
| | | |
| | | // 根据平台构造上传URL |
| | | let uploadUrl = '/api/common/upload'; |
| | | // #ifdef MP-WEIXIN |
| | | // 小程序需要完整URL,baseUrlHt 来自 userStore(如 https://opo.qduh.cn) |
| | | uploadUrl = 'https://opo.qduh.cn/common/upload'; |
| | | // #endif |
| | | |
| | | uni.uploadFile({ |
| | | url: '/api/common/upload', |
| | | filePath: file.path || file.url, |
| | | url: uploadUrl, |
| | | filePath: filePath, |
| | | name: 'file', |
| | | header: { |
| | | 'Authorization': `Bearer ${token}` |
| | | }, |
| | | header: { 'Authorization': `Bearer ${token}` }, |
| | | success: (res) => { |
| | | if (res.statusCode === 200) { |
| | | const data = JSON.parse(res.data); |
| | | console.log(data, '文件'); |
| | | |
| | | if (data.code === 200) { |
| | | resolve({ |
| | | ...data, |
| | | fileName: data.fileName |
| | | }); |
| | | } else { |
| | | reject(new Error(data.msg || '上传失败')); |
| | | } |
| | | } else { |
| | | reject(new Error(`上传失败,状态码: ${res.statusCode}`)); |
| | | } |
| | | 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", |
| | | }); |
| | | 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'); |
| | | const res = await uploadFile(file, 'base'); |
| | | Object.assign(file, { |
| | | url: res.url, |
| | | fileName: res.name, |
| | |
| | | size: res.size |
| | | }); |
| | | emit("upload-base", file); |
| | | } catch (error) { |
| | | console.error('上传失败:', error); |
| | | } catch (err) { |
| | | file.status = 'error'; |
| | | uni.showToast({ |
| | | title: `文件 ${file.name} 上传失败`, |
| | | icon: 'none' |
| | | }); |
| | | 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'); |
| | | const res = await uploadFile(file, 'grade'); |
| | | Object.assign(file, { |
| | | url: res.fileName, |
| | | fileName: res.fileName, |
| | |
| | | status: 'success' |
| | | }); |
| | | emit("upload-grade", file); |
| | | } catch (error) { |
| | | console.error('成绩单附件上传失败:', error); |
| | | } catch (err) { |
| | | file.status = 'error'; |
| | | uni.showToast({ |
| | | title: `文件 ${file.name} 上传失败`, |
| | | icon: 'none' |
| | | }); |
| | | 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' |
| | | }); |
| | | 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; |
| | | font-weight: bold; |
| | | border-bottom: 4rpx solid #67AFAB; |
| | | } |
| | | |
| | | .required-mark { |
| | | color: red; |
| | | position: absolute; |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 其他样式保持不变 |
| | | .attachment-btn { |
| | | position: fixed; |
| | | width: 160rpx; |
| | |
| | | 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; |
| | |
| | | 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 { |
| | | 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; |
| | | 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; |
| | | } |
| | | |
| | | &: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; |
| | | flex-direction: column; |
| | |
| | | padding: 60rpx 0; |
| | | color: #999; |
| | | font-size: 28rpx; |
| | | |
| | | .uni-icons { |
| | | margin-bottom: 20rpx; |
| | | } |
| | | .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; |
| | | } |
| | | &:active { opacity: 0.8; } |
| | | &.primary { background-color: #67afab; color: #fff; } |
| | | } |
| | | } |
| | | } |