Files
im-uniapp/components/x-native-uploader/x-native-uploader.vue
2026-04-17 12:09:43 +08:00

533 lines
14 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="x-native-uploader">
<view class="upload-trigger" @click="handleSelect">
<slot>
<view class="default-btn">
<text class="plus">+</text>
<text class="text">选择文件并上传</text>
</view>
</slot>
</view>
<!-- 进度条展示 -->
<view v-if="files.length > 0" class="file-list">
<view v-for="(file, index) in files" :key="index" class="file-item">
<view class="file-info">
<text class="file-name">{{ file.name }}</text>
<view class="file-actions">
<text class="file-status">{{ file.statusText }}</text>
<text
class="delete-btn"
hover-class="delete-btn--hover"
hover-stay-time="80"
@click.stop="removeFile(index)"
>删除</text>
</view>
</view>
<view class="progress-bar">
<view class="progress-inner" :style="{ width: file.progress + '%' }"></view>
</view>
</view>
</view>
<!-- 文件选择器弹窗 -->
<x-file-picker ref="filePicker" :multiple="multiple" :limit="limit" @select="onFilePicked"></x-file-picker>
</view>
</template>
<script>
import xFilePicker from '../x-file-picker/x-file-picker.vue';
export default {
name: 'x-native-uploader',
components: {
xFilePicker
},
props: {
url: {
type: String,
required: true,
default: ''
},
// 上传参数
formData: {
type: Object,
default: () => ({})
},
// 请求头
header: {
type: Object,
default: () => ({})
},
// 文件字段名
name: {
type: String,
default: 'file'
},
// 是否自动上传
autoUpload: {
type: Boolean,
default: true
},
// 文件选择类型: image, video, all
fileType: {
type: String,
default: 'image'
},
// 是否多选
multiple: {
type: Boolean,
default: false
},
// 最大选择数量
limit: {
type: Number,
default: 9
}
},
data() {
return {
files: []
}
},
methods: {
handleSelect() {
// #ifdef APP-PLUS
this.selectFileNative();
// #endif
// #ifndef APP-PLUS
this.selectFileWeb();
// #endif
},
// Web/小程序端兼容
selectFileWeb() {
uni.chooseImage({
count: 1,
success: (res) => {
const tempFilePath = res.tempFilePaths[0];
this.processFile(tempFilePath);
}
});
},
// App端 Native 逻辑
selectFileNative() {
if (this.fileType === 'all') {
// 使用自定义的 Native.js 文件选择器 (x-file-picker)
// 替代不稳定的 uni.chooseFile
this.$refs.filePicker.open();
} else if (this.fileType === 'video') {
uni.chooseVideo({
count: 1,
success: (res) => {
const tempFilePath = res.tempFilePath;
// 尝试获取文件名,如果没有则从路径截取
let fileName = 'video.mp4';
if (res.tempFile && res.tempFile.name) {
fileName = res.tempFile.name;
} else {
fileName = tempFilePath.substring(tempFilePath.lastIndexOf('/') + 1);
}
this.processFile(tempFilePath, fileName);
}
});
} else {
// 默认图片
uni.chooseImage({
count: 1,
success: (res) => {
const tempFilePath = res.tempFilePaths[0];
// 从路径提取文件名
let fileName = 'image.jpg';
if (res.tempFiles && res.tempFiles[0] && res.tempFiles[0].name) {
fileName = res.tempFiles[0].name;
} else {
fileName = tempFilePath.substring(tempFilePath.lastIndexOf('/') + 1);
}
this.processFile(tempFilePath, fileName);
}
});
}
},
// 自定义文件选择器回调
onFilePicked(e) {
console.log('onFilePicked event:', e);
// e 可以是单个对象 {path, name} 或数组 [{path, name}, ...]
if (Array.isArray(e)) {
console.log('Processing array of files:', e.length);
e.forEach(item => {
this.processFile(item.path, item.name);
});
} else {
console.log('Processing single file');
this.processFile(e.path, e.name);
}
},
// 解析content:// URI为真实文件路径
resolveContentUri(path) {
// 非content URI直接返回
if (!path.startsWith('content://')) {
return Promise.resolve(path);
}
return new Promise((resolve) => {
// 1. 最优先处理用户遇到的raw:格式URI
// 匹配: content://.../document/raw%3A%2Fstorage%2Femulated%2F0%2F...
const rawMatch = path.match(/document\/raw%3A(.+)/);
if (rawMatch) {
try {
// 解码并恢复路径分隔符
const decodedPath = decodeURIComponent(rawMatch[1]);
resolve(decodedPath);
return;
} catch (e) {
console.warn('解码raw URI失败:', e);
}
}
// 2. 尝试使用plus.io解析
try {
plus.io.resolveLocalFileSystemURL(path, (entry) => {
resolve(entry.fullPath);
}, () => {
// 解析失败时返回原始路径
resolve(path);
});
} catch (e) {
// 任何异常都返回原始路径
resolve(path);
}
});
},
// 获取MediaStore中的文件路径
getMediaStorePath(contentUri, mainActivity) {
try {
const resolver = mainActivity.getContentResolver();
const cursor = resolver.query(plus.android.invoke('android.net.Uri', 'parse', contentUri),
['_data'], null, null, null);
if (cursor && cursor.moveToFirst()) {
const path = cursor.getString(0);
cursor.close();
return path;
}
} catch (e) {
console.error('获取MediaStore路径失败:', e);
}
return null;
},
processFile(path, name = 'unknown') {
this.resolveContentUri(path).then((realPath) => {
const fileObj = {
path: realPath,
name: name,
progress: 0,
status: 'pending', // pending, uploading, success, error
statusText: '等待上传'
};
this.files.push(fileObj);
if (this.autoUpload) {
// #ifdef APP-PLUS
this.uploadByNative(fileObj);
// #endif
// #ifndef APP-PLUS
this.uploadByUni(fileObj);
// #endif
}
}).catch((error) => {
// 如果解析失败仍然创建fileObj但是状态为error
const fileObj = {
path: path,
name: name,
progress: 0,
status: 'error',
statusText: '无法解析文件路径: ' + error.message
};
this.files.push(fileObj);
this.$emit('fail', { file: fileObj, error: error.message });
});
},
/**
* 使用 uni.uploadFile 进行上传
* 兼容 H5 和 小程序
*/
uploadByUni(fileObj) {
if (!this.url) {
uni.showToast({ title: '请配置上传URL', icon: 'none' });
return;
}
this.resolveContentUri(fileObj.path).then((realPath) => {
fileObj.status = 'uploading';
fileObj.statusText = '上传中...';
const uploadTask = uni.uploadFile({
url: this.url,
filePath: realPath,
name: this.name,
header: this.header,
formData: this.formData,
success: (uploadFileRes) => {
if (uploadFileRes.statusCode === 200) {
fileObj.progress = 100;
fileObj.status = 'success';
fileObj.statusText = '上传成功';
this.$emit('success', {
file: fileObj,
response: uploadFileRes.data
});
} else {
fileObj.status = 'error';
fileObj.statusText = '上传失败';
this.$emit('fail', {
file: fileObj,
error: uploadFileRes.statusCode
});
}
},
fail: (err) => {
fileObj.status = 'error';
fileObj.statusText = '上传失败';
this.$emit('fail', {
file: fileObj,
error: err
});
}
});
fileObj._task = uploadTask;
uploadTask.onProgressUpdate((res) => {
fileObj.progress = res.progress;
this.$emit('progress', {
file: fileObj,
progress: res.progress
});
});
}).catch((error) => {
fileObj.status = 'error';
fileObj.statusText = '无法解析文件路径: ' + error.message;
console.error('UploadByUni error:', error);
this.$emit('fail', { file: fileObj, error: error.message });
});
},
/**
* 使用 HTML5+ Native API 进行上传
* 这是 App 端推荐的“原生”上传方式
*/
uploadByNative(fileObj) {
if (!this.url) {
uni.showToast({ title: '请配置上传URL', icon: 'none' });
return;
}
this.resolveContentUri(fileObj.path).then((realPath) => {
fileObj.status = 'uploading';
fileObj.statusText = '上传中...';
// 创建上传任务
// 文档: https://www.html5plus.org/doc/zh_cn/uploader.html
const task = plus.uploader.createUpload(
this.url,
{
method: 'POST',
priority: 100
},
(upload, status) => {
// 上传完成回调
if (status === 200) {
fileObj.progress = 100;
fileObj.status = 'success';
fileObj.statusText = '上传成功';
this.$emit('success', {
file: fileObj,
response: upload.responseText
});
console.log('Upload success: ' + upload.responseText);
} else {
fileObj.status = 'error';
fileObj.statusText = '上传失败';
this.$emit('fail', {
file: fileObj,
error: status
});
console.log('Upload failed: ' + status);
}
}
);
fileObj._task = task;
// 添加上传文件
// addFile(path, options)
const added = task.addFile(realPath, { key: this.name });
if (!added) {
fileObj.status = 'error';
fileObj.statusText = '文件添加失败';
console.error('Task addFile failed');
this.$emit('fail', { file: fileObj, error: 'Failed to add file' });
return;
}
// 添加 header
if (this.header) {
for (const key in this.header) {
task.setRequestHeader(key, this.header[key]);
}
}
// 添加 formData
if (this.formData) {
for (const key in this.formData) {
task.addData(key, String(this.formData[key]));
}
}
// 监听进度
task.addEventListener('statechanged', (upload, status) => {
// upload.state: 3 (Connected/Uploading), 4 (Completed)
if (upload.state === 3) { // 上传中
const percent = Math.floor((upload.uploadedSize / upload.totalSize) * 100);
fileObj.progress = percent;
this.$emit('progress', {
file: fileObj,
progress: percent
});
}
});
// 开始上传
task.start();
}).catch((error) => {
fileObj.status = 'error';
fileObj.statusText = '无法解析文件路径: ' + error.message;
console.error('UploadByNative error:', error);
this.$emit('fail', { file: fileObj, error: error.message });
});
},
removeFile(index) {
const fileObj = this.files[index];
if (!fileObj) return;
const task = fileObj._task;
if (fileObj.status === 'uploading' && task) {
try {
if (typeof task.abort === 'function') {
task.abort();
}
} catch (e) {}
}
this.files.splice(index, 1);
this.$emit('remove', {
file: fileObj,
index
});
}
}
}
</script>
<style scoped>
.x-native-uploader {
width: 100%;
}
.upload-trigger {
width: 100%;
}
.default-btn {
width: 100%;
height: 100px;
border: 1px dashed #ccc;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f9f9f9;
}
.plus {
font-size: 40px;
color: #999;
line-height: 1;
}
.text {
font-size: 14px;
color: #666;
margin-top: 10px;
}
.file-list {
margin-top: 15px;
}
.file-item {
margin-bottom: 10px;
padding: 10px;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.file-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
font-size: 14px;
}
.file-actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 12px;
}
.file-status {
color: #8a8f98;
font-size: 12px;
}
.delete-btn {
margin-left: 8px;
height: 24px;
line-height: 24px;
padding: 0 10px;
border-radius: 999px;
color: #ff3b30;
font-size: 12px;
font-weight: 500;
background: rgba(255, 59, 48, 0.08);
border: 1px solid rgba(255, 59, 48, 0.25);
}
.delete-btn--hover {
background: rgba(255, 59, 48, 0.16);
border-color: rgba(255, 59, 48, 0.35);
}
.file-name {
color: #333;
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.progress-bar {
height: 4px;
background: #eee;
border-radius: 2px;
overflow: hidden;
}
.progress-inner {
height: 100%;
background: #007aff;
transition: width 0.3s;
}
</style>