OfficeSystem/components/task/AttachmentFileUploader.vue
2025-11-24 15:55:40 +08:00

615 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="attachment-block">
<view class="form-item clickable-item" :class="{ disabled: isReadonly }" @click="handleChooseFiles">
<view class="form-icon">{{ icon }}</view>
<text class="form-label">{{ title }}</text>
<text class="arrow"></text>
</view>
<view class="images-preview" v-if="imageFiles.length">
<view
class="image-item"
v-for="(file, index) in imageFiles"
:key="(file.uid || file.path) + index"
>
<image
:src="file.path"
mode="aspectFill"
class="preview-image"
@click="previewImage(index)"
/>
<view class="remove-btn" v-if="!isReadonly" @click.stop="removeFile(file)">✕</view>
</view>
</view>
<view class="files-list" v-if="otherFiles.length">
<view
class="file-item"
v-for="(file, index) in otherFiles"
:key="(file.uid || file.path) + index"
@click="previewFile(file)"
>
<text class="file-type-badge" :class="getFileTypeClass(file.name)">
{{ getFileTypeLabel(file.name) }}
</text>
<view class="file-info">
<text class="file-name">{{ file.name }}</text>
<text class="file-size" v-if="file.size > 0">{{ formatFileSize(file.size) }}</text>
</view>
<view class="file-icon">{{ getFileIcon(file.name) }}</view>
<view class="remove-btn" v-if="!isReadonly" @click.stop="removeFile(file)">✕</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
import { batchUploadFilesToQiniu } from '@/utils/qiniu.js';
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
maxCount: {
type: Number,
default: 5
},
title: {
type: String,
default: '添加文件'
},
icon: {
type: String,
default: '📄'
},
extensions: {
type: Array,
default: () => ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.zip', '.rar', '.jpg', '.png']
},
readonly: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'change']);
const files = computed({
get: () => props.modelValue,
set: (val) => {
emit('update:modelValue', val);
emit('change', val);
}
});
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic', 'heif', 'svg'];
const isImageFile = (file) => {
if (!file) return false;
const ext = getFileExtension(file.name);
return imageExtensions.includes(ext);
};
const imageFiles = computed(() => files.value.filter((file) => isImageFile(file)));
const otherFiles = computed(() => files.value.filter((file) => !isImageFile(file)));
const isReadonly = computed(() => props.readonly);
const handleChooseFiles = async () => {
if (isReadonly.value) {
return;
}
const remainingCount = props.maxCount - files.value.length;
if (remainingCount <= 0) {
uni.showToast({
title: `最多只能添加${props.maxCount}个文件`,
icon: 'none'
});
return;
}
try {
// #ifdef APP-PLUS
await chooseFilesNative(remainingCount);
// #endif
// #ifndef APP-PLUS
await chooseFilesWithUni(remainingCount);
// #endif
} catch (error) {
console.error('选择文件失败:', error);
uni.showToast({
title: error?.message || '选择文件失败',
icon: 'none'
});
}
};
const chooseFilesWithUni = (remainingCount) => {
return new Promise((resolve, reject) => {
uni.chooseFile({
count: remainingCount,
extension: props.extensions,
success: async (res) => {
try {
await uploadFiles(res.tempFiles.map((file) => ({
path: file.path,
name: file.name,
size: file.size || 0
})));
resolve();
} catch (error) {
reject(error);
}
},
fail: () => {
reject(new Error('文件选择被取消或失败'));
}
});
});
};
const chooseFilesNative = async (remainingCount) => {
if (typeof plus === 'undefined') {
uni.showToast({
title: '当前环境不支持文件选择',
icon: 'none'
});
throw new Error('native file picker not available');
}
return new Promise((resolve, reject) => {
try {
const Intent = plus.android.importClass('android.content.Intent');
const main = plus.android.runtimeMainActivity();
const intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType('*/*');
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
const originalOnActivityResult = main.onActivityResult;
main.startActivityForResult(intent, 1001);
main.onActivityResult = async (requestCode, resultCode, data) => {
if (requestCode === 1001) {
if (resultCode === -1 && data) {
try {
const filesToUpload = extractFilesFromIntent(data, main, remainingCount);
if (filesToUpload.length) {
await uploadFiles(filesToUpload);
}
cleanup();
resolve();
} catch (error) {
cleanup();
reject(error);
}
} else {
cleanup();
reject(new Error('用户取消选择'));
}
} else {
cleanup();
reject(new Error('无效的请求'));
}
};
const cleanup = () => {
if (originalOnActivityResult) {
main.onActivityResult = originalOnActivityResult;
}
};
} catch (error) {
reject(error);
}
});
};
const extractFilesFromIntent = (data, main, remainingCount) => {
const clipData = data.getClipData();
const filesToUpload = [];
const getFileName = (uri) => {
try {
const cursor = main.getContentResolver().query(uri, null, null, null, null);
if (cursor && cursor.moveToFirst()) {
const nameIndex = cursor.getColumnIndex('_display_name');
if (nameIndex !== -1) {
const fileName = cursor.getString(nameIndex);
cursor.close();
return fileName;
}
cursor.close();
}
} catch (e) {
console.error('获取文件名失败:', e);
}
return `file_${Date.now()}`;
};
if (clipData) {
const count = clipData.getItemCount();
for (let i = 0; i < count && filesToUpload.length < remainingCount; i++) {
const item = clipData.getItemAt(i);
const uri = item.getUri();
const uriString = uri.toString();
const fileName = getFileName(uri);
filesToUpload.push({
name: fileName,
path: uriString,
size: 0
});
}
} else {
const uri = data.getData();
if (uri) {
const uriString = uri.toString();
const fileName = getFileName(uri);
filesToUpload.push({
name: fileName,
path: uriString,
size: 0
});
}
}
return filesToUpload;
};
const uploadFiles = async (fileList) => {
if (!fileList.length) return;
try {
uni.showLoading({
title: '上传中...',
mask: true
});
const uploadResults = await batchUploadFilesToQiniu(fileList);
const newFiles = uploadResults.map(result => ({
name: result.name,
path: result.url,
size: result.size || 0
}));
files.value = [...files.value, ...newFiles];
uni.showToast({
title: `成功添加${newFiles.length}个文件`,
icon: 'success'
});
} catch (error) {
console.error('上传文件失败:', error);
uni.showToast({
title: error?.message || '上传文件失败',
icon: 'none'
});
throw error;
} finally {
uni.hideLoading();
}
};
const removeFile = (file) => {
if (isReadonly.value) return;
const next = files.value.filter((item) => item !== file);
files.value = next;
};
const getFileExtension = (fileName = '') => {
if (!fileName) return '';
return fileName.split('.').pop().toLowerCase();
};
const getFileIcon = (fileName) => {
const ext = getFileExtension(fileName);
const iconMap = {
pdf: '📕',
doc: '📘',
docx: '📘',
xls: '📗',
xlsx: '📗',
ppt: '📙',
pptx: '📙',
txt: '📄',
zip: '📦',
rar: '📦',
jpg: '🖼️',
jpeg: '🖼️',
png: '🖼️',
gif: '🖼️'
};
return iconMap[ext] || '📄';
};
const getFileTypeKey = (fileName) => {
const ext = getFileExtension(fileName);
if (!ext) return 'other';
if (imageExtensions.includes(ext)) return 'image';
if (['pdf'].includes(ext)) return 'pdf';
if (['doc', 'docx', 'wps'].includes(ext)) return 'doc';
if (['xls', 'xlsx', 'csv'].includes(ext)) return 'xls';
if (['ppt', 'pptx'].includes(ext)) return 'ppt';
if (['zip', 'rar', '7z'].includes(ext)) return 'zip';
return 'other';
};
const getFileTypeLabel = (fileName) => {
const type = getFileTypeKey(fileName);
const labelMap = {
image: 'IMG',
pdf: 'PDF',
doc: 'DOC',
xls: 'XLS',
ppt: 'PPT',
zip: 'ZIP',
other: 'FILE'
};
return labelMap[type] || 'FILE';
};
const getFileTypeClass = (fileName) => {
return `badge-${getFileTypeKey(fileName)}`;
};
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
const previewImage = (index) => {
const urls = imageFiles.value.map((file) => file.path);
if (!urls.length) return;
uni.previewImage({
urls,
current: urls[index] || urls[0]
});
};
const previewFile = (file) => {
if (!file?.path) {
uni.showToast({
title: '文件路径不存在',
icon: 'none'
});
return;
}
const ext = getFileExtension(file.name);
if (imageExtensions.includes(ext)) {
previewImage(imageFiles.value.findIndex((item) => item === file));
return;
}
// #ifdef H5
window.open(file.path, '_blank');
// #endif
// #ifdef APP-PLUS
plus.runtime.openURL(file.path);
// #endif
// #ifndef H5 || APP-PLUS
uni.showToast({
title: '点击下载文件',
icon: 'none'
});
uni.downloadFile({
url: file.path,
success: (res) => {
if (res.statusCode === 200) {
uni.openDocument({
filePath: res.tempFilePath,
success: () => {
console.log('打开文档成功');
},
fail: (err) => {
console.error('打开文档失败:', err);
uni.showToast({
title: '无法打开此文件',
icon: 'none'
});
}
});
}
},
fail: (err) => {
console.error('下载文件失败:', err);
uni.showToast({
title: '下载文件失败',
icon: 'none'
});
}
});
// #endif
};
</script>
<style scoped lang="scss">
.attachment-block {
margin-bottom: 12px;
}
.form-item {
display: flex;
align-items: center;
padding: 16px;
background-color: #fff;
border-radius: 8px;
gap: 12px;
}
.clickable-item {
cursor: pointer;
&:active {
background-color: #f5f5f5;
}
&.disabled {
cursor: default;
opacity: 0.6;
&:active {
background-color: inherit;
}
}
}
.form-icon {
font-size: 20px;
flex-shrink: 0;
}
.form-label {
flex: 1;
font-size: 15px;
color: #333;
}
.arrow {
font-size: 20px;
color: #999;
flex-shrink: 0;
}
.files-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
padding: 12px;
background-color: #fff;
border-radius: 8px;
margin-bottom: 8px;
gap: 12px;
&:active {
background-color: #f5f5f5;
}
}
.file-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.file-name {
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.file-size {
font-size: 12px;
color: #999;
}
.remove-btn {
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
cursor: pointer;
}
.images-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.image-item {
position: relative;
width: calc((100% - 16px) / 3);
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
background-color: #f4f4f5;
}
.preview-image {
width: 100%;
height: 100%;
}
.image-item .remove-btn {
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
}
.file-type-badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: #333;
background-color: #f5f5f5;
flex-shrink: 0;
}
.badge-pdf {
background-color: #fff1f0;
color: #f5222d;
}
.badge-doc {
background-color: #f0f5ff;
color: #2f54eb;
}
.badge-xls {
background-color: #f6ffed;
color: #52c41a;
}
.badge-ppt {
background-color: #fff7e6;
color: #fa8c16;
}
.badge-zip {
background-color: #f9f0ff;
color: #722ed1;
}
.badge-image {
background-color: #fff7e6;
color: #d48806;
}
.badge-other {
background-color: #f5f5f5;
color: #888;
}
.file-icon {
font-size: 20px;
color: #999;
flex-shrink: 0;
}
</style>