<template>
|
<view class="attachment-upload">
|
<!-- 附件悬浮按钮 -->
|
<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"
|
>
|
<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>
|
</view>
|
<view
|
class="tab-item"
|
:class="{ active: currentTab === 'grade' }"
|
@click="currentTab = 'grade'"
|
>
|
<text>成绩单附件</text>
|
<text class="required-mark" v-if="isGradeRequired">*</text>
|
</view>
|
</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-icon" @click="previewFile(file)">
|
<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-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>
|
</view>
|
<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-icon" @click="previewFile(file)">
|
<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-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>
|
</view>
|
<uni-icons
|
v-if="!readonly"
|
type="trash"
|
size="20"
|
color="#ff4d4f"
|
@click="(e) => removeFile('grade', index, e)"
|
/>
|
</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-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 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 { 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
|
}
|
});
|
|
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 currentFileList = computed(() => {
|
if (!props.showGradeSlip) {
|
return baseFiles.value;
|
}
|
return currentTab.value === 'base' ? baseFiles.value : gradeFiles.value;
|
});
|
|
// 计算文件总数
|
const totalFileCount = computed(() => {
|
return baseFiles.value.length + gradeFiles.value.length;
|
});
|
|
// 监听外部传入的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;
|
}
|
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 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("word")) return "file-word";
|
if (type && type.includes("excel")) return "file-excel";
|
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("word")) return "#2b579a";
|
if (type && type.includes("excel")) return "#217346";
|
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();
|
}
|
};
|
|
// 关闭弹层
|
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 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",
|
});
|
return;
|
}
|
|
// 严格区分存储位置
|
if (isGradeTab) {
|
gradeFiles.value = [...gradeFiles.value, ...newFiles];
|
emit("update:gradesFiles", gradeFiles.value);
|
} else {
|
baseFiles.value = [...baseFiles.value, ...newFiles];
|
emit("update:files", baseFiles.value);
|
}
|
};
|
|
// 获取所有文件
|
const getAllFiles = () => {
|
return [...baseFiles.value, ...gradeFiles.value];
|
};
|
|
const removeFile = (type, index, event) => {
|
if (event) {
|
event.stopPropagation();
|
}
|
|
// 严格按类型删除
|
if (type === 'grade') {
|
gradeFiles.value.splice(index, 1);
|
emit("update:gradesFiles", gradeFiles.value);
|
} else {
|
baseFiles.value.splice(index, 1);
|
emit("update:files", baseFiles.value);
|
}
|
};
|
|
// 预览文件
|
const previewFile = (file) => {
|
const fullUrl = getFullUrl(file.url);
|
|
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,
|
});
|
} 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",
|
});
|
},
|
});
|
} else {
|
// 其他文件类型触发预览事件
|
emit("preview", file);
|
}
|
};
|
|
// 文件上传方法
|
const uploadFile = (file, type) => {
|
return new Promise((resolve, reject) => {
|
const token = uni.getStorageSync('token');
|
|
uni.uploadFile({
|
url: '/api/common/upload',
|
filePath: file.path || file.url,
|
name: 'file',
|
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}`));
|
}
|
},
|
fail: (err) => {
|
reject(err);
|
}
|
});
|
});
|
};
|
|
// 确认上传
|
const confirmUpload = async () => {
|
// 检查成绩单附件是否必填
|
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",
|
});
|
return;
|
}
|
|
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) {
|
try {
|
file.status = 'uploading';
|
const res = await uploadFile(file.file, 'base');
|
Object.assign(file, {
|
url: res.url,
|
fileName: res.name,
|
newFileName: res.newFileName,
|
originalFilename: res.originalFilename,
|
status: 'success',
|
size:res.size
|
});
|
emit("upload-base", file);
|
} catch (error) {
|
console.error('基础附件上传失败:', error);
|
file.status = 'error';
|
uni.showToast({
|
title: `文件 ${file.name} 上传失败`,
|
icon: 'none'
|
});
|
}
|
}
|
|
// 上传成绩单附件
|
for (const file of pendingGradeFiles) {
|
try {
|
file.status = 'uploading';
|
const res = await uploadFile(file.file, 'grade');
|
Object.assign(file, {
|
url: res.fileName,
|
fileName: res.fileName,
|
newFileName: res.newFileName,
|
originalFilename: res.originalFilename,
|
status: 'success'
|
});
|
emit("upload-grade", file);
|
} catch (error) {
|
console.error('成绩单附件上传失败:', error);
|
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'
|
});
|
closePopup();
|
} catch (error) {
|
console.error('上传出错:', error);
|
uni.showToast({
|
title: '上传出错',
|
icon: 'none'
|
});
|
} finally {
|
uni.hideLoading();
|
}
|
};
|
|
// 获取特定类型的文件(供父组件调用)
|
const getFilesByType = (type) => {
|
return type === 'grade' ? gradeFiles.value : baseFiles.value;
|
};
|
|
// 暴露方法给父组件
|
defineExpose({
|
getFilesByType,
|
getAllFiles
|
});
|
</script>
|
|
<style lang="scss">
|
.attachment-upload {
|
.attachment-tabs {
|
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;
|
top: 10rpx;
|
right: 20rpx;
|
font-size: 24rpx;
|
}
|
}
|
}
|
|
// 其他样式保持不变
|
.attachment-btn {
|
position: fixed;
|
width: 160rpx;
|
height: 80rpx;
|
border-radius: 8rpx;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
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);
|
}
|
|
.badge {
|
position: absolute;
|
top: -10rpx;
|
right: -10rpx;
|
background-color: #ff4d4f;
|
color: #fff;
|
font-size: 20rpx;
|
width: 36rpx;
|
height: 36rpx;
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
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;
|
align-items: center;
|
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;
|
}
|
}
|
}
|
|
.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;
|
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;
|
}
|
}
|
}
|
|
.empty {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
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;
|
line-height: 80rpx;
|
text-align: center;
|
font-size: 28rpx;
|
border-radius: 8rpx;
|
background-color: #f5f5f5;
|
color: #666;
|
border: none;
|
transition: all 0.2s;
|
|
&:active {
|
opacity: 0.8;
|
}
|
|
&.primary {
|
background-color: #67afab;
|
color: #fff;
|
}
|
}
|
}
|
}
|
}
|
</style>
|