<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">添加</button>
|
<button class="btn primary" @click="confirmUpload">确认上传</button>
|
</view>
|
</view>
|
</uni-popup>
|
</view>
|
</template>
|
|
<script setup>
|
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 },
|
});
|
|
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 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;
|
});
|
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 },
|
);
|
|
// 获取完整URL - 平台差异化处理
|
const getFullUrl = (path) => {
|
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 (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 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 = () => {
|
popup.value?.open();
|
};
|
const closePopup = () => {
|
popup.value?.close();
|
};
|
|
const addFilesToCurrentTab = (newFiles) => {
|
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);
|
});
|
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 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();
|
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);
|
} else {
|
baseFiles.value.splice(index, 1);
|
emit("update:files", baseFiles.value);
|
}
|
};
|
|
// 预览文件
|
const previewFile = (file) => {
|
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 = [...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")) {
|
uni.downloadFile({
|
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');
|
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: uploadUrl,
|
filePath,
|
name: 'file',
|
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}`));
|
}
|
},
|
fail: reject
|
});
|
});
|
};
|
|
// 确认上传
|
const confirmUpload = async () => {
|
if (
|
props.showGradeSlip &&
|
props.isGradeRequired &&
|
gradeFiles.value.length === 0
|
) {
|
uni.showToast({ title: "请上传成绩单附件", icon: "none" });
|
currentTab.value = "grade";
|
return;
|
}
|
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 });
|
try {
|
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");
|
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 (err) {
|
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");
|
Object.assign(file, {
|
url: res.fileName,
|
fileName: res.fileName,
|
newFileName: res.newFileName,
|
originalFilename: res.originalFilename,
|
status: "success",
|
});
|
emit("upload-grade", file);
|
} catch (err) {
|
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" });
|
closePopup();
|
} catch (error) {
|
console.error("上传出错:", error);
|
uni.showToast({ title: "上传出错", icon: "none" });
|
} finally {
|
uni.hideLoading();
|
}
|
};
|
|
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 = [];
|
});
|
|
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;
|
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;
|
&: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;
|
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;
|
&:active {
|
opacity: 0.8;
|
}
|
&.primary {
|
background-color: #67afab;
|
color: #fff;
|
}
|
}
|
}
|
}
|
}
|
</style>
|